diff --git a/.env.example b/.env.example index 8920850..2d826f1 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ -LEAN_ID= -LEAN_KEY= -LEAN_MASTER_KEY= -LEAN_SERVER= +POSTGRES_DATABASE= +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_HOST= +PORSTGRES_PORT= +POSTGRES_PREFIX= +POSTGRES_SSL= diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index ad9e442..570c991 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -13,6 +13,12 @@ jobs: - name: Check out repository uses: actions/checkout@v4 + - name: Pack webhook plugin + run: | + cd waline-plugin-webhook + npm pack + mv waline-plugin-webhook-*.tgz ../ + - name: Set Docker image tag id: set-tag run: | diff --git a/.github/workflows/sync-waline.yml b/.github/workflows/sync-waline.yml new file mode 100644 index 0000000..4ee81db --- /dev/null +++ b/.github/workflows/sync-waline.yml @@ -0,0 +1,118 @@ +name: Sync Waline with Official Repository + +on: + schedule: + - cron: '0 0 * * 0' # 每周日运行 + workflow_dispatch: # 允许手动触发 + +jobs: + sync: + runs-on: windows-latest + timeout-minutes: 30 + + steps: + - name: Checkout local repository + uses: actions/checkout@v4 + with: + path: local + fetch-depth: 0 + + - name: Checkout official Waline repository + uses: actions/checkout@v4 + with: + repository: walinejs/waline + path: official + ref: main + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + + - name: Compare version numbers + id: compare-versions + run: | + $localVersion = Get-Content -Path "local/waline/package.json" | ConvertFrom-Json | Select-Object -ExpandProperty version + $officialVersion = Get-Content -Path "official/packages/server/package.json" | ConvertFrom-Json | Select-Object -ExpandProperty version + + Write-Host "Local version: $localVersion" + Write-Host "Official version: $officialVersion" + + if ($localVersion -ne $officialVersion) { + Write-Host "Versions differ, proceeding with sync" + echo "needs-sync=true" >> $env:GITHUB_OUTPUT + echo "local-version=$localVersion" >> $env:GITHUB_OUTPUT + echo "official-version=$officialVersion" >> $env:GITHUB_OUTPUT + } else { + Write-Host "Versions match, skipping sync" + echo "needs-sync=false" >> $env:GITHUB_OUTPUT + } + shell: pwsh + + - name: Sync files + if: steps.compare-versions.outputs.needs-sync == 'true' + run: | + # Create sync directory + New-Item -ItemType Directory -Path "sync" -Force + + # Copy official files to sync directory + Copy-Item -Path "official/packages/server/*" -Destination "sync" -Recurse -Force + + # Remove files that should not be synced + Remove-Item -Path "sync/Dockerfile" -Force -ErrorAction SilentlyContinue + Remove-Item -Path "sync/config.js" -Force -ErrorAction SilentlyContinue + + # Process dashboard.js - preserve title and icon + if (Test-Path "sync/src/middleware/dashboard.js") { + $dashboardContent = Get-Content -Path "sync/src/middleware/dashboard.js" -Raw + $localDashboardContent = Get-Content -Path "local/waline/src/middleware/dashboard.js" -Raw + + # Extract title and icon lines from local file + $titleLine = $localDashboardContent -match '.*<\/title>' | ForEach-Object { $Matches[0] } + $iconLine = $localDashboardContent -match '<link rel="icon" href=".*">' | ForEach-Object { $Matches[0] } + + # Replace title and icon in sync file + if ($titleLine -and $iconLine) { + $dashboardContent = $dashboardContent -replace '<title>.*<\/title>', $titleLine + $dashboardContent = $dashboardContent -replace '<link rel="icon" href=".*">', $iconLine + Set-Content -Path "sync/src/middleware/dashboard.js" -Value $dashboardContent + } + } + + # Process index.js - preserve title and icon + if (Test-Path "sync/src/controller/index.js") { + $indexContent = Get-Content -Path "sync/src/controller/index.js" -Raw + $localIndexContent = Get-Content -Path "local/waline/src/controller/index.js" -Raw + + # Extract title and icon lines from local file + $titleLine = $localIndexContent -match '<title>.*<\/title>' | ForEach-Object { $Matches[0] } + $iconLine = $localIndexContent -match '<link rel="icon" href=".*">' | ForEach-Object { $Matches[0] } + + # Replace title and icon in sync file + if ($titleLine -and $iconLine) { + $indexContent = $indexContent -replace '<title>.*<\/title>', $titleLine + $indexContent = $indexContent -replace '<link rel="icon" href=".*">', $iconLine + Set-Content -Path "sync/src/controller/index.js" -Value $indexContent + } + } + + # Copy synced files to local directory + Copy-Item -Path "sync/*" -Destination "local/waline" -Recurse -Force + + # Update version in local package.json + $localPackageJson = Get-Content -Path "local/waline/package.json" | ConvertFrom-Json + $officialPackageJson = Get-Content -Path "official/packages/server/package.json" | ConvertFrom-Json + $localPackageJson.version = $officialPackageJson.version + $localPackageJson | ConvertTo-Json -Depth 100 | Set-Content -Path "local/waline/package.json" + shell: pwsh + + - name: Commit and push changes + if: steps.compare-versions.outputs.needs-sync == 'true' + run: | + cd local + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add . + git commit -m "Sync with official Waline repository (v${{ steps.compare-versions.outputs.official-version }})" + git push + shell: bash diff --git a/.gitignore b/.gitignore index 6096ed2..e985853 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .vercel -node_modules diff --git a/index.cjs b/index.cjs index 67fcd99..15bf5e4 100644 --- a/index.cjs +++ b/index.cjs @@ -1,4 +1,4 @@ -const Application = require('waline'); +const Application = require('@waline/vercel'); module.exports = Application({ plugins: [], diff --git a/package.json b/package.json index c976399..79a93a8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.0.1", "private": true, "dependencies": { - "waline": "file:./waline" + "@waline/vercel": "file:./waline", + "@waline-plugins/tencent-tms": "latest", + "waline-plugin-llm-reviewer": "file:./waline-plugin-llm-reviewer", + "waline-plugin-webhook": "file:./waline-plugin-webhook" } } diff --git a/vercel.json b/vercel.json index 66fab6a..a7ef2da 100644 --- a/vercel.json +++ b/vercel.json @@ -4,6 +4,10 @@ "silent": true }, "builds": [ + { + "src": "robots.txt", + "use": "@vercel/static" + }, { "src": "index.cjs", "use": "@vercel/node" @@ -11,7 +15,7 @@ ], "rewrites": [ { - "source": "/(.*)", + "source": "/((?!robots\\.txt$).*)", "destination": "index.cjs" } ] diff --git a/waline-plugin-webhook/index.js b/waline-plugin-webhook/index.js new file mode 100644 index 0000000..f4cf745 --- /dev/null +++ b/waline-plugin-webhook/index.js @@ -0,0 +1,107 @@ +const nunjucks = require('nunjucks'); + +module.exports = function ({ webhookUrl, webhookTemplate, webhookHeaders }) { + const { WEBHOOK_URL, WEBHOOK_TEMPLATE, WEBHOOK_HEADERS, SITE_NAME, SITE_URL } = process.env; + + const url = webhookUrl || WEBHOOK_URL; + const template = webhookTemplate || WEBHOOK_TEMPLATE; + const headers = webhookHeaders || WEBHOOK_HEADERS; + + if (!url) { + return {}; + } + + const parsedHeaders = {}; + if (headers) { + headers.split('\n').forEach(line => { + const [key, ...valueParts] = line.split(':'); + if (key && valueParts.length > 0) { + parsedHeaders[key.trim()] = valueParts.join(':').trim(); + } + }); + } + + // 处理评论内容:解码 HTML 实体并移除 HTML 标签 + const decodeHTML = (str) => { + if (typeof str !== 'string') return str; + return str + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/(<([^>]+)>)/gi, ''); // 移除所有 HTML 标签 + }; + + const sendWebhook = async (data, parent = null) => { + // 准备模板数据 + const templateData = { + self: { + ...data, + comment: decodeHTML(data.comment), + objectId: data.objectId || data.id + }, + parent: parent ? { + ...parent, + comment: decodeHTML(parent.comment), + objectId: parent.objectId || parent.id + } : null, + site: { + name: SITE_NAME, + url: SITE_URL, + postUrl: SITE_URL + (data.url || '') + '#' + (data.objectId || data.id), + }, + }; + + // 使用自定义模板或默认模板 + const webhookTemplate = template || + `【新评论通知】{{site.name}} + ======================== + 💬 评论者:{{self.nick}}{% if self.mail %} ({{self.mail}}){% endif %} + 📍 归属地:{% if self.addr %}{{self.addr}}{% else %}未知{% endif %} + 💻 设备:{{self.os}} / {{self.browser}} + 📋 状态:{% if self.status == 'approved' %}审核通过{% elif self.status == 'waiting' %}等待审核{% elif self.status == 'spam' %}垃圾评论{% else %}{{self.status}}{% endif %} + + {% if self.status == 'approved' %}{{self.comment}}{% elif self.status == 'waiting' %}云审查疑似失效,评论等待人工审核,请前往站点审核{% elif self.status == 'spam' %}垃圾评论,请人工审核{% else %}未知评论状态:{{self.status}},请人工审核{% endif %} + {% if parent %} + ======================== + 此评论回复了:{{parent.nick}}{% if parent.mail %} ({{parent.mail}}){% endif %} + {% if parent.status == 'approved' %}{{parent.comment}}{% elif parent.status == 'waiting' %}云审查疑似失效,评论等待人工审核,请前往站点审核{% elif parent.status == 'spam' %}垃圾评论,请人工审核{% else %}未知评论状态:{{parent.status}},请人工审核{% endif %} + {% endif %}`; + + // 渲染模板 + const renderedBody = nunjucks.renderString(webhookTemplate, templateData); + + const body = { + Title: 'Waline Notify', + Body: renderedBody + }; + + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...parsedHeaders + }, + body: JSON.stringify(body), + }); + + return resp.ok; + } catch (error) { + console.error('Waline webhook error:', error); + return false; + } + }; + + return { + hooks: { + async postSave(comment, pComment) { + await sendWebhook(comment, pComment); + }, + async postReply(comment, pComment) { + await sendWebhook(comment, pComment); + } + } + }; +} \ No newline at end of file diff --git a/waline-plugin-webhook/package.json b/waline-plugin-webhook/package.json new file mode 100644 index 0000000..17b4a66 --- /dev/null +++ b/waline-plugin-webhook/package.json @@ -0,0 +1,12 @@ +{ + "name": "waline-plugin-webhook", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/waline/.gitignore b/waline/.gitignore new file mode 100644 index 0000000..b8f5541 --- /dev/null +++ b/waline/.gitignore @@ -0,0 +1,40 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage/ + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules/ + +# IDE config +.idea + +# output +output/ +output.tar.gz + +runtime/ +app/ + +config.development.js +adapter.development.js + +.vercel +.env \ No newline at end of file diff --git a/waline/Dockerfile b/waline/Dockerfile index 866f196..c83a845 100644 --- a/waline/Dockerfile +++ b/waline/Dockerfile @@ -1,12 +1,15 @@ # https://github.com/nodejs/LTS FROM node:lts AS build WORKDIR /app -ENV NODE_ENV=production +ENV NODE_ENV="production" +COPY waline-plugin-webhook-*.tgz /tmp/ RUN set -eux; \ - # npm config set registry https://registry.npmmirror.com; \ - npm install --production --silent @waline/vercel \ - npm install --production --silent @waline-plugins/tencent-tms \ - npm install --production --silent waline-plugin-llm-reviewer + # npm config set registry https://registry.npm.taobao.org; \ + npm install --production --silent @waline/vercel; \ + npm install --production --silent @waline-plugins/tencent-tms; \ + npm install --production --silent waline-plugin-llm-reviewer; \ + npm install --production --silent /tmp/waline-plugin-webhook-*.tgz; \ + rm -rf /tmp/waline-plugin-webhook-*.tgz COPY waline/config.js ./node_modules/@waline/vercel/config.js COPY waline/src/service/notify.js ./node_modules/@waline/vercel/src/service/notify.js @@ -17,8 +20,8 @@ COPY waline-plugin-llm-reviewer/index.js ./node_modules/waline-plugin-llm-review FROM node:lts-slim WORKDIR /app -ENV TZ=Asia/Shanghai -ENV NODE_ENV=production +ENV TZ="Asia/Shanghai" +ENV NODE_ENV="production" COPY --from=build /app . EXPOSE 8360 CMD ["node", "node_modules/@waline/vercel/vanilla.js"] diff --git a/waline/Dockerfile.alpine b/waline/Dockerfile.alpine index adfa99e..b00f409 100644 --- a/waline/Dockerfile.alpine +++ b/waline/Dockerfile.alpine @@ -1,15 +1,15 @@ # https://github.com/nodejs/LTS FROM node:lts AS build WORKDIR /app -ENV NODE_ENV production +ENV NODE_ENV="production" RUN set -eux; \ # npm config set registry https://registry.npm.taobao.org; \ npm install --production --silent @waline/vercel FROM node:lts-alpine WORKDIR /app -ENV TZ Asia/Shanghai -ENV NODE_ENV production +ENV TZ="Asia/Shanghai" +ENV NODE_ENV="production" RUN set -eux; \ # sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories; \ apk add --no-cache bash; \ diff --git a/waline/__tests__/xss.spec.js b/waline/__tests__/xss.spec.js index 3d2665a..946629d 100644 --- a/waline/__tests__/xss.spec.js +++ b/waline/__tests__/xss.spec.js @@ -3,8 +3,7 @@ import { describe, expect, it } from 'vitest'; import { sanitize } from '../src/service/markdown/xss'; -const parser = (content) => - sanitize(new MarkdownIt({ html: true }).render(content)); +const parser = (content) => sanitize(new MarkdownIt({ html: true }).render(content)); describe('XSS test', () => { it('Should render', () => { @@ -36,20 +35,16 @@ Waline is a good framework. :money: }); it('Should protect', () => { - expect(parser(`<img src="x" onerror="alert('img')">`)).toEqual( - '<img src="x">', - ); + expect(parser(`<img src="x" onerror="alert('img')">`)).toEqual('<img src="x">'); expect(parser('<script>alert("hello world")</script>')).toEqual(''); expect( - parser( - '<p>Waline is <iframe//src=jaVa script:alert(3)></iframe>awesome</p>', - ), + parser('<p>Waline is <iframe//src=jaVa script:alert(3)></iframe>awesome</p>'), ).toEqual('<p>Waline is awesome</p>'); - expect( - parser('<p>Waline is <iframe//src=jaVa script:alert(3)>awesome</p>'), - ).toEqual('<p>Waline is </p>'); + expect(parser('<p>Waline is <iframe//src=jaVa script:alert(3)>awesome</p>')).toEqual( + '<p>Waline is </p>', + ); }); it('Should resolve unmatching html tags', () => { @@ -60,14 +55,14 @@ Waline is a good framework. :money: it('Should not autoplay or preload media', () => { expect(parser('<audio autoplay preload="auto" src="x">')).toEqual( - '<audio src="x" preload="none"></audio>', + '<audio preload="none" src="x"></audio>', ); expect(parser('<audio autoplay src="x"></audio>')).toEqual( '<p><audio src="x" preload="none"></audio></p>\n', ); expect(parser('<video autoplay preload="auto" src="x">')).toEqual( - '<video src="x" preload="none"></video>', + '<video preload="none" src="x"></video>', ); expect(parser('<video autoplay src="x"></video>')).toEqual( '<p><video src="x" preload="none"></video></p>\n', @@ -78,12 +73,8 @@ Waline is a good framework. :money: expect(parser('[link](https://example.com)')).toEqual( '<p><a href="https://example.com" target="_blank" rel="nofollow noreferrer noopener">link</a></p>\n', ); - expect( - parser( - '<p><a href="https://example.com" rel="opener prefetch">link</a></p>', - ), - ).toEqual( - '<p><a rel="nofollow noreferrer noopener" href="https://example.com" target="_blank">link</a></p>', + expect(parser('<p><a href="https://example.com" rel="opener prefetch">link</a></p>')).toEqual( + '<p><a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">link</a></p>', ); }); @@ -94,9 +85,7 @@ Waline is a good framework. :money: it('Should forbid style', () => { expect( - parser( - '<div style="position:fixed;top:0;left:0;width:100vh;height:100vh;">广告文字</div>', - ), + parser('<div style="position:fixed;top:0;left:0;width:100vh;height:100vh;">广告文字</div>'), ).toEqual('<div>广告文字</div>'); expect( parser( diff --git a/waline/config.js b/waline/config.js index 93b4238..3111257 100644 --- a/waline/config.js +++ b/waline/config.js @@ -2,6 +2,8 @@ const TencentTMS = require('@waline-plugins/tencent-tms'); // 导入GPTReviewer插件 const GPTReviewer = require('waline-plugin-llm-reviewer'); +// 导入Webhook插件 +const WebhookPlugin = require('../waline-plugin-webhook'); // 创建配置对象 const config = {}; @@ -48,4 +50,20 @@ if (openaiBaseUrl && openaiModel && openaiApiKey) { console.info('GPTReviewer plugin not enabled: Missing required environment variables. Required: OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_KEY'); } +// 配置Webhook插件 +const webhookUrl = process.env.WEBHOOK_URL; +if (webhookUrl) { + if (!config.plugins) config.plugins = []; + config.plugins.push( + WebhookPlugin({ + webhookUrl: webhookUrl, + webhookTemplate: process.env.WEBHOOK_TEMPLATE, // 可选参数 + webhookHeaders: process.env.WEBHOOK_HEADERS // 可选参数 + }) + ); +} else if (process.env.WEBHOOK_TEMPLATE || process.env.WEBHOOK_HEADERS) { + // Webhook插件URL未配置 + console.info('Webhook plugin not enabled: Missing required environment variable. Required: WEBHOOK_URL'); +} + module.exports = config; \ No newline at end of file diff --git a/waline/development.js b/waline/development.js index 5fb5708..b3dbe4f 100644 --- a/waline/development.js +++ b/waline/development.js @@ -21,6 +21,7 @@ instance.run(); let config = {}; try { + // oxlint-disable-next-line node/global-require config = require('./config.js'); } catch { // do nothing diff --git a/waline/index.js b/waline/index.js index 4374629..1c70cee 100644 --- a/waline/index.js +++ b/waline/index.js @@ -4,7 +4,7 @@ const path = require('node:path'); const Application = require('thinkjs'); const Loader = require('thinkjs/lib/loader'); -module.exports = function (configParams = {}) { +module.exports = function main(configParams = {}) { const { env, ...config } = configParams; const app = new Application({ @@ -20,10 +20,11 @@ module.exports = function (configParams = {}) { loader.loadAll('worker'); + // oxlint-disable-next-line func-names return function (req, res) { - for (const k in config) { + for (const key in config) { // fix https://github.com/walinejs/waline/issues/2649 with alias model config name - think.config(k === 'model' ? 'customModel' : k, config[k]); + think.config(key === 'model' ? 'customModel' : key, config[key]); } return think diff --git a/waline/package.json b/waline/package.json index aa9c9b2..7e3809f 100644 --- a/waline/package.json +++ b/waline/package.json @@ -1,43 +1,45 @@ { "name": "@waline/vercel", - "version": "1.32.8", + "version": "1.37.0", "description": "vercel server for waline comment system", "keywords": [ - "waline", - "vercel", + "blog", "comment", - "blog" + "vercel", + "waline" ], + "license": "MIT", + "author": "lizheming <i@imnerd.org>", "repository": { "url": "https://github.com/walinejs/waline", "directory": "packages/server" }, - "license": "MIT", - "author": "lizheming <i@imnerd.org>", + "publishConfig": { + "provenance": true + }, "scripts": { "dev": "node development.js 9090" }, "dependencies": { - "@cloudbase/node-sdk": "^3.10.1", + "@cloudbase/node-sdk": "^3.18.0", "@koa/cors": "^5.0.0", "@mdit/plugin-katex": "0.23.4-cjs.0", "@mdit/plugin-mathjax": "0.23.4-cjs.0", "@mdit/plugin-sub": "0.22.5-cjs.0", "@mdit/plugin-sup": "0.22.5-cjs.0", "akismet": "^2.0.7", - "deta": "^2.0.0", - "dompurify": "^3.3.0", - "dy-node-ip2region": "^1.0.1", + "dompurify": "^3.3.1", "fast-csv": "^5.0.5", - "form-data": "^4.0.4", + "form-data": "^4.0.5", + "ip2region": "^2.3.0", "jsdom": "^19.0.0", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", "koa-compose": "^4.1.0", "leancloud-storage": "^4.15.2", - "markdown-it": "^14.1.0", + "markdown-it": "^14.1.1", "markdown-it-emoji": "^3.0.0", "mathjax-full": "^3.2.2", - "nodemailer": "^7.0.9", + "nodemailer": "^8.0.1", "nunjucks": "^3.2.4", "phpass": "^0.1.1", "prismjs": "^1.30.0", @@ -52,16 +54,13 @@ "think-mongo": "^2.2.1", "think-router-rest": "^1.0.5", "thinkjs": "4.0.0-alpha.0", - "ua-parser-js": "^2.0.6" + "ua-parser-js": "^2.0.9" }, "devDependencies": { - "dotenv": "17.2.3", + "dotenv": "17.2.4", "think-watcher": "3.0.4" }, "engines": { "node": ">=20" - }, - "publishConfig": { - "provenance": true } } diff --git a/waline/src/config/adapter.js b/waline/src/config/adapter.js index f3e3977..39e5dd6 100644 --- a/waline/src/config/adapter.js +++ b/waline/src/config/adapter.js @@ -6,9 +6,10 @@ const Postgresql = require('think-model-postgresql'); let Sqlite; try { + // oxlint-disable-next-line node/global-require Sqlite = require('think-model-sqlite'); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-extraneous-class + // oxlint-disable-next-line typescript/no-extraneous-class Sqlite = class {}; console.log(err); } @@ -46,6 +47,7 @@ const { POSTGRES_USER, PG_SSL, POSTGRES_SSL, + POSTGRES_URL, MONGO_AUTHSOURCE, MONGO_DB, MONGO_HOST, @@ -64,11 +66,11 @@ if (MONGO_AUTHSOURCE) mongoOpt.authSource = MONGO_AUTHSOURCE; if (MONGO_DB) { type = 'mongo'; for (const envKeys in process.env) { - if (/MONGO_OPT_/.test(envKeys)) { + if (envKeys.includes('MONGO_OPT_')) { const key = envKeys .slice(10) .toLocaleLowerCase() - .replace(/_([a-z])/g, (_, b) => b.toUpperCase()); + .replaceAll(/_([a-z])/g, (_, b) => b.toUpperCase()); mongoOpt[key] = process.env[envKeys]; } @@ -83,14 +85,13 @@ if (MONGO_DB) { type = 'tidb'; } -const isVercelPostgres = - type === 'postgresql' && POSTGRES_HOST?.endsWith('vercel-storage.com'); - exports.model = { type, common: { logSql: true, - logger: (msg) => think.logger.info(msg), + logger: (msg) => { + think.logger.info(msg); + }, }, mongo: { @@ -99,11 +100,7 @@ exports.model = { ? JSON.parse(MONGO_HOST) : MONGO_HOST : '127.0.0.1', - port: MONGO_PORT - ? MONGO_PORT.startsWith('[') - ? JSON.parse(MONGO_PORT) - : MONGO_PORT - : 27017, + port: MONGO_PORT ? (MONGO_PORT.startsWith('[') ? JSON.parse(MONGO_PORT) : MONGO_PORT) : 27017, user: MONGO_USER, password: MONGO_PASSWORD, database: MONGO_DB, @@ -116,11 +113,11 @@ exports.model = { password: PG_PASSWORD || POSTGRES_PASSWORD, database: PG_DB || POSTGRES_DATABASE, host: PG_HOST || POSTGRES_HOST || '127.0.0.1', - port: PG_PORT || POSTGRES_PORT || (isVercelPostgres ? '5432' : '3211'), + port: PG_PORT || POSTGRES_PORT || '5432', connectionLimit: 1, prefix: PG_PREFIX || POSTGRES_PREFIX || 'wl_', ssl: - (PG_SSL || POSTGRES_SSL) == 'true' || isVercelPostgres + (PG_SSL || POSTGRES_SSL) === 'true' || POSTGRES_URL?.includes('sslmode=require') ? { rejectUnauthorized: false, } diff --git a/waline/src/config/config.js b/waline/src/config/config.js index df4c823..bc250fa 100644 --- a/waline/src/config/config.js +++ b/waline/src/config/config.js @@ -21,7 +21,6 @@ const { DISABLE_REGION, AVATAR_PROXY, GITHUB_TOKEN, - DETA_PROJECT_KEY, OAUTH_URL, MARKDOWN_CONFIG = '{}', @@ -74,9 +73,6 @@ if (LEAN_KEY) { } else if (think.env === 'cloudbase' || TCB_ENV) { storage = 'cloudbase'; jwtKey = jwtKey || TENCENTCLOUD_SECRETKEY || TCB_KEY || TCB_ENV; -} else if (DETA_PROJECT_KEY) { - storage = 'deta'; - jwtKey = jwtKey || DETA_PROJECT_KEY; } if (think.env === 'cloudbase' && storage === 'sqlite') { @@ -85,8 +81,7 @@ if (think.env === 'cloudbase' && storage === 'sqlite') { const forbiddenWords = FORBIDDEN_WORDS ? FORBIDDEN_WORDS.split(/\s*,\s*/) : []; -const isFalse = (content) => - content && ['0', 'false'].includes(content.toLowerCase()); +const isFalse = (content) => content && ['0', 'false'].includes(content.toLowerCase()); const markdown = { config: JSON.parse(MARKDOWN_CONFIG), @@ -105,7 +100,7 @@ if (isFalse(MARKDOWN_HIGHLIGHT)) markdown.config.highlight = false; let avatarProxy = ''; if (AVATAR_PROXY) { - avatarProxy = !isFalse(AVATAR_PROXY) ? AVATAR_PROXY : ''; + avatarProxy = isFalse(AVATAR_PROXY) ? '' : AVATAR_PROXY; } const oauthUrl = OAUTH_URL || 'https://oauth.lithub.cc'; @@ -116,13 +111,10 @@ module.exports = { jwtKey, forbiddenWords, disallowIPList: [], - secureDomains: SECURE_DOMAINS ? SECURE_DOMAINS.split(/\s*,\s*/) : undefined, + secureDomains: SECURE_DOMAINS ? SECURE_DOMAINS.split(/\s*,\s*/) : null, disableUserAgent: DISABLE_USERAGENT && !isFalse(DISABLE_USERAGENT), disableRegion: DISABLE_REGION && !isFalse(DISABLE_REGION), - levels: - !LEVELS || isFalse(LEVELS) - ? false - : LEVELS.split(/\s*,\s*/).map((v) => Number(v)), + levels: !LEVELS || isFalse(LEVELS) ? false : LEVELS.split(/\s*,\s*/).map(Number), audit: COMMENT_AUDIT && !isFalse(COMMENT_AUDIT), avatarProxy, diff --git a/waline/src/config/extend.js b/waline/src/config/extend.js index cf93961..ff91f2e 100644 --- a/waline/src/config/extend.js +++ b/waline/src/config/extend.js @@ -3,8 +3,6 @@ const Mongo = require('think-mongo'); const { isNetlify, netlifyFunctionPrefix } = require('./netlify'); -const isDeta = think.env === 'deta' || process.env.DETA_RUNTIME === 'true'; - module.exports = [ Model(think.app), Mongo(think.app), @@ -23,10 +21,6 @@ module.exports = [ return `${protocol}://${host}${netlifyFunctionPrefix}`; } - if (isDeta) { - return `https://${host}`; - } - return `${protocol}://${host}`; }, async webhook(type, data) { diff --git a/waline/src/config/middleware.js b/waline/src/config/middleware.js index aa09722..287385f 100644 --- a/waline/src/config/middleware.js +++ b/waline/src/config/middleware.js @@ -5,11 +5,12 @@ const { isNetlify, netlifyFunctionPrefix } = require('./netlify.js'); const isDev = think.env === 'development'; const isTcb = think.env === 'cloudbase'; -const isDeta = think.env === 'deta' || process.env.DETA_RUNTIME === 'true'; -const isAliyunFC = - think.env === 'aliyun-fc' || Boolean(process.env.FC_RUNTIME_VERSION); +const isAliyunFC = think.env === 'aliyun-fc' || Boolean(process.env.FC_RUNTIME_VERSION); module.exports = [ + { + handle: 'fetch-oauth-service', + }, { handle: 'dashboard', match: isNetlify ? new RegExp(`${netlifyFunctionPrefix}/ui`, 'i') : /^\/ui/, @@ -23,8 +24,7 @@ module.exports = [ options: { logRequest: isDev, sendResponseTime: isDev, - requestTimeoutCallback: - isTcb || isDeta || isAliyunFC || isNetlify ? false : () => {}, + requestTimeoutCallback: isTcb || isAliyunFC || isNetlify ? false : () => {}, }, }, diff --git a/waline/src/controller/article.js b/waline/src/controller/article.js index 5db679f..571604c 100644 --- a/waline/src/controller/article.js +++ b/waline/src/controller/article.js @@ -11,14 +11,14 @@ module.exports = class extends BaseRest { const { deprecated } = this.ctx.state; // path is required - if (!Array.isArray(path) || !path.length) { + if (!Array.isArray(path) || path.length === 0) { return this.jsonOrSuccess(0); } const resp = await this.modelInstance.select({ url: ['IN', path] }); if (think.isEmpty(resp)) { - const counters = new Array(path.length).fill( + const counters = Array(path.length).fill( type.length === 1 && deprecated ? 0 : type.reduce((o, field) => { @@ -38,9 +38,7 @@ module.exports = class extends BaseRest { // - single path and multiple type: [{[type]: 0}] // - multiple path and single type: [{[type]: 0}] // - multiple path and multiple type: [{[type]: 0}] - return this.jsonOrSuccess( - path.length === 1 && deprecated ? counters[0] : counters, - ); + return this.jsonOrSuccess(path.length === 1 && deprecated ? counters[0] : counters); } const respObj = resp.reduce((o, n) => { @@ -89,17 +87,12 @@ module.exports = class extends BaseRest { const ret = await this.modelInstance.update( (counter) => ({ - [type]: - action === 'desc' - ? (counter[type] || 1) - 1 - : (counter[type] || 0) + 1, + [type]: action === 'desc' ? (counter[type] || 1) - 1 : (counter[type] || 0) + 1, updatedAt: new Date(), }), { objectId: ['IN', resp.map(({ objectId }) => objectId)] }, ); - return this.jsonOrSuccess( - deprecated ? ret[0][type] : [{ [type]: ret[0][type] }], - ); + return this.jsonOrSuccess(deprecated ? ret[0][type] : [{ [type]: ret[0][type] }]); } }; diff --git a/waline/src/controller/comment.js b/waline/src/controller/comment.js index b68771c..cb0e378 100644 --- a/waline/src/controller/comment.js +++ b/waline/src/controller/comment.js @@ -4,12 +4,13 @@ const { getMarkdownParser } = require('../service/markdown/index.js'); const markdownParser = getMarkdownParser(); -async function formatCmt( +// oxlint-disable-next-line max-statements +const formatCmt = async ( { ua, ip, ...comment }, users = [], { avatarProxy, deprecated }, loginUser, -) { +) => { ua = think.uaParser(ua); if (!think.config('disableUserAgent')) { comment.browser = `${ua.browser.name || ''}${(ua.browser.version || '') @@ -29,13 +30,11 @@ async function formatCmt( comment.label = user.label; } - const avatarUrl = user?.avatar - ? user.avatar - : await think.service('avatar').stringify(comment); + const avatarUrl = user?.avatar ? user.avatar : await think.service('avatar').stringify(comment); comment.avatar = avatarProxy && !avatarUrl.includes(avatarProxy) - ? avatarProxy + '?url=' + encodeURIComponent(avatarUrl) + ? `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}` : avatarUrl; const isAdmin = loginUser && loginUser.type === 'administrator'; @@ -69,7 +68,7 @@ async function formatCmt( delete comment.updatedAt; return comment; -} +}; module.exports = class extends BaseRest { constructor(ctx) { @@ -81,12 +80,12 @@ module.exports = class extends BaseRest { const { type } = this.get(); const fnMap = { - recent: this.getRecentCommentList, - count: this.getCommentCount, - list: this.getAdminCommentList, + recent: this['getRecentCommentList'], + count: this['getCommentCount'], + list: this['getAdminCommentList'], }; - const fn = fnMap[type] || this.getCommentList; + const fn = fnMap[type] || this['getCommentList']; const data = await fn.call(this); return this.jsonOrSuccess(data); @@ -124,7 +123,7 @@ module.exports = class extends BaseRest { if ( think.isArray(disallowIPList) && - disallowIPList.length && + disallowIPList.length > 0 && disallowIPList.includes(data.ip) ) { think.logger.debug(`Comment IP ${data.ip} is in disallowIPList`); @@ -144,9 +143,7 @@ module.exports = class extends BaseRest { }); if (!think.isEmpty(duplicate)) { - think.logger.debug( - 'The comment author had post same comment content before', - ); + think.logger.debug('The comment author had post same comment content before'); return this.fail(this.locale('Duplicate Content')); } @@ -175,9 +172,9 @@ module.exports = class extends BaseRest { think.logger.debug(`Comment initial status is ${data.status}`); if (data.status === 'approved') { - const spam = await akismet(data, this.ctx.serverURL).catch((err) => - console.log(err), - ); // ignore akismet error + const spam = await akismet(data, this.ctx.serverURL).catch((err) => { + console.log(err); + }); // ignore akismet error if (spam === true) { data.status = 'spam'; @@ -255,9 +252,7 @@ module.exports = class extends BaseRest { await notify.run( { ...cmtReturn, mail: resp.mail, rawComment: comment }, - parentReturn - ? { ...parentReturn, mail: parentComment.mail } - : undefined, + parentReturn ? { ...parentReturn, mail: parentComment.mail } : undefined, ); } @@ -280,7 +275,7 @@ module.exports = class extends BaseRest { async putAction() { const { userInfo } = this.ctx.state; const isAdmin = userInfo.type === 'administrator'; - let data = isAdmin ? this.post() : this.post('comment,like'); + const data = isAdmin ? this.post() : this.post('comment,like'); let oldData = await this.modelInstance.select({ objectId: this.id }); if (think.isEmpty(oldData) || think.isEmpty(data)) { @@ -292,8 +287,7 @@ module.exports = class extends BaseRest { const likeIncMax = this.config('LIKE_INC_MAX') || 1; data.like = - (Number(oldData.like) || 0) + - (data.like ? Math.ceil(Math.random() * likeIncMax) : -1); + (Number(oldData.like) || 0) + (data.like ? Math.ceil(Math.random() * likeIncMax) : -1); data.like = Math.max(data.like, 0); } @@ -325,11 +319,7 @@ module.exports = class extends BaseRest { userInfo, ); - if ( - oldData.status === 'waiting' && - data.status === 'approved' && - oldData.pid - ) { + if (oldData.status === 'waiting' && data.status === 'approved' && oldData.pid) { let pComment = await this.modelInstance.select({ objectId: oldData.pid, }); @@ -390,7 +380,7 @@ module.exports = class extends BaseRest { const { path: url, page, pageSize, sortBy } = this.get(); const where = { url }; - if (think.isEmpty(userInfo) || this.config('storage') === 'deta') { + if (think.isEmpty(userInfo)) { where.status = ['NOT IN', ['waiting', 'spam']]; } else if (userInfo.type !== 'administrator') { where._complex = { @@ -462,9 +452,7 @@ module.exports = class extends BaseRest { rootComments.forEach(({ objectId }) => { rootIds[objectId] = true; }); - comments = comments.filter( - (cmt) => rootIds[cmt.objectId] || rootIds[cmt.rid], - ); + comments = comments.filter((cmt) => rootIds[cmt.objectId] || rootIds[cmt.rid]); } else { comments = await this.modelInstance.select( { ...where, rid: undefined }, @@ -488,12 +476,10 @@ module.exports = class extends BaseRest { } const userModel = this.getModel('Users'); - const user_ids = Array.from( - new Set(comments.map(({ user_id }) => user_id).filter((v) => v)), - ); + const user_ids = [...new Set(comments.map(({ user_id }) => user_id).filter((v) => v))]; let users = []; - if (user_ids.length) { + if (user_ids.length > 0) { users = await userModel.select( { objectId: ['IN', user_ids] }, { @@ -508,14 +494,12 @@ module.exports = class extends BaseRest { _complex: {}, }; - if (user_ids.length) { + if (user_ids.length > 0) { countWhere._complex.user_id = ['IN', user_ids]; } - const mails = Array.from( - new Set(comments.map(({ mail }) => mail).filter((v) => v)), - ); + const mails = [...new Set(comments.map(({ mail }) => mail).filter((v) => v))]; - if (mails.length) { + if (mails.length > 0) { countWhere._complex.mail = ['IN', mails]; } if (!think.isEmpty(countWhere._complex)) { @@ -629,13 +613,11 @@ module.exports = class extends BaseRest { }); const userModel = this.getModel('Users'); - const user_ids = Array.from( - new Set(comments.map(({ user_id }) => user_id).filter((v) => v)), - ); + const user_ids = [...new Set(comments.map(({ user_id }) => user_id).filter((v) => v))]; let users = []; - if (user_ids.length) { + if (user_ids.length > 0) { users = await userModel.select( { objectId: ['IN', user_ids] }, { @@ -668,7 +650,7 @@ module.exports = class extends BaseRest { const { userInfo } = this.ctx.state; const where = {}; - if (think.isEmpty(userInfo) || this.config('storage') === 'deta') { + if (think.isEmpty(userInfo)) { where.status = ['NOT IN', ['waiting', 'spam']]; } else { where._complex = { @@ -700,13 +682,11 @@ module.exports = class extends BaseRest { }); const userModel = this.getModel('Users'); - const user_ids = Array.from( - new Set(comments.map(({ user_id }) => user_id).filter((v) => v)), - ); + const user_ids = [...new Set(comments.map(({ user_id }) => user_id).filter((v) => v))]; let users = []; - if (user_ids.length) { + if (user_ids.length > 0) { users = await userModel.select( { objectId: ['IN', user_ids] }, { @@ -730,9 +710,9 @@ module.exports = class extends BaseRest { async getCommentCount() { const { url } = this.get(); const { userInfo } = this.ctx.state; - const where = Array.isArray(url) && url.length ? { url: ['IN', url] } : {}; + const where = Array.isArray(url) && url.length > 0 ? { url: ['IN', url] } : {}; - if (think.isEmpty(userInfo) || this.config('storage') === 'deta') { + if (think.isEmpty(userInfo)) { where.status = ['NOT IN', ['waiting', 'spam']]; } else { where._complex = { diff --git a/waline/src/controller/db.js b/waline/src/controller/db.js index bd26bf8..47cf31d 100644 --- a/waline/src/controller/db.js +++ b/waline/src/controller/db.js @@ -38,15 +38,9 @@ module.exports = class extends BaseRest { } if (storage === 'mysql') { - if (item.insertedAt) - item.insertedAt = think.datetime( - item.insertedAt, - 'YYYY-MM-DD HH:mm:ss', - ); - if (item.createdAt) - item.createdAt = think.datetime(item.createdAt, 'YYYY-MM-DD HH:mm:ss'); - if (item.updatedAt) - item.updatedAt = think.datetime(item.updatedAt, 'YYYY-MM-DD HH:mm:ss'); + if (item.insertedAt) item.insertedAt = think.datetime(item.insertedAt, 'YYYY-MM-DD HH:mm:ss'); + if (item.createdAt) item.createdAt = think.datetime(item.createdAt, 'YYYY-MM-DD HH:mm:ss'); + if (item.updatedAt) item.updatedAt = think.datetime(item.updatedAt, 'YYYY-MM-DD HH:mm:ss'); } delete item.objectId; diff --git a/waline/src/controller/oauth.js b/waline/src/controller/oauth.js index f22129c..653a881 100644 --- a/waline/src/controller/oauth.js +++ b/waline/src/controller/oauth.js @@ -10,8 +10,7 @@ module.exports = class extends think.Controller { const { code, oauth_verifier, oauth_token, type, redirect } = this.get(); const { oauthUrl } = this.config(); - const hasCode = - type === 'twitter' ? oauth_token && oauth_verifier : Boolean(code); + const hasCode = type === 'twitter' ? oauth_token && oauth_verifier : Boolean(code); if (!hasCode) { const { serverURL } = this.ctx; @@ -20,12 +19,13 @@ module.exports = class extends think.Controller { type, }); - return this.redirect( + this.redirect( think.buildUrl(`${oauthUrl}/${type}`, { redirect: redirectUrl, state: this.ctx.state.token || '', }), ); + return; } /** @@ -59,22 +59,22 @@ module.exports = class extends think.Controller { const userBySocial = await this.modelInstance.select({ [type]: user.id }); + // when the social account has been linked, then redirect to this linked account profile page. It may be current account or another. + // If it's another account, user should unlink the social type in that account and then link it. if (!think.isEmpty(userBySocial)) { - const token = jwt.sign(userBySocial[0].email, this.config('jwtKey')); + const token = jwt.sign(userBySocial[0].objectId, this.config('jwtKey')); if (redirect) { - return this.redirect(think.buildUrl(redirect, { token })); + this.redirect(think.buildUrl(redirect, { token })); + return; } return this.success(); } - if (!user.email) { - user.email = `${user.id}@mail.${type}`; - } - const current = this.ctx.state.userInfo; + // when login user link social type, then update data if (!think.isEmpty(current)) { const updateData = { [type]: user.id }; @@ -86,41 +86,31 @@ module.exports = class extends think.Controller { objectId: current.objectId, }); - return this.redirect('/ui/profile'); + this.redirect('/ui/profile'); + return; } - const userByEmail = await this.modelInstance.select({ email: user.email }); - - if (think.isEmpty(userByEmail)) { - const count = await this.modelInstance.count(); - const data = { - display_name: user.name, - email: user.email, - url: user.url, - avatar: user.avatar, - [type]: user.id, - password: this.hashPassword(Math.random()), - type: think.isEmpty(count) ? 'administrator' : 'guest', - }; - - await this.modelInstance.add(data); - } else { - const updateData = { [type]: user.id }; - - if (!userByEmail.avatar && user.avatar) { - updateData.avatar = user.avatar; - } - await this.modelInstance.update(updateData, { email: user.email }); + // when user has not login, then we create account by the social type! + const count = await this.modelInstance.count(); + const data = { + display_name: user.name, + email: user.email, + url: user.url, + avatar: user.avatar, + [type]: user.id, + passowrd: this.hashPassword(Math.random()), + type: think.isEmpty(count) ? 'administrator' : 'guest', + }; + + await this.modelInstance.add(data); + + if (!redirect) { + return this.success(); } - const token = jwt.sign(user.email, this.config('jwtKey')); - - if (redirect) { - return this.redirect( - redirect + (redirect.includes('?') ? '&' : '?') + 'token=' + token, - ); - } + // and then generate token! + const token = jwt.sign(user.objectId, this.config('jwtKey')); - return this.success(); + this.redirect(redirect + (redirect.includes('?') ? '&' : '?') + 'token=' + token); } }; diff --git a/waline/src/controller/rest.js b/waline/src/controller/rest.js index 9d003b2..e44fb65 100644 --- a/waline/src/controller/rest.js +++ b/waline/src/controller/rest.js @@ -21,7 +21,7 @@ module.exports = class extends think.Controller { const filename = this.__filename || __filename; const last = filename.lastIndexOf(path.sep); - return filename.substr(last + 1, filename.length - last - 4); + return filename.slice(last + 1, filename.length - last - 4); } getId() { diff --git a/waline/src/controller/token.js b/waline/src/controller/token.js index 9163a3e..6baad0b 100644 --- a/waline/src/controller/token.js +++ b/waline/src/controller/token.js @@ -1,6 +1,5 @@ const jwt = require('jsonwebtoken'); const speakeasy = require('speakeasy'); -const helper = require('think-helper'); const BaseRest = require('./rest.js'); @@ -60,8 +59,7 @@ module.exports = class extends BaseRest { return this.success({ ...user[0], password: null, - mailMd5: helper.md5(user[0].email.toLowerCase()), - token: jwt.sign(user[0].email, this.config('jwtKey')), + token: jwt.sign(user[0].objectId, this.config('jwtKey')), }); } diff --git a/waline/src/controller/user.js b/waline/src/controller/user.js index 3659392..c0fd5f1 100644 --- a/waline/src/controller/user.js +++ b/waline/src/controller/user.js @@ -1,6 +1,6 @@ const BaseRest = require('./rest.js'); -module.exports = class extends BaseRest { +module.exports = class UserController extends BaseRest { constructor(...args) { super(...args); this.modelInstance = this.getModel('Users'); @@ -50,28 +50,17 @@ module.exports = class extends BaseRest { email: data.email, }); - if ( - !think.isEmpty(resp) && - ['administrator', 'guest'].includes(resp[0].type) - ) { + if (!think.isEmpty(resp) && ['administrator', 'guest'].includes(resp[0].type)) { return this.fail(this.locale('USER_EXIST')); } const count = await this.modelInstance.count(); - const { - SMTP_HOST, - SMTP_SERVICE, - SENDER_EMAIL, - SENDER_NAME, - SMTP_USER, - SITE_NAME, - } = process.env; + const { SMTP_HOST, SMTP_SERVICE, SENDER_EMAIL, SENDER_NAME, SMTP_USER, SITE_NAME } = + process.env; const hasMailService = SMTP_HOST || SMTP_SERVICE; - const token = Array.from({ length: 4 }, () => - Math.round(Math.random() * 9), - ).join(''); + const token = Array.from({ length: 4 }, () => Math.round(Math.random() * 9)).join(''); const normalType = hasMailService ? `verify:${token}:${Date.now() + 1 * 60 * 60 * 1000}` : 'guest'; @@ -91,16 +80,13 @@ module.exports = class extends BaseRest { try { const notify = this.service('notify', this); - const apiUrl = think.buildUrl(this.ctx.serverURL + '/verification', { + const apiUrl = think.buildUrl(`${this.ctx.serverURL}/verification`, { token, email: data.email, }); await notify.transporter.sendMail({ - from: - SENDER_EMAIL && SENDER_NAME - ? `"${SENDER_NAME}" <${SENDER_EMAIL}>` - : SMTP_USER, + from: SENDER_EMAIL && SENDER_NAME ? `"${SENDER_NAME}" <${SENDER_EMAIL}>` : SMTP_USER, to: data.email, subject: this.locale('[{{name | safe}}] Registration Confirm Mail', { name: SITE_NAME || 'Waline', @@ -125,7 +111,7 @@ module.exports = class extends BaseRest { } async putAction() { - const { display_name, url, avatar, password, type, label } = this.post(); + const { display_name, url, avatar, password, type, label, email } = this.post(); const { objectId } = this.ctx.state.userInfo; const twoFactorAuth = this.post('2fa'); @@ -139,6 +125,19 @@ module.exports = class extends BaseRest { updateData.label = label; } + if (email) { + const user = await this.modelInstance.select({ + email, + objectId: ['!=', objectId], + }); + + if (!think.isEmpty(user)) { + return this.fail(); + } + + updateData.email = email; + } + if (display_name) { updateData.display_name = display_name; } @@ -159,7 +158,7 @@ module.exports = class extends BaseRest { updateData['2fa'] = twoFactorAuth; } - const socials = ['github', 'twitter', 'facebook', 'google', 'weibo', 'qq']; + const socials = this.ctx.state.oauthServices.map(({ name }) => name); socials.forEach((social) => { const nextSocial = this.post(social); @@ -180,6 +179,7 @@ module.exports = class extends BaseRest { return this.success(); } + // oxlint-disable-next-line max-statements async getUsersListByCount() { const { pageSize } = this.get(); const commentModel = this.getModel('Comment'); @@ -195,13 +195,11 @@ module.exports = class extends BaseRest { counts.sort((a, b) => b.count - a.count); counts.length = Math.min(pageSize, counts.length); - const userIds = counts - .filter(({ user_id }) => user_id) - .map(({ user_id }) => user_id); + const userIds = counts.filter(({ user_id }) => user_id).map(({ user_id }) => user_id); - let usersMap = {}; + const usersMap = {}; - if (userIds.length) { + if (userIds.length > 0) { const users = await this.modelInstance.select({ objectId: ['IN', userIds], }); @@ -223,10 +221,7 @@ module.exports = class extends BaseRest { let level = 0; if (user.count) { - const _level = think.findLastIndex( - this.config('levels'), - (l) => l <= user.count, - ); + const _level = think.findLastIndex(this.config('levels'), (level) => level <= user.count); if (_level !== -1) { level = _level; @@ -236,15 +231,10 @@ module.exports = class extends BaseRest { } if (count.user_id && users[count.user_id]) { - const { - display_name: nick, - url: link, - avatar: avatarUrl, - label, - } = users[count.user_id]; + const { display_name: nick, url: link, avatar: avatarUrl, label } = users[count.user_id]; const avatar = avatarProxy && !avatarUrl.includes(avatarProxy) - ? avatarProxy + '?url=' + encodeURIComponent(avatarUrl) + ? `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}` : avatarUrl; Object.assign(user, { nick, link, avatar, label }); @@ -252,15 +242,12 @@ module.exports = class extends BaseRest { continue; } - const comments = await commentModel.select( - { mail: count.mail }, - { limit: 1 }, - ); + const comments = await commentModel.select({ mail: count.mail }, { limit: 1 }); if (think.isEmpty(comments)) { continue; } - const comment = comments[0]; + const [comment] = comments; if (think.isEmpty(comment)) { continue; @@ -269,7 +256,7 @@ module.exports = class extends BaseRest { const avatarUrl = await think.service('avatar').stringify(comment); const avatar = avatarProxy && !avatarUrl.includes(avatarProxy) - ? avatarProxy + '?url=' + encodeURIComponent(avatarUrl) + ? `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}` : avatarUrl; Object.assign(user, { nick, link, avatar }); diff --git a/waline/src/controller/user/password.js b/waline/src/controller/user/password.js index 058f90c..dd17571 100644 --- a/waline/src/controller/user/password.js +++ b/waline/src/controller/user/password.js @@ -4,14 +4,8 @@ const BaseRest = require('../rest.js'); module.exports = class extends BaseRest { async putAction() { - const { - SMTP_HOST, - SMTP_SERVICE, - SENDER_EMAIL, - SENDER_NAME, - SMTP_USER, - SITE_NAME, - } = process.env; + const { SMTP_HOST, SMTP_SERVICE, SENDER_EMAIL, SENDER_NAME, SMTP_USER, SITE_NAME } = + process.env; const hasMailService = SMTP_HOST || SMTP_SERVICE; if (!hasMailService) { @@ -27,14 +21,11 @@ module.exports = class extends BaseRest { } const notify = this.service('notify', this); - const token = jwt.sign(user[0].email, this.config('jwtKey')); + const token = jwt.sign(user[0].objectId, this.config('jwtKey')); const profileUrl = `${this.ctx.serverURL}/ui/profile?token=${token}`; await notify.transporter.sendMail({ - from: - SENDER_EMAIL && SENDER_NAME - ? `"${SENDER_NAME}" <${SENDER_EMAIL}>` - : SMTP_USER, + from: SENDER_EMAIL && SENDER_NAME ? `"${SENDER_NAME}" <${SENDER_EMAIL}>` : SMTP_USER, to: user[0].email, subject: this.locale('[{{name | safe}}] Reset Password', { name: SITE_NAME || 'Waline', diff --git a/waline/src/controller/verification.js b/waline/src/controller/verification.js index b310c0d..8c137b6 100644 --- a/waline/src/controller/verification.js +++ b/waline/src/controller/verification.js @@ -21,10 +21,11 @@ module.exports = class extends BaseRest { return this.fail(this.locale('USER_REGISTERED')); } - if (token === match[1] && Date.now() < parseInt(match[2])) { + if (token === match[1] && Date.now() < Number.parseInt(match[2])) { await this.modelInstance.update({ type: 'guest' }, { email }); - return this.redirect('/ui/login'); + this.redirect('/ui/login'); + return; } return this.fail(this.locale('TOKEN_EXPIRED')); diff --git a/waline/src/extend/think.js b/waline/src/extend/think.js index 15d3e22..ced2b18 100644 --- a/waline/src/extend/think.js +++ b/waline/src/extend/think.js @@ -1,10 +1,24 @@ -const ip2region = require('dy-node-ip2region'); -const helper = require('think-helper'); +const IP2Region = require('ip2region').default; const parser = require('ua-parser-js'); const preventMessage = 'PREVENT_NEXT_PROCESS'; -const regionSearch = ip2region.create(process.env.IP2REGION_DB); +// Cached IP2Region instance using IIFE closure pattern +// Instance is created on first access and reused for all subsequent calls +const getIP2RegionInstance = (() => { + let instance = null; + + return () => { + if (!instance) { + instance = new IP2Region({ + ipv4db: process.env.IP2REGION_DB_V4 || process.env.IP2REGION_DB, + ipv6db: process.env.IP2REGION_DB_V6, + }); + } + + return instance; + }; +})(); const OS_VERSION_MAP = { Windows: { @@ -34,15 +48,17 @@ module.exports = { }, promiseAllQueue(promises, taskNum) { return new Promise((resolve, reject) => { - if (!promises.length) { - return resolve(); + if (promises.length === 0) { + resolve(); + + return; } const ret = []; let index = 0; let count = 0; - function runTask() { + const runTask = () => { const idx = index; index += 1; @@ -59,7 +75,7 @@ module.exports = { return runTask(); }, reject); - } + }; for (let i = 0; i < taskNum; i++) { runTask(); @@ -67,21 +83,17 @@ module.exports = { }); }, async ip2region(ip, { depth = 1 }) { - if (!ip || ip.includes(':')) return ''; + if (!ip) return ''; try { - const search = helper.promisify(regionSearch.btreeSearch, regionSearch); - const result = await search(ip); + const res = getIP2RegionInstance().search(ip); - if (!result) { + if (!res) { return ''; } - const { region } = result; - const [, , province, city, isp] = region.split('|'); - const address = Array.from( - new Set([province, city, isp].filter((v) => v)), - ); + const { province, city, isp } = res; + const address = [...new Set([province, city, isp].filter(Boolean))]; return address.slice(0, depth).join(' '); } catch (err) { console.log(err); @@ -106,7 +118,7 @@ module.exports = { return defaultLevel; } - const level = think.findLastIndex(levels, (l) => l <= val); + const level = think.findLastIndex(levels, (level) => level <= val); return level === -1 ? defaultLevel : level; }, @@ -141,7 +153,7 @@ module.exports = { } if (think.isArray(middleware)) { - return middleware.filter((m) => think.isFunction(m)); + return middleware.filter((middleware) => think.isFunction(middleware)); } }); @@ -149,10 +161,8 @@ module.exports = { }, getPluginHook(hookName) { return think - .pluginMap('hooks', (hook) => - think.isFunction(hook[hookName]) ? hook[hookName] : undefined, - ) - .filter((v) => v); + .pluginMap('hooks', (hook) => (think.isFunction(hook[hookName]) ? hook[hookName] : null)) + .filter(Boolean); }, buildUrl(path, query = {}) { const notEmptyQuery = {}; @@ -169,7 +179,7 @@ module.exports = { let destUrl = path; if (destUrl && notEmptyQueryStr) { - destUrl += destUrl.indexOf('?') !== -1 ? '&' : '?'; + destUrl += destUrl.includes('?') ? '&' : '?'; } if (notEmptyQueryStr) { destUrl += notEmptyQueryStr; diff --git a/waline/src/logic/base.js b/waline/src/logic/base.js index db460aa..69f13fb 100644 --- a/waline/src/logic/base.js +++ b/waline/src/logic/base.js @@ -2,9 +2,8 @@ const path = require('node:path'); const qs = require('node:querystring'); const jwt = require('jsonwebtoken'); -const helper = require('think-helper'); -module.exports = class extends think.Logic { +module.exports = class BaseLogic extends think.Logic { constructor(...args) { super(...args); this.modelInstance = this.getModel('Users'); @@ -12,53 +11,49 @@ module.exports = class extends think.Logic { this.id = this.getId(); } + // oxlint-disable-next-line max-statements async __before() { const referrer = this.ctx.referrer(true); - let origin = this.ctx.origin; + let { origin } = this.ctx; if (origin) { try { const parsedOrigin = new URL(origin); origin = parsedOrigin.hostname; - } catch (e) { - console.error('Invalid origin format:', origin, e); + } catch (err) { + console.error('Invalid origin format:', origin, err); } } let { secureDomains } = this.config(); if (secureDomains) { - secureDomains = think.isArray(secureDomains) - ? secureDomains - : [secureDomains]; + secureDomains = think.isArray(secureDomains) ? secureDomains : [secureDomains]; + secureDomains.push( 'localhost', '127.0.0.1', - 'github.com', - 'api.twitter.com', - 'www.facebook.com', - 'api.weibo.com', - 'graph.qq.com', + // 'github.com', + // 'api.twitter.com', + // 'www.facebook.com', + // 'api.weibo.com', + // 'graph.qq.com', ); + secureDomains = [ + ...secureDomains, + ...this.ctx.state.oauthServices.map(({ origin }) => origin), + ]; // 转换可能的正则表达式字符串为正则表达式对象 secureDomains = secureDomains .map((domain) => { // 如果是正则表达式字符串,创建一个 RegExp 对象 - if ( - typeof domain === 'string' && - domain.startsWith('/') && - domain.endsWith('/') - ) { + if (typeof domain === 'string' && domain.startsWith('/') && domain.endsWith('/')) { try { return new RegExp(domain.slice(1, -1)); // 去掉斜杠并创建 RegExp 对象 - } catch (e) { - console.error( - 'Invalid regex pattern in secureDomains:', - domain, - e, - ); + } catch (err) { + console.error('Invalid regex pattern in secureDomains:', domain, err); return null; } @@ -69,11 +64,9 @@ module.exports = class extends think.Logic { .filter(Boolean); // 过滤掉无效的正则表达式 // 有 referrer 检查 referrer,没有则检查 origin - const checking = referrer ? referrer : origin; + const checking = referrer || origin; const isSafe = secureDomains.some((domain) => - think.isFunction(domain.test) - ? domain.test(checking) - : domain === checking, + think.isFunction(domain.test) ? domain.test(checking) : domain === checking, ); if (!isSafe) { @@ -89,20 +82,20 @@ module.exports = class extends think.Logic { return; } const token = state || authorization.replace(/^Bearer /, ''); - let userMail = ''; + let userId = ''; try { - userMail = jwt.verify(token, think.config('jwtKey')); - } catch (e) { - think.logger.debug(e); + userId = jwt.verify(token, think.config('jwtKey')); + } catch (err) { + think.logger.debug(err); } - if (think.isEmpty(userMail) || !think.isString(userMail)) { + if (think.isEmpty(userId) || !think.isString(userId)) { return; } const user = await this.modelInstance.select( - { email: userMail }, + { objectId: userId }, { field: [ 'id', @@ -110,15 +103,10 @@ module.exports = class extends think.Logic { 'url', 'display_name', 'type', - 'github', - 'twitter', - 'facebook', - 'google', - 'weibo', - 'qq', 'avatar', '2fa', 'label', + ...this.ctx.state.oauthServices.map(({ name }) => name), ], }, ); @@ -127,22 +115,21 @@ module.exports = class extends think.Logic { return; } - const userInfo = user[0]; + const [userInfo] = user; - let avatarUrl = userInfo.avatar - ? userInfo.avatar - : await think.service('avatar').stringify({ - mail: userInfo.email, - nick: userInfo.display_name, - link: userInfo.url, - }); + let avatarUrl = + userInfo.avatar || + (await think.service('avatar').stringify({ + mail: userInfo.email, + nick: userInfo.display_name, + link: userInfo.url, + })); const { avatarProxy } = think.config(); if (avatarProxy) { - avatarUrl = avatarProxy + '?url=' + encodeURIComponent(avatarUrl); + avatarUrl = `${avatarProxy}?url=${encodeURIComponent(avatarUrl)}`; } userInfo.avatar = avatarUrl; - userInfo.mailMd5 = helper.md5(userInfo.email); this.ctx.state.userInfo = userInfo; this.ctx.state.token = token; } @@ -151,7 +138,7 @@ module.exports = class extends think.Logic { const filename = this.__filename || __filename; const last = filename.lastIndexOf(path.sep); - return filename.substr(last + 1, filename.length - last - 4); + return filename.slice(last + 1, filename.length - last - 4); } getId() { @@ -208,28 +195,22 @@ module.exports = class extends think.Logic { remoteip: this.ctx.ip, }); - const requestUrl = method === 'GET' ? api + '?' + query : api; + const requestUrl = method === 'GET' ? `${api}?${query}` : api; const options = method === 'GET' ? {} : { method, headers: { - 'content-type': - 'application/x-www-form-urlencoded; charset=UTF-8', + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', }, body: query, }; - const response = await fetch(requestUrl, options).then((resp) => - resp.json(), - ); + const response = await fetch(requestUrl, options).then((resp) => resp.json()); if (!response.success) { - think.logger.debug( - 'RecaptchaV3 or Turnstile Result:', - JSON.stringify(response, null, '\t'), - ); + think.logger.debug('RecaptchaV3 or Turnstile Result:', JSON.stringify(response, null, '\t')); return this.ctx.throw(403); } diff --git a/waline/src/logic/comment.js b/waline/src/logic/comment.js index b3f9d4d..fe51f63 100644 --- a/waline/src/logic/comment.js +++ b/waline/src/logic/comment.js @@ -1,6 +1,6 @@ const Base = require('./base.js'); -module.exports = class extends Base { +module.exports = class CommentLogic extends Base { checkAdmin() { const { userInfo } = this.ctx.state; @@ -117,7 +117,7 @@ module.exports = class extends Base { } switch (type) { - case 'recent': + case 'recent': { this.rules = { count: { int: { max: 50 }, @@ -125,14 +125,16 @@ module.exports = class extends Base { }, }; break; + } - case 'count': + case 'count': { this.rules = { url: { array: true, }, }; break; + } case 'list': { const { userInfo } = this.ctx.state; @@ -153,7 +155,7 @@ module.exports = class extends Base { break; } - default: + default: { this.rules = { path: { string: true, @@ -173,6 +175,7 @@ module.exports = class extends Base { }, }; break; + } } } diff --git a/waline/src/logic/db.js b/waline/src/logic/db.js index 158ab24..22367fe 100644 --- a/waline/src/logic/db.js +++ b/waline/src/logic/db.js @@ -1,6 +1,6 @@ const Base = require('./base.js'); -module.exports = class extends Base { +module.exports = class DatabaseLogic extends Base { async __before(...args) { await super.__before(...args); diff --git a/waline/src/logic/token.js b/waline/src/logic/token.js index a7e4901..0e60e89 100644 --- a/waline/src/logic/token.js +++ b/waline/src/logic/token.js @@ -16,7 +16,6 @@ module.exports = class extends Base { * @apiSuccess (200) {String} data.display_name user nick name * @apiSuccess (200) {String} data.email user email address * @apiSuccess (200) {String} data.github user github account name - * @apiSuccess (200) {String} data.mailMd5 user mail md5 * @apiSuccess (200) {String} data.objectId user id * @apiSuccess (200) {String} data.type user type, administrator or guest * @apiSuccess (200) {String} data.url user link @@ -35,8 +34,8 @@ module.exports = class extends Base { * @apiSuccess (200) {Number} errno 0 * @apiSuccess (200) {String} errmsg return error message if error */ - postAction() { - return this.useCaptchaCheck(); + async postAction() { + await this.useCaptchaCheck(); } /** diff --git a/waline/src/logic/user.js b/waline/src/logic/user.js index 830facf..1792dbe 100644 --- a/waline/src/logic/user.js +++ b/waline/src/logic/user.js @@ -93,6 +93,7 @@ module.exports = class extends Base { * @apiVersion 0.0.1 * * @apiParam {String} [display_name] user new nick name + * @apiParam {String} [email] user email * @apiParam {String} [url] user new link * @apiParam {String} [password] user new password * @apiParam {String} [github] user github account name @@ -113,5 +114,11 @@ module.exports = class extends Base { if (this.id && userInfo.type !== 'administrator') { return this.fail(); } + + this.rules = { + email: { + email: true, + }, + }; } }; diff --git a/waline/src/middleware/dashboard.js b/waline/src/middleware/dashboard.js index 4f1d0b4..f154bb7 100644 --- a/waline/src/middleware/dashboard.js +++ b/waline/src/middleware/dashboard.js @@ -1,3 +1,4 @@ +// oxlint-disable-next-line func-names module.exports = function () { return (ctx) => { ctx.type = 'html'; @@ -15,6 +16,7 @@ module.exports = function () { window.SITE_NAME = ${JSON.stringify(process.env.SITE_NAME)}; window.recaptchaV3Key = ${JSON.stringify(process.env.RECAPTCHA_V3_KEY)}; window.turnstileKey = ${JSON.stringify(process.env.TURNSTILE_KEY)}; + window.oauthServices = ${JSON.stringify(ctx.state.oauthServices || [])}; window.serverURL = '${ctx.serverURL}/api/'; </script> <script src="${ diff --git a/waline/src/middleware/fetch-oauth-service.js b/waline/src/middleware/fetch-oauth-service.js new file mode 100644 index 0000000..399d1ab --- /dev/null +++ b/waline/src/middleware/fetch-oauth-service.js @@ -0,0 +1,16 @@ +module.exports = () => async (ctx, next) => { + const { oauthUrl } = think.config(); + const oauthResp = await fetch(oauthUrl, { + method: 'GET', + headers: { + 'user-agent': '@waline', + }, + }).then((resp) => resp.json()); + + if (!oauthResp || !Array.isArray(oauthResp.services)) { + ctx.throw(502); + } + ctx.state.oauthServices = oauthResp.services || []; + + await next(); +}; diff --git a/waline/src/middleware/plugin.js b/waline/src/middleware/plugin.js index e6cef1f..651c643 100644 --- a/waline/src/middleware/plugin.js +++ b/waline/src/middleware/plugin.js @@ -3,7 +3,7 @@ const compose = require('koa-compose'); module.exports = () => async (ctx, next) => { const middlewares = think.getPluginMiddlewares(); - if (!think.isArray(middlewares) || !middlewares.length) { + if (!think.isArray(middlewares) || middlewares.length === 0) { return next(); } diff --git a/waline/src/service/akismet.js b/waline/src/service/akismet.js index 1cc7389..e8c7087 100644 --- a/waline/src/service/akismet.js +++ b/waline/src/service/akismet.js @@ -2,6 +2,7 @@ const Akismet = require('akismet'); const DEFAULT_KEY = '70542d86693e'; +// oxlint-disable-next-line func-names module.exports = function (comment, blog) { let { AKISMET_KEY, SITE_URL } = process.env; @@ -13,14 +14,18 @@ module.exports = function (comment, blog) { return Promise.resolve(false); } + // oxlint-disable-next-line func-names return new Promise(function (resolve, reject) { const akismet = Akismet.client({ blog, apiKey: AKISMET_KEY }); + // oxlint-disable-next-line func-names akismet.verifyKey(function (err, verifyKey) { if (err) { - return reject(err); + reject(err); + return; } else if (!verifyKey) { - return reject(new Error('Akismet API_KEY verify failed!')); + reject(new Error('Akismet API_KEY verify failed!')); + return; } akismet.checkComment( @@ -30,9 +35,11 @@ module.exports = function (comment, blog) { comment_author: comment.nick, comment_content: comment.comment, }, + // oxlint-disable-next-line func-names function (err, spam) { if (err) { - return reject(err); + reject(err); + return; } resolve(spam); }, diff --git a/waline/src/service/avatar.js b/waline/src/service/avatar.js index be486b4..508e8bb 100644 --- a/waline/src/service/avatar.js +++ b/waline/src/service/avatar.js @@ -1,4 +1,4 @@ -const crypto = require('crypto'); +const crypto = require('node:crypto'); const nunjucks = require('nunjucks'); const helper = require('think-helper'); @@ -8,9 +8,7 @@ const { GRAVATAR_STR } = process.env; const env = new nunjucks.Environment(); env.addFilter('md5', (str) => helper.md5(str)); -env.addFilter('sha256', (str) => - crypto.createHash('sha256').update(str).digest('hex'), -); +env.addFilter('sha256', (str) => crypto.createHash('sha256').update(str).digest('hex')); const DEFAULT_GRAVATAR_STR = `{%- set numExp = r/^[0-9]+$/g -%} {%- set qqMailExp = r/^[0-9]+@qq.com$/ig -%} diff --git a/waline/src/service/markdown/highlight.js b/waline/src/service/markdown/highlight.js index 8a9f6a5..b8e6b09 100644 --- a/waline/src/service/markdown/highlight.js +++ b/waline/src/service/markdown/highlight.js @@ -7,7 +7,7 @@ rawLoadLanguages.silent = true; const loadLanguages = (languages = []) => { const langsToLoad = languages.filter((item) => !prism.languages[item]); - if (langsToLoad.length) { + if (langsToLoad.length > 0) { rawLoadLanguages(langsToLoad); } }; diff --git a/waline/src/service/markdown/katex.js b/waline/src/service/markdown/katex.js deleted file mode 100644 index d713288..0000000 --- a/waline/src/service/markdown/katex.js +++ /dev/null @@ -1,50 +0,0 @@ -const katex = require('katex'); - -const { inlineTeX, blockTeX } = require('./mathCommon.js'); -const { escapeHtml } = require('./utils.js'); - -// set KaTeX as the renderer for markdown-it-simplemath -const katexInline = (tex, options) => { - options.displayMode = false; - try { - return katex.renderToString(tex, options); - } catch (error) { - if (options.throwOnError) console.warn(error); - - return `<span class='katex-error' title='${escapeHtml( - error.toString(), - )}'>${escapeHtml(tex)}</span>`; - } -}; - -const katexBlock = (tex, options) => { - options.displayMode = true; - try { - return `<p class='katex-block'>${katex.renderToString(tex, options)}</p>`; - } catch (error) { - if (options.throwOnError) console.warn(error); - - return `<p class='katex-block katex-error' title='${escapeHtml( - error.toString(), - )}'>${escapeHtml(tex)}</p>`; - } -}; - -const katexPlugin = (md, options = { throwOnError: false }) => { - md.inline.ruler.after('escape', 'inlineTeX', inlineTeX); - - // It’s a workaround here because types issue - md.block.ruler.after('blockquote', 'blockTeX', blockTeX, { - alt: ['paragraph', 'reference', 'blockquote', 'list'], - }); - - md.renderer.rules.inlineTeX = (tokens, idx) => - katexInline(tokens[idx].content, options); - - md.renderer.rules.blockTeX = (tokens, idx) => - `${katexBlock(tokens[idx].content, options)}\n`; -}; - -module.exports = { - katexPlugin, -}; diff --git a/waline/src/service/markdown/mathCommon.js b/waline/src/service/markdown/mathCommon.js index 5e0d9ff..1171454 100644 --- a/waline/src/service/markdown/mathCommon.js +++ b/waline/src/service/markdown/mathCommon.js @@ -12,11 +12,7 @@ const isValidDelim = (state, pos) => { * Check non-whitespace conditions for opening and closing, and * check that closing delimiter isn’t followed by a number */ - canClose: !( - prevChar === ' ' || - prevChar === '\t' || - /[0-9]/u.exec(nextChar) - ), + canClose: !(prevChar === ' ' || prevChar === '\t' || /[0-9]/u.exec(nextChar)), }; }; @@ -141,9 +137,7 @@ const blockTeX = (state, start, end, silent) => { ? `${firstLine}\n` : '') + state.getLines(start + 1, next, state.tShift[start], true) + - ((lastLine === null || lastLine === void 0 ? void 0 : lastLine.trim()) - ? lastLine - : ''); + ((lastLine === null || lastLine === void 0 ? void 0 : lastLine.trim()) ? lastLine : ''); token.map = [start, state.line]; token.markup = '$$'; diff --git a/waline/src/service/markdown/mathjax.js b/waline/src/service/markdown/mathjax.js index 2400722..c5377d7 100644 --- a/waline/src/service/markdown/mathjax.js +++ b/waline/src/service/markdown/mathjax.js @@ -60,8 +60,7 @@ const mathjaxPlugin = (md) => { md.renderer.rules.inlineTeX = (tokens, idx) => inline(tokens[idx].content); - md.renderer.rules.blockTeX = (tokens, idx) => - `${block(tokens[idx].content)}\n`; + md.renderer.rules.blockTeX = (tokens, idx) => `${block(tokens[idx].content)}\n`; }; module.exports = { diff --git a/waline/src/service/markdown/utils.js b/waline/src/service/markdown/utils.js index 1f61fd8..242a991 100644 --- a/waline/src/service/markdown/utils.js +++ b/waline/src/service/markdown/utils.js @@ -1,10 +1,10 @@ const escapeHtml = (unsafeHTML) => unsafeHTML - .replace(/&/gu, '&') - .replace(/</gu, '<') - .replace(/>/gu, '>') - .replace(/"/gu, '"') - .replace(/'/gu, '''); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); module.exports = { escapeHtml, diff --git a/waline/src/service/markdown/xss.js b/waline/src/service/markdown/xss.js index 3bbd888..da92ad4 100644 --- a/waline/src/service/markdown/xss.js +++ b/waline/src/service/markdown/xss.js @@ -28,16 +28,11 @@ DOMPurify.addHook('afterSanitizeAttributes', (node) => { }); const sanitize = (content) => - DOMPurify.sanitize( - content, - Object.assign( - { - FORBID_TAGS: ['form', 'input', 'style'], - FORBID_ATTR: ['autoplay', 'style'], - }, - think.config('domPurify') || {}, - ), - ); + DOMPurify.sanitize(content, { + FORBID_TAGS: ['form', 'input', 'style'], + FORBID_ATTR: ['autoplay', 'style'], + ...think.config('domPurify'), + }); module.exports = { sanitize, diff --git a/waline/src/service/notify.js b/waline/src/service/notify.js index 6aabfbc..c7fd981 100644 --- a/waline/src/service/notify.js +++ b/waline/src/service/notify.js @@ -1,23 +1,15 @@ const crypto = require('node:crypto'); const FormData = require('form-data'); -const fetch = require('node-fetch'); const nodemailer = require('nodemailer'); const nunjucks = require('nunjucks'); -module.exports = class extends think.Service { - constructor(ctx) { - super(ctx); +module.exports = class NotifyService extends think.Service { + constructor(controller) { + super(controller); - this.ctx = ctx; - const { - SMTP_USER, - SMTP_PASS, - SMTP_HOST, - SMTP_PORT, - SMTP_SECURE, - SMTP_SERVICE, - } = process.env; + this.controller = controller; + const { SMTP_USER, SMTP_PASS, SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_SERVICE } = process.env; if (SMTP_HOST || SMTP_SERVICE) { const config = { @@ -28,7 +20,7 @@ module.exports = class extends think.Service { config.service = SMTP_SERVICE; } else { config.host = SMTP_HOST; - config.port = parseInt(SMTP_PORT); + config.port = Number.parseInt(SMTP_PORT, 10); config.secure = SMTP_SECURE && SMTP_SECURE !== 'false'; } this.transporter = nodemailer.createTransport(config); @@ -36,7 +28,9 @@ module.exports = class extends think.Service { } async sleep(second) { - return new Promise((resolve) => setTimeout(resolve, second * 1000)); + return new Promise((resolve) => { + setTimeout(resolve, second * 1000); + }); } async mail({ to, title, content }, self, parent) { @@ -44,26 +38,22 @@ module.exports = class extends think.Service { return; } - const { SITE_NAME, SITE_URL, SMTP_USER, SENDER_EMAIL, SENDER_NAME } = - process.env; + const { SITE_NAME, SITE_URL, SMTP_USER, SENDER_EMAIL, SENDER_NAME } = process.env; const data = { self, parent, site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; - title = this.ctx.locale(title, data); - content = this.ctx.locale(content, data); + title = this.controller.locale(title, data); + content = this.controller.locale(content, data); return this.transporter.sendMail({ - from: - SENDER_EMAIL && SENDER_NAME - ? `"${SENDER_NAME}" <${SENDER_EMAIL}>` - : SMTP_USER, + from: SENDER_EMAIL && SENDER_NAME ? `"${SENDER_NAME}" <${SENDER_EMAIL}>` : SMTP_USER, to, subject: title, html: content, @@ -83,7 +73,7 @@ module.exports = class extends think.Service { site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; @@ -95,8 +85,8 @@ module.exports = class extends think.Service { 【内容】:{{self.comment}} 【地址】:{{site.postUrl}}`; - title = this.ctx.locale(title, data); - content = this.ctx.locale(contentWechat, data); + title = this.controller.locale(title, data); + content = this.controller.locale(contentWechat, data); const form = new FormData(); @@ -111,8 +101,7 @@ module.exports = class extends think.Service { } async qywxAmWechat({ title, content }, self, parent) { - const { QYWX_AM, QYWX_PROXY, QYWX_PROXY_PORT, SITE_NAME, SITE_URL } = - process.env; + const { QYWX_AM, QYWX_PROXY, QYWX_PROXY_PORT, SITE_NAME, SITE_URL } = process.env; if (!QYWX_AM) { return false; @@ -120,8 +109,8 @@ module.exports = class extends think.Service { const QYWX_AM_AY = QYWX_AM.split(','); const comment = self.comment - .replace(/<a href="(.*?)">(.*?)<\/a>/g, '\n[$2] $1\n') - .replace(/<[^>]+>/g, ''); + .replaceAll(/<a href="(.*?)">(.*?)<\/a>/g, '\n[$2] $1\n') + .replaceAll(/<[^>]+>/g, ''); const postName = self.url; const data = { @@ -134,7 +123,7 @@ module.exports = class extends think.Service { site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; @@ -146,10 +135,10 @@ module.exports = class extends think.Service { 【内容】:{{self.comment}} <a href='{{site.postUrl}}'>查看详情</a>`; - title = this.ctx.locale(title, data); - const desp = this.ctx.locale(contentWechat, data); + title = this.controller.locale(title, data); + const desp = this.controller.locale(contentWechat, data); - content = desp.replace(/\n/g, '<br/>'); + content = desp.replaceAll('\n', '<br/>'); const querystring = new URLSearchParams(); @@ -159,48 +148,38 @@ module.exports = class extends think.Service { let baseUrl = 'https://qyapi.weixin.qq.com'; if (QYWX_PROXY) { - if (!QYWX_PROXY_PORT) { - baseUrl = `http://${QYWX_PROXY}`; - } else { - baseUrl = `http://${QYWX_PROXY}:${QYWX_PROXY_PORT}`; - } + baseUrl = `http://${QYWX_PROXY}${QYWX_PROXY_PORT ? `:${QYWX_PROXY_PORT}` : ''}`; } - const { access_token } = await fetch( - `${baseUrl}/cgi-bin/gettoken?${querystring.toString()}`, - { - headers: { - 'content-type': 'application/json', - }, + const { access_token } = await fetch(`${baseUrl}/cgi-bin/gettoken?${querystring.toString()}`, { + headers: { + 'content-type': 'application/json', }, - ).then((resp) => resp.json()); - - return fetch( - `${baseUrl}/cgi-bin/message/send?access_token=${access_token}`, - { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - touser: `${QYWX_AM_AY[2]}`, - agentid: `${QYWX_AM_AY[3]}`, - msgtype: 'mpnews', - mpnews: { - articles: [ - { - title, - thumb_media_id: `${QYWX_AM_AY[4]}`, - author: `Waline Comment`, - content_source_url: `${data.site.postUrl}`, - content: `${content}`, - digest: `${desp}`, - }, - ], - }, - }), + }).then((resp) => resp.json()); + + return fetch(`${baseUrl}/cgi-bin/message/send?access_token=${access_token}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', }, - ).then((resp) => resp.json()); + body: JSON.stringify({ + touser: `${QYWX_AM_AY[2]}`, + agentid: `${QYWX_AM_AY[3]}`, + msgtype: 'mpnews', + mpnews: { + articles: [ + { + title, + thumb_media_id: `${QYWX_AM_AY[4]}`, + author: `Waline Comment`, + content_source_url: `${data.site.postUrl}`, + content: `${content}`, + digest: `${desp}`, + }, + ], + }, + }), + }).then((resp) => resp.json()); } async qq(self, parent) { @@ -211,8 +190,8 @@ module.exports = class extends think.Service { } const comment = self.comment - .replace(/<a href="(.*?)">(.*?)<\/a>/g, '') - .replace(/<[^>]+>/g, ''); + .replaceAll(/<a href="(.*?)">(.*?)<\/a>/g, '') + .replaceAll(/<[^>]+>/g, ''); const data = { self: { @@ -223,7 +202,7 @@ module.exports = class extends think.Service { site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; @@ -234,20 +213,29 @@ module.exports = class extends think.Service { {{self.comment}} 仅供预览评论,请前往上述页面查看完整內容。`; - const form = new FormData(); - - form.append('msg', this.ctx.locale(contentQQ, data)); - form.append('qq', QQ_ID); + const qmsgHost = QMSG_HOST ? QMSG_HOST.replace(/\/$/, '') : 'https://qmsg.zendee.cn'; - const qmsgHost = QMSG_HOST - ? QMSG_HOST.replace(/\/$/, '') - : 'https://qmsg.zendee.cn'; + const postBodyData = { + qq: QQ_ID, + msg: this.controller.locale(contentQQ, data), + }; + const postBody = Object.keys(postBodyData) + .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(postBodyData[key])) + .join('&'); return fetch(`${qmsgHost}/send/${QMSG_KEY}`, { method: 'POST', - header: form.getHeaders(), - body: form, - }).then((resp) => resp.json()); + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: postBody, + }).then((resp) => + resp.json().then((json) => { + think.logger.debug(`qq notify response: ${JSON.stringify(json)}`); + + return json; + }), + ); } async telegram(self, parent) { @@ -260,23 +248,19 @@ module.exports = class extends think.Service { let commentLink = ''; const href = self.comment.match(/<a href="(.*?)">(.*?)<\/a>/g); - if (href !== null) { + if (href != null) { for (let i = 0; i < href.length; i++) { href[i] = - '[Link: ' + - href[i].replace(/<a href="(.*?)">(.*?)<\/a>/g, '$2') + - '](' + - href[i].replace(/<a href="(.*?)">(.*?)<\/a>/g, '$1') + - ') '; - commentLink = commentLink + href[i]; + `[Link: ${href[i].replaceAll(/<a href="(.*?)">(.*?)<\/a>/g, '$2')}](${href[i].replaceAll(/<a href="(.*?)">(.*?)<\/a>/g, '$1')}) `; + commentLink += href[i]; } } if (commentLink !== '') { - commentLink = `\n` + commentLink + `\n`; + commentLink = `\n${commentLink}\n`; } const comment = self.comment - .replace(/<a href="(.*?)">(.*?)<\/a>/g, '[Link:$2]') - .replace(/<[^>]+>/g, ''); + .replaceAll(/<a href="(.*?)">(.*?)<\/a>/g, '[Link:$2]') + .replaceAll(/<[^>]+>/g, ''); const contentTG = think.config('TGTemplate') || @@ -289,7 +273,7 @@ module.exports = class extends think.Service { \`\`\` {{-self.commentLink}} *邮箱:*\`{{self.mail}}\` -*审核:*{{self.status}} +*审核:*{{self.status}} 仅供评论预览,点击[查看完整內容]({{site.postUrl}})`; @@ -303,27 +287,24 @@ module.exports = class extends think.Service { site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; - const form = new FormData(); - - form.append('text', this.ctx.locale(contentTG, data)); - form.append('chat_id', TG_CHAT_ID); - form.append('parse_mode', 'MarkdownV2'); - - const resp = await fetch( - `https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage`, - { - method: 'POST', - header: form.getHeaders(), - body: form, + const resp = await fetch(`https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - ).then((resp) => resp.json()); + body: JSON.stringify({ + chat_id: TG_CHAT_ID, + text: this.controller.locale(contentTG, data), + parse_mode: 'MarkdownV2', + }), + }).then((resp) => resp.json()); if (!resp.ok) { - console.log('Telegram Notification Failed:' + JSON.stringify(resp)); + console.log(`Telegram Notification Failed:${JSON.stringify(resp)}`); } } @@ -349,27 +330,29 @@ module.exports = class extends think.Service { site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; - title = this.ctx.locale(title, data); - content = this.ctx.locale(content, data); + title = this.controller.locale(title, data); + content = this.controller.locale(content, data); - const form = new FormData(); + const form = new URLSearchParams(); - if (topic) form.append('topic', topic); - if (template) form.append('template', template); - if (channel) form.append('channel', channel); - if (webhook) form.append('webhook', webhook); - if (callbackUrl) form.append('callbackUrl', callbackUrl); - if (title) form.append('title', title); - if (content) form.append('content', content); + if (topic) form.set('topic', topic); + if (template) form.set('template', template); + if (channel) form.set('channel', channel); + if (webhook) form.set('webhook', webhook); + if (callbackUrl) form.set('callbackUrl', callbackUrl); + if (title) form.set('title', title); + if (content) form.set('content', content); return fetch(`http://www.pushplus.plus/send/${PUSH_PLUS_KEY}`, { method: 'POST', - header: form.getHeaders(), - body: form, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), }).then((resp) => resp.json()); } @@ -386,12 +369,12 @@ module.exports = class extends think.Service { site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; - title = this.ctx.locale(title, data); - content = this.ctx.locale( + title = this.controller.locale(title, data); + content = this.controller.locale( think.config('DiscordTemplate') || `💬 {{site.name|safe}} 有新评论啦 【评论者昵称】:{{self.nick}} @@ -407,7 +390,7 @@ module.exports = class extends think.Service { return fetch(DISCORD_WEBHOOK, { method: 'POST', - header: form.getHeaders(), + headers: form.getHeaders(), body: form, }).then((resp) => resp.statusText); // Expected return value: No Content @@ -421,7 +404,7 @@ module.exports = class extends think.Service { return false; } - self.comment = self.comment.replace(/(<([^>]+)>)/gi, ''); + self.comment = self.comment.replaceAll(/(<([^>]+)>)/gi, ''); const data = { self, @@ -429,7 +412,7 @@ module.exports = class extends think.Service { site: { name: SITE_NAME, url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, + postUrl: `${SITE_URL}${self.url}#${self.objectId}`, }, }; @@ -441,7 +424,7 @@ module.exports = class extends think.Service { const post = { en_us: { - title: this.ctx.locale(title, data), + title: this.controller.locale(title, data), content: [ [ { @@ -462,13 +445,13 @@ module.exports = class extends think.Service { }; const sign = (timestamp, secret) => { - const signStr = timestamp + '\n' + secret; + const signStr = `${timestamp}\n${secret}`; return crypto.createHmac('sha256', signStr).update('').digest('base64'); }; if (LARK_SECRET) { - const timestamp = parseInt(+new Date() / 1000); + const timestamp = Number.parseInt(Date.now() / 1000, 10); signData = { timestamp: timestamp, sign: sign(timestamp, LARK_SECRET) }; } @@ -485,93 +468,15 @@ module.exports = class extends think.Service { }).then((resp) => resp.json()); if (resp.status !== 200) { - console.log('Lark Notification Failed:' + JSON.stringify(resp)); + console.log(`Lark Notification Failed:${JSON.stringify(resp)}`); } - console.log('FeiShu Notification Success:' + JSON.stringify(resp)); - } - - // 新增 AKAMS 通知方法 - async akams(self, parent) { - const { AKAMS_WEBHOOK, AKAMS_TEMPLATE, SITE_NAME, SITE_URL } = process.env; - - if (!AKAMS_WEBHOOK) { - return false; - } - - // 处理评论内容:解码 HTML 实体并移除 HTML 标签 - const decodeHTML = (str) => { - if (typeof str !== 'string') return str; - return str - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/(<([^>]+)>)/gi, ''); // 移除所有 HTML 标签 - }; - - // 准备模板数据 - const data = { - self: { - ...self, - comment: decodeHTML(self.comment), - }, - parent: parent ? { - ...parent, - comment: decodeHTML(parent.comment), - } : null, - site: { - name: SITE_NAME, - url: SITE_URL, - postUrl: SITE_URL + self.url + '#' + self.objectId, - }, - }; - - // 使用自定义模板或默认模板 - const template = AKAMS_TEMPLATE || - `【新评论通知】{{site.name}} -======================== -💬 评论者:{{self.nick}}{% if self.mail %} ({{self.mail}}){% endif %} -📍 归属地:{% if self.addr %}{{self.addr}}{% else %}未知{% endif %} -💻 设备:{{self.os}} / {{self.browser}} -📋 状态:{% if self.status == 'approved' %}审核通过{% elif self.status == 'waiting' %}等待审核{% elif self.status == 'spam' %}垃圾评论{% else %}{{self.status}}{% endif %} - -{% if self.status == 'approved' %}{{self.comment}}{% elif self.status == 'waiting' %}云审查疑似失效,评论等待人工审核,请前往站点审核{% elif self.status == 'spam' %}垃圾评论,请人工审核{% else %}未知评论状态:{{self.status}},请人工审核{% endif %} -{% if parent %} -======================== -此评论回复了:{{parent.nick}}{% if parent.mail %} ({{parent.mail}}){% endif %} -{% if parent.status == 'approved' %}{{parent.comment}}{% elif parent.status == 'waiting' %}云审查疑似失效,评论等待人工审核,请前往站点审核{% elif parent.status == 'spam' %}垃圾评论,请人工审核{% else %}未知评论状态:{{parent.status}},请人工审核{% endif %} -{% endif %}`; - - // 渲染模板 - const renderedBody = nunjucks.renderString(template, data); - - const body = { - Title: 'AKAMS Notify', - Body: renderedBody - }; - - try { - const resp = await fetch(AKAMS_WEBHOOK, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - return resp.ok; - } catch (e) { - console.error('AKAMS notification failed:', e); - return false; - } + console.log(`FeiShu Notification Success:${JSON.stringify(resp)}`); } async run(comment, parent, disableAuthorNotify = false) { const { AUTHOR_EMAIL, DISABLE_AUTHOR_NOTIFY } = process.env; - const { mailSubject, mailTemplate, mailSubjectAdmin, mailTemplateAdmin } = - think.config(); + const { mailSubject, mailTemplate, mailSubjectAdmin, mailTemplateAdmin } = think.config(); const AUTHOR = AUTHOR_EMAIL; const mailList = []; @@ -582,38 +487,30 @@ module.exports = class extends think.Service { ? parent && (parent.mail || '').toLowerCase() === AUTHOR.toLowerCase() : false; const isCommentSelf = - parent && - (parent.mail || '').toLowerCase() === (comment.mail || '').toLowerCase(); + parent && (parent.mail || '').toLowerCase() === (comment.mail || '').toLowerCase(); const title = mailSubjectAdmin || 'MAIL_SUBJECT_ADMIN'; const content = mailTemplateAdmin || 'MAIL_TEMPLATE_ADMIN'; if (!DISABLE_AUTHOR_NOTIFY && !isAuthorComment && !disableAuthorNotify) { const wechat = await this.wechat({ title, content }, comment, parent); - const qywxAmWechat = await this.qywxAmWechat( - { title, content }, - comment, - parent, - ); + const qywxAmWechat = await this.qywxAmWechat({ title, content }, comment, parent); const qq = await this.qq(comment, parent); const telegram = await this.telegram(comment, parent); const pushplus = await this.pushplus({ title, content }, comment, parent); const discord = await this.discord({ title, content }, comment, parent); const lark = await this.lark({ title, content }, comment, parent); - const akams = await this.akams(comment, parent); // 新增 AKAMS 通知 if ( - [wechat, qq, telegram, qywxAmWechat, pushplus, discord, lark, akams].every( - think.isEmpty + [wechat, qq, telegram, qywxAmWechat, pushplus, discord, lark].every((item) => + think.isEmpty(item), ) ) { mailList.push({ to: AUTHOR, title, content }); } } - const disallowList = ['github', 'twitter', 'facebook', 'qq', 'weibo'].map( - (social) => 'mail.' + social, - ); + const disallowList = this.controller.ctx.state.oauthServices.map(({ name }) => `mail.${name}`); const fakeMail = new RegExp(`@(${disallowList.join('|')})$`, 'i'); if ( @@ -635,9 +532,9 @@ module.exports = class extends think.Service { const response = await this.mail(mail, comment, parent); console.log('Notification mail send success: %s', response); - } catch (e) { - console.log('Mail send fail:', e); + } catch (err) { + console.log('Mail send fail:', err); } } } -}; \ No newline at end of file +}; diff --git a/waline/src/service/storage/base.js b/waline/src/service/storage/base.js index be2e379..3e412ce 100644 --- a/waline/src/service/storage/base.js +++ b/waline/src/service/storage/base.js @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable typescript/no-unused-vars */ module.exports = class extends think.Service { constructor(tableName) { @@ -14,12 +14,7 @@ module.exports = class extends think.Service { //to be implemented } - async add( - data, - { - access: { read = true, write = true } = { read: true, write: true }, - } = {}, - ) { + async add(data, { access: { read = true, write = true } = { read: true, write: true } } = {}) { //to be implemented } diff --git a/waline/src/service/storage/cloudbase.js b/waline/src/service/storage/cloudbase.js index a32a9eb..2a4e149 100644 --- a/waline/src/service/storage/cloudbase.js +++ b/waline/src/service/storage/cloudbase.js @@ -27,14 +27,14 @@ module.exports = class extends Base { collections[tableName] = true; return db.collection(tableName); - } catch (e) { - if (e.code === 'DATABASE_COLLECTION_NOT_EXIST') { + } catch (err) { + if (err.code === 'DATABASE_COLLECTION_NOT_EXIST') { await db.createCollection(tableName); collections[tableName] = true; return db.collection(tableName); } - throw e; + throw err; } } @@ -46,7 +46,7 @@ module.exports = class extends Base { const filter = {}; const parseKey = (k) => (k === 'objectId' ? '_id' : k); - for (let k in where) { + for (const k in where) { if (k === '_complex') { continue; } @@ -62,12 +62,14 @@ module.exports = class extends Base { const handler = where[k][0].toUpperCase(); switch (handler) { - case 'IN': + case 'IN': { filter[parseKey(k)] = _.in(where[k][1]); break; - case 'NOT IN': + } + case 'NOT IN': { filter[parseKey(k)] = _.nin(where[k][1]); break; + } case 'LIKE': { const first = where[k][1][0]; const last = where[k][1].slice(-1); @@ -152,7 +154,7 @@ module.exports = class extends Base { async select(where, options = {}) { let data = []; let ret = []; - let offset = options.offset || 0; + const offset = options.offset ?? 0; do { options.offset = offset + data.length; diff --git a/waline/src/service/storage/deta.js b/waline/src/service/storage/deta.js deleted file mode 100644 index 7fba373..0000000 --- a/waline/src/service/storage/deta.js +++ /dev/null @@ -1,310 +0,0 @@ -const { performance } = require('perf_hooks'); - -const { Deta } = require('deta'); - -const Base = require('./base.js'); - -module.exports = class extends Base { - constructor(tableName) { - super(tableName); - const deta = Deta(process.env.DETA_PROJECT_KEY); - - this.instance = deta.Base(tableName); - } - - complex(obj, keys) { - const result = new Array(keys.reduce((a, b) => a * obj[b].length, 1)); - - for (let i = 0; i < result.length; i++) { - result[i] = { ...obj }; - for (let n = 0; n < keys.length; n++) { - const divisor = keys - .slice(n + 1) - .reduce((a, b) => a * obj[b].length, 1); - const idx = Math.floor(i / divisor) % obj[keys[n]].length; - - result[i][keys[n]] = obj[keys[n]][idx]; - } - } - - return result; - } - - /** - * deta base doesn't support order data by field - * it will order by key default - * so we need create a lower key than before to keep latest data in front - * @returns string - */ - async uuid() { - const items = await this.select({}, { limit: 1 }); - let lastKey; - - if (items.length && !isNaN(parseInt(items[0].objectId))) { - lastKey = parseInt(items[0].objectId); - } else { - lastKey = Number.MAX_SAFE_INTEGER - performance.now(); - } - - return (lastKey - Math.round(Math.random() * 100)).toString(); - } - - parseWhere(where) { - if (think.isEmpty(where)) { - return; - } - - const parseKey = (k) => (k === 'objectId' ? 'key' : k); - const conditions = {}; - const _isArrayKeys = []; - - for (let k in where) { - if (think.isString(where[k])) { - conditions[parseKey(k)] = where[k]; - continue; - } - if (where[k] === undefined) { - conditions[parseKey(k)] = null; - } - - if (!think.isArray(where[k]) || !where[k][0]) { - continue; - } - const handler = where[k][0].toUpperCase(); - - switch (handler) { - case 'IN': - conditions[parseKey(k)] = where[k][1]; - if (think.isArray(where[k][1])) { - _isArrayKeys.push(parseKey(k)); - } - break; - case 'NOT IN': - /** - * deta base doesn't support not equal with multiple value query - * so we have to transfer it into equal with some value in most of scene - */ - if (Array.isArray(where[k][1]) && parseKey(k) === 'status') { - const STATUS = ['approved', 'waiting', 'spam']; - let val = STATUS.filter((s) => !where[k][1].includes(s)); - - if (val.length === 1) { - val = val[0]; - } - conditions[parseKey(k)] = val; - } - conditions[parseKey(k) + '?ne'] = where[k][1]; - break; - case 'LIKE': { - const first = where[k][1][0]; - const last = where[k][1].slice(-1); - - if (first === '%' && last === '%') { - conditions[parseKey(k) + '?contains'] = where[k][1].slice(1, -1); - } else if (first === '%') { - conditions[parseKey(k) + '?contains'] = where[k][1].slice(1); - } else if (last === '%') { - conditions[parseKey(k) + '?pfx'] = where[k][1].slice(0, -1); - } - break; - } - case '!=': - conditions[parseKey(k) + '?ne'] = where[k][1]; - break; - case '>': - conditions[parseKey(k) + '?gt'] = where[k][1]; - break; - } - } - - if (_isArrayKeys.length === 0) { - return conditions; - } - - return this.complex(conditions, _isArrayKeys); - } - - where(where) { - const filter = this.parseWhere(where); - - if (!where._complex) { - return filter; - } - - const filters = []; - - for (const k in where._complex) { - if (k === '_logic') { - continue; - } - filters.push({ - ...this.parseWhere({ [k]: where._complex[k] }), - ...filter, - }); - } - - // just support OR logic for deta - return filters; - } - - async select(where, { limit, offset, field } = {}) { - const conditions = this.where(where); - - if (think.isArray(conditions)) { - return Promise.all( - conditions.map((condition) => - this.select(condition, { limit, offset, field }), - ), - ).then((data) => data.flat()); - } - - let data = []; - - if ( - think.isObject(conditions) && - think.isString(conditions.key) && - conditions.key - ) { - /** - * deta base doesn't support fetch with key field query - * if you want query by key field - * you need use `get()` rather than `fetch()` method. - */ - const item = await this.instance.get(conditions.key); - - if (item) data.push(item); - } else if (offset) { - /** - * deta base need last data key when pagination - * so we need fetch data list again and again - * because only that we can get last data key - */ - while (data.length < limit + offset) { - const lastData = data[data.length - 1]; - const last = lastData ? lastData.key : undefined; - const { items } = await this.instance.fetch(conditions, { - limit, - last, - }); - - data = data.concat(items); - - if (items.length < limit) { - break; - } - } - - data = data.slice(offset, offset + limit); - } else { - const { items } = await this.instance.fetch(conditions, { - limit: limit, - }); - - data = items || []; - } - - data = data.map(({ key, ...cmt }) => ({ - ...cmt, - objectId: key, - })); - - if (Array.isArray(field)) { - const fieldMap = new Set(field); - - fieldMap.add('objectId'); - data.forEach((item) => { - for (const key in item) { - if (!fieldMap.has(key)) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete item[key]; - } - } - }); - } - - return data; - } - - async count(where = {}, { group } = {}) { - if (!group) { - const conditions = this.where(where); - - if (think.isArray(conditions)) { - return Promise.all( - conditions.map((condition) => this.count(condition)), - ).then((counts) => counts.reduce((a, b) => a + b, 0)); - } - - const { count } = await this.instance.fetch(conditions); - - return count; - } - - const counts = []; - - for (let i = 0; i < group.length; i++) { - const groupName = group[i]; - - if (!where._complex || !Array.isArray(where._complex[groupName])) { - continue; - } - - const groupFlatValue = {}; - - group.slice(0, i).forEach((group) => { - groupFlatValue[group] = null; - }); - - for (const item of where._complex[groupName][1]) { - const groupWhere = { - ...where, - ...groupFlatValue, - _complex: undefined, - [groupName]: item, - }; - const num = await this.count(groupWhere); - - counts.push({ - ...groupFlatValue, - [groupName]: item, - count: num, - }); - } - } - - return counts; - } - - async add(data) { - const uuid = await this.uuid(); - const resp = await this.instance.put(data, uuid); - - resp.objectId = resp.key; - delete resp.key; - - return resp; - } - - async update(data, where) { - const items = await this.select(where); - - return Promise.all( - items.map(async (item) => { - const updateData = typeof data === 'function' ? data(item) : data; - const nextData = { ...item, ...updateData }; - - await this.instance.put(nextData, item.objectId); - - return nextData; - }), - ); - } - - async delete(where) { - const items = await this.select(where); - - return Promise.all( - items.map(({ objectId }) => this.instance.delete(objectId)), - ); - } -}; diff --git a/waline/src/service/storage/github.js b/waline/src/service/storage/github.js index ac9d8f9..40374f2 100644 --- a/waline/src/service/storage/github.js +++ b/waline/src/service/storage/github.js @@ -38,6 +38,7 @@ const CSV_HEADERS = { 'google', 'weibo', 'qq', + 'oidc', 'createdAt', 'updatedAt', ], @@ -52,8 +53,7 @@ class Github { // content api can only get file < 1MB async get(filename) { const resp = await fetch( - 'https://api.github.com/repos/' + - path.join(this.repo, 'contents', filename), + 'https://api.github.com/repos/' + path.join(this.repo, 'contents', filename), { headers: { accept: 'application/vnd.github.v3+json', @@ -63,11 +63,11 @@ class Github { }, ) .then((resp) => resp.json()) - .catch((e) => { - const isTooLarge = e.message.includes('"too_large"'); + .catch((err) => { + const isTooLarge = err.message.includes('"too_large"'); if (!isTooLarge) { - throw e; + throw err; } return this.getLargeFile(filename); @@ -82,9 +82,7 @@ class Github { // blob api can get file larger than 1MB async getLargeFile(filename) { const { tree } = await fetch( - 'https://api.github.com/repos/' + - path.join(this.repo, 'git/trees/HEAD') + - '?recursive=1', + 'https://api.github.com/repos/' + path.join(this.repo, 'git/trees/HEAD') + '?recursive=1', { headers: { accept: 'application/vnd.github.v3+json', @@ -113,23 +111,19 @@ class Github { } async set(filename, content, { sha }) { - return fetch( - 'https://api.github.com/repos/' + - path.join(this.repo, 'contents', filename), - { - method: 'PUT', - headers: { - accept: 'application/vnd.github.v3+json', - authorization: 'token ' + this.token, - 'user-agent': 'Waline', - }, - body: JSON.stringify({ - sha, - message: 'feat(waline): update comment data', - content: Buffer.from(content, 'utf-8').toString('base64'), - }), + return fetch('https://api.github.com/repos/' + path.join(this.repo, 'contents', filename), { + method: 'PUT', + headers: { + accept: 'application/vnd.github.v3+json', + authorization: 'token ' + this.token, + 'user-agent': 'Waline', }, - ); + body: JSON.stringify({ + sha, + message: 'feat(waline): update comment data', + content: Buffer.from(content, 'utf-8').toString('base64'), + }), + }); } } @@ -146,11 +140,11 @@ module.exports = class extends Base { async collection(tableName) { const filename = path.join(this.basePath, tableName + '.csv'); - const file = await this.git.get(filename).catch((e) => { - if (e.statusCode === 404) { + const file = await this.git.get(filename).catch((err) => { + if (err.statusCode === 404) { return ''; } - throw e; + throw err; }); return new Promise((resolve, reject) => { @@ -163,7 +157,9 @@ module.exports = class extends Base { }) .on('error', reject) .on('data', (row) => data.push(row)) - .on('end', () => resolve(data)); + .on('end', () => { + resolve(data); + }); }); } @@ -186,7 +182,7 @@ module.exports = class extends Base { const filters = []; - for (let k in where) { + for (const k in where) { if (k === '_complex') { continue; } @@ -209,12 +205,14 @@ module.exports = class extends Base { const handler = where[k][0].toUpperCase(); switch (handler) { - case 'IN': + case 'IN': { filters.push((item) => where[k][1].includes(item[k])); break; - case 'NOT IN': + } + case 'NOT IN': { filters.push((item) => !where[k][1].includes(item[k])); break; + } case 'LIKE': { const first = where[k][1][0]; const last = where[k][1].slice(-1); @@ -230,12 +228,14 @@ module.exports = class extends Base { filters.push((item) => reg.test(item[k])); break; } - case '!=': + case '!=': { filters.push((item) => item[k] !== where[k][1]); break; - case '>': + } + case '>': { filters.push((item) => item[k] >= where[k][1]); break; + } } } @@ -265,9 +265,7 @@ module.exports = class extends Base { const logicFn = logicMap[where._complex._logic]; - return data.filter((item) => - logicFn.call(filters, (filter) => filter.every((fn) => fn(item))), - ); + return data.filter((item) => logicFn.call(filters, (filter) => filter.every((fn) => fn(item)))); } async select(where, { desc, limit, offset, field } = {}) { @@ -287,7 +285,7 @@ module.exports = class extends Base { }); } - data = data.slice(limit || 0, offset || data.length); + data = data.slice(limit ?? 0, offset ?? data.length); if (field) { field.push('id'); const fieldObj = {}; @@ -320,9 +318,9 @@ module.exports = class extends Base { const counts = {}; // FIXME: The loop is weird @lizheming - // eslint-disable-next-line @typescript-eslint/prefer-for-of + // oxlint-disable-next-line typescript/prefer-for-of for (let i = 0; i < data.length; i++) { - const key = group.map((field) => data[field]).join(); + const key = group.map((field) => data[field]).join(','); if (!counts[key]) { counts[key] = { count: 0 }; @@ -341,7 +339,7 @@ module.exports = class extends Base { // { access: { read = true, write = true } = { read: true, write: true } } = {} ) { const instance = await this.collection(this.tableName); - const id = Math.random().toString(36).substr(2, 15); + const id = Math.random().toString(36).slice(2, 15); instance.push({ ...data, id }); await this.save(this.tableName, instance, instance.sha); @@ -372,8 +370,8 @@ module.exports = class extends Base { async delete(where) { const instance = await this.collection(this.tableName); const deleteData = this.where(instance, where); - const deleteId = deleteData.map(({ id }) => id); - const data = instance.filter((data) => !deleteId.includes(data.id)); + const deleteId = new Set(deleteData.map(({ id }) => id)); + const data = instance.filter((data) => !deleteId.has(data.id)); await this.save(this.tableName, data, instance.sha); } diff --git a/waline/src/service/storage/leancloud.js b/waline/src/service/storage/leancloud.js index bb52b92..57c27bb 100644 --- a/waline/src/service/storage/leancloud.js +++ b/waline/src/service/storage/leancloud.js @@ -41,12 +41,14 @@ module.exports = class extends Base { const handler = where[k][0].toUpperCase(); switch (handler) { - case 'IN': + case 'IN': { instance.containedIn(k, where[k][1]); break; - case 'NOT IN': + } + case 'NOT IN': { instance.notContainedIn(k, where[k][1]); break; + } case 'LIKE': { const first = where[k][1][0]; const last = where[k][1].slice(-1); @@ -60,12 +62,14 @@ module.exports = class extends Base { } break; } - case '!=': + case '!=': { instance.notEqualTo(k, where[k][1]); break; - case '>': + } + case '>': { instance.greaterThan(k, where[k][1]); break; + } } } } @@ -113,11 +117,11 @@ module.exports = class extends Base { instance.select(field); } - const data = await instance.find().catch((e) => { - if (e.code === 101) { + const data = await instance.find().catch((err) => { + if (err.code === 101) { return []; } - throw e; + throw err; }); return data.map((item) => item.toJSON()); @@ -126,7 +130,7 @@ module.exports = class extends Base { async select(where, options = {}) { let data = []; let ret = []; - let offset = options.offset || 0; + const offset = options.offset ?? 0; do { options.offset = offset + data.length; @@ -185,10 +189,7 @@ module.exports = class extends Base { } async _updateCmtGroupByMailUserIdCache(data, method) { - if ( - this.tableName !== 'Comment' || - !think.isArray(think.config('levels')) - ) { + if (this.tableName !== 'Comment' || !think.isArray(think.config('levels'))) { return; } @@ -200,9 +201,7 @@ module.exports = class extends Base { const cacheData = await this.select({ _complex: { _logic: 'or', - user_id: think.isObject(data.user_id) - ? data.user_id.toString() - : data.user_id, + user_id: think.isObject(data.user_id) ? data.user_id.toString() : data.user_id, mail: data.mail, }, }); @@ -211,37 +210,38 @@ module.exports = class extends Base { return; } - let count = cacheData[0].count; + let { count } = cacheData[0]; switch (method) { - case 'add': + case 'add': { if (data.status === 'approved') { count += 1; } break; - case 'udpate_status': + } + case 'udpate_status': { if (data.status === 'approved') { count += 1; } else { count -= 1; } break; - case 'delete': + } + case 'delete': { count -= 1; break; + } } const currentTableName = this.tableName; this.tableName = cacheTableName; - await this.update({ count }, { objectId: cacheData[0].objectId }).catch( - (e) => { - if (e.code === 101) { - return; - } - throw e; - }, - ); + await this.update({ count }, { objectId: cacheData[0].objectId }).catch((err) => { + if (err.code === 101) { + return; + } + throw err; + }); this.tableName = currentTableName; } @@ -249,22 +249,19 @@ module.exports = class extends Base { const instance = this.where(this.tableName, where); if (!options.group) { - return instance.count(options).catch((e) => { - if (e.code === 101) { + return instance.count(options).catch((err) => { + if (err.code === 101) { return 0; } - throw e; + throw err; }); } // get group count cache by group field where data - const cacheData = await this._getCmtGroupByMailUserIdCache( - options.group.join('_'), - where, - ); + const cacheData = await this._getCmtGroupByMailUserIdCache(options.group.join('_'), where); if (!where._complex) { - if (cacheData.length) { + if (cacheData.length > 0) { return cacheData; } @@ -272,9 +269,7 @@ module.exports = class extends Base { const countsMap = {}; for (const count of counts) { - const key = options.group - .map((item) => count[item] || undefined) - .join('_'); + const key = options.group.map((item) => count[item] ?? undefined).join('_'); if (!countsMap[key]) { countsMap[key] = {}; @@ -298,9 +293,7 @@ module.exports = class extends Base { const cacheDataMap = {}; for (const item of cacheData) { - const key = options.group - .map((item) => item[item] || undefined) - .join('_'); + const key = options.group.map((item) => item[item] ?? undefined).join('_'); cacheDataMap[key] = item; } @@ -328,7 +321,7 @@ module.exports = class extends Base { ({ ...groupFlatValue, [groupName]: item, - })[item] || undefined, + })[item] ?? undefined, ) .join('_'); @@ -364,19 +357,14 @@ module.exports = class extends Base { return [...cacheData, ...counts]; } - async add( - data, - { - access: { read = true, write = true } = { read: true, write: true }, - } = {}, - ) { + async add(data, { access: { read = true, write = true } = { read: true, write: true } } = {}) { const Table = AV.Object.extend(this.tableName); const instance = new Table(); - const REVERSED_KEYS = ['objectId', 'createdAt', 'updatedAt']; + const REVERSED_KEYS = new Set(['objectId', 'createdAt', 'updatedAt']); for (const k in data) { - if (REVERSED_KEYS.includes(k)) { + if (REVERSED_KEYS.has(k)) { continue; } instance.set(k, data[k]); @@ -403,13 +391,12 @@ module.exports = class extends Base { ret.map(async (item) => { const _oldStatus = item.get('status'); - const updateData = - typeof data === 'function' ? data(item.toJSON()) : data; + const updateData = typeof data === 'function' ? data(item.toJSON()) : data; - const REVERSED_KEYS = ['createdAt', 'updatedAt']; + const REVERSED_KEYS = new Set(['createdAt', 'updatedAt']); for (const k in updateData) { - if (REVERSED_KEYS.includes(k)) { + if (REVERSED_KEYS.has(k)) { continue; } item.set(k, updateData[k]); diff --git a/waline/src/service/storage/mongodb.js b/waline/src/service/storage/mongodb.js index 975203d..b71c373 100644 --- a/waline/src/service/storage/mongodb.js +++ b/waline/src/service/storage/mongodb.js @@ -11,7 +11,7 @@ module.exports = class extends Base { const filter = {}; const parseKey = (k) => (k === 'objectId' ? '_id' : k); - for (let k in where) { + for (const k in where) { if (k === '_complex') { continue; } @@ -29,7 +29,7 @@ module.exports = class extends Base { const handler = where[k][0].toUpperCase(); switch (handler) { - case 'IN': + case 'IN': { if (k === 'objectId') { filter[parseKey(k)] = { $in: where[k][1].map(ObjectId) }; } else { @@ -38,12 +38,13 @@ module.exports = class extends Base { }; } break; - case 'NOT IN': + } + case 'NOT IN': { filter[parseKey(k)] = { - $nin: - k === 'objectId' ? where[k][1].map(ObjectId) : where[k][1], + $nin: k === 'objectId' ? where[k][1].map(ObjectId) : where[k][1], }; break; + } case 'LIKE': { const first = where[k][1][0]; const last = where[k][1].slice(-1); @@ -62,12 +63,14 @@ module.exports = class extends Base { } break; } - case '!=': + case '!=': { filter[parseKey(k)] = { $ne: where[k][1] }; break; - case '>': + } + case '>': { filter[parseKey(k)] = { $gt: where[k][1] }; break; + } } } } @@ -109,7 +112,7 @@ module.exports = class extends Base { instance.order(`${desc} DESC`); } if (limit || offset) { - instance.limit(offset || 0, limit); + instance.limit(offset ?? 0, limit); } if (field) { instance.field(field); diff --git a/waline/src/service/storage/mysql.js b/waline/src/service/storage/mysql.js index cb8fa8f..d8c3039 100644 --- a/waline/src/service/storage/mysql.js +++ b/waline/src/service/storage/mysql.js @@ -25,7 +25,7 @@ module.exports = class extends Base { } if (Array.isArray(filter[k])) { - if (filter[k][0] === 'IN' && !filter[k][1].length) { + if (filter[k][0] === 'IN' && filter[k][1].length === 0) { continue; } if (think.isDate(filter[k][1])) { @@ -47,7 +47,7 @@ module.exports = class extends Base { instance.order({ [desc]: 'DESC' }); } if (limit || offset) { - instance.limit(offset || 0, limit); + instance.limit(offset ?? 0, limit); } if (field) { field.push('id'); @@ -80,8 +80,8 @@ module.exports = class extends Base { } const date = new Date(); - if (!data.createdAt) data.createdAt = date; - if (!data.updatedAt) data.updatedAt = date; + data.createdAt ??= date; + data.updatedAt ??= date; const instance = this.model(this.tableName); const id = await instance.add(data); @@ -90,17 +90,13 @@ module.exports = class extends Base { } async update(data, where) { - const list = await this.model(this.tableName) - .where(this.parseWhere(where)) - .select(); + const list = await this.model(this.tableName).where(this.parseWhere(where)).select(); return Promise.all( list.map(async (item) => { const updateData = typeof data === 'function' ? data(item) : data; - await this.model(this.tableName) - .where({ id: item.id }) - .update(updateData); + await this.model(this.tableName).where({ id: item.id }).update(updateData); return { ...item, ...updateData }; }), @@ -116,8 +112,6 @@ module.exports = class extends Base { async setSeqId(id) { const instance = this.model(this.tableName); - return instance.query( - `ALTER TABLE ${instance.tableName} AUTO_INCREMENT = ${id};`, - ); + return instance.query(`ALTER TABLE ${instance.tableName} AUTO_INCREMENT = ${id};`); } }; diff --git a/waline/src/service/storage/postgresql.js b/waline/src/service/storage/postgresql.js index df5a6cc..754c7b0 100644 --- a/waline/src/service/storage/postgresql.js +++ b/waline/src/service/storage/postgresql.js @@ -48,10 +48,8 @@ module.exports = class extends MySQL { const val = data[key]; data[key.toLowerCase()] = - val instanceof Date - ? think.datetime(val, 'YYYY-MM-DD HH:mm:ss') - : val; - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + val instanceof Date ? think.datetime(val, 'YYYY-MM-DD HH:mm:ss') : val; + // oxlint-disable-next-line typescript/no-dynamic-delete delete data[key]; }); @@ -64,13 +62,13 @@ module.exports = class extends MySQL { try { if (Array.isArray(result)) { result.forEach((r) => { - r.count = parseInt(r.count); + r.count = Number.parseInt(r.count); }); } else { - result = parseInt(result); + result = Number.parseInt(result); } - } catch (e) { - console.log(e); + } catch (err) { + console.log(err); } return result; @@ -79,8 +77,6 @@ module.exports = class extends MySQL { async setSeqId(id) { const instance = this.model(this.tableName); - return instance.query( - `ALTER SEQUENCE ${instance.tableName}_seq RESTART WITH ${id};`, - ); + return instance.query(`ALTER SEQUENCE ${instance.tableName}_seq RESTART WITH ${id};`); } };