Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support antispam #84

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@sendgrid/mail": "^7.4.2",
"@sentry/node": "^6.3.1",
"@sentry/tracing": "^6.3.1",
"akismet-api": "^5.1.0",
"autoprefixer": "^10.2.5",
"axios": "^0.21.1",
"class-validator": "^0.13.1",
Expand All @@ -53,6 +54,7 @@
"react-query": "^3.6.0",
"redaxios": "^0.4.1",
"reflect-metadata": "^0.1.13",
"request-ip": "^2.1.3",
"rollup-plugin-svelte": "^7.1.0",
"sqlite3": "^5.0.2",
"svelte": "^3.37.0",
Expand Down
36 changes: 24 additions & 12 deletions pages/api/open/comments.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import Cors from 'cors'
import { NextApiRequest, NextApiResponse } from 'next'
import requestIp from 'request-ip'
import { antiSpamServive } from '../../../service/antispam.service'
import {
CommentService,
CommentWrapper,
CommentWrapper
} from '../../../service/comment.service'
import { initMiddleware, prisma, resolvedConfig } from '../../../utils.server'
import Cors from 'cors'
import { ProjectService } from '../../../service/project.service'
import { statService } from '../../../service/stat.service'
import { initMiddleware } from '../../../utils.server'

const cors = initMiddleware(
// You can read more about the available options here: https://github.com/expressjs/cors#configuration-options
Expand Down Expand Up @@ -49,19 +51,29 @@ export default async function handler(
return
}

const opts = {
content: body.content,
email: body.email,
nickname: body.nickname,
pageTitle: body.pageTitle,
pageUrl: body.pageUrl,
}

const clientIp = requestIp.getClientIp(req)
const useragent = req.headers['user-agent']
const status = await antiSpamServive.checkSpam({
...opts,
ip: clientIp,
useragent,
})

const comment = await commentService.addComment(
body.appId,
body.pageId,
{
content: body.content,
email: body.email,
nickname: body.nickname,
pageTitle: body.pageTitle,
pageUrl: body.pageUrl,
},
opts,
body.parentId,
status,
)

// send confirm email
if (body.acceptNotify === true && body.email) {
try {
Expand All @@ -78,7 +90,7 @@ export default async function handler(
statService.capture('add_comment')

res.json({
data: comment,
data: 'ok',
})
} else if (req.method === 'GET') {
// get all comments
Expand Down
85 changes: 85 additions & 0 deletions service/antispam.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { AkismetClient } from 'akismet-api'
import { resolvedConfig } from '../utils.server'
import { CommentStatus } from './comment.service'

interface IAntiSpamService {
checkSpam(comment: spamComment): Promise<CommentStatus>
}

type spamComment = {
ip: string
useragent: string
content?: string
email?: string
name?: string
}

export enum AntiSpamMode {
Auto = 'auto',
HalfAuto = 'half-auto',
Manual = 'manual',
}

export class AntiSpamService implements IAntiSpamService {
private provider: IAntiSpamService = undefined
private mode: AntiSpamMode

constructor() {
if (resolvedConfig.akismet.key) {
const akismetService = new AkismetService(
resolvedConfig.akismet.key,
resolvedConfig.host,
)
akismetService.verifyKey().catch(err => {
throw err
})
this.provider = akismetService
this.mode = <AntiSpamMode>resolvedConfig.antispamMode
}
}

async checkSpam(comment: spamComment): Promise<CommentStatus> {
// by manual
if (this.mode === AntiSpamMode.Manual) {
return CommentStatus.Pending
}

let status: CommentStatus = await this.provider.checkSpam(comment)

// by auto
if (this.mode === AntiSpamMode.Auto) {
return status
}

// by half-auto
return status === CommentStatus.Spam ? CommentStatus.Pending : CommentStatus.Approved
}
}

class AkismetService implements IAntiSpamService {
private client: AkismetClient

constructor(key: String, url: String) {
this.client = new AkismetClient({ key, blog: url })
}

async verifyKey(): Promise<Boolean> {
try {
const isValid = await this.client.verifyKey()
return isValid
} catch (err) {
throw Error('Cound not reach Akismet: ' + err.message)
}
}
async checkSpam(comment: spamComment): Promise<CommentStatus> {
try {
const isSpam = await this.client.checkSpam(comment)
return isSpam ? CommentStatus.Spam : CommentStatus.Approved
} catch (err) {
console.error('Akismet check spam went wrong:', err.message)
return CommentStatus.Spam
}
}
}

export const antiSpamServive = new AntiSpamService()
25 changes: 20 additions & 5 deletions service/comment.service.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { Comment, Page, Prisma, User } from '@prisma/client'
import { RequestScopeService, UserSession } from '.'
import { prisma, resolvedConfig } from '../utils.server'
import { PageService } from './page.service'
import dayjs from 'dayjs'
import MarkdownIt from 'markdown-it'
import { RequestScopeService } from '.'
import { makeConfirmReplyNotificationTemplate } from '../templates/confirm_reply_notification'
import { prisma, resolvedConfig } from '../utils.server'
import { EmailService } from './email.service'
import { HookService } from './hook.service'
import { PageService } from './page.service'
import { statService } from './stat.service'
import { EmailService } from './email.service'
import { TokenService } from './token.service'
import { makeConfirmReplyNotificationTemplate } from '../templates/confirm_reply_notification'

export const markdown = MarkdownIt({
linkify: true,
})

markdown.disable(['image', 'link'])

export enum CommentStatus {
Approved = 'approved',
Pending = 'pending',
Spam = 'spam',
Deleted = 'deleted',
}

export type CommentWrapper = {
commentCount: number
pageSize: number
Expand Down Expand Up @@ -169,20 +176,28 @@ export class CommentService extends RequestScopeService {
pageTitle?: string
},
parentId?: string,
status?: CommentStatus,
) {
// touch page
const page = await this.pageService.upsertPage(pageSlug, projectId, {
pageTitle: body.pageTitle,
pageUrl: body.pageUrl,
})

let deletedAt: Date = null
if (status === CommentStatus.Deleted) {
deletedAt = new Date()
}

const created = await prisma.comment.create({
data: {
content: body.content,
by_email: body.email,
by_nickname: body.nickname,
pageId: page.id,
parentId,
deletedAt,
approved: status === CommentStatus.Approved,
},
})

Expand Down
9 changes: 6 additions & 3 deletions utils.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { getSession as nextAuthGetSession } from 'next-auth/client'
import * as Sentry from '@sentry/node'
import * as Tracing from '@sentry/tracing'


type EnvVariable = string | undefined
export const resolvedConfig = {
useLocalAuth: process.env.USERNAME && process.env.PASSWORD,
Expand Down Expand Up @@ -41,6 +40,10 @@ export const resolvedConfig = {
sentry: {
dsn: process.env.SENTRY_DSN as EnvVariable,
},
antispamMode: (process.env.ANTISPAM_MODE as EnvVariable) || 'auto',
akismet: {
key: process.env.AKISMET_KEY as EnvVariable,
},
}

export const singleton = async <T>(id: string, fn: () => Promise<T>) => {
Expand Down Expand Up @@ -82,7 +85,7 @@ export const sentry = singletonSync('sentry', () => {
export function initMiddleware(middleware) {
return (req, res) =>
new Promise((resolve, reject) => {
middleware(req, res, (result) => {
middleware(req, res, result => {
if (result instanceof Error) {
return reject(result)
}
Expand All @@ -91,6 +94,6 @@ export function initMiddleware(middleware) {
})
}

export const getSession = async (req) => {
export const getSession = async req => {
return (await nextAuthGetSession({ req })) as UserSession
}