add initial complete webui, more ai commands for moderation, add api
This commit is contained in:
parent
19e794e34c
commit
173d4e7a52
112 changed files with 8176 additions and 780 deletions
2
webui/lib/auth-constants.ts
Executable file
2
webui/lib/auth-constants.ts
Executable 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
135
webui/lib/auth-helpers.ts
Executable 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
148
webui/lib/auth.ts
Executable 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
13
webui/lib/db.ts
Executable 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
52
webui/lib/schema.ts
Executable 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
6
webui/lib/utils.ts
Executable 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))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue