add initial complete webui, more ai commands for moderation, add api

This commit is contained in:
Aidan 2025-07-05 14:36:17 -04:00
parent 19e794e34c
commit 173d4e7a52
112 changed files with 8176 additions and 780 deletions

2
webui/lib/auth-constants.ts Executable file
View file

@ -0,0 +1,2 @@
export const SESSION_COOKIE_NAME = "kowalski-session";
export const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000;

135
webui/lib/auth-helpers.ts Executable file
View file

@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server';
import { validateSession } from './auth';
import { SESSION_COOKIE_NAME } from './auth-constants';
export async function requireAuth(request: NextRequest) {
const sessionToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
if (!sessionToken) {
throw NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
const sessionData = await validateSession(sessionToken);
if (!sessionData || !sessionData.user) {
throw NextResponse.json({ error: "Invalid or expired session" }, { status: 401 });
}
return sessionData;
}
export async function validateJsonRequest(request: NextRequest) {
const contentType = request.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw NextResponse.json({ error: "Invalid content type" }, { status: 400 });
}
try {
const body = await request.json();
if (!body || typeof body !== 'object') {
throw NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
return body;
} catch {
throw NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
}
export function validateString(value: unknown, fieldName: string, minLength = 1, maxLength = 1000): string {
if (typeof value !== 'string') {
throw NextResponse.json({ error: `${fieldName} must be a string` }, { status: 400 });
}
if (value.length < minLength || value.length > maxLength) {
throw NextResponse.json({
error: `${fieldName} must be between ${minLength} and ${maxLength} characters`
}, { status: 400 });
}
return value;
}
export function validateArray(value: unknown, fieldName: string, maxLength = 100): unknown[] {
if (!Array.isArray(value)) {
throw NextResponse.json({ error: `${fieldName} must be an array` }, { status: 400 });
}
if (value.length > maxLength) {
throw NextResponse.json({
error: `${fieldName} cannot have more than ${maxLength} items`
}, { status: 400 });
}
return value;
}
export function validateNumber(value: unknown, fieldName: string, min?: number, max?: number): number {
const num = Number(value);
if (isNaN(num)) {
throw NextResponse.json({ error: `${fieldName} must be a valid number` }, { status: 400 });
}
if (min !== undefined && num < min) {
throw NextResponse.json({ error: `${fieldName} must be at least ${min}` }, { status: 400 });
}
if (max !== undefined && num > max) {
throw NextResponse.json({ error: `${fieldName} must be at most ${max}` }, { status: 400 });
}
return num;
}
export function handleApiError(error: unknown, operation: string) {
console.error(`Error in ${operation}:`, error);
if (error instanceof NextResponse) {
return error;
}
return NextResponse.json({
error: "Internal server error"
}, { status: 500 });
}
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
export function rateLimit(identifier: string, maxAttempts = 5, windowMs = 15 * 60 * 1000) {
const now = Date.now();
const key = identifier;
const record = rateLimitMap.get(key);
if (!record) {
rateLimitMap.set(key, { count: 1, timestamp: now });
return { allowed: true, remaining: maxAttempts - 1 };
}
if (now - record.timestamp > windowMs) {
rateLimitMap.set(key, { count: 1, timestamp: now });
return { allowed: true, remaining: maxAttempts - 1 };
}
record.count++;
if (record.count > maxAttempts) {
return { allowed: false, remaining: 0 };
}
return { allowed: true, remaining: maxAttempts - record.count };
}
export function cleanupRateLimit() {
const now = Date.now();
const windowMs = 15 * 60 * 1000; // 15m
for (const [key, record] of rateLimitMap.entries()) {
if (now - record.timestamp > windowMs) {
rateLimitMap.delete(key);
}
}
}
setInterval(cleanupRateLimit, 10 * 60 * 1000);

148
webui/lib/auth.ts Executable file
View file

@ -0,0 +1,148 @@
import { eq, and, gt, lt } from "drizzle-orm";
import { db } from "./db";
import { sessionsTable, usersTable } from "./schema";
import { randomBytes } from "crypto";
export interface SessionData {
id: string;
userId: string;
sessionToken: string;
expiresAt: Date;
user?: {
telegramId: string;
username: string;
firstName: string;
lastName: string;
aiEnabled: boolean;
showThinking: boolean;
customAiModel: string;
aiTemperature: number;
aiRequests: number;
aiCharacters: number;
disabledCommands: string[];
languageCode: string;
};
}
import { SESSION_COOKIE_NAME, SESSION_DURATION } from "./auth-constants";
export { SESSION_COOKIE_NAME };
export function generateSessionToken(): string {
return randomBytes(32).toString("hex");
}
export function generateSessionId(): string {
return randomBytes(16).toString("hex");
}
export async function createSession(userId: string): Promise<SessionData> {
const sessionId = generateSessionId();
const sessionToken = generateSessionToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
await db.delete(sessionsTable)
.where(
and(
eq(sessionsTable.userId, userId),
lt(sessionsTable.expiresAt, new Date())
)
);
const [session] = await db.insert(sessionsTable)
.values({
id: sessionId,
userId,
sessionToken,
expiresAt,
})
.returning();
return session;
}
export async function validateSession(sessionToken: string): Promise<SessionData | null> {
if (!sessionToken || typeof sessionToken !== 'string' || sessionToken.length < 32) {
return null;
}
try {
const sessionWithUser = await db
.select({
session: sessionsTable,
user: usersTable,
})
.from(sessionsTable)
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.telegramId))
.where(
and(
eq(sessionsTable.sessionToken, sessionToken),
gt(sessionsTable.expiresAt, new Date())
)
)
.limit(1);
if (sessionWithUser.length === 0) {
await cleanupExpiredSessions();
return null;
}
const { session, user } = sessionWithUser[0];
const oneDay = 24 * 60 * 60 * 1000;
const timeUntilExpiry = session.expiresAt.getTime() - Date.now();
if (timeUntilExpiry < oneDay) {
const newExpiresAt = new Date(Date.now() + SESSION_DURATION);
await db.update(sessionsTable)
.set({ expiresAt: newExpiresAt })
.where(eq(sessionsTable.id, session.id));
session.expiresAt = newExpiresAt;
}
return {
id: session.id,
userId: session.userId,
sessionToken: session.sessionToken,
expiresAt: session.expiresAt,
user: {
telegramId: user.telegramId,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
aiEnabled: user.aiEnabled,
showThinking: user.showThinking,
customAiModel: user.customAiModel,
aiTemperature: user.aiTemperature,
aiRequests: user.aiRequests,
aiCharacters: user.aiCharacters,
disabledCommands: user.disabledCommands || [],
languageCode: user.languageCode,
},
};
} catch (error) {
console.error("Error validating session:", error);
return null;
}
}
export async function invalidateSession(sessionToken: string): Promise<void> {
await db.delete(sessionsTable)
.where(eq(sessionsTable.sessionToken, sessionToken));
}
export async function cleanupExpiredSessions(): Promise<void> {
await db.delete(sessionsTable)
.where(lt(sessionsTable.expiresAt, new Date()));
}
export function getSessionCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: SESSION_DURATION / 1000,
path: "/",
};
}

13
webui/lib/db.ts Executable file
View file

@ -0,0 +1,13 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.databaseUrl,
});
export const db = drizzle(pool, { schema });
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});

52
webui/lib/schema.ts Executable file
View file

@ -0,0 +1,52 @@
import {
integer,
pgTable,
varchar,
timestamp,
boolean,
real,
index
} from "drizzle-orm/pg-core";
export const usersTable = pgTable("users", {
telegramId: varchar({ length: 255 }).notNull().primaryKey(),
username: varchar({ length: 255 }).notNull(),
firstName: varchar({ length: 255 }).notNull(),
lastName: varchar({ length: 255 }).notNull(),
aiEnabled: boolean().notNull().default(false),
showThinking: boolean().notNull().default(false),
customAiModel: varchar({ length: 255 }).notNull().default("deepseek-r1:1.5b"),
aiTemperature: real().notNull().default(0.9),
aiRequests: integer().notNull().default(0),
aiCharacters: integer().notNull().default(0),
disabledCommands: varchar({ length: 255 }).array().notNull().default([]),
languageCode: varchar({ length: 255 }).notNull(),
aiTimeoutUntil: timestamp(),
aiMaxExecutionTime: integer().default(0),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
});
export const twoFactorTable = pgTable("two_factor", {
userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId).primaryKey(),
currentCode: varchar({ length: 255 }).notNull(),
codeExpiresAt: timestamp().notNull(),
codeAttempts: integer().notNull().default(0),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
}, (table) => [
index("idx_two_factor_user_id").on(table.userId),
index("idx_two_factor_code_expires_at").on(table.codeExpiresAt),
]);
export const sessionsTable = pgTable("sessions", {
id: varchar({ length: 255 }).notNull().primaryKey(),
userId: varchar({ length: 255 }).notNull().references(() => usersTable.telegramId),
sessionToken: varchar({ length: 255 }).notNull().unique(),
expiresAt: timestamp().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
}, (table) => [
index("idx_sessions_user_id").on(table.userId),
index("idx_sessions_expires_at").on(table.expiresAt),
]);

6
webui/lib/utils.ts Executable file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}