feat: bans for shoutbox

This commit is contained in:
alina 🌸 2024-08-06 22:54:45 +03:00
parent 3f1049c6f5
commit b4fd6303f2
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
9 changed files with 189 additions and 3 deletions

View file

@ -0,0 +1,4 @@
CREATE TABLE `shouts_bans` (
`ip` text PRIMARY KEY NOT NULL,
`expires` integer NOT NULL
);

View file

@ -0,0 +1,94 @@
{
"version": "6",
"dialect": "sqlite",
"id": "98471e1c-ba87-4757-bd0d-89bc45907686",
"prevId": "4ec9eb07-fd65-4fd0-9e47-10bdc1522d0e",
"tables": {
"shouts": {
"name": "shouts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"serial": {
"name": "serial",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"from_ip": {
"name": "from_ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pending": {
"name": "pending",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"shouts_bans": {
"name": "shouts_bans",
"columns": {
"ip": {
"name": "ip",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1722558165317,
"tag": "0000_legal_gamma_corps",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1722973085867,
"tag": "0001_sharp_luckman",
"breakpoints": true
}
]
}

View file

@ -26,6 +26,7 @@
"dotenv": "^16.4.5",
"drizzle-kit": "^0.23.1",
"drizzle-orm": "^0.32.1",
"parse-duration": "^1.1.0",
"rate-limiter-flexible": "^5.0.3",
"solid-js": "^1.8.19",
"typescript": "^5.5.4",

View file

@ -50,6 +50,9 @@ importers:
drizzle-orm:
specifier: ^0.32.1
version: 0.32.1(@types/better-sqlite3@7.6.11)(better-sqlite3@11.1.2)
parse-duration:
specifier: ^1.1.0
version: 1.1.0
rate-limiter-flexible:
specifier: ^5.0.3
version: 5.0.3
@ -2930,6 +2933,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-duration@1.1.0:
resolution: {integrity: sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==}
parse-entities@2.0.0:
resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
@ -6903,6 +6909,8 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-duration@1.1.0: {}
parse-entities@2.0.0:
dependencies:
character-entities: 1.2.4

View file

@ -1,8 +1,9 @@
import { CallbackDataBuilder, Dispatcher, filters } from '@mtcute/dispatcher'
import { html } from '@mtcute/node'
import parseDuration from 'parse-duration'
import { env } from '../env'
import { approveShout, declineShout, deleteBySerial } from '../service/shoutbox'
import { approveShout, banShouts, declineShout, deleteBySerial, unbanShouts } from '../service/shoutbox'
export const ShoutboxAction = new CallbackDataBuilder('shoutbox', 'id', 'action')
@ -38,4 +39,26 @@ dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('sho
await ctx.answerText('deleted')
})
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_ban')), async (ctx) => {
const ip = ctx.command[1]
const duration = parseDuration(ctx.command[2])
if (!duration) {
await ctx.answerText('invalid duration')
return
}
const until = Date.now() + duration
banShouts(ip, until)
await ctx.answerText(`banned ${ip} until ${new Date(until).toISOString()}`)
})
dp.onNewMessage(filters.and(filters.chatId(env.TG_CHAT_ID), filters.command('shoutbox_unban')), async (ctx) => {
const ip = ctx.command[1]
unbanShouts(ip)
await ctx.answerText('done')
})
export { dp as shoutboxDp }

View file

@ -11,3 +11,8 @@ export const shouts = sqliteTable('shouts', {
text: text('text'),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
})
export const shoutsBans = sqliteTable('shouts_bans', {
ip: text('ip').primaryKey(),
expires: integer('expires').notNull(),
})

View file

@ -2,7 +2,7 @@ import { BotKeyboard, html } from '@mtcute/node'
import { and, desc, eq, gt, not, or, sql } from 'drizzle-orm'
import { ShoutboxAction } from '../bot/shoutbox.js'
import { shouts } from '../models/index.js'
import { shouts, shoutsBans } from '../models/index.js'
import { URL_REGEX } from '../utils/url.js'
import { db } from '../db'
import { env } from '../env'
@ -80,6 +80,37 @@ export function deleteBySerial(serial: number) {
.run({ serial })
}
export function banShouts(ip: string, expires: number) {
db.insert(shoutsBans)
.values({
ip,
expires,
})
.onConflictDoUpdate({
target: shoutsBans.ip,
set: { expires },
})
.execute()
}
export function unbanShouts(ip: string) {
db.delete(shoutsBans)
.where(eq(shoutsBans.ip, ip))
.execute()
}
export function isShoutboxBanned(ip: string): Date | null {
const ban = db.select()
.from(shoutsBans)
.where(eq(shoutsBans.ip, ip))
.get()
if (!ban) return null
const expires = ban.expires
if (Date.now() > expires) return null
return new Date(ban.expires)
}
function validateShout(text: string, isPublic: boolean) {
if (text.length < 3) {
return 'too short, come on'

View file

@ -3,7 +3,7 @@ import { z } from 'zod'
import { fromError } from 'zod-validation-error'
import { RateLimiterMemory } from 'rate-limiter-flexible'
import { createShout, fetchShouts } from '~/backend/service/shoutbox'
import { createShout, fetchShouts, isShoutboxBanned } from '~/backend/service/shoutbox'
import { getRequestIp } from '~/backend/utils/request'
import { verifyCsrfToken } from '~/backend/utils/csrf'
import { HttpResponse } from '~/backend/utils/response'
@ -43,6 +43,19 @@ export const POST: APIRoute = async (ctx) => {
}, { status: 400 })
}
if (isShoutboxBanned('GLOBAL')) {
return HttpResponse.json({
error: 'shoutbox is temporarily disabled',
}, { status: 400 })
}
const bannedUntil = isShoutboxBanned(ip)
if (bannedUntil) {
return HttpResponse.json({
error: `you were banned until ${bannedUntil}`,
}, { status: 400 })
}
const remainingLocal = await rateLimitPerIp.get(ip)
const remainingGlobal = await rateLimitGlobal.get('GLOBAL')
if (remainingLocal?.remainingPoints === 0) {