feat: bans for shoutbox
This commit is contained in:
parent
3f1049c6f5
commit
b4fd6303f2
9 changed files with 189 additions and 3 deletions
4
drizzle/0001_sharp_luckman.sql
Normal file
4
drizzle/0001_sharp_luckman.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
CREATE TABLE `shouts_bans` (
|
||||
`ip` text PRIMARY KEY NOT NULL,
|
||||
`expires` integer NOT NULL
|
||||
);
|
94
drizzle/meta/0001_snapshot.json
Normal file
94
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue