mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
Fix KoN rating spam not caught by sanity system (#2130)
* Fix Knights of New Order rating spam not caught by sanity system - Tighten voting rate limits (75→20/min, 4500→900/hr, 4510→1000 abuse) - Immediate smite for severe under-rating on sanity checks (2+ levels off) - Increase sanity check frequency (~2 per 20-image batch) with stratified selection biasing toward non-PG images - Update fervor formula to penalize low accuracy (accuracy-weighted) - Add periodic abuse detection job (6-hourly ClickHouse scan, Axiom logging) NOTE: The Leaderboard table query for knights-new-order also needs a manual update to match the new fervor formula. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Harden KoN anti-abuse: plug sanity check leaks, Redis-backed rate limits, Discord alerts - Remove isSanityCheck flag and nsfwLevel from sanity check images in queue response so clients cannot distinguish them from regular images - Detect sanity check images server-side in processImageRating and route transparently - Remove public addSanityCheckRating tRPC endpoint (now internal only) - Move rate limit config to Redis (NEW_ORDER.CONFIG key) to keep values out of public repo - Add daily rate limit window alongside minute/hour - Add mod endpoint (GET/PUT /api/mod/new-order/rate-limit-config) for managing limits - Stop resetting sanity failure counter on smite cleanse (only reset on career reset) - Add Discord webhook alerts for abuse detection job - Fix logToAxiom calls using nonexistent 'new-order' datastream Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
3b7029e131
commit
55954e1c56
@@ -45,6 +45,7 @@ REDDIT_CLIENT_SECRET=
|
||||
# Integrations
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_GUILD_ID=
|
||||
DISCORD_WEBHOOK_MOD_ALERTS=
|
||||
|
||||
# File uploading
|
||||
S3_UPLOAD_KEY=REFER_TO_README
|
||||
|
||||
@@ -335,35 +335,7 @@ export const useAddImageRating = (opts?: { filters?: GetImagesQueueSchema }) =>
|
||||
},
|
||||
});
|
||||
|
||||
const addSanityCheckRatingMutation = trpc.games.newOrder.addSanityCheckRating.useMutation({
|
||||
onMutate: async (payload) => {
|
||||
await queryUtils.games.newOrder.getImagesQueue.cancel();
|
||||
|
||||
queryUtils.games.newOrder.getImagesQueue.setData(opts?.filters, (old) => {
|
||||
if (!old) return old;
|
||||
return old.filter((image) => image.id !== payload.imageId);
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification({ title: 'Failed to send rating', error: new Error(error.message) });
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddRating = (input: Omit<AddImageRatingInput, 'playerId'>) => {
|
||||
// Check if this is a sanity check image
|
||||
const prevQueue = queryUtils.games.newOrder.getImagesQueue.getData(opts?.filters);
|
||||
const matchedImage = prevQueue?.find((image) => image.id === input.imageId);
|
||||
const isSanityCheck = matchedImage?.metadata?.isSanityCheck === true;
|
||||
|
||||
if (isSanityCheck) {
|
||||
// Use sanity check endpoint (only imageId and rating, no damnedReason)
|
||||
return addSanityCheckRatingMutation.mutateAsync({
|
||||
imageId: input.imageId,
|
||||
rating: input.rating,
|
||||
});
|
||||
}
|
||||
|
||||
// Use regular rating endpoint
|
||||
return addRatingMutation.mutateAsync(input);
|
||||
};
|
||||
|
||||
@@ -378,7 +350,7 @@ export const useAddImageRating = (opts?: { filters?: GetImagesQueueSchema }) =>
|
||||
|
||||
return {
|
||||
addRating: handleAddRating,
|
||||
isLoading: addRatingMutation.isLoading || addSanityCheckRatingMutation.isLoading,
|
||||
isLoading: addRatingMutation.isLoading,
|
||||
skipRating: handleSkipImage,
|
||||
};
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -52,6 +52,7 @@ export const serverSchema = z.object({
|
||||
DISCORD_CLIENT_SECRET: z.string(),
|
||||
DISCORD_BOT_TOKEN: z.string().optional(),
|
||||
DISCORD_GUILD_ID: z.string().optional(),
|
||||
DISCORD_WEBHOOK_MOD_ALERTS: z.string().optional(),
|
||||
GITHUB_CLIENT_ID: z.string(),
|
||||
GITHUB_CLIENT_SECRET: z.string(),
|
||||
GOOGLE_CLIENT_ID: z.string(),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import * as z from 'zod';
|
||||
import { REDIS_SYS_KEYS, sysRedis } from '~/server/redis/client';
|
||||
import { ModEndpoint } from '~/server/utils/endpoint-helpers';
|
||||
|
||||
const configSchema = z.object({
|
||||
perMinute: z.number().int().min(1).optional(),
|
||||
perHour: z.number().int().min(1).optional(),
|
||||
perDay: z.number().int().min(1).optional(),
|
||||
abuseThreshold: z.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
export default ModEndpoint(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const key = REDIS_SYS_KEYS.NEW_ORDER.CONFIG;
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const config = await sysRedis.packed.get(key);
|
||||
return res.status(200).json({ config: config ?? null });
|
||||
}
|
||||
|
||||
// PUT — update config
|
||||
const parsed = configSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Invalid config', details: parsed.error.format() });
|
||||
}
|
||||
|
||||
// Merge with existing config so partial updates work
|
||||
const existing = (await sysRedis.packed.get(key)) ?? {};
|
||||
const merged = { ...existing, ...parsed.data };
|
||||
|
||||
await sysRedis.packed.set(key, merged);
|
||||
|
||||
return res.status(200).json({ config: merged });
|
||||
},
|
||||
['GET', 'PUT']
|
||||
);
|
||||
@@ -753,12 +753,29 @@ export const getImageRatingsCounter = (imageId: number) => {
|
||||
return counter;
|
||||
};
|
||||
|
||||
// Rate limiting configuration for voting
|
||||
export const VOTING_RATE_LIMITS = {
|
||||
perMinute: 75, // Max votes per minute
|
||||
perHour: 4500, // Max votes per hour
|
||||
abuseThreshold: 4510, // Auto-reset career threshold per hour
|
||||
} as const;
|
||||
type VotingRateLimitConfig = {
|
||||
perMinute: number;
|
||||
perHour: number;
|
||||
perDay: number;
|
||||
abuseThreshold: number;
|
||||
};
|
||||
|
||||
const ALLOWED_RESPONSE = { allowed: true, remaining: 0, resetTime: 0, isAbuse: false } as const;
|
||||
const MINUTE_WINDOW = 60 * 1000;
|
||||
const HOUR_WINDOW = 60 * MINUTE_WINDOW;
|
||||
const DAY_WINDOW = 24 * HOUR_WINDOW;
|
||||
|
||||
/**
|
||||
* Get rate limit config from Redis. Returns null if unavailable — callers
|
||||
* should skip rate limiting entirely when config is not set.
|
||||
*/
|
||||
async function getVotingRateLimitConfig(): Promise<VotingRateLimitConfig | null> {
|
||||
try {
|
||||
return await sysRedis.packed.get<VotingRateLimitConfig>(REDIS_SYS_KEYS.NEW_ORDER.CONFIG);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple sliding window rate limiter for voting
|
||||
export async function checkVotingRateLimit(userId: number): Promise<{
|
||||
@@ -767,63 +784,60 @@ export async function checkVotingRateLimit(userId: number): Promise<{
|
||||
resetTime: number;
|
||||
isAbuse: boolean;
|
||||
}> {
|
||||
if (!redis) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: VOTING_RATE_LIMITS.perMinute,
|
||||
resetTime: Date.now() + 60000,
|
||||
isAbuse: false,
|
||||
};
|
||||
}
|
||||
if (!redis) return ALLOWED_RESPONSE;
|
||||
|
||||
const limits = await getVotingRateLimitConfig();
|
||||
if (!limits) return ALLOWED_RESPONSE;
|
||||
|
||||
const now = Date.now();
|
||||
const minuteKey = `${REDIS_KEYS.CACHES.NEW_ORDER.RATE_LIMIT.MINUTE}:${userId}` as const;
|
||||
const hourKey = `${REDIS_KEYS.CACHES.NEW_ORDER.RATE_LIMIT.HOUR}:${userId}` as const;
|
||||
const minuteWindow = 60 * 1000; // 1 minute
|
||||
const hourWindow = 60 * 60 * 1000; // 1 hour
|
||||
const dayKey = `${REDIS_KEYS.CACHES.NEW_ORDER.RATE_LIMIT.DAY}:${userId}` as const;
|
||||
|
||||
try {
|
||||
// Clean up old entries
|
||||
await redis.zRemRangeByScore(minuteKey, '-inf', now - minuteWindow);
|
||||
await redis.zRemRangeByScore(hourKey, '-inf', now - hourWindow);
|
||||
await Promise.all([
|
||||
redis.zRemRangeByScore(minuteKey, '-inf', now - MINUTE_WINDOW),
|
||||
redis.zRemRangeByScore(hourKey, '-inf', now - HOUR_WINDOW),
|
||||
redis.zRemRangeByScore(dayKey, '-inf', now - DAY_WINDOW),
|
||||
]);
|
||||
|
||||
// Count current requests
|
||||
const [minuteCount, hourCount] = await Promise.all([
|
||||
const [minuteCount, hourCount, dayCount] = await Promise.all([
|
||||
redis.zCard(minuteKey),
|
||||
redis.zCard(hourKey),
|
||||
redis.zCard(dayKey),
|
||||
]);
|
||||
|
||||
// Check limits
|
||||
const minuteAllowed = minuteCount < VOTING_RATE_LIMITS.perMinute;
|
||||
const hourAllowed = hourCount < VOTING_RATE_LIMITS.perHour;
|
||||
const isAbuse = hourCount >= VOTING_RATE_LIMITS.abuseThreshold;
|
||||
const allowed = minuteAllowed && hourAllowed && !isAbuse;
|
||||
const minuteAllowed = minuteCount < limits.perMinute;
|
||||
const hourAllowed = hourCount < limits.perHour;
|
||||
const dayAllowed = dayCount < limits.perDay;
|
||||
const isAbuse = hourCount >= limits.abuseThreshold;
|
||||
const allowed = minuteAllowed && hourAllowed && dayAllowed && !isAbuse;
|
||||
|
||||
if (allowed) {
|
||||
// Add current request
|
||||
const requestId = `${now}-${Math.random()}`;
|
||||
await Promise.all([
|
||||
redis.zAdd(minuteKey, { score: now, value: requestId }),
|
||||
redis.zAdd(hourKey, { score: now, value: requestId }),
|
||||
redis.expire(minuteKey, 60),
|
||||
redis.expire(hourKey, 3600),
|
||||
]);
|
||||
await redis
|
||||
.multi()
|
||||
.zAdd(minuteKey, { score: now, value: requestId })
|
||||
.zAdd(hourKey, { score: now, value: requestId })
|
||||
.zAdd(dayKey, { score: now, value: requestId })
|
||||
.expire(minuteKey, 60)
|
||||
.expire(hourKey, 3600)
|
||||
.expire(dayKey, 86400)
|
||||
.exec();
|
||||
}
|
||||
|
||||
return {
|
||||
allowed,
|
||||
remaining: Math.max(0, VOTING_RATE_LIMITS.perMinute - minuteCount - (allowed ? 1 : 0)),
|
||||
resetTime: now + minuteWindow,
|
||||
remaining: Math.max(0, limits.perMinute - minuteCount - (allowed ? 1 : 0)),
|
||||
resetTime: now + MINUTE_WINDOW,
|
||||
isAbuse,
|
||||
};
|
||||
} catch (error) {
|
||||
handleLogError(error as Error, `Rate limiting failed for user ${userId}`);
|
||||
// Fallback to allow if Redis fails
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: VOTING_RATE_LIMITS.perMinute,
|
||||
resetTime: now + 60000,
|
||||
isAbuse: false,
|
||||
};
|
||||
return ALLOWED_RESPONSE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { env } from '~/env/server';
|
||||
import dayjs from '~/shared/utils/dayjs';
|
||||
import { chunk } from 'lodash-es';
|
||||
import { clickhouse } from '~/server/clickhouse/client';
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
setActiveSlot,
|
||||
} from '~/server/games/new-order/utils';
|
||||
import { createJob } from '~/server/jobs/job';
|
||||
import { TransactionType } from '~/shared/constants/buzz.constants';
|
||||
import { logToAxiom } from '~/server/logging/client';
|
||||
import { createBuzzTransactionMany } from '~/server/services/buzz.service';
|
||||
import {
|
||||
calculateFervor,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
processFinalRatings,
|
||||
} from '~/server/services/games/new-order.service';
|
||||
import { limitConcurrency } from '~/server/utils/concurrency-helpers';
|
||||
import { TransactionType } from '~/shared/constants/buzz.constants';
|
||||
import { NewOrderRankType } from '~/shared/utils/prisma/enums';
|
||||
import { createLogger } from '~/utils/logging';
|
||||
|
||||
@@ -491,6 +493,101 @@ const newOrderChangeRateTarget = createJob(
|
||||
}
|
||||
);
|
||||
|
||||
// Periodic abuse detection: identify users with suspicious rating patterns
|
||||
// Runs every 6 hours, logs to Axiom for monitoring
|
||||
const newOrderAbuseDetection = createJob('new-order-abuse-detection', '0 23 * * *', async () => {
|
||||
if (!clickhouse) return;
|
||||
log('AbuseDetection :: Scanning for suspicious rating patterns');
|
||||
|
||||
const suspects = await clickhouse.$query<{
|
||||
userId: number;
|
||||
totalRatings: number;
|
||||
uniqueRatings: number;
|
||||
dominantRating: number;
|
||||
dominantPct: number;
|
||||
avgPerMinute: number;
|
||||
}>`
|
||||
WITH user_dominant AS (
|
||||
SELECT
|
||||
userId,
|
||||
topK(1)(rating)[1] as dominantRating
|
||||
FROM knights_new_order_image_rating
|
||||
WHERE createdAt >= now() - INTERVAL 48 HOUR
|
||||
AND rank != 'Acolyte'
|
||||
GROUP BY userId
|
||||
)
|
||||
SELECT
|
||||
r.userId,
|
||||
count() as totalRatings,
|
||||
uniq(r.rating) as uniqueRatings,
|
||||
d.dominantRating,
|
||||
countIf(r.rating = d.dominantRating) / count() * 100 as dominantPct,
|
||||
count() / greatest(dateDiff('minute', min(r.createdAt), max(r.createdAt)), 1) as avgPerMinute
|
||||
FROM knights_new_order_image_rating r
|
||||
JOIN user_dominant d ON r.userId = d.userId
|
||||
WHERE r.createdAt >= now() - INTERVAL 48 HOUR
|
||||
AND r.rank != 'Acolyte'
|
||||
GROUP BY r.userId, d.dominantRating
|
||||
HAVING totalRatings >= 200
|
||||
AND (uniqueRatings = 1 OR dominantPct >= 90 OR avgPerMinute > 15)
|
||||
ORDER BY totalRatings DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
|
||||
if (suspects.length > 0) {
|
||||
log(`AbuseDetection :: Found ${suspects.length} suspicious users`);
|
||||
await logToAxiom({
|
||||
type: 'warning',
|
||||
name: 'new-order-abuse-detection-scan',
|
||||
details: {
|
||||
suspectCount: suspects.length,
|
||||
suspects: suspects.map((s) => ({
|
||||
userId: s.userId,
|
||||
totalRatings: s.totalRatings,
|
||||
uniqueRatings: s.uniqueRatings,
|
||||
dominantRating: s.dominantRating,
|
||||
dominantPct: Math.round(s.dominantPct),
|
||||
avgPerMinute: Math.round(s.avgPerMinute * 10) / 10,
|
||||
})),
|
||||
},
|
||||
message: `Abuse detection scan found ${suspects.length} suspicious users in the last 48 hours`,
|
||||
}).catch(() => null);
|
||||
|
||||
// Alert moderators via Discord webhook
|
||||
if (env.DISCORD_WEBHOOK_MOD_ALERTS) {
|
||||
const suspectLines = suspects
|
||||
.slice(0, 10) // Cap at 10 to keep the embed manageable
|
||||
.map(
|
||||
(s) =>
|
||||
`• **User ${s.userId}** — ${s.totalRatings} votes, ${s.uniqueRatings} unique rating(s), ` +
|
||||
`${Math.round(s.dominantPct)}% same value, ${(
|
||||
Math.round(s.avgPerMinute * 10) / 10
|
||||
).toFixed(1)}/min`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
await fetch(env.DISCORD_WEBHOOK_MOD_ALERTS, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [
|
||||
{
|
||||
title: `⚠️ KoN Abuse Detection — ${suspects.length} suspect(s)`,
|
||||
description:
|
||||
suspectLines +
|
||||
(suspects.length > 10 ? `\n... and ${suspects.length - 10} more` : ''),
|
||||
color: 0xff9800,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).catch(() => null);
|
||||
}
|
||||
} else {
|
||||
log('AbuseDetection :: No suspicious users found');
|
||||
}
|
||||
});
|
||||
|
||||
export const newOrderJobs = [
|
||||
newOrderGrantBlessedBuzz,
|
||||
newOrderDailyReset,
|
||||
@@ -498,4 +595,5 @@ export const newOrderJobs = [
|
||||
// newOrderCleanupQueues,
|
||||
newOrderChangeFillTarget,
|
||||
newOrderChangeRateTarget,
|
||||
newOrderAbuseDetection,
|
||||
];
|
||||
|
||||
@@ -722,6 +722,7 @@ export const REDIS_SYS_KEYS = {
|
||||
POOL: 'new-order:sanity-checks',
|
||||
FAILURES: 'new-order:sanity-failures',
|
||||
},
|
||||
CONFIG: 'new-order:config',
|
||||
PROCESSING: {
|
||||
LAST_PROCESSED_AT: 'new-order:processing:last-processed-at',
|
||||
BATCH_CUTOFF: 'new-order:processing:batch-cutoff',
|
||||
@@ -858,6 +859,7 @@ export const REDIS_KEYS = {
|
||||
RATE_LIMIT: {
|
||||
MINUTE: 'new-order:rate-limit:minute',
|
||||
HOUR: 'new-order:rate-limit:hour',
|
||||
DAY: 'new-order:rate-limit:day',
|
||||
},
|
||||
},
|
||||
TOP_EARNERS: 'packed:caches:top-earners',
|
||||
|
||||
@@ -5,7 +5,6 @@ import { env } from '~/env/server';
|
||||
import { TransactionType } from '~/shared/constants/buzz.constants';
|
||||
import {
|
||||
addImageRatingSchema,
|
||||
addSanityCheckRatingSchema,
|
||||
cleanseSmiteSchema,
|
||||
getHistorySchema,
|
||||
getImageRatersSchema,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
import { createBuzzTransaction, refundTransaction } from '~/server/services/buzz.service';
|
||||
import {
|
||||
addImageRating,
|
||||
addSanityCheckRating,
|
||||
cleanseSmite,
|
||||
getImageRaters,
|
||||
getImagesQueue,
|
||||
@@ -146,15 +144,6 @@ export const gamesRouter = router({
|
||||
isModerator: ctx.user.isModerator,
|
||||
})
|
||||
),
|
||||
addSanityCheckRating: guardedProcedure
|
||||
.input(addSanityCheckRatingSchema)
|
||||
.use(isFlagProtected('newOrderGame'))
|
||||
.mutation(({ input, ctx }) =>
|
||||
addSanityCheckRating({
|
||||
...input,
|
||||
playerId: ctx.user.id,
|
||||
})
|
||||
),
|
||||
resetCareer: guardedProcedure
|
||||
.use(isFlagProtected('newOrderGame'))
|
||||
.mutation(({ ctx }) => resetPlayer({ playerId: ctx.user.id })),
|
||||
|
||||
@@ -243,7 +243,6 @@ export async function cleanseAllSmites({
|
||||
});
|
||||
|
||||
await smitesCounter.reset({ id: playerId });
|
||||
await sanityCheckFailuresCounter.reset({ id: playerId });
|
||||
|
||||
if (data.count === 0) return; // Nothing done :shrug:
|
||||
|
||||
@@ -271,7 +270,6 @@ export async function cleanseSmite({ id, cleansedReason, playerId }: CleanseSmit
|
||||
});
|
||||
|
||||
const smiteCount = await smitesCounter.decrement({ id: playerId });
|
||||
await sanityCheckFailuresCounter.reset({ id: playerId });
|
||||
|
||||
await signalClient
|
||||
.send({
|
||||
@@ -349,18 +347,18 @@ async function processImageRating({
|
||||
chTracker,
|
||||
isModerator,
|
||||
}: AddImageRatingInput & { playerId: number; chTracker?: Tracker; isModerator?: boolean }) {
|
||||
// Check player existence
|
||||
// Validate player and image existence before consuming rate limit budget
|
||||
const player = await getPlayerById({ playerId });
|
||||
if (!player) throw throwNotFoundError(`No player with id ${playerId}`);
|
||||
|
||||
// Check image existence
|
||||
const image = await dbRead.image.findUnique({
|
||||
where: { id: imageId },
|
||||
select: { id: true, nsfwLevel: true, metadata: true },
|
||||
});
|
||||
if (!image) throw throwNotFoundError(`No image with id ${imageId}`);
|
||||
|
||||
// Skip rate limiting for moderators
|
||||
// Rate limiting — before sanity check detection so that all votes
|
||||
// (regular and sanity) are counted and rate-limited uniformly
|
||||
if (!isModerator) {
|
||||
const rateLimitResult = await withSpan('games:newOrder:rateLimit', () =>
|
||||
checkVotingRateLimit(playerId)
|
||||
@@ -368,21 +366,17 @@ async function processImageRating({
|
||||
|
||||
// If abuse threshold exceeded, reset player career
|
||||
if (rateLimitResult.isAbuse) {
|
||||
// Log abuse detection for monitoring
|
||||
logToAxiom(
|
||||
{
|
||||
type: 'warning',
|
||||
name: 'new-order-abuse-detection',
|
||||
details: {
|
||||
playerId,
|
||||
imageId,
|
||||
action: 'career-reset',
|
||||
reason: 'excessive-voting',
|
||||
},
|
||||
message: `Player ${playerId} exceeded abuse threshold and was reset`,
|
||||
logToAxiom({
|
||||
type: 'warning',
|
||||
name: 'new-order-abuse-detection',
|
||||
details: {
|
||||
playerId,
|
||||
imageId,
|
||||
action: 'career-reset',
|
||||
reason: 'excessive-voting',
|
||||
},
|
||||
'new-order'
|
||||
).catch(() => null);
|
||||
message: `Player ${playerId} exceeded abuse threshold and was reset`,
|
||||
}).catch(() => null);
|
||||
|
||||
await resetPlayer({
|
||||
playerId,
|
||||
@@ -396,22 +390,17 @@ async function processImageRating({
|
||||
|
||||
// Standard rate limiting
|
||||
if (!rateLimitResult.allowed) {
|
||||
// Log rate limit hits for monitoring (but only occasionally to avoid spam)
|
||||
if (Math.random() < 0.1) {
|
||||
// 10% sampling
|
||||
logToAxiom(
|
||||
{
|
||||
type: 'info',
|
||||
name: 'new-order-rate-limit',
|
||||
details: {
|
||||
playerId,
|
||||
remaining: rateLimitResult.remaining,
|
||||
resetTime: rateLimitResult.resetTime,
|
||||
},
|
||||
message: `Player ${playerId} hit rate limit`,
|
||||
logToAxiom({
|
||||
type: 'info',
|
||||
name: 'new-order-rate-limit',
|
||||
details: {
|
||||
playerId,
|
||||
remaining: rateLimitResult.remaining,
|
||||
resetTime: rateLimitResult.resetTime,
|
||||
},
|
||||
'new-order'
|
||||
).catch(() => null);
|
||||
message: `Player ${playerId} hit rate limit`,
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
throw throwBadRequestError(
|
||||
@@ -422,6 +411,37 @@ async function processImageRating({
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept sanity check images — the client doesn't know which images are sanity checks,
|
||||
// so we detect it server-side and route to the sanity check handler transparently
|
||||
if (sysRedis) {
|
||||
const exactMatch = await sysRedis.sIsMember(
|
||||
REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL,
|
||||
`${imageId}:${rating}`
|
||||
);
|
||||
let isSanityImage = !!exactMatch;
|
||||
// Also check if imageId exists with ANY level (in case the rating is wrong)
|
||||
if (!isSanityImage) {
|
||||
const possibleLevels = [
|
||||
NsfwLevel.PG,
|
||||
NsfwLevel.PG13,
|
||||
NsfwLevel.R,
|
||||
NsfwLevel.X,
|
||||
NsfwLevel.XXX,
|
||||
];
|
||||
const memberChecks = await Promise.all(
|
||||
possibleLevels.map((level) =>
|
||||
sysRedis.sIsMember(REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL, `${imageId}:${level}`)
|
||||
)
|
||||
);
|
||||
isSanityImage = memberChecks.some(Boolean);
|
||||
}
|
||||
|
||||
if (isSanityImage) {
|
||||
await addSanityCheckRating({ playerId, imageId, rating });
|
||||
return { stats: player.stats };
|
||||
}
|
||||
}
|
||||
|
||||
// Find which specific queue this image is in
|
||||
const allowedRankTypes = isModerator
|
||||
? (['Inquisitor', NewOrderRankType.Knight] as NewOrderHighRankType[])
|
||||
@@ -507,26 +527,23 @@ async function processImageRating({
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
logToAxiom(
|
||||
{
|
||||
type: 'info',
|
||||
name: 'new-order-down-rating-escalated',
|
||||
details: {
|
||||
imageId,
|
||||
currentLevel: image.nsfwLevel,
|
||||
consensusLevel: consensus,
|
||||
distance: Flags.distance(image.nsfwLevel, consensus),
|
||||
voteCount: newVoteCount,
|
||||
},
|
||||
message: `Image ${imageId} down-rating consensus (${
|
||||
image.nsfwLevel
|
||||
} → ${consensus}, distance ${Flags.distance(
|
||||
image.nsfwLevel,
|
||||
consensus
|
||||
)}) escalated to Inquisitor queue`,
|
||||
logToAxiom({
|
||||
type: 'info',
|
||||
name: 'new-order-down-rating-escalated',
|
||||
details: {
|
||||
imageId,
|
||||
currentLevel: image.nsfwLevel,
|
||||
consensusLevel: consensus,
|
||||
distance: Flags.distance(image.nsfwLevel, consensus),
|
||||
voteCount: newVoteCount,
|
||||
},
|
||||
'new-order'
|
||||
).catch(() => null);
|
||||
message: `Image ${imageId} down-rating consensus (${
|
||||
image.nsfwLevel
|
||||
} → ${consensus}, distance ${Flags.distance(
|
||||
image.nsfwLevel,
|
||||
consensus
|
||||
)}) escalated to Inquisitor queue`,
|
||||
}).catch(() => null);
|
||||
} else {
|
||||
// Apply the consensus decision (same level, up-rating, or down-rating by 1 level)
|
||||
if (consensus) {
|
||||
@@ -1073,9 +1090,16 @@ export function calculateFervor({
|
||||
correctJudgments: number;
|
||||
allJudgments: number;
|
||||
}) {
|
||||
// New formula: number_of_ratings + (number_correct_ratings * 100)
|
||||
// This rewards both activity (total ratings) and accuracy (correct ratings)
|
||||
return allJudgments + correctJudgments * 100;
|
||||
// Formula: correct_ratings * 100 * accuracy_ratio
|
||||
// The accuracy multiplier penalizes spammers: a user with 20% accuracy gets only 20% of
|
||||
// the fervor they would otherwise earn. Legitimate users with 80%+ correct barely notice.
|
||||
// Floor at 0.1 to avoid zeroing out completely.
|
||||
// Examples:
|
||||
// Legitimate (500 total, 400 correct, 80%): 400 * 100 * 0.8 = 32,000
|
||||
// Spammer (5000 total, 1000 correct, 20%): 1000 * 100 * 0.2 = 20,000
|
||||
const accuracyRatio = allJudgments > 0 ? correctJudgments / allJudgments : 0;
|
||||
const accuracyMultiplier = Math.max(0.1, accuracyRatio);
|
||||
return Math.floor(correctJudgments * 100 * accuracyMultiplier);
|
||||
}
|
||||
|
||||
export function calculateVoteWeight({ level, smites }: { level: number; smites: number }): number {
|
||||
@@ -1135,15 +1159,30 @@ export async function getSanityCheckImage(): Promise<SanityCheck | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSanityCheckFailure(playerId: number, imageId: number) {
|
||||
export async function handleSanityCheckFailure({
|
||||
playerId,
|
||||
imageId,
|
||||
submittedRating,
|
||||
correctNsfwLevel,
|
||||
}: {
|
||||
playerId: number;
|
||||
imageId: number;
|
||||
submittedRating: NsfwLevel;
|
||||
correctNsfwLevel: NsfwLevel;
|
||||
}) {
|
||||
if (!sysRedis) return;
|
||||
|
||||
try {
|
||||
// Increment failure counter (auto-expires after 24 hours from first failure)
|
||||
const failureCount = await sanityCheckFailuresCounter.increment({ id: playerId });
|
||||
|
||||
if (failureCount === 1) {
|
||||
// First failure - warning only
|
||||
// Severe under-rating: rating content 2+ levels below its actual level (e.g., XXX→PG)
|
||||
// Uses Flags.distance() for safe bitwise-flag comparison
|
||||
const isSevereUnderRating =
|
||||
submittedRating < correctNsfwLevel && Flags.distance(correctNsfwLevel, submittedRating) >= 2;
|
||||
|
||||
if (failureCount === 1 && !isSevereUnderRating) {
|
||||
// First failure (non-severe) - warning only
|
||||
await createNotification({
|
||||
category: NotificationCategory.System,
|
||||
type: 'new-order-sanity-warning',
|
||||
@@ -1172,12 +1211,13 @@ export async function handleSanityCheckFailure(playerId: number, imageId: number
|
||||
})
|
||||
.catch((e) => handleLogError(e, 'signals:new-order-sanity-warning'));
|
||||
} else {
|
||||
// Additional failure - apply smite
|
||||
// Severe under-rating OR additional failure - apply smite immediately
|
||||
await smitePlayer({
|
||||
playerId,
|
||||
modId: -1, // System
|
||||
reason:
|
||||
'You failed another sanity check within 24 hours. A smite penalty has been applied, reducing your vote weight.',
|
||||
reason: isSevereUnderRating
|
||||
? 'You severely under-rated a sanity check image. A smite penalty has been applied.'
|
||||
: 'You failed another sanity check within 24 hours. A smite penalty has been applied, reducing your vote weight.',
|
||||
size: newOrderConfig.smiteSize * 10,
|
||||
});
|
||||
}
|
||||
@@ -1215,8 +1255,25 @@ export async function addSanityCheckRating({
|
||||
`${imageId}:${rating}`
|
||||
);
|
||||
|
||||
// Handle failure if incorrect
|
||||
if (!isCorrect) await handleSanityCheckFailure(playerId, imageId);
|
||||
// Handle failure if incorrect — look up the correct level for penalty severity
|
||||
if (!isCorrect) {
|
||||
// Probe all possible NSFW levels via O(1) sIsMember checks instead of fetching the whole set
|
||||
const possibleLevels = [NsfwLevel.PG, NsfwLevel.PG13, NsfwLevel.R, NsfwLevel.X, NsfwLevel.XXX];
|
||||
const memberChecks = await Promise.all(
|
||||
possibleLevels.map((level) =>
|
||||
sysRedis.sIsMember(REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL, `${imageId}:${level}`)
|
||||
)
|
||||
);
|
||||
const matchIndex = memberChecks.findIndex(Boolean);
|
||||
const correctNsfwLevel = matchIndex >= 0 ? possibleLevels[matchIndex] : rating; // fallback to submitted rating (won't trigger severe penalty)
|
||||
|
||||
await handleSanityCheckFailure({
|
||||
playerId,
|
||||
imageId,
|
||||
submittedRating: rating,
|
||||
correctNsfwLevel,
|
||||
});
|
||||
}
|
||||
|
||||
// Return result (no XP, no stats update, no ClickHouse tracking)
|
||||
return { isCorrect };
|
||||
@@ -1277,6 +1334,7 @@ export async function resetPlayer({
|
||||
// Reset all counters for player
|
||||
await Promise.all([
|
||||
smitesCounter.reset({ id: playerId }),
|
||||
sanityCheckFailuresCounter.reset({ id: playerId }),
|
||||
correctJudgmentsCounter.reset({ id: playerId }),
|
||||
allJudgmentsCounter.reset({ id: playerId }),
|
||||
expCounter.reset({ id: playerId }),
|
||||
@@ -1478,7 +1536,7 @@ export async function getImagesQueue({
|
||||
id: number;
|
||||
url: string;
|
||||
nsfwLevel?: number;
|
||||
metadata: ImageMetadata & { isSanityCheck?: boolean };
|
||||
metadata: ImageMetadata;
|
||||
}> = [];
|
||||
|
||||
// Moderators can specify queueType to test different queues (Acolyte or Knight only)
|
||||
@@ -1575,29 +1633,53 @@ export async function getImagesQueue({
|
||||
const allSanityChecks = await sysRedis!.sMembers(REDIS_SYS_KEYS.NEW_ORDER.SANITY_CHECKS.POOL);
|
||||
|
||||
if (allSanityChecks && allSanityChecks.length > 0) {
|
||||
// Randomly select N sanity checks
|
||||
const selectedCheck = getRandom(allSanityChecks);
|
||||
// Extract image IDs for batch DB query
|
||||
const [sanityImageId, sanityImageNsfwLevel] = selectedCheck.split(':').map(Number);
|
||||
// Insert sanity checks scaled to queue size (~2 per 20 images, ~10 per 100)
|
||||
// At least 1 even if the queue is tiny, scaling up for larger fetches
|
||||
const sanityCheckCount = Math.max(1, Math.ceil(finalImages.length / 10));
|
||||
|
||||
// Batch fetch image data from database
|
||||
const sanityImageData = await dbRead.image.findUnique({
|
||||
where: { id: sanityImageId },
|
||||
// Stratify selection: bias toward non-PG sanity images to catch under-raters
|
||||
const pgChecks: string[] = [];
|
||||
const nonPgChecks: string[] = [];
|
||||
for (const entry of allSanityChecks) {
|
||||
const nsfwLevel = Number(entry.split(':')[1]);
|
||||
if (nsfwLevel <= NsfwLevel.PG13) {
|
||||
pgChecks.push(entry);
|
||||
} else {
|
||||
nonPgChecks.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Select with bias: at least 40% non-PG if available
|
||||
const shuffledNonPg = shuffle(nonPgChecks);
|
||||
const shuffledPg = shuffle(pgChecks);
|
||||
const minNonPg = Math.min(Math.ceil(sanityCheckCount * 0.4), shuffledNonPg.length);
|
||||
const selectedChecks = [
|
||||
...shuffledNonPg.slice(0, minNonPg),
|
||||
...shuffle([...shuffledNonPg.slice(minNonPg), ...shuffledPg]),
|
||||
].slice(0, sanityCheckCount);
|
||||
|
||||
// Batch fetch image data for all selected sanity checks
|
||||
const sanityImageIds = selectedChecks.map((entry) => Number(entry.split(':')[0]));
|
||||
const sanityImagesData = await dbRead.image.findMany({
|
||||
where: { id: { in: sanityImageIds } },
|
||||
select: { id: true, url: true, metadata: true },
|
||||
});
|
||||
const sanityImageMap = new Map(sanityImagesData.map((img) => [img.id, img]));
|
||||
|
||||
for (const entry of selectedChecks) {
|
||||
const [imageIdStr] = entry.split(':');
|
||||
const sanityImageId = Number(imageIdStr);
|
||||
const imageData = sanityImageMap.get(sanityImageId);
|
||||
if (!imageData) continue;
|
||||
|
||||
if (sanityImageData) {
|
||||
const sanityCheckImage = {
|
||||
id: sanityImageId,
|
||||
url: sanityImageData.url,
|
||||
nsfwLevel: sanityImageNsfwLevel as NsfwLevel,
|
||||
metadata: {
|
||||
...(sanityImageData.metadata as ImageMetadata),
|
||||
isSanityCheck: true,
|
||||
} as ImageMetadata,
|
||||
url: imageData.url,
|
||||
nsfwLevel: undefined, // Never leak correct level to the client
|
||||
metadata: imageData.metadata as ImageMetadata,
|
||||
};
|
||||
|
||||
const randomPosition = Math.floor(Math.random() * finalImages.length);
|
||||
const randomPosition = Math.floor(Math.random() * (finalImages.length + 1));
|
||||
finalImages.splice(randomPosition, 0, sanityCheckImage);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user