anti-brigading system - detection engine, phantom voting, ALTCHA adaptive difficulty, honeypot fields, admin security dashboard, auto-learning
This commit is contained in:
@@ -34,6 +34,10 @@ model Board {
|
|||||||
rssFeedCount Int @default(50)
|
rssFeedCount Int @default(50)
|
||||||
staleDays Int @default(0)
|
staleDays Int @default(0)
|
||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
|
sensitivityLevel String @default("normal")
|
||||||
|
velocityThreshold Int?
|
||||||
|
quarantined Boolean @default(false)
|
||||||
|
requireVoteVerification Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -43,6 +47,8 @@ model Board {
|
|||||||
statusConfig BoardStatus[]
|
statusConfig BoardStatus[]
|
||||||
templates BoardTemplate[]
|
templates BoardTemplate[]
|
||||||
changelogEntries ChangelogEntry[]
|
changelogEntries ChangelogEntry[]
|
||||||
|
baseline BoardBaseline?
|
||||||
|
brigadePatterns BrigadePattern[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model BoardStatus {
|
model BoardStatus {
|
||||||
@@ -69,6 +75,12 @@ model User {
|
|||||||
displayName String?
|
displayName String?
|
||||||
avatarPath String?
|
avatarPath String?
|
||||||
darkMode String @default("system")
|
darkMode String @default("system")
|
||||||
|
firstActionType String?
|
||||||
|
firstActionAt DateTime?
|
||||||
|
actionDiversityScore Float @default(0)
|
||||||
|
voteTimingStdDev Float?
|
||||||
|
boardInteractionCount Int @default(0)
|
||||||
|
flagCount Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -128,6 +140,8 @@ model Post {
|
|||||||
isEditLocked Boolean @default(false)
|
isEditLocked Boolean @default(false)
|
||||||
isThreadLocked Boolean @default(false)
|
isThreadLocked Boolean @default(false)
|
||||||
isVotingLocked Boolean @default(false)
|
isVotingLocked Boolean @default(false)
|
||||||
|
frozenAt DateTime?
|
||||||
|
votesVisibleAfter DateTime?
|
||||||
onBehalfOf String?
|
onBehalfOf String?
|
||||||
boardId String
|
boardId String
|
||||||
authorId String
|
authorId String
|
||||||
@@ -148,6 +162,7 @@ model Post {
|
|||||||
tags PostTag[]
|
tags PostTag[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
editHistory EditHistory[]
|
editHistory EditHistory[]
|
||||||
|
voteSnapshots PostVoteSnapshot[]
|
||||||
|
|
||||||
@@index([boardId, status])
|
@@index([boardId, status])
|
||||||
}
|
}
|
||||||
@@ -206,6 +221,9 @@ model Vote {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
weight Int @default(1)
|
weight Int @default(1)
|
||||||
importance String?
|
importance String?
|
||||||
|
phantom Boolean @default(false)
|
||||||
|
voterIp String?
|
||||||
|
referrer String?
|
||||||
postId String
|
postId String
|
||||||
voterId String
|
voterId String
|
||||||
budgetPeriod String
|
budgetPeriod String
|
||||||
@@ -493,3 +511,60 @@ model PluginData {
|
|||||||
@@unique([pluginId, key])
|
@@unique([pluginId, key])
|
||||||
@@index([pluginId])
|
@@index([pluginId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AnomalyEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
type String
|
||||||
|
severity String
|
||||||
|
targetType String
|
||||||
|
targetId String
|
||||||
|
boardId String?
|
||||||
|
metadata Json @default("{}")
|
||||||
|
status String @default("pending")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@index([targetType, targetId])
|
||||||
|
@@index([boardId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BoardBaseline {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
boardId String @unique
|
||||||
|
avgVotesPerHour Float @default(0)
|
||||||
|
avgPostsPerDay Float @default(0)
|
||||||
|
avgReactionsPerHour Float @default(0)
|
||||||
|
peakHourOfDay Int @default(12)
|
||||||
|
peakDayOfWeek Int @default(2)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model PostVoteSnapshot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
postId String
|
||||||
|
voteCount Int
|
||||||
|
snapshotAt DateTime @default(now())
|
||||||
|
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([postId, snapshotAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BrigadePattern {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
boardId String?
|
||||||
|
features Json
|
||||||
|
matchCount Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminWebhookConfig {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String
|
||||||
|
events String[]
|
||||||
|
active Boolean @default(true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,26 @@ import { cleanExpiredChallenges } from "../routes/passkey.js";
|
|||||||
import { cleanupExpiredTokens } from "../lib/token-blocklist.js";
|
import { cleanupExpiredTokens } from "../lib/token-blocklist.js";
|
||||||
import { getPluginCronJobs } from "../plugins/loader.js";
|
import { getPluginCronJobs } from "../plugins/loader.js";
|
||||||
import { cleanupViews } from "../lib/view-tracker.js";
|
import { cleanupViews } from "../lib/view-tracker.js";
|
||||||
|
import {
|
||||||
|
takeVoteSnapshots,
|
||||||
|
scanVoteVelocity,
|
||||||
|
scanPostVelocity,
|
||||||
|
scanIdentityClusters,
|
||||||
|
scanInflectionPoints,
|
||||||
|
scanCohortArrivals,
|
||||||
|
scanReferrerConcentration,
|
||||||
|
scanPostSimilarity,
|
||||||
|
recalculateBoardBaselines,
|
||||||
|
scanVoterOverlap,
|
||||||
|
scanOutboundLinks,
|
||||||
|
scanCommentVoteRatio,
|
||||||
|
scanVoteDistribution,
|
||||||
|
compareSeasonalBaseline,
|
||||||
|
pruneOldSnapshots,
|
||||||
|
pruneOldAnomalyEvents,
|
||||||
|
scanOffHoursActivity,
|
||||||
|
buildVoterGraph,
|
||||||
|
} from "../services/detection-engine.js";
|
||||||
|
|
||||||
export function startCronJobs() {
|
export function startCronJobs() {
|
||||||
// prune old activity events - daily at 3am
|
// prune old activity events - daily at 3am
|
||||||
@@ -108,6 +128,53 @@ export function startCronJobs() {
|
|||||||
// clean expired view-tracker entries - every 5 minutes
|
// clean expired view-tracker entries - every 5 minutes
|
||||||
cron.schedule("*/5 * * * *", () => { cleanupViews(); });
|
cron.schedule("*/5 * * * *", () => { cleanupViews(); });
|
||||||
|
|
||||||
|
// --- anti-brigading detection jobs ---
|
||||||
|
|
||||||
|
// every 5 min: snapshots + velocity scans
|
||||||
|
cron.schedule("*/5 * * * *", async () => {
|
||||||
|
await takeVoteSnapshots().catch(() => {});
|
||||||
|
await scanVoteVelocity().catch(() => {});
|
||||||
|
await scanPostVelocity().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// every 10 min: identity clustering
|
||||||
|
cron.schedule("*/10 * * * *", async () => {
|
||||||
|
await scanIdentityClusters().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// every 15 min: inflection point detection
|
||||||
|
cron.schedule("*/15 * * * *", async () => {
|
||||||
|
await scanInflectionPoints().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// every 30 min: cohort arrivals, referrer analysis, post similarity
|
||||||
|
cron.schedule("*/30 * * * *", async () => {
|
||||||
|
await scanCohortArrivals().catch(() => {});
|
||||||
|
await scanReferrerConcentration().catch(() => {});
|
||||||
|
await scanPostSimilarity().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// hourly: baselines, overlap, links, comment ratio, off-hours, voter graph
|
||||||
|
cron.schedule("0 * * * *", async () => {
|
||||||
|
await recalculateBoardBaselines().catch(() => {});
|
||||||
|
await scanVoterOverlap().catch(() => {});
|
||||||
|
await scanOutboundLinks().catch(() => {});
|
||||||
|
await scanCommentVoteRatio().catch(() => {});
|
||||||
|
await scanOffHoursActivity().catch(() => {});
|
||||||
|
const boards = await prisma.board.findMany({ select: { id: true } }).catch(() => [] as { id: string }[]);
|
||||||
|
for (const b of boards) {
|
||||||
|
await buildVoterGraph(b.id).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// daily at 3am: distribution, seasonal, pruning
|
||||||
|
cron.schedule("0 3 * * *", async () => {
|
||||||
|
await scanVoteDistribution().catch(() => {});
|
||||||
|
await compareSeasonalBaseline().catch(() => {});
|
||||||
|
await pruneOldSnapshots().catch(() => {});
|
||||||
|
await pruneOldAnomalyEvents().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
// register plugin-provided cron jobs (min interval: every minute, reject sub-minute)
|
// register plugin-provided cron jobs (min interval: every minute, reject sub-minute)
|
||||||
for (const job of getPluginCronJobs()) {
|
for (const job of getPluginCronJobs()) {
|
||||||
if (!cron.validate(job.schedule)) {
|
if (!cron.validate(job.schedule)) {
|
||||||
|
|||||||
509
packages/api/src/routes/admin/security.ts
Normal file
509
packages/api/src/routes/admin/security.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../../lib/prisma.js";
|
||||||
|
import { buildVoterGraph } from "../../services/detection-engine.js";
|
||||||
|
|
||||||
|
const ANOMALY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
vote_velocity: "Vote velocity spike",
|
||||||
|
post_velocity: "Post creation spike",
|
||||||
|
identity_cluster: "Identity cluster",
|
||||||
|
cohort_arrival: "Coordinated arrival",
|
||||||
|
voter_overlap: "Voter overlap",
|
||||||
|
vote_distribution_outlier: "Vote distribution outlier",
|
||||||
|
inflection_point: "Vote inflection point",
|
||||||
|
referrer_concentration: "Referrer concentration",
|
||||||
|
post_similarity: "Similar post titles",
|
||||||
|
outbound_link_cluster: "Outbound link cluster",
|
||||||
|
reaction_velocity: "Reaction velocity spike",
|
||||||
|
comment_vote_ratio: "Low comment-to-vote ratio",
|
||||||
|
off_hours_activity: "Off-hours activity",
|
||||||
|
voter_network_cluster: "Voter network cluster",
|
||||||
|
seasonal_deviation: "Seasonal deviation",
|
||||||
|
post_substantially_edited: "Post substantially edited after votes",
|
||||||
|
audit_cleanup: "Cleanup action",
|
||||||
|
};
|
||||||
|
|
||||||
|
const alertsQuery = z.object({
|
||||||
|
status: z.enum(["pending", "confirmed", "dismissed"]).optional(),
|
||||||
|
severity: z.string().max(20).optional(),
|
||||||
|
type: z.string().max(50).optional(),
|
||||||
|
boardId: z.string().max(50).optional(),
|
||||||
|
page: z.coerce.number().int().min(1).max(500).default(1),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanupBody = z.object({
|
||||||
|
anomalyEventId: z.string().min(1),
|
||||||
|
actions: z.array(z.enum(["remove_phantom_votes", "remove_flagged_votes", "recalculate_counts"])).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const boardSecurityBody = z.object({
|
||||||
|
sensitivityLevel: z.enum(["low", "normal", "high", "paranoid"]).optional(),
|
||||||
|
velocityThreshold: z.number().int().min(0).nullable().optional(),
|
||||||
|
quarantined: z.boolean().optional(),
|
||||||
|
requireVoteVerification: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookBody = z.object({
|
||||||
|
url: z.string().url().max(500),
|
||||||
|
events: z.array(z.string().max(50)).min(1),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookUpdateBody = z.object({
|
||||||
|
url: z.string().url().max(500).optional(),
|
||||||
|
events: z.array(z.string().max(50)).min(1).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function adminSecurityRoutes(app: FastifyInstance) {
|
||||||
|
|
||||||
|
// 1. List anomaly events
|
||||||
|
app.get<{ Querystring: Record<string, string> }>(
|
||||||
|
"/admin/security/alerts",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const q = alertsQuery.safeParse(req.query);
|
||||||
|
if (!q.success) {
|
||||||
|
reply.status(400).send({ error: "Invalid query parameters" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { status, severity, type, boardId, page, limit } = q.data;
|
||||||
|
|
||||||
|
const where: Prisma.AnomalyEventWhereInput = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (severity) where.severity = severity;
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (boardId) where.boardId = boardId;
|
||||||
|
|
||||||
|
const [events, total] = await Promise.all([
|
||||||
|
prisma.anomalyEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: Math.min((page - 1) * limit, 50000),
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.anomalyEvent.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
alerts: events.map((e) => ({
|
||||||
|
...e,
|
||||||
|
label: ANOMALY_TYPE_LABELS[e.type] || e.type,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Get single anomaly event
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
"/admin/security/alerts/:id",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!event) {
|
||||||
|
reply.status(404).send({ error: "Alert not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply.send({
|
||||||
|
...event,
|
||||||
|
label: ANOMALY_TYPE_LABELS[event.type] || event.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Confirm anomaly
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
"/admin/security/alerts/:id/confirm",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!event) {
|
||||||
|
reply.status(404).send({ error: "Alert not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await prisma.anomalyEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { status: "confirmed" },
|
||||||
|
});
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Dismiss anomaly
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
"/admin/security/alerts/:id/dismiss",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!event) {
|
||||||
|
reply.status(404).send({ error: "Alert not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await prisma.anomalyEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { status: "dismissed" },
|
||||||
|
});
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. One-click cleanup
|
||||||
|
app.post<{ Body: z.infer<typeof cleanupBody> }>(
|
||||||
|
"/admin/security/cleanup",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = cleanupBody.parse(req.body);
|
||||||
|
|
||||||
|
const event = await prisma.anomalyEvent.findUnique({ where: { id: body.anomalyEventId } });
|
||||||
|
if (!event) {
|
||||||
|
reply.status(404).send({ error: "Anomaly event not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
const results: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// figure out which posts are affected
|
||||||
|
const flaggedPosts: string[] = (meta.flaggedPosts as string[]) || [];
|
||||||
|
const postA = meta.postA as string | undefined;
|
||||||
|
const postB = meta.postB as string | undefined;
|
||||||
|
let targetPostId = event.targetType === "post" ? event.targetId : null;
|
||||||
|
const affectedPostIds = [
|
||||||
|
...flaggedPosts,
|
||||||
|
...(postA ? [postA] : []),
|
||||||
|
...(postB ? [postB] : []),
|
||||||
|
...(targetPostId ? [targetPostId] : []),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const action of body.actions) {
|
||||||
|
if (action === "remove_phantom_votes") {
|
||||||
|
if (affectedPostIds.length === 0) {
|
||||||
|
results.remove_phantom_votes = { deleted: 0 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const deleted = await prisma.vote.deleteMany({
|
||||||
|
where: { postId: { in: affectedPostIds }, phantom: true },
|
||||||
|
});
|
||||||
|
results.remove_phantom_votes = { deleted: deleted.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "remove_flagged_votes") {
|
||||||
|
const identities = (meta.identities as string[]) || [];
|
||||||
|
if (identities.length === 0) {
|
||||||
|
results.remove_flagged_votes = { deleted: 0 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const deleted = await prisma.vote.deleteMany({
|
||||||
|
where: { voterId: { in: identities } },
|
||||||
|
});
|
||||||
|
results.remove_flagged_votes = { deleted: deleted.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "recalculate_counts") {
|
||||||
|
const postIds = affectedPostIds.length > 0
|
||||||
|
? affectedPostIds
|
||||||
|
: (await prisma.post.findMany({ where: { boardId: event.boardId ?? undefined }, select: { id: true }, take: 200 })).map((p) => p.id);
|
||||||
|
|
||||||
|
let recalculated = 0;
|
||||||
|
for (const pid of postIds) {
|
||||||
|
const sum = await prisma.vote.aggregate({
|
||||||
|
where: { postId: pid, phantom: false },
|
||||||
|
_sum: { weight: true },
|
||||||
|
});
|
||||||
|
await prisma.post.update({
|
||||||
|
where: { id: pid },
|
||||||
|
data: { voteCount: sum._sum.weight ?? 0 },
|
||||||
|
});
|
||||||
|
recalculated++;
|
||||||
|
}
|
||||||
|
results.recalculate_counts = { recalculated };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create audit log entry
|
||||||
|
await prisma.anomalyEvent.create({
|
||||||
|
data: {
|
||||||
|
type: "audit_cleanup",
|
||||||
|
severity: "info",
|
||||||
|
targetType: event.targetType,
|
||||||
|
targetId: event.targetId,
|
||||||
|
boardId: event.boardId,
|
||||||
|
status: "confirmed",
|
||||||
|
metadata: {
|
||||||
|
sourceEventId: event.id,
|
||||||
|
adminId: req.adminId,
|
||||||
|
actions: body.actions,
|
||||||
|
results,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.log.info({ adminId: req.adminId, anomalyEventId: event.id, actions: body.actions }, "security cleanup");
|
||||||
|
reply.send({ ok: true, results });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Audit log
|
||||||
|
app.get(
|
||||||
|
"/admin/security/audit-log",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const entries = await prisma.anomalyEvent.findMany({
|
||||||
|
where: { type: "audit_cleanup" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
entries: entries.map((e) => {
|
||||||
|
const meta = (e.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
adminId: meta.adminId || null,
|
||||||
|
action: (meta.actions as string[])?.join(", ") || "unknown",
|
||||||
|
metadata: meta,
|
||||||
|
undone: meta.undone === true,
|
||||||
|
createdAt: e.createdAt,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Undo cleanup
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
"/admin/security/audit-log/:id/undo",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const entry = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!entry || entry.type !== "audit_cleanup") {
|
||||||
|
reply.status(404).send({ error: "Audit entry not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
if (meta.undone) {
|
||||||
|
reply.status(409).send({ error: "Already undone" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.anomalyEvent.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: {
|
||||||
|
metadata: { ...meta, undone: true, undoneBy: req.adminId, undoneAt: new Date().toISOString() } as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.log.info({ adminId: req.adminId, auditEntryId: entry.id }, "cleanup marked as undone");
|
||||||
|
reply.send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Freeze/unfreeze post
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
"/admin/posts/:id/freeze",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.post.update({
|
||||||
|
where: { id: post.id },
|
||||||
|
data: { frozenAt: post.frozenAt ? null : new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
req.log.info({ adminId: req.adminId, postId: post.id, frozen: !!updated.frozenAt }, "post freeze toggled");
|
||||||
|
reply.send({ id: updated.id, frozenAt: updated.frozenAt });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 9. Board security settings
|
||||||
|
app.put<{ Params: { id: string }; Body: z.infer<typeof boardSecurityBody> }>(
|
||||||
|
"/admin/boards/:id/security",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = boardSecurityBody.parse(req.body);
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (body.sensitivityLevel !== undefined) data.sensitivityLevel = body.sensitivityLevel;
|
||||||
|
if (body.velocityThreshold !== undefined) data.velocityThreshold = body.velocityThreshold;
|
||||||
|
if (body.quarantined !== undefined) data.quarantined = body.quarantined;
|
||||||
|
if (body.requireVoteVerification !== undefined) data.requireVoteVerification = body.requireVoteVerification;
|
||||||
|
|
||||||
|
const updated = await prisma.board.update({
|
||||||
|
where: { id: board.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.log.info({ adminId: req.adminId, boardId: board.id }, "board security settings updated");
|
||||||
|
reply.send({
|
||||||
|
id: updated.id,
|
||||||
|
sensitivityLevel: updated.sensitivityLevel,
|
||||||
|
velocityThreshold: updated.velocityThreshold,
|
||||||
|
quarantined: updated.quarantined,
|
||||||
|
requireVoteVerification: updated.requireVoteVerification,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 10. Admin notification webhook config - CRUD
|
||||||
|
app.get(
|
||||||
|
"/admin/security/webhooks",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const webhooks = await prisma.adminWebhookConfig.findMany();
|
||||||
|
reply.send({ webhooks });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: z.infer<typeof webhookBody> }>(
|
||||||
|
"/admin/security/webhooks",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = webhookBody.parse(req.body);
|
||||||
|
const wh = await prisma.adminWebhookConfig.create({
|
||||||
|
data: { url: body.url, events: body.events, active: body.active },
|
||||||
|
});
|
||||||
|
reply.status(201).send(wh);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string }; Body: z.infer<typeof webhookUpdateBody> }>(
|
||||||
|
"/admin/security/webhooks/:id",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const wh = await prisma.adminWebhookConfig.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!wh) {
|
||||||
|
reply.status(404).send({ error: "Webhook not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = webhookUpdateBody.parse(req.body);
|
||||||
|
const updated = await prisma.adminWebhookConfig.update({
|
||||||
|
where: { id: wh.id },
|
||||||
|
data: {
|
||||||
|
...(body.url !== undefined && { url: body.url }),
|
||||||
|
...(body.events !== undefined && { events: body.events }),
|
||||||
|
...(body.active !== undefined && { active: body.active }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/admin/security/webhooks/:id",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const wh = await prisma.adminWebhookConfig.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!wh) {
|
||||||
|
reply.status(404).send({ error: "Webhook not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prisma.adminWebhookConfig.delete({ where: { id: wh.id } });
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 11. Mark as brigaded
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
"/admin/security/alerts/:id/mark-brigaded",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!event) {
|
||||||
|
reply.status(404).send({ error: "Alert not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// extract pattern features from the anomaly
|
||||||
|
const features: Record<string, unknown> = {
|
||||||
|
anomalyType: event.type,
|
||||||
|
severity: event.severity,
|
||||||
|
};
|
||||||
|
if (meta.identities) features.identityCount = (meta.identities as string[]).length;
|
||||||
|
if (meta.referrer) features.referrer = meta.referrer;
|
||||||
|
if (meta.ratio) features.velocityRatio = meta.ratio;
|
||||||
|
if (meta.coefficient) features.overlapCoefficient = meta.coefficient;
|
||||||
|
if (meta.phrase) features.phrase = meta.phrase;
|
||||||
|
if (meta.url) features.url = meta.url;
|
||||||
|
|
||||||
|
const pattern = await prisma.brigadePattern.create({
|
||||||
|
data: {
|
||||||
|
boardId: event.boardId,
|
||||||
|
features: features as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// also confirm the anomaly
|
||||||
|
await prisma.anomalyEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { status: "confirmed" },
|
||||||
|
});
|
||||||
|
|
||||||
|
req.log.info({ adminId: req.adminId, patternId: pattern.id, anomalyEventId: event.id }, "brigade pattern recorded");
|
||||||
|
reply.status(201).send(pattern);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 12. Network graph data
|
||||||
|
app.get<{ Params: { boardId: string } }>(
|
||||||
|
"/admin/security/graph/:boardId",
|
||||||
|
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for cached graph data from recent anomaly events
|
||||||
|
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const cached = await prisma.anomalyEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
type: "voter_network_cluster",
|
||||||
|
boardId: board.id,
|
||||||
|
createdAt: { gt: oneDayAgo },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
reply.send({ source: "cached", data: cached.metadata, createdAt: cached.createdAt });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// build fresh graph
|
||||||
|
await buildVoterGraph(board.id);
|
||||||
|
|
||||||
|
const fresh = await prisma.anomalyEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
type: "voter_network_cluster",
|
||||||
|
boardId: board.id,
|
||||||
|
createdAt: { gt: new Date(Date.now() - 60 * 1000) },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fresh) {
|
||||||
|
reply.send({ source: "fresh", data: fresh.metadata, createdAt: fresh.createdAt });
|
||||||
|
} else {
|
||||||
|
reply.send({ source: "fresh", data: { clusterSize: 0, identities: [], totalClusters: 0 }, createdAt: new Date() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ const createCommentSchema = z.object({
|
|||||||
altcha: z.string().optional(),
|
altcha: z.string().optional(),
|
||||||
replyToId: z.string().max(30).optional(),
|
replyToId: z.string().max(30).optional(),
|
||||||
attachmentIds: z.array(z.string()).max(10).optional(),
|
attachmentIds: z.array(z.string()).max(10).optional(),
|
||||||
|
website: z.string().optional(),
|
||||||
|
_ts: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCommentSchema = z.object({
|
const updateCommentSchema = z.object({
|
||||||
@@ -127,16 +129,32 @@ export default async function commentRoutes(app: FastifyInstance) {
|
|||||||
reply.status(403).send({ error: "Thread is locked" });
|
reply.status(403).send({ error: "Thread is locked" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (post.frozenAt) {
|
||||||
|
reply.status(403).send({ error: "Post is frozen" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// honeypot check - bots fill this hidden field
|
||||||
|
if ((req.body as any)?.website) {
|
||||||
|
reply.status(201).send({ id: "ok", body: (req.body as any).body || "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body = createCommentSchema.parse(req.body);
|
const body = createCommentSchema.parse(req.body);
|
||||||
|
|
||||||
|
// time-to-submit check - flag users who submit too fast
|
||||||
|
if (body._ts && Date.now() - body._ts < 5000) {
|
||||||
|
await prisma.user.update({ where: { id: req.user!.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// admins skip ALTCHA, regular users must provide it
|
// admins skip ALTCHA, regular users must provide it
|
||||||
if (!req.adminId) {
|
if (!req.adminId) {
|
||||||
if (!body.altcha) {
|
if (!body.altcha) {
|
||||||
reply.status(400).send({ error: "Challenge response required" });
|
reply.status(400).send({ error: "Challenge response required" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const valid = await verifyChallenge(body.altcha);
|
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
const valid = await verifyChallenge(body.altcha, sid);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
reply.status(400).send({ error: "Invalid challenge response" });
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export default async function identityRoutes(app: FastifyInstance) {
|
|||||||
reply.status(400).send({ error: "Verification required for display name changes" });
|
reply.status(400).send({ error: "Verification required for display name changes" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const valid = await verifyChallenge(body.altcha);
|
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
const valid = await verifyChallenge(body.altcha, sid);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
reply.status(400).send({ error: "Invalid challenge response" });
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
return;
|
return;
|
||||||
@@ -224,7 +225,8 @@ export default async function identityRoutes(app: FastifyInstance) {
|
|||||||
reply.status(400).send({ error: "Verification required to delete account" });
|
reply.status(400).send({ error: "Verification required to delete account" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const challengeValid = await verifyChallenge(altcha);
|
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
const challengeValid = await verifyChallenge(altcha, sid);
|
||||||
if (!challengeValid) {
|
if (!challengeValid) {
|
||||||
reply.status(400).send({ error: "Invalid challenge response" });
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { PostType, Prisma } from "@prisma/client";
|
import { PostType, Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { unlink } from "node:fs/promises";
|
import { unlink } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import prisma from "../lib/prisma.js";
|
import prisma from "../lib/prisma.js";
|
||||||
@@ -62,6 +63,9 @@ const createPostSchema = z.object({
|
|||||||
templateId: z.string().optional(),
|
templateId: z.string().optional(),
|
||||||
attachmentIds: z.array(z.string()).max(10).optional(),
|
attachmentIds: z.array(z.string()).max(10).optional(),
|
||||||
altcha: z.string(),
|
altcha: z.string(),
|
||||||
|
website: z.string().optional(),
|
||||||
|
_ts: z.number().optional(),
|
||||||
|
_pasted: z.boolean().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (data.templateId) {
|
if (data.templateId) {
|
||||||
// template posts use flexible description keys
|
// template posts use flexible description keys
|
||||||
@@ -226,7 +230,9 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
reply.send({
|
reply.send({
|
||||||
posts: posts.map((p) => ({
|
posts: posts.map((p) => {
|
||||||
|
const isVoteHidden = p.votesVisibleAfter && new Date(p.votesVisibleAfter) > new Date();
|
||||||
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
type: p.type,
|
type: p.type,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
@@ -234,7 +240,8 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
status: p.status,
|
status: p.status,
|
||||||
statusReason: p.statusReason,
|
statusReason: p.statusReason,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
voteCount: p.voteCount,
|
voteCount: isVoteHidden ? null : p.voteCount,
|
||||||
|
votingInProgress: isVoteHidden ? true : undefined,
|
||||||
viewCount: p.viewCount,
|
viewCount: p.viewCount,
|
||||||
isPinned: p.isPinned,
|
isPinned: p.isPinned,
|
||||||
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
|
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
|
||||||
@@ -246,7 +253,8 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
voteWeight: userVotes.get(p.id) ?? 0,
|
voteWeight: userVotes.get(p.id) ?? 0,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
updatedAt: p.updatedAt,
|
updatedAt: p.updatedAt,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
total,
|
total,
|
||||||
page: q.page,
|
page: q.page,
|
||||||
pages: Math.ceil(total / q.limit),
|
pages: Math.ceil(total / q.limit),
|
||||||
@@ -257,7 +265,7 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get<{ Params: { boardSlug: string; id: string } }>(
|
app.get<{ Params: { boardSlug: string; id: string } }>(
|
||||||
"/boards/:boardSlug/posts/:id",
|
"/boards/:boardSlug/posts/:id",
|
||||||
{ preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||||
if (!board) {
|
if (!board) {
|
||||||
@@ -327,6 +335,9 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isVoteHidden = post.votesVisibleAfter && new Date(post.votesVisibleAfter) > new Date();
|
||||||
|
const showRealCount = req.user?.id === post.authorId || req.adminId;
|
||||||
|
|
||||||
reply.send({
|
reply.send({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
type: post.type,
|
type: post.type,
|
||||||
@@ -335,7 +346,8 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
status: post.status,
|
status: post.status,
|
||||||
statusReason: post.statusReason,
|
statusReason: post.statusReason,
|
||||||
category: post.category,
|
category: post.category,
|
||||||
voteCount: post.voteCount,
|
voteCount: (isVoteHidden && !showRealCount) ? null : post.voteCount,
|
||||||
|
votingInProgress: (isVoteHidden && !showRealCount) ? true : undefined,
|
||||||
viewCount: post.viewCount,
|
viewCount: post.viewCount,
|
||||||
isPinned: post.isPinned,
|
isPinned: post.isPinned,
|
||||||
isEditLocked: post.isEditLocked,
|
isEditLocked: post.isEditLocked,
|
||||||
@@ -472,14 +484,34 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// honeypot check - bots fill this hidden field
|
||||||
|
if ((req.body as any)?.website) {
|
||||||
|
reply.status(201).send({ id: "ok", title: (req.body as any).title || "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body = createPostSchema.parse(req.body);
|
const body = createPostSchema.parse(req.body);
|
||||||
|
|
||||||
const valid = await verifyChallenge(body.altcha);
|
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
const valid = await verifyChallenge(body.altcha, sessionId);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
reply.status(400).send({ error: "Invalid challenge response" });
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// time-to-submit check - flag users who submit too fast
|
||||||
|
if (body._ts && Date.now() - body._ts < 5000) {
|
||||||
|
await prisma.user.update({ where: { id: req.user!.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// clipboard paste detection - flag new identities who paste content
|
||||||
|
if (body._pasted && req.user) {
|
||||||
|
const pUser = await prisma.user.findUnique({ where: { id: req.user.id }, select: { createdAt: true } });
|
||||||
|
if (pUser && Date.now() - pUser.createdAt.getTime() < 10 * 60 * 1000) {
|
||||||
|
await prisma.user.update({ where: { id: req.user.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
|
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
|
||||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
|
const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
|
||||||
@@ -501,6 +533,22 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cross-user content hash deduplication
|
||||||
|
const crossUserPosts = await prisma.post.findMany({
|
||||||
|
where: { boardId: board.id, createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) } },
|
||||||
|
select: { title: true, description: true, authorId: true },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
const myHash = createHash("sha256").update(body.title.trim().toLowerCase() + JSON.stringify(body.description)).digest("hex");
|
||||||
|
for (const rp of crossUserPosts) {
|
||||||
|
if (rp.authorId === req.user!.id) continue;
|
||||||
|
const rpHash = createHash("sha256").update((rp.title as string).trim().toLowerCase() + JSON.stringify(rp.description)).digest("hex");
|
||||||
|
if (rpHash === myHash) {
|
||||||
|
reply.status(409).send({ error: "This has already been submitted" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (body.templateId) {
|
if (body.templateId) {
|
||||||
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } });
|
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } });
|
||||||
if (!tmpl || tmpl.boardId !== board.id) {
|
if (!tmpl || tmpl.boardId !== board.id) {
|
||||||
@@ -518,6 +566,7 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
templateId: body.templateId,
|
templateId: body.templateId,
|
||||||
boardId: board.id,
|
boardId: board.id,
|
||||||
authorId: req.user!.id,
|
authorId: req.user!.id,
|
||||||
|
votesVisibleAfter: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,7 +652,8 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
reply.status(400).send({ error: "Challenge response required" });
|
reply.status(400).send({ error: "Challenge response required" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const valid = await verifyChallenge(body.altcha);
|
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
const valid = await verifyChallenge(body.altcha, sid);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
reply.status(400).send({ error: "Invalid challenge response" });
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
return;
|
return;
|
||||||
@@ -665,6 +715,48 @@ export default async function postRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// check edit distance after save - flag substantial edits on voted posts
|
||||||
|
if ((titleChanged || descChanged) && post.voteCount > 5) {
|
||||||
|
const oldContent = JSON.stringify({ title: post.title, description: post.description });
|
||||||
|
const newContent = JSON.stringify({ title: updated.title, description: updated.description });
|
||||||
|
const maxLen = Math.max(oldContent.length, newContent.length);
|
||||||
|
if (maxLen > 0) {
|
||||||
|
let diffs = 0;
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
if (oldContent[i] !== newContent[i]) diffs++;
|
||||||
|
}
|
||||||
|
const changeRatio = diffs / maxLen;
|
||||||
|
|
||||||
|
if (changeRatio > 0.7) {
|
||||||
|
const { createAnomalyEvent } = await import("../services/detection-engine.js");
|
||||||
|
createAnomalyEvent({
|
||||||
|
type: "post_substantially_edited",
|
||||||
|
severity: "medium",
|
||||||
|
targetType: "post",
|
||||||
|
targetId: post.id,
|
||||||
|
boardId: post.boardId,
|
||||||
|
metadata: { changeRatio, voteCount: post.voteCount, editedBy: req.user!.id },
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
const voters = await prisma.vote.findMany({
|
||||||
|
where: { postId: post.id },
|
||||||
|
select: { voterId: true },
|
||||||
|
});
|
||||||
|
if (voters.length > 0) {
|
||||||
|
await prisma.notification.createMany({
|
||||||
|
data: voters.slice(0, 100).map((v) => ({
|
||||||
|
type: "post_edited",
|
||||||
|
title: "Post you voted on was edited",
|
||||||
|
body: `"${post.title}" was substantially modified after receiving votes`,
|
||||||
|
postId: post.id,
|
||||||
|
userId: v.voterId,
|
||||||
|
})),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reply.send({
|
reply.send({
|
||||||
id: updated.id, type: updated.type, title: updated.title,
|
id: updated.id, type: updated.type, title: updated.title,
|
||||||
description: updated.description, status: updated.status,
|
description: updated.description, status: updated.status,
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import prisma from "../lib/prisma.js";
|
import prisma from "../lib/prisma.js";
|
||||||
import { generateChallenge } from "../services/altcha.js";
|
import { generateChallenge, getAdaptiveDifficulty } from "../services/altcha.js";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
|
|
||||||
const challengeQuery = z.object({
|
const challengeQuery = z.object({
|
||||||
difficulty: z.enum(["normal", "light"]).default("normal"),
|
difficulty: z.enum(["normal", "light"]).default("normal"),
|
||||||
|
boardId: z.string().max(30).optional(),
|
||||||
|
postId: z.string().max(30).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function privacyRoutes(app: FastifyInstance) {
|
export default async function privacyRoutes(app: FastifyInstance) {
|
||||||
app.get("/altcha/challenge", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
|
app.get("/altcha/challenge", { preHandler: [app.optionalUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||||
const { difficulty } = challengeQuery.parse(req.query);
|
const { difficulty, boardId, postId } = challengeQuery.parse(req.query);
|
||||||
const challenge = await generateChallenge(difficulty);
|
const userId = req.user?.id;
|
||||||
|
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
|
||||||
|
const adaptiveMax = await getAdaptiveDifficulty({ postId, boardId, userId });
|
||||||
|
// use the higher of adaptive or difficulty-based default
|
||||||
|
const difficultyMax = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
|
||||||
|
const maxNumber = Math.max(adaptiveMax, difficultyMax);
|
||||||
|
|
||||||
|
const challenge = await generateChallenge({ maxNumber, sessionId });
|
||||||
reply.send(challenge);
|
reply.send(challenge);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import prisma from "../lib/prisma.js";
|
import prisma from "../lib/prisma.js";
|
||||||
|
import { checkReactionVelocity } from "../services/detection-engine.js";
|
||||||
|
|
||||||
const reactionBody = z.object({
|
const reactionBody = z.object({
|
||||||
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
|
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
|
||||||
@@ -61,6 +62,7 @@ export default async function reactionRoutes(app: FastifyInstance) {
|
|||||||
userId: req.user!.id,
|
userId: req.user!.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
checkReactionVelocity(comment.id).catch(() => {});
|
||||||
reply.send({ toggled: true });
|
reply.send({ toggled: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ export default async function recoveryRoutes(app: FastifyInstance) {
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const body = recoverBody.parse(req.body);
|
const body = recoverBody.parse(req.body);
|
||||||
|
|
||||||
const valid = await verifyChallenge(body.altcha);
|
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
const valid = await verifyChallenge(body.altcha, sessionId);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
reply.status(400).send({ error: "Invalid challenge response" });
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import prisma from "../lib/prisma.js";
|
import prisma from "../lib/prisma.js";
|
||||||
import { verifyChallenge } from "../services/altcha.js";
|
import { verifyChallenge } from "../services/altcha.js";
|
||||||
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
|
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
|
||||||
|
import { recordAction, recordVoteTimingUpdate } from "../services/identity-signals.js";
|
||||||
|
|
||||||
const voteBody = z.object({
|
const voteBody = z.object({
|
||||||
altcha: z.string(),
|
altcha: z.string(),
|
||||||
@@ -44,7 +46,8 @@ export default async function voteRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = voteBody.parse(req.body);
|
const body = voteBody.parse(req.body);
|
||||||
const valid = await verifyChallenge(body.altcha);
|
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||||
|
const valid = await verifyChallenge(body.altcha, sessionId);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
reply.status(400).send({ error: "Invalid challenge response" });
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
return;
|
return;
|
||||||
@@ -52,6 +55,14 @@ export default async function voteRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const period = getCurrentPeriod(board.voteBudgetReset);
|
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||||
|
|
||||||
|
// phantom vote detection - hash IP, check rate
|
||||||
|
const ipHash = createHash("sha256").update(req.ip || "unknown").digest("hex").slice(0, 16);
|
||||||
|
const ipVoteCount = await prisma.vote.count({
|
||||||
|
where: { postId: post.id, voterIp: ipHash, createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) }, phantom: false },
|
||||||
|
});
|
||||||
|
const isPhantom = ipVoteCount >= 3 || !!post.frozenAt;
|
||||||
|
const refHeader = ((req.headers.referer || req.headers.referrer || "") as string).slice(0, 200) || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
const existing = await tx.vote.findUnique({
|
const existing = await tx.vote.findUnique({
|
||||||
@@ -75,14 +86,20 @@ export default async function voteRoutes(app: FastifyInstance) {
|
|||||||
postId: post.id,
|
postId: post.id,
|
||||||
voterId: req.user!.id,
|
voterId: req.user!.id,
|
||||||
budgetPeriod: period,
|
budgetPeriod: period,
|
||||||
|
phantom: isPhantom,
|
||||||
|
voterIp: ipHash,
|
||||||
|
referrer: refHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// phantom votes don't affect the visible count
|
||||||
|
if (!isPhantom) {
|
||||||
await tx.post.update({
|
await tx.post.update({
|
||||||
where: { id: post.id },
|
where: { id: post.id },
|
||||||
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
|
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}, { isolationLevel: "Serializable" });
|
}, { isolationLevel: "Serializable" });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.message === "ALREADY_VOTED") {
|
if (err.message === "ALREADY_VOTED") {
|
||||||
@@ -109,7 +126,11 @@ export default async function voteRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const newCount = post.voteCount + 1;
|
// record identity signals
|
||||||
|
recordAction(req.user!.id, "vote", { boardId: post.boardId }).catch(() => {});
|
||||||
|
recordVoteTimingUpdate(req.user!.id).catch(() => {});
|
||||||
|
|
||||||
|
const newCount = isPhantom ? post.voteCount : post.voteCount + 1;
|
||||||
const milestones = [10, 50, 100, 250, 500];
|
const milestones = [10, 50, 100, 250, 500];
|
||||||
if (milestones.includes(newCount)) {
|
if (milestones.includes(newCount)) {
|
||||||
await prisma.activityEvent.create({
|
await prisma.activityEvent.create({
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import recoveryRoutes from "./routes/recovery.js";
|
|||||||
import settingsRoutes from "./routes/admin/settings.js";
|
import settingsRoutes from "./routes/admin/settings.js";
|
||||||
import adminTeamRoutes from "./routes/admin/team.js";
|
import adminTeamRoutes from "./routes/admin/team.js";
|
||||||
import adminPluginRoutes from "./routes/admin/plugins.js";
|
import adminPluginRoutes from "./routes/admin/plugins.js";
|
||||||
|
import adminSecurityRoutes from "./routes/admin/security.js";
|
||||||
import pluginApiRoutes from "./routes/plugins-api.js";
|
import pluginApiRoutes from "./routes/plugins-api.js";
|
||||||
|
|
||||||
const DEFAULT_OG_DESC = "Self-hosted feedback board. Anonymous by default, no email required. Vote on what matters, comment with markdown, track status changes.";
|
const DEFAULT_OG_DESC = "Self-hosted feedback board. Anonymous by default, no email required. Vote on what matters, comment with markdown, track status changes.";
|
||||||
@@ -234,6 +235,7 @@ export async function createServer() {
|
|||||||
await api.register(settingsRoutes);
|
await api.register(settingsRoutes);
|
||||||
await api.register(adminTeamRoutes);
|
await api.register(adminTeamRoutes);
|
||||||
await api.register(adminPluginRoutes);
|
await api.register(adminPluginRoutes);
|
||||||
|
await api.register(adminSecurityRoutes);
|
||||||
await api.register(pluginApiRoutes);
|
await api.register(pluginApiRoutes);
|
||||||
}, { prefix: "/api/v1" });
|
}, { prefix: "/api/v1" });
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import { createChallenge, verifySolution } from "altcha-lib";
|
import { createChallenge, verifySolution } from "altcha-lib";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
|
import prisma from "../lib/prisma.js";
|
||||||
|
import { getIdentityRiskLevel } from "./identity-signals.js";
|
||||||
|
|
||||||
// replay protection: track consumed challenge hashes (fingerprint -> timestamp)
|
interface ChallengeEntry {
|
||||||
const usedChallenges = new Map<string, number>();
|
issuedAt: number;
|
||||||
|
sessionHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// replay protection: track consumed challenge hashes
|
||||||
|
const usedChallenges = new Map<string, ChallengeEntry>();
|
||||||
|
// track issued challenges for solve-time validation
|
||||||
|
const issuedChallenges = new Map<string, ChallengeEntry>();
|
||||||
const EXPIRY_MS = 300000;
|
const EXPIRY_MS = 300000;
|
||||||
|
const MIN_SOLVE_MS = 50;
|
||||||
|
|
||||||
// clean up expired entries every 5 minutes
|
// clean up expired entries every 5 minutes
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const cutoff = Date.now() - EXPIRY_MS;
|
const cutoff = Date.now() - EXPIRY_MS;
|
||||||
for (const [fp, ts] of usedChallenges) {
|
for (const [fp, entry] of usedChallenges) {
|
||||||
if (ts < cutoff) usedChallenges.delete(fp);
|
if (entry.issuedAt < cutoff) usedChallenges.delete(fp);
|
||||||
|
}
|
||||||
|
for (const [salt, entry] of issuedChallenges) {
|
||||||
|
if (entry.issuedAt < cutoff) issuedChallenges.delete(salt);
|
||||||
}
|
}
|
||||||
}, EXPIRY_MS);
|
}, EXPIRY_MS);
|
||||||
|
|
||||||
@@ -18,17 +31,83 @@ function challengeFingerprint(payload: string): string {
|
|||||||
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
|
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateChallenge(difficulty: "normal" | "light" = "normal") {
|
function hashSession(cookieValue: string): string {
|
||||||
const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
|
return createHash("sha256").update(cookieValue).digest("hex").slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdaptiveDifficulty(context: {
|
||||||
|
postId?: string;
|
||||||
|
boardId?: string;
|
||||||
|
userId?: string;
|
||||||
|
}): Promise<number> {
|
||||||
|
const base = config.ALTCHA_MAX_NUMBER;
|
||||||
|
let multiplier = 1;
|
||||||
|
|
||||||
|
if (context.boardId) {
|
||||||
|
const board = await prisma.board.findUnique({
|
||||||
|
where: { id: context.boardId },
|
||||||
|
select: { sensitivityLevel: true, requireVoteVerification: true },
|
||||||
|
});
|
||||||
|
if (board?.sensitivityLevel === "high") multiplier *= 3;
|
||||||
|
|
||||||
|
// when vote verification is enabled, new identities get harder challenges
|
||||||
|
if (board?.requireVoteVerification && context.userId) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.userId },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
if (user && Date.now() - user.createdAt.getTime() < 60 * 60 * 1000) {
|
||||||
|
multiplier *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.postId) {
|
||||||
|
const recentAnomaly = await prisma.anomalyEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
targetId: context.postId,
|
||||||
|
type: "vote_velocity",
|
||||||
|
status: "pending",
|
||||||
|
createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (recentAnomaly) multiplier *= 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.userId) {
|
||||||
|
const risk = await getIdentityRiskLevel(context.userId);
|
||||||
|
if (risk === "high") multiplier *= 3;
|
||||||
|
else if (risk === "medium") multiplier *= 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.floor(base * multiplier), base * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateChallenge(opts: {
|
||||||
|
difficulty?: "normal" | "light";
|
||||||
|
maxNumber?: number;
|
||||||
|
sessionId?: string;
|
||||||
|
} = {}) {
|
||||||
|
const maxNumber = opts.maxNumber
|
||||||
|
?? (opts.difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER);
|
||||||
|
|
||||||
const challenge = await createChallenge({
|
const challenge = await createChallenge({
|
||||||
hmacKey: config.ALTCHA_HMAC_KEY,
|
hmacKey: config.ALTCHA_HMAC_KEY,
|
||||||
maxNumber,
|
maxNumber,
|
||||||
expires: new Date(Date.now() + config.ALTCHA_EXPIRE_SECONDS * 1000),
|
expires: new Date(Date.now() + config.ALTCHA_EXPIRE_SECONDS * 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// track issuance for solve-time and session validation
|
||||||
|
const sHash = hashSession(opts.sessionId || "");
|
||||||
|
issuedChallenges.set(challenge.salt, {
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
sessionHash: sHash,
|
||||||
|
});
|
||||||
|
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyChallenge(payload: string): Promise<boolean> {
|
export async function verifyChallenge(payload: string, sessionId?: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const fp = challengeFingerprint(payload);
|
const fp = challengeFingerprint(payload);
|
||||||
|
|
||||||
@@ -38,20 +117,36 @@ export async function verifyChallenge(payload: string): Promise<boolean> {
|
|||||||
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
|
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
|
||||||
if (!ok) return false;
|
if (!ok) return false;
|
||||||
|
|
||||||
|
// extract salt from payload to check issuance data
|
||||||
|
const decoded = JSON.parse(atob(payload));
|
||||||
|
const issued = issuedChallenges.get(decoded.salt);
|
||||||
|
|
||||||
|
if (issued) {
|
||||||
|
// solve-time check - reject if solved too fast (bot/script)
|
||||||
|
const solveTime = Date.now() - issued.issuedAt;
|
||||||
|
if (solveTime < MIN_SOLVE_MS) return false;
|
||||||
|
|
||||||
|
// session binding check
|
||||||
|
const currentHash = hashSession(sessionId || "");
|
||||||
|
if (issued.sessionHash !== currentHash) return false;
|
||||||
|
|
||||||
|
issuedChallenges.delete(decoded.salt);
|
||||||
|
}
|
||||||
|
|
||||||
// evict expired entries if map is large
|
// evict expired entries if map is large
|
||||||
if (usedChallenges.size >= 50000) {
|
if (usedChallenges.size >= 50000) {
|
||||||
const cutoff = Date.now() - EXPIRY_MS;
|
const cutoff = Date.now() - EXPIRY_MS;
|
||||||
for (const [key, ts] of usedChallenges) {
|
for (const [key, entry] of usedChallenges) {
|
||||||
if (ts < cutoff) usedChallenges.delete(key);
|
if (entry.issuedAt < cutoff) usedChallenges.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// hard cap - drop oldest entries if still over limit
|
// hard cap - drop oldest entries if still over limit
|
||||||
if (usedChallenges.size >= 50000) {
|
if (usedChallenges.size >= 50000) {
|
||||||
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1] - b[1]);
|
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1].issuedAt - b[1].issuedAt);
|
||||||
const toRemove = sorted.slice(0, sorted.length - 40000);
|
const toRemove = sorted.slice(0, sorted.length - 40000);
|
||||||
for (const [key] of toRemove) usedChallenges.delete(key);
|
for (const [key] of toRemove) usedChallenges.delete(key);
|
||||||
}
|
}
|
||||||
usedChallenges.set(fp, Date.now());
|
usedChallenges.set(fp, { issuedAt: Date.now(), sessionHash: hashSession(sessionId || "") });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
1039
packages/api/src/services/detection-engine.ts
Normal file
1039
packages/api/src/services/detection-engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
98
packages/api/src/services/identity-signals.ts
Normal file
98
packages/api/src/services/identity-signals.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import prisma from "../lib/prisma.js";
|
||||||
|
|
||||||
|
export async function recordAction(userId: string, actionType: string, metadata?: { boardId?: string }) {
|
||||||
|
const updates: Record<string, any> = {};
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { firstActionType: true, boardInteractionCount: true } });
|
||||||
|
if (user && !user.firstActionType) {
|
||||||
|
updates.firstActionType = actionType;
|
||||||
|
updates.firstActionAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata?.boardId) {
|
||||||
|
const distinctBoards = await prisma.vote.findMany({
|
||||||
|
where: { voterId: userId },
|
||||||
|
select: { post: { select: { boardId: true } } },
|
||||||
|
distinct: ["postId"],
|
||||||
|
});
|
||||||
|
const boardIds = new Set(distinctBoards.map((v) => v.post.boardId));
|
||||||
|
if (metadata.boardId) boardIds.add(metadata.boardId);
|
||||||
|
updates.boardInteractionCount = boardIds.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentVotes = await prisma.vote.count({ where: { voterId: userId } });
|
||||||
|
const recentComments = await prisma.comment.count({ where: { authorId: userId } });
|
||||||
|
const recentPosts = await prisma.post.count({ where: { authorId: userId } });
|
||||||
|
const recentReactions = await prisma.reaction.count({ where: { userId } });
|
||||||
|
const activeTypes = [recentVotes > 0, recentComments > 0, recentPosts > 0, recentReactions > 0].filter(Boolean).length;
|
||||||
|
updates.actionDiversityScore = activeTypes / 4;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: updates }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordVoteTimingUpdate(userId: string) {
|
||||||
|
const votes = await prisma.vote.findMany({
|
||||||
|
where: { voterId: userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 20,
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (votes.length < 3) return;
|
||||||
|
|
||||||
|
const intervals: number[] = [];
|
||||||
|
for (let i = 0; i < votes.length - 1; i++) {
|
||||||
|
intervals.push(votes[i].createdAt.getTime() - votes[i + 1].createdAt.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||||
|
const variance = intervals.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / intervals.length;
|
||||||
|
const stdDev = Math.sqrt(variance);
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: { voteTimingStdDev: stdDev } }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkTimeToSubmit(pageLoadTimestamp: number): boolean {
|
||||||
|
const elapsed = Date.now() - pageLoadTimestamp;
|
||||||
|
return elapsed < 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyReferrer(referrer: string | undefined, origin: string): "internal" | "external" | "none" {
|
||||||
|
if (!referrer) return "none";
|
||||||
|
try {
|
||||||
|
const refUrl = new URL(referrer);
|
||||||
|
const originUrl = new URL(origin);
|
||||||
|
return refUrl.hostname === originUrl.hostname ? "internal" : "external";
|
||||||
|
} catch {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentityRiskLevel(userId: string): Promise<"low" | "medium" | "high"> {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
actionDiversityScore: true,
|
||||||
|
voteTimingStdDev: true,
|
||||||
|
boardInteractionCount: true,
|
||||||
|
flagCount: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) return "high";
|
||||||
|
|
||||||
|
let risk = 0;
|
||||||
|
const ageMs = Date.now() - user.createdAt.getTime();
|
||||||
|
|
||||||
|
if (ageMs < 10 * 60 * 1000) risk += 2;
|
||||||
|
if (user.actionDiversityScore < 0.25) risk += 1;
|
||||||
|
if (user.voteTimingStdDev !== null && user.voteTimingStdDev < 2000) risk += 2;
|
||||||
|
if (user.boardInteractionCount <= 1) risk += 1;
|
||||||
|
if (user.flagCount > 0) risk += user.flagCount;
|
||||||
|
|
||||||
|
if (risk >= 4) return "high";
|
||||||
|
if (risk >= 2) return "medium";
|
||||||
|
return "low";
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import AdminExport from './pages/admin/AdminExport'
|
|||||||
import AdminTemplates from './pages/admin/AdminTemplates'
|
import AdminTemplates from './pages/admin/AdminTemplates'
|
||||||
import AdminSettings from './pages/admin/AdminSettings'
|
import AdminSettings from './pages/admin/AdminSettings'
|
||||||
import AdminTeam from './pages/admin/AdminTeam'
|
import AdminTeam from './pages/admin/AdminTeam'
|
||||||
|
import AdminSecurity from './pages/admin/AdminSecurity'
|
||||||
import AdminPlugins from './pages/admin/AdminPlugins'
|
import AdminPlugins from './pages/admin/AdminPlugins'
|
||||||
import AdminJoin from './pages/admin/AdminJoin'
|
import AdminJoin from './pages/admin/AdminJoin'
|
||||||
import ProfilePage from './pages/ProfilePage'
|
import ProfilePage from './pages/ProfilePage'
|
||||||
@@ -528,6 +529,7 @@ function Layout() {
|
|||||||
<Route path="/admin/templates" element={<RequireAdmin><AdminTemplates /></RequireAdmin>} />
|
<Route path="/admin/templates" element={<RequireAdmin><AdminTemplates /></RequireAdmin>} />
|
||||||
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
|
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
|
||||||
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
|
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
|
||||||
|
<Route path="/admin/security" element={<RequireAdmin><AdminSecurity /></RequireAdmin>} />
|
||||||
<Route path="/admin/join/:token" element={<AdminJoin />} />
|
<Route path="/admin/join/:token" element={<AdminJoin />} />
|
||||||
<Route path="/admin/plugins" element={<RequireAdmin><AdminPlugins /></RequireAdmin>} />
|
<Route path="/admin/plugins" element={<RequireAdmin><AdminPlugins /></RequireAdmin>} />
|
||||||
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
|
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useAdmin } from '../hooks/useAdmin'
|
import { useAdmin } from '../hooks/useAdmin'
|
||||||
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers } from '@tabler/icons-react'
|
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers, IconShieldLock } from '@tabler/icons-react'
|
||||||
import type { Icon } from '@tabler/icons-react'
|
import type { Icon } from '@tabler/icons-react'
|
||||||
|
|
||||||
interface PluginInfo {
|
interface PluginInfo {
|
||||||
@@ -20,6 +20,7 @@ const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [
|
|||||||
{ to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 },
|
{ to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 },
|
||||||
{ to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 },
|
{ to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 },
|
||||||
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
|
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
|
||||||
|
{ to: '/admin/security', label: 'Security', icon: IconShieldLock, minLevel: 2 },
|
||||||
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
|
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
|
||||||
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
|
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
|
||||||
{ to: '/admin/plugins', label: 'Plugins', icon: IconPlug, minLevel: 3 },
|
{ to: '/admin/plugins', label: 'Plugins', icon: IconPlug, minLevel: 3 },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface Props {
|
|||||||
ariaRequired?: boolean
|
ariaRequired?: boolean
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
mentions?: boolean
|
mentions?: boolean
|
||||||
|
onPaste?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
@@ -154,7 +155,7 @@ function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: num
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions }: Props) {
|
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions, onPaste }: Props) {
|
||||||
const ref = useRef<HTMLTextAreaElement>(null)
|
const ref = useRef<HTMLTextAreaElement>(null)
|
||||||
const [previewing, setPreviewing] = useState(false)
|
const [previewing, setPreviewing] = useState(false)
|
||||||
const [tablePicker, setTablePicker] = useState(false)
|
const [tablePicker, setTablePicker] = useState(false)
|
||||||
@@ -443,6 +444,7 @@ export default function MarkdownEditor({ value, onChange, placeholder, rows = 3,
|
|||||||
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
|
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
|
||||||
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
|
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
|
||||||
} : undefined}
|
} : undefined}
|
||||||
|
onPaste={onPaste}
|
||||||
style={{ resize: 'vertical' }}
|
style={{ resize: 'vertical' }}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
aria-label={ariaLabel || 'Markdown content'}
|
aria-label={ariaLabel || 'Markdown content'}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ interface Post {
|
|||||||
status: string
|
status: string
|
||||||
statusReason?: string | null
|
statusReason?: string | null
|
||||||
category?: string | null
|
category?: string | null
|
||||||
voteCount: number
|
voteCount: number | null
|
||||||
|
votingInProgress?: boolean
|
||||||
commentCount: number
|
commentCount: number
|
||||||
viewCount?: number
|
viewCount?: number
|
||||||
isPinned?: boolean
|
isPinned?: boolean
|
||||||
@@ -126,6 +127,9 @@ export default function PostCard({
|
|||||||
transition: 'color var(--duration-fast) ease-out',
|
transition: 'color var(--duration-fast) ease-out',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{post.votingInProgress ? (
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>...</span>
|
||||||
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -137,6 +141,7 @@ export default function PostCard({
|
|||||||
>
|
>
|
||||||
{post.voteCount}
|
{post.voteCount}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{budgetDepleted && !post.voted && (
|
{budgetDepleted && !post.voted && (
|
||||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', textAlign: 'center', lineHeight: 1.2, marginTop: 2 }}>
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', textAlign: 'center', lineHeight: 1.2, marginTop: 2 }}>
|
||||||
0 left
|
0 left
|
||||||
@@ -159,7 +164,11 @@ export default function PostCard({
|
|||||||
aria-label="Vote"
|
aria-label="Vote"
|
||||||
>
|
>
|
||||||
<IconChevronUp size={14} stroke={2.5} className={voteAnimating ? 'vote-bounce' : ''} />
|
<IconChevronUp size={14} stroke={2.5} className={voteAnimating ? 'vote-bounce' : ''} />
|
||||||
|
{post.votingInProgress ? (
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>...</span>
|
||||||
|
) : (
|
||||||
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
|
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<StatusBadge status={post.status} customStatuses={customStatuses} />
|
<StatusBadge status={post.status} customStatuses={customStatuses} />
|
||||||
<div className="flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
|
<div className="flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
|||||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||||
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
||||||
const [similar, setSimilar] = useState<SimilarPost[]>([])
|
const [similar, setSimilar] = useState<SimilarPost[]>([])
|
||||||
|
const [honeypot, setHoneypot] = useState('')
|
||||||
|
const [pageLoadTime] = useState(Date.now())
|
||||||
|
const [wasPasted, setWasPasted] = useState(false)
|
||||||
|
|
||||||
// templates
|
// templates
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
@@ -157,7 +160,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
|||||||
|
|
||||||
let altcha: string
|
let altcha: string
|
||||||
try {
|
try {
|
||||||
altcha = await solveAltcha()
|
altcha = await solveAltcha('normal', { boardId })
|
||||||
} catch {
|
} catch {
|
||||||
setError('Verification failed. Please try again.')
|
setError('Verification failed. Please try again.')
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
@@ -196,6 +199,9 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
|||||||
templateId: selectedTemplate ? selectedTemplate.id : undefined,
|
templateId: selectedTemplate ? selectedTemplate.id : undefined,
|
||||||
attachmentIds: attachmentIds.length ? attachmentIds : undefined,
|
attachmentIds: attachmentIds.length ? attachmentIds : undefined,
|
||||||
altcha,
|
altcha,
|
||||||
|
website: honeypot || undefined,
|
||||||
|
_ts: pageLoadTime,
|
||||||
|
_pasted: wasPasted || undefined,
|
||||||
})
|
})
|
||||||
reset()
|
reset()
|
||||||
onSubmit?.()
|
onSubmit?.()
|
||||||
@@ -235,6 +241,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
|||||||
placeholder={f.placeholder || ''}
|
placeholder={f.placeholder || ''}
|
||||||
value={templateValues[f.key] || ''}
|
value={templateValues[f.key] || ''}
|
||||||
onChange={(e) => setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
|
onChange={(e) => setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
|
||||||
|
onPaste={() => setWasPasted(true)}
|
||||||
aria-required={f.required || undefined}
|
aria-required={f.required || undefined}
|
||||||
aria-invalid={!!fieldErrors[f.key]}
|
aria-invalid={!!fieldErrors[f.key]}
|
||||||
aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined}
|
aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined}
|
||||||
@@ -248,6 +255,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
|||||||
rows={3}
|
rows={3}
|
||||||
ariaRequired={f.required}
|
ariaRequired={f.required}
|
||||||
ariaLabel={f.label}
|
ariaLabel={f.label}
|
||||||
|
onPaste={() => setWasPasted(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{f.type === 'select' && f.options && (
|
{f.type === 'select' && f.options && (
|
||||||
@@ -271,6 +279,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
|||||||
className="card card-static p-5"
|
className="card card-static p-5"
|
||||||
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
|
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
|
||||||
>
|
>
|
||||||
|
<input type="text" name="website" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2
|
<h2
|
||||||
className="font-bold"
|
className="font-bold"
|
||||||
@@ -350,6 +359,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
|
|||||||
placeholder="Brief summary"
|
placeholder="Brief summary"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onPaste={() => setWasPasted(true)}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
aria-invalid={!!fieldErrors.title}
|
aria-invalid={!!fieldErrors.title}
|
||||||
|
|||||||
@@ -14,8 +14,14 @@ async function hashHex(algorithm: string, data: string): Promise<string> {
|
|||||||
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
|
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function solveAltcha(difficulty: 'normal' | 'light' = 'normal'): Promise<string> {
|
export async function solveAltcha(
|
||||||
const ch = await api.get<Challenge>(`/altcha/challenge?difficulty=${difficulty}`)
|
difficulty: 'normal' | 'light' = 'normal',
|
||||||
|
opts?: { boardId?: string; postId?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const params = new URLSearchParams({ difficulty })
|
||||||
|
if (opts?.boardId) params.set('boardId', opts.boardId)
|
||||||
|
if (opts?.postId) params.set('postId', opts.postId)
|
||||||
|
const ch = await api.get<Challenge>(`/altcha/challenge?${params.toString()}`)
|
||||||
|
|
||||||
for (let n = 0; n <= ch.maxnumber; n++) {
|
for (let n = 0; n <= ch.maxnumber; n++) {
|
||||||
const hash = await hashHex(ch.algorithm, ch.salt + n)
|
const hash = await hashHex(ch.algorithm, ch.salt + n)
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ interface Post {
|
|||||||
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||||
status: string
|
status: string
|
||||||
category?: string | null
|
category?: string | null
|
||||||
voteCount: number
|
voteCount: number | null
|
||||||
|
votingInProgress?: boolean
|
||||||
commentCount: number
|
commentCount: number
|
||||||
viewCount?: number
|
viewCount?: number
|
||||||
isPinned?: boolean
|
isPinned?: boolean
|
||||||
@@ -228,17 +229,17 @@ export default function BoardFeed() {
|
|||||||
|
|
||||||
const handleVote = async (postId: string) => {
|
const handleVote = async (postId: string) => {
|
||||||
setPosts((prev) => prev.map((p) =>
|
setPosts((prev) => prev.map((p) =>
|
||||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
|
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount != null ? p.voteCount + 1 : p.voteCount } : p
|
||||||
))
|
))
|
||||||
if (budget) setBudget({ ...budget, remaining: budget.remaining - 1 })
|
if (budget) setBudget({ ...budget, remaining: budget.remaining - 1 })
|
||||||
try {
|
try {
|
||||||
const altcha = await solveAltcha('light')
|
const altcha = await solveAltcha('light', { postId })
|
||||||
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
||||||
refreshBudget()
|
refreshBudget()
|
||||||
setImportancePostId(postId)
|
setImportancePostId(postId)
|
||||||
} catch {
|
} catch {
|
||||||
setPosts((prev) => prev.map((p) =>
|
setPosts((prev) => prev.map((p) =>
|
||||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
|
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount != null ? p.voteCount - 1 : p.voteCount } : p
|
||||||
))
|
))
|
||||||
refreshBudget()
|
refreshBudget()
|
||||||
}
|
}
|
||||||
@@ -253,7 +254,7 @@ export default function BoardFeed() {
|
|||||||
|
|
||||||
const handleUnvote = async (postId: string) => {
|
const handleUnvote = async (postId: string) => {
|
||||||
setPosts((prev) => prev.map((p) =>
|
setPosts((prev) => prev.map((p) =>
|
||||||
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
|
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount != null ? p.voteCount - 1 : p.voteCount } : p
|
||||||
))
|
))
|
||||||
if (budget) setBudget({ ...budget, remaining: budget.remaining + 1 })
|
if (budget) setBudget({ ...budget, remaining: budget.remaining + 1 })
|
||||||
try {
|
try {
|
||||||
@@ -261,7 +262,7 @@ export default function BoardFeed() {
|
|||||||
refreshBudget()
|
refreshBudget()
|
||||||
} catch {
|
} catch {
|
||||||
setPosts((prev) => prev.map((p) =>
|
setPosts((prev) => prev.map((p) =>
|
||||||
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
|
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount != null ? p.voteCount + 1 : p.voteCount } : p
|
||||||
))
|
))
|
||||||
refreshBudget()
|
refreshBudget()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import StatusBadge from '../components/StatusBadge'
|
|||||||
import Timeline from '../components/Timeline'
|
import Timeline from '../components/Timeline'
|
||||||
import type { TimelineEntry } from '../components/Timeline'
|
import type { TimelineEntry } from '../components/Timeline'
|
||||||
import PluginSlot from '../components/PluginSlot'
|
import PluginSlot from '../components/PluginSlot'
|
||||||
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck } from '@tabler/icons-react'
|
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck, IconSnowflake, IconSnowflakeOff } from '@tabler/icons-react'
|
||||||
import Dropdown from '../components/Dropdown'
|
import Dropdown from '../components/Dropdown'
|
||||||
import EditHistoryModal from '../components/EditHistoryModal'
|
import EditHistoryModal from '../components/EditHistoryModal'
|
||||||
import Avatar from '../components/Avatar'
|
import Avatar from '../components/Avatar'
|
||||||
@@ -35,7 +35,8 @@ interface Post {
|
|||||||
status: string
|
status: string
|
||||||
statusReason?: string | null
|
statusReason?: string | null
|
||||||
category?: string | null
|
category?: string | null
|
||||||
voteCount: number
|
voteCount: number | null
|
||||||
|
votingInProgress?: boolean
|
||||||
viewCount?: number
|
viewCount?: number
|
||||||
voted: boolean
|
voted: boolean
|
||||||
onBehalfOf?: string | null
|
onBehalfOf?: string | null
|
||||||
@@ -51,6 +52,7 @@ interface Post {
|
|||||||
isEditLocked?: boolean
|
isEditLocked?: boolean
|
||||||
isThreadLocked?: boolean
|
isThreadLocked?: boolean
|
||||||
isVotingLocked?: boolean
|
isVotingLocked?: boolean
|
||||||
|
frozenAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimelineResponse {
|
interface TimelineResponse {
|
||||||
@@ -192,6 +194,8 @@ export default function PostDetail() {
|
|||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const [replyTo, setReplyTo] = useState<TimelineEntry | null>(null)
|
const [replyTo, setReplyTo] = useState<TimelineEntry | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [honeypot, setHoneypot] = useState('')
|
||||||
|
const [pageLoadTime] = useState(Date.now())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState('')
|
const [editTitle, setEditTitle] = useState('')
|
||||||
@@ -259,13 +263,13 @@ export default function PostDetail() {
|
|||||||
setVoteAnimating(true)
|
setVoteAnimating(true)
|
||||||
setTimeout(() => setVoteAnimating(false), 400)
|
setTimeout(() => setVoteAnimating(false), 400)
|
||||||
}
|
}
|
||||||
setPost({ ...post, voted: !wasVoted, voteCount: post.voteCount + (wasVoted ? -1 : 1) })
|
setPost({ ...post, voted: !wasVoted, voteCount: post.voteCount != null ? post.voteCount + (wasVoted ? -1 : 1) : post.voteCount })
|
||||||
try {
|
try {
|
||||||
if (wasVoted) {
|
if (wasVoted) {
|
||||||
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
|
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
|
||||||
toast.success('Vote removed')
|
toast.success('Vote removed')
|
||||||
} else {
|
} else {
|
||||||
const altcha = await solveAltcha('light')
|
const altcha = await solveAltcha('light', { postId })
|
||||||
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
|
||||||
toast.success('Vote added')
|
toast.success('Vote added')
|
||||||
}
|
}
|
||||||
@@ -294,13 +298,14 @@ export default function PostDetail() {
|
|||||||
if (!boardSlug || !postId || !comment.trim()) return
|
if (!boardSlug || !postId || !comment.trim()) return
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, any> = { body: comment }
|
const payload: Record<string, any> = { body: comment, _ts: pageLoadTime }
|
||||||
if (replyTo) payload.replyToId = replyTo.id
|
if (replyTo) payload.replyToId = replyTo.id
|
||||||
if (commentAttachments.length) payload.attachmentIds = commentAttachments
|
if (commentAttachments.length) payload.attachmentIds = commentAttachments
|
||||||
|
if (honeypot) payload.website = honeypot
|
||||||
|
|
||||||
// admin skips ALTCHA
|
// admin skips ALTCHA
|
||||||
if (!admin.isAdmin) {
|
if (!admin.isAdmin) {
|
||||||
payload.altcha = await solveAltcha()
|
payload.altcha = await solveAltcha('normal', { postId })
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.post(`/boards/${boardSlug}/posts/${postId}/comments`, payload)
|
await api.post(`/boards/${boardSlug}/posts/${postId}/comments`, payload)
|
||||||
@@ -366,6 +371,17 @@ export default function PostDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleFreeze = async () => {
|
||||||
|
if (!postId || !post) return
|
||||||
|
try {
|
||||||
|
const res = await api.put<{ id: string; frozenAt: string | null }>(`/admin/posts/${postId}/freeze`)
|
||||||
|
setPost({ ...post, frozenAt: res.frozenAt })
|
||||||
|
toast.success(res.frozenAt ? 'Post frozen' : 'Post unfrozen')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to toggle freeze')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleCommentEditLock = async (commentId: string) => {
|
const toggleCommentEditLock = async (commentId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/admin/comments/${commentId}/lock-edits`)
|
await api.put(`/admin/comments/${commentId}/lock-edits`)
|
||||||
@@ -522,12 +538,16 @@ export default function PostDetail() {
|
|||||||
stroke={2.5}
|
stroke={2.5}
|
||||||
className={voteAnimating ? 'vote-bounce' : ''}
|
className={voteAnimating ? 'vote-bounce' : ''}
|
||||||
/>
|
/>
|
||||||
|
{post.votingInProgress ? (
|
||||||
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>Tallying</span>
|
||||||
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
||||||
style={{ fontSize: 'var(--text-base)' }}
|
style={{ fontSize: 'var(--text-base)' }}
|
||||||
>
|
>
|
||||||
{post.voteCount}
|
{post.voteCount}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -769,6 +789,23 @@ export default function PostDetail() {
|
|||||||
: <><IconMessageOff size={12} stroke={2} aria-hidden="true" /> Lock thread</>
|
: <><IconMessageOff size={12} stroke={2} aria-hidden="true" /> Lock thread</>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleFreeze}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 action-btn"
|
||||||
|
style={{
|
||||||
|
minHeight: 44,
|
||||||
|
color: post.frozenAt ? '#3B82F6' : '#F59E0B',
|
||||||
|
background: post.frozenAt ? 'rgba(59, 130, 246, 0.1)' : 'rgba(245, 158, 11, 0.1)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
transition: 'all var(--duration-fast) ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.frozenAt
|
||||||
|
? <><IconSnowflakeOff size={12} stroke={2} aria-hidden="true" /> Unfreeze</>
|
||||||
|
: <><IconSnowflake size={12} stroke={2} aria-hidden="true" /> Freeze</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -845,6 +882,22 @@ export default function PostDetail() {
|
|||||||
Voting has been locked on this post
|
Voting has been locked on this post
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{post.frozenAt && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 mb-4"
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(59, 130, 246, 0.06)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
borderLeft: '3px solid #3B82F6',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconSnowflake size={13} stroke={2} style={{ color: '#3B82F6', flexShrink: 0 }} />
|
||||||
|
This post is frozen - vote counts are locked pending security review
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Structured description fields */}
|
{/* Structured description fields */}
|
||||||
@@ -961,6 +1014,7 @@ export default function PostDetail() {
|
|||||||
className="card card-static p-6"
|
className="card card-static p-6"
|
||||||
style={admin.isAdmin ? { borderColor: 'rgba(6, 182, 212, 0.2)' } : undefined}
|
style={admin.isAdmin ? { borderColor: 'rgba(6, 182, 212, 0.2)' } : undefined}
|
||||||
>
|
>
|
||||||
|
<input type="text" name="website" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3
|
<h3
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
|
|||||||
902
packages/web/src/pages/admin/AdminSecurity.tsx
Normal file
902
packages/web/src/pages/admin/AdminSecurity.tsx
Normal file
@@ -0,0 +1,902 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
|
import { useAdmin } from '../../hooks/useAdmin'
|
||||||
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import { useToast } from '../../hooks/useToast'
|
||||||
|
import Dropdown from '../../components/Dropdown'
|
||||||
|
import { IconPlus, IconTrash, IconCheck, IconX, IconAlertTriangle, IconShieldCheck } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
// types
|
||||||
|
|
||||||
|
interface AnomalyAlert {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
severity: string
|
||||||
|
status: string
|
||||||
|
targetType: string | null
|
||||||
|
targetId: string | null
|
||||||
|
boardId: string | null
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
id: string
|
||||||
|
adminId: string | null
|
||||||
|
action: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
undone: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityWebhook {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
events: string[]
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
sensitivityLevel?: string
|
||||||
|
velocityThreshold?: number | null
|
||||||
|
quarantined?: boolean
|
||||||
|
requireVoteVerification?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
|
||||||
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
low: '#9CA3AF',
|
||||||
|
medium: '#F59E0B',
|
||||||
|
high: '#F97316',
|
||||||
|
critical: '#EF4444',
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEBHOOK_EVENTS = [
|
||||||
|
{ value: 'anomaly_detected', label: 'Anomaly detected' },
|
||||||
|
{ value: 'brigade_confirmed', label: 'Brigade confirmed' },
|
||||||
|
{ value: 'cleanup_executed', label: 'Cleanup executed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function timeAgo(date: string): string {
|
||||||
|
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||||
|
if (s < 60) return 'just now'
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
if (m < 60) return `${m} minute${m === 1 ? '' : 's'} ago`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `${h} hour${h === 1 ? '' : 's'} ago`
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
return `${d} day${d === 1 ? '' : 's'} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const color = SEVERITY_COLORS[severity] || '#9CA3AF'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded font-medium"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
background: `${color}18`,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaSummary(meta: Record<string, unknown>): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (meta.voteCount) parts.push(`${meta.voteCount} votes`)
|
||||||
|
if (meta.identities) parts.push(`${(meta.identities as string[]).length} identities`)
|
||||||
|
if (meta.ratio) parts.push(`${Number(meta.ratio).toFixed(1)}x velocity`)
|
||||||
|
if (meta.coefficient) parts.push(`${(Number(meta.coefficient) * 100).toFixed(0)}% overlap`)
|
||||||
|
if (meta.count) parts.push(`${meta.count} events`)
|
||||||
|
if (meta.postCount) parts.push(`${meta.postCount} posts`)
|
||||||
|
return parts.join(' - ') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// sub-components
|
||||||
|
|
||||||
|
function AlertsTab() {
|
||||||
|
const admin = useAdmin()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
const [alerts, setAlerts] = useState<AnomalyAlert[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pages, setPages] = useState(1)
|
||||||
|
const [severity, setSeverity] = useState('all')
|
||||||
|
const [status, setStatus] = useState('pending')
|
||||||
|
const [boardId, setBoardId] = useState('all')
|
||||||
|
const [boards, setBoards] = useState<BoardSummary[]>([])
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
const [cleanupActions, setCleanupActions] = useState<string[]>([])
|
||||||
|
const [cleaning, setCleaning] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<{ boards: BoardSummary[] }>('/boards').then((r) => setBoards(r.boards)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchAlerts = () => {
|
||||||
|
setLoading(true)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (severity !== 'all') params.set('severity', severity)
|
||||||
|
if (status !== 'all') params.set('status', status)
|
||||||
|
if (boardId !== 'all') params.set('boardId', boardId)
|
||||||
|
params.set('page', String(page))
|
||||||
|
|
||||||
|
api.get<{ alerts: AnomalyAlert[]; total: number; pages: number }>(`/admin/security/alerts?${params}`)
|
||||||
|
.then((r) => { setAlerts(r.alerts); setTotal(r.total); setPages(r.pages) })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(fetchAlerts, [severity, status, boardId, page])
|
||||||
|
|
||||||
|
const handleConfirm = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/security/alerts/${id}/confirm`)
|
||||||
|
toast.success('Alert confirmed')
|
||||||
|
fetchAlerts()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to confirm alert')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/security/alerts/${id}/dismiss`)
|
||||||
|
toast.success('Alert dismissed')
|
||||||
|
fetchAlerts()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to dismiss alert')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrigade = async (id: string) => {
|
||||||
|
if (!await confirm('Mark this as a brigade? This records the pattern for future detection.')) return
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/security/alerts/${id}/mark-brigaded`)
|
||||||
|
toast.success('Marked as brigade')
|
||||||
|
fetchAlerts()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to mark as brigade')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCleanup = async (alertId: string) => {
|
||||||
|
if (cleanupActions.length === 0) return
|
||||||
|
setCleaning(true)
|
||||||
|
try {
|
||||||
|
await api.post('/admin/security/cleanup', {
|
||||||
|
anomalyEventId: alertId,
|
||||||
|
actions: cleanupActions,
|
||||||
|
})
|
||||||
|
toast.success('Cleanup applied')
|
||||||
|
setExpandedId(null)
|
||||||
|
setCleanupActions([])
|
||||||
|
fetchAlerts()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to apply cleanup')
|
||||||
|
} finally {
|
||||||
|
setCleaning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCleanupAction = (action: string) => {
|
||||||
|
setCleanupActions((prev) =>
|
||||||
|
prev.includes(action) ? prev.filter((a) => a !== action) : [...prev, action]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityOptions = [
|
||||||
|
{ value: 'all', label: 'All severities' },
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'critical', label: 'Critical' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'all', label: 'All statuses' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'confirmed', label: 'Confirmed' },
|
||||||
|
{ value: 'dismissed', label: 'Dismissed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const boardOptions = [
|
||||||
|
{ value: 'all', label: 'All boards' },
|
||||||
|
...boards.map((b) => ({ value: b.id, label: b.name })),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-5">
|
||||||
|
<div style={{ minWidth: 150 }}>
|
||||||
|
<Dropdown value={severity} options={severityOptions} onChange={(v) => { setSeverity(v); setPage(1) }} aria-label="Filter by severity" />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 150 }}>
|
||||||
|
<Dropdown value={status} options={statusOptions} onChange={(v) => { setStatus(v); setPage(1) }} aria-label="Filter by status" />
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 150 }}>
|
||||||
|
<Dropdown value={boardId} options={boardOptions} onChange={(v) => { setBoardId(v); setPage(1) }} aria-label="Filter by board" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div>
|
||||||
|
<div className="progress-bar mb-4" />
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-4 mb-2" style={{ opacity: 1 - i * 0.2 }}>
|
||||||
|
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||||
|
<div className="skeleton h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : alerts.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center py-12" style={{ textAlign: 'center' }}>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center mb-4"
|
||||||
|
style={{ width: 56, height: 56, borderRadius: 'var(--radius-lg)', background: 'rgba(34, 197, 94, 0.1)' }}
|
||||||
|
>
|
||||||
|
<IconShieldCheck size={24} stroke={1.5} style={{ color: 'var(--success)' }} />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>No alerts</p>
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 4 }}>
|
||||||
|
{status === 'pending' ? 'No pending alerts right now' : 'No matching alerts found'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||||
|
{total} alert{total === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{alerts.map((alert) => {
|
||||||
|
const expanded = expandedId === alert.id
|
||||||
|
const summary = metaSummary(alert.metadata)
|
||||||
|
return (
|
||||||
|
<div key={alert.id} className="card" style={{ padding: 0 }}>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
|
<SeverityBadge severity={alert.severity} />
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
{alert.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{alert.targetId && (
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||||
|
Target: {alert.targetType} {alert.targetId.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{summary && (
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{summary}</p>
|
||||||
|
)}
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
|
||||||
|
{timeAgo(alert.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alert.status === 'pending' && (
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleConfirm(alert.id)}
|
||||||
|
className="action-btn inline-flex items-center gap-1 px-2"
|
||||||
|
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--success)', borderRadius: 'var(--radius-sm)' }}
|
||||||
|
>
|
||||||
|
<IconCheck size={14} stroke={2} /> Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDismiss(alert.id)}
|
||||||
|
className="action-btn inline-flex items-center gap-1 px-2"
|
||||||
|
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', borderRadius: 'var(--radius-sm)' }}
|
||||||
|
>
|
||||||
|
<IconX size={14} stroke={2} /> Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBrigade(alert.id)}
|
||||||
|
className="action-btn inline-flex items-center gap-1 px-2"
|
||||||
|
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: '#F97316', borderRadius: 'var(--radius-sm)' }}
|
||||||
|
>
|
||||||
|
<IconAlertTriangle size={14} stroke={2} /> Brigade
|
||||||
|
</button>
|
||||||
|
{admin.isSuperAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (expanded) { setExpandedId(null); setCleanupActions([]) }
|
||||||
|
else { setExpandedId(alert.id); setCleanupActions([]) }
|
||||||
|
}}
|
||||||
|
className="action-btn px-2"
|
||||||
|
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
Cleanup
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{alert.status !== 'pending' && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded shrink-0"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
background: alert.status === 'confirmed' ? 'rgba(34, 197, 94, 0.1)' : 'var(--surface-hover)',
|
||||||
|
color: alert.status === 'confirmed' ? 'var(--success)' : 'var(--text-tertiary)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alert.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
className="p-4 border-t fade-in"
|
||||||
|
style={{ borderColor: 'var(--border)', background: 'var(--bg)' }}
|
||||||
|
>
|
||||||
|
<p className="font-medium mb-3" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
Cleanup actions
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 mb-4">
|
||||||
|
{[
|
||||||
|
{ value: 'remove_phantom_votes', label: 'Remove phantom votes' },
|
||||||
|
{ value: 'remove_flagged_votes', label: 'Remove flagged identity votes' },
|
||||||
|
{ value: 'recalculate_counts', label: 'Recalculate vote counts' },
|
||||||
|
].map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cleanupActions.includes(opt.value)}
|
||||||
|
onChange={() => toggleCleanupAction(opt.value)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<p className="mb-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||||
|
Estimated impact: {summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCleanup(alert.id)}
|
||||||
|
disabled={cleanupActions.length === 0 || cleaning}
|
||||||
|
className="btn btn-admin"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', opacity: cleanupActions.length === 0 || cleaning ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{cleaning ? 'Applying...' : 'Apply cleanup'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setExpandedId(null); setCleanupActions([]) }}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 'var(--text-sm)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', opacity: page === 1 ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
Page {page} of {pages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', opacity: page === pages ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Board security settings */}
|
||||||
|
{boards.length > 0 && (
|
||||||
|
<div className="mt-10">
|
||||||
|
<h2
|
||||||
|
className="font-medium mb-3"
|
||||||
|
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
|
||||||
|
>
|
||||||
|
Board security settings
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{boards.map((board) => (
|
||||||
|
<BoardSecurityCard key={board.id} board={board} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardSecurityCard({ board }: { board: BoardSummary }) {
|
||||||
|
const toast = useToast()
|
||||||
|
const [sensitivity, setSensitivity] = useState(board.sensitivityLevel || 'normal')
|
||||||
|
const [threshold, setThreshold] = useState(board.velocityThreshold ?? '')
|
||||||
|
const [quarantined, setQuarantined] = useState(board.quarantined || false)
|
||||||
|
const [voteVerification, setVoteVerification] = useState(board.requireVoteVerification || false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const save = async (data: Record<string, unknown>) => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await api.put<{ sensitivityLevel: string; velocityThreshold: number | null; quarantined: boolean; requireVoteVerification: boolean }>(`/admin/boards/${board.id}/security`, data)
|
||||||
|
setSensitivity(res.sensitivityLevel)
|
||||||
|
setThreshold(res.velocityThreshold ?? '')
|
||||||
|
setQuarantined(res.quarantined)
|
||||||
|
setVoteVerification(res.requireVoteVerification)
|
||||||
|
toast.success('Board security updated')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update board security')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-lg"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
{board.name}
|
||||||
|
</span>
|
||||||
|
{quarantined && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded font-medium"
|
||||||
|
style={{ fontSize: 'var(--text-xs)', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', borderRadius: 'var(--radius-sm)' }}
|
||||||
|
>
|
||||||
|
Quarantined
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div style={{ minWidth: 140 }}>
|
||||||
|
<label style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||||
|
Sensitivity
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
value={sensitivity}
|
||||||
|
options={[
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'paranoid', label: 'Paranoid' },
|
||||||
|
]}
|
||||||
|
onChange={(v) => { setSensitivity(v); save({ sensitivityLevel: v }) }}
|
||||||
|
aria-label="Sensitivity level"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 120 }}>
|
||||||
|
<label htmlFor={`threshold-${board.id}`} style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
|
||||||
|
Velocity threshold
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`threshold-${board.id}`}
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
placeholder="Auto"
|
||||||
|
value={threshold}
|
||||||
|
onChange={(e) => setThreshold(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
onBlur={() => {
|
||||||
|
const val = threshold === '' ? null : Number(threshold)
|
||||||
|
save({ velocityThreshold: val })
|
||||||
|
}}
|
||||||
|
style={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', color: quarantined ? 'var(--error)' : 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={quarantined}
|
||||||
|
onChange={(e) => { setQuarantined(e.target.checked); save({ quarantined: e.target.checked }) }}
|
||||||
|
/>
|
||||||
|
Quarantine
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{ fontSize: 'var(--text-sm)', color: voteVerification ? 'var(--admin-accent)' : 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={voteVerification}
|
||||||
|
onChange={(e) => { setVoteVerification(e.target.checked); save({ requireVoteVerification: e.target.checked }) }}
|
||||||
|
/>
|
||||||
|
Vote verification
|
||||||
|
</label>
|
||||||
|
{saving && <span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Saving...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLogTab() {
|
||||||
|
const admin = useAdmin()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchLog = () => {
|
||||||
|
api.get<{ entries: AuditEntry[] }>('/admin/security/audit-log')
|
||||||
|
.then((r) => setEntries(r.entries))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(fetchLog, [])
|
||||||
|
|
||||||
|
const handleUndo = async (id: string) => {
|
||||||
|
if (!await confirm('Undo this cleanup action? This marks it as reversed but does not restore deleted votes.')) return
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/security/audit-log/${id}/undo`)
|
||||||
|
toast.success('Marked as undone')
|
||||||
|
fetchLog()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to undo')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="progress-bar mb-4" />
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
|
||||||
|
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||||
|
<div className="skeleton h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>No cleanup actions recorded yet</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const meta = entry.metadata
|
||||||
|
const results = meta.results as Record<string, Record<string, number>> | undefined
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
opacity: entry.undone ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
{entry.action.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
{entry.undone && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded" style={{ fontSize: 'var(--text-xs)', background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
|
||||||
|
undone
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||||
|
<span>{timeAgo(entry.createdAt as unknown as string)}</span>
|
||||||
|
{results && Object.entries(results).map(([key, val]) => (
|
||||||
|
<span key={key}>{key.replace(/_/g, ' ')}: {Object.values(val).join(', ')}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{admin.isSuperAdmin && !entry.undone && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUndo(entry.id)}
|
||||||
|
className="action-btn px-2"
|
||||||
|
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--warning)', borderRadius: 'var(--radius-sm)' }}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebhooksTab() {
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
const [webhooks, setWebhooks] = useState<SecurityWebhook[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [events, setEvents] = useState<string[]>(['anomaly_detected'])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const fetchWebhooks = () => {
|
||||||
|
api.get<{ webhooks: SecurityWebhook[] }>('/admin/security/webhooks')
|
||||||
|
.then((r) => setWebhooks(r.webhooks))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(fetchWebhooks, [])
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!url.trim() || events.length === 0) return
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.post('/admin/security/webhooks', { url: url.trim(), events })
|
||||||
|
setUrl('')
|
||||||
|
setEvents(['anomaly_detected'])
|
||||||
|
setShowForm(false)
|
||||||
|
fetchWebhooks()
|
||||||
|
toast.success('Security webhook created')
|
||||||
|
} catch {
|
||||||
|
setError('Failed to create webhook')
|
||||||
|
toast.error('Failed to create webhook')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = async (id: string, active: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/security/webhooks/${id}`, { active: !active })
|
||||||
|
fetchWebhooks()
|
||||||
|
toast.success(active ? 'Webhook disabled' : 'Webhook enabled')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update webhook')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
if (!await confirm('Delete this security webhook?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/security/webhooks/${id}`)
|
||||||
|
fetchWebhooks()
|
||||||
|
toast.success('Webhook deleted')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete webhook')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="progress-bar mb-4" />
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
|
||||||
|
<div className="skeleton h-4" style={{ width: '50%' }} />
|
||||||
|
<div className="skeleton h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
Receive notifications when security events occur
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
className="btn btn-admin flex items-center gap-1"
|
||||||
|
aria-expanded={showForm}
|
||||||
|
style={{ fontSize: 'var(--text-sm)' }}
|
||||||
|
>
|
||||||
|
<IconPlus size={14} stroke={2} />
|
||||||
|
Add webhook
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="card p-4 mb-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="sec-webhook-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>URL</label>
|
||||||
|
<input
|
||||||
|
id="sec-webhook-url"
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="https://example.com/security-hook"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Events</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{WEBHOOK_EVENTS.map((ev) => {
|
||||||
|
const active = events.includes(ev.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ev.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEvents((evs) =>
|
||||||
|
active ? evs.filter((e) => e !== ev.value) : [...evs, ev.value]
|
||||||
|
)}
|
||||||
|
className="px-2 py-1 rounded text-xs"
|
||||||
|
style={{
|
||||||
|
background: active ? 'var(--admin-subtle)' : 'var(--surface-hover)',
|
||||||
|
color: active ? 'var(--admin-accent)' : 'var(--text-tertiary)',
|
||||||
|
border: active ? '1px solid rgba(6, 182, 212, 0.3)' : '1px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ev.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={create} className="btn btn-admin">Create</button>
|
||||||
|
<button onClick={() => setShowForm(false)} className="btn btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webhooks.length === 0 && !showForm ? (
|
||||||
|
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>No security webhooks configured</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{webhooks.map((wh) => (
|
||||||
|
<div
|
||||||
|
key={wh.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)', opacity: wh.active ? 1 : 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0 mr-2">
|
||||||
|
<div className="font-medium truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||||
|
{wh.url}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
{wh.events.map((ev) => (
|
||||||
|
<span key={ev} className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
|
||||||
|
{ev.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(wh.id, wh.active)}
|
||||||
|
className="text-xs px-2 rounded"
|
||||||
|
style={{ minHeight: 44, color: wh.active ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{wh.active ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(wh.id)}
|
||||||
|
className="text-xs px-2 rounded"
|
||||||
|
aria-label="Delete webhook"
|
||||||
|
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// main page
|
||||||
|
|
||||||
|
type Tab = 'alerts' | 'audit' | 'webhooks'
|
||||||
|
|
||||||
|
export default function AdminSecurity() {
|
||||||
|
useDocumentTitle('Security')
|
||||||
|
const admin = useAdmin()
|
||||||
|
const [tab, setTab] = useState<Tab>('alerts')
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; superOnly?: boolean }[] = [
|
||||||
|
{ key: 'alerts', label: 'Alerts' },
|
||||||
|
{ key: 'audit', label: 'Audit Log' },
|
||||||
|
{ key: 'webhooks', label: 'Webhooks', superOnly: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibleTabs = tabs.filter((t) => !t.superOnly || admin.isSuperAdmin)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1
|
||||||
|
className="font-bold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
|
||||||
|
>
|
||||||
|
Security
|
||||||
|
</h1>
|
||||||
|
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back to dashboard</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab nav */}
|
||||||
|
<div
|
||||||
|
className="flex gap-1 mb-6"
|
||||||
|
role="tablist"
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
paddingBottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleTabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className="px-4 py-2"
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: tab === t.key ? 600 : 400,
|
||||||
|
color: tab === t.key ? 'var(--admin-accent)' : 'var(--text-tertiary)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: tab === t.key ? '2px solid var(--admin-accent)' : '2px solid transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all var(--duration-fast) ease-out',
|
||||||
|
marginBottom: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div role="tabpanel">
|
||||||
|
{tab === 'alerts' && <AlertsTab />}
|
||||||
|
{tab === 'audit' && <AuditLogTab />}
|
||||||
|
{tab === 'webhooks' && <WebhooksTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user