Skip to content
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
7 changes: 7 additions & 0 deletions .changeset/admin-moderation-greptile-941.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"nostream": patch
---

fix(admin): Greptile follow-ups for moderation limits and activity stats

Moderation limit PATCH and ban/unban actions could interleave read–update–save across workers and drop updates. Those paths now run under an async settings write lock that yields while waiting and reclaims stale locks without deleting a fresh holder’s file. Activity stats windows filter on `first_seen` (when the relay first saw the event), not `event_created_at`, with partial indexes on live rows for those queries. IPv6 addresses on the rate-limit bypass list are normalized the same way as connection IPs so bypass rules cannot be skipped by formatting, including when removing entries stored in another spelling. PATCH rejects kind ranges with `start` greater than `end`. PATCH and moderation actions return the normalized limits read back from settings after save.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Supports admin moderation activity stats (queryTopPublishingPubkeys /
* queryTopClientIps), which filter live events by relay receipt time:
*
* WHERE deleted_at IS NULL AND first_seen >= ?
*
* Partial indexes keep the working set small on relays where most rows are
* soft-deleted history. CREATE INDEX CONCURRENTLY cannot run inside a
* transaction.
*/

exports.config = { transaction: false }

exports.up = async function (knex) {
await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS events_live_first_seen_pubkey_idx
ON events (first_seen, event_pubkey)
WHERE deleted_at IS NULL
`)

await knex.raw(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS events_live_first_seen_remote_address_idx
ON events (first_seen, remote_address)
WHERE deleted_at IS NULL AND remote_address IS NOT NULL
`)
}

exports.down = async function (knex) {
await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_live_first_seen_remote_address_idx')
await knex.raw('DROP INDEX CONCURRENTLY IF EXISTS events_live_first_seen_pubkey_idx')
}
9 changes: 5 additions & 4 deletions src/controllers/admin/patch-moderation-limits-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,9 @@ export class PatchAdminModerationLimitsController implements IController {
return
}

const next = mergeAdminModerationLimitsPatch(readAdminModerationLimits(), validation.value)

try {
await saveModerationLimitsFromRequest(
next,
(current) => mergeAdminModerationLimitsPatch(current, validation.value),
request,
this.notificationOutboxRepository,
'moderation.limits.updated',
Expand All @@ -47,6 +45,9 @@ export class PatchAdminModerationLimitsController implements IController {
throw error
}

response.status(200).setHeader('content-type', 'application/json').send({ ok: true, limits: next })
response
.status(200)
.setHeader('content-type', 'application/json')
.send({ ok: true, limits: readAdminModerationLimits() })
}
}
24 changes: 16 additions & 8 deletions src/controllers/admin/post-moderation-actions-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@ export class PostAdminModerationActionsController implements IController {
}

const { action, pubkey, ip } = validation.value
let next

try {
next = applyAdminModerationAction(readAdminModerationLimits(), action, { pubkey, ip })
applyAdminModerationAction(readAdminModerationLimits(), action, { pubkey, ip })
} catch (error) {
response
.status(400)
Expand All @@ -35,11 +34,17 @@ export class PostAdminModerationActionsController implements IController {
}

try {
await saveModerationLimitsFromRequest(next, request, this.notificationOutboxRepository, 'moderation.action', {
action,
pubkey,
ip,
})
await saveModerationLimitsFromRequest(
(current) => applyAdminModerationAction(current, action, { pubkey, ip }),
request,
this.notificationOutboxRepository,
'moderation.action',
{
action,
pubkey,
ip,
},
)
} catch (error) {
if (error instanceof ValidationError) {
response
Expand All @@ -57,6 +62,9 @@ export class PostAdminModerationActionsController implements IController {
throw error
}

response.status(200).setHeader('content-type', 'application/json').send({ ok: true, limits: next })
response
.status(200)
.setHeader('content-type', 'application/json')
.send({ ok: true, limits: readAdminModerationLimits() })
}
}
8 changes: 7 additions & 1 deletion src/schemas/admin-moderation-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { z } from 'zod'

const kindRuleSchema = z.union([z.number().int().min(0).max(65535), z.tuple([z.number().int(), z.number().int()])])
const kindValueSchema = z.number().int().min(0).max(65535)
const kindRuleSchema = z.union([
kindValueSchema,
z.tuple([kindValueSchema, kindValueSchema]).refine(([start, end]) => start <= end, {
message: 'Kind range start must not exceed its end',
}),
])

export const adminModerationLimitsPatchSchema = z
.object({
Expand Down
10 changes: 7 additions & 3 deletions src/utils/admin-moderation-persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { INotificationOutboxRepository } from '../@types/repositories'
import { createLogger } from '../factories/logger-factory'
import { appendSettingsAuditLog } from './settings-config'
import { recordAdminAuditFromRequest } from './record-admin-audit'
import { AdminModerationLimits, persistAdminModerationLimits, ValidationError } from './admin-moderation'
import {
AdminModerationLimitsUpdater,
updateAdminModerationLimits,
ValidationError,
} from './admin-moderation'

const logger = createLogger('admin-moderation-persist')

Expand All @@ -19,14 +23,14 @@ const MODERATION_SETTINGS_PATHS = [
] as const

export const saveModerationLimitsFromRequest = async (
limits: AdminModerationLimits,
update: AdminModerationLimitsUpdater,
request: Request,
notificationOutboxRepository: INotificationOutboxRepository,
auditAction: 'moderation.limits.updated' | 'moderation.action',
auditDetail?: Record<string, unknown>,
): Promise<void> => {
try {
persistAdminModerationLimits(limits)
await updateAdminModerationLimits(update)
} catch (error) {
if (error instanceof ValidationError) {
throw error
Expand Down
4 changes: 2 additions & 2 deletions src/utils/admin-moderation-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const queryTopPublishingPubkeys = async (
.select(db.raw(`encode(event_pubkey, 'hex') as pubkey`))
.count('* as eventCount')
.whereNull('deleted_at')
.where('event_created_at', '>=', sinceUnixSeconds)
.where('first_seen', '>=', new Date(sinceUnixSeconds * 1000))
Comment thread
Ferryx349 marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
.groupBy('event_pubkey')
.orderBy('eventCount', 'desc')
.limit(limit)
Expand All @@ -73,7 +73,7 @@ export const queryTopClientIps = async (
.count('* as eventCount')
.whereNull('deleted_at')
.whereNotNull('remote_address')
.where('event_created_at', '>=', sinceUnixSeconds)
.where('first_seen', '>=', new Date(sinceUnixSeconds * 1000))
.groupBy('remote_address')
.orderBy('eventCount', 'desc')
.limit(limit)
Expand Down
58 changes: 49 additions & 9 deletions src/utils/admin-moderation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isIP } from 'node:net'
import { isIP, SocketAddress } from 'node:net'
import { mergeDeepRight } from 'ramda'

import { Settings } from '../@types/settings'
Expand All @@ -9,6 +9,7 @@ import {
loadUserSettings,
saveSettings,
validateSettings,
withSettingsWriteLock,
} from './settings-config'

export interface AdminModerationLimits {
Expand Down Expand Up @@ -53,13 +54,28 @@ const normalizePubkeyList = (values: string[], label: string): string[] => {
return normalized
}

export const normalizeModerationIpAddress = (raw: string): string => {
const ip = raw.trim()
if (!ip || isIP(ip) === 0) {
throw new Error('ip must be a valid IPv4 or IPv6 address')
}

const family = isIP(ip)
return new SocketAddress({
address: ip,
family: family === 6 ? 'ipv6' : 'ipv4',
}).address
}

const normalizeIpList = (values: string[], label: string): string[] => {
const normalized: string[] = []
const seen = new Set<string>()

for (const raw of values) {
const ip = raw.trim()
if (!ip || isIP(ip) === 0) {
let ip: string
try {
ip = normalizeModerationIpAddress(raw)
} catch {
throw new Error(`${label} must contain valid IPv4 or IPv6 addresses`)
}

Expand Down Expand Up @@ -119,7 +135,9 @@ const applyLimitsToUserSettings = (userSettings: Settings, limits: AdminModerati
}
}

export const persistAdminModerationLimits = (next: AdminModerationLimits): void => {
export type AdminModerationLimitsUpdater = (current: AdminModerationLimits) => AdminModerationLimits

const persistAdminModerationLimitsUnlocked = (next: AdminModerationLimits): void => {
const normalized: AdminModerationLimits = {
pubkey: {
blacklist: normalizePubkeyList(next.pubkey.blacklist, 'pubkey blacklist'),
Expand Down Expand Up @@ -147,6 +165,17 @@ export const persistAdminModerationLimits = (next: AdminModerationLimits): void
saveSettings(nextUserSettings)
}

export const updateAdminModerationLimits = async (updater: AdminModerationLimitsUpdater): Promise<void> => {
await withSettingsWriteLock(() => {
const current = readAdminModerationLimits()
persistAdminModerationLimitsUnlocked(updater(current))
})
}

export const persistAdminModerationLimits = async (next: AdminModerationLimits): Promise<void> => {
await updateAdminModerationLimits(() => next)
}

export class ValidationError extends Error {
public constructor(
message: string,
Expand Down Expand Up @@ -183,6 +212,15 @@ export const mergeAdminModerationLimitsPatch = (

const withoutValue = <T>(list: T[], value: T): T[] => list.filter((entry) => entry !== value)

const withoutNormalizedModerationIp = (list: string[], normalizedTarget: string): string[] =>
list.filter((stored) => {
try {
return normalizeModerationIpAddress(stored) !== normalizedTarget
} catch {
return stored.trim().toLowerCase() !== normalizedTarget.trim().toLowerCase()
}
})

export const applyAdminModerationAction = (
current: AdminModerationLimits,
action: AdminModerationAction,
Expand Down Expand Up @@ -248,10 +286,11 @@ export const applyAdminModerationAction = (
}
}
case 'whitelist-ip': {
const ip = fields.ip?.trim()
if (!ip || isIP(ip) === 0) {
const rawIp = fields.ip?.trim()
if (!rawIp) {
throw new Error('ip is required and must be a valid IPv4 or IPv6 address')
}
const ip = normalizeModerationIpAddress(rawIp)
return {
...current,
rateLimitBypass: {
Expand All @@ -261,15 +300,16 @@ export const applyAdminModerationAction = (
}
}
case 'unwhitelist-ip': {
const ip = fields.ip?.trim()
if (!ip || isIP(ip) === 0) {
const rawIp = fields.ip?.trim()
if (!rawIp) {
throw new Error('ip is required and must be a valid IPv4 or IPv6 address')
}
const ip = normalizeModerationIpAddress(rawIp)
Comment thread
Ferryx349 marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return {
...current,
rateLimitBypass: {
...current.rateLimitBypass,
ipAddresses: withoutValue(current.rateLimitBypass.ipAddresses, ip),
ipAddresses: withoutNormalizedModerationIp(current.rateLimitBypass.ipAddresses, ip),
},
}
}
Expand Down
Loading
Loading