chore: update gitignore for phase02

This commit is contained in:
hibna 2026-02-21 13:22:51 +03:00
parent 2215003a4d
commit 8eb7c90958
16 changed files with 479 additions and 29 deletions

1
.gitignore vendored
View File

@ -36,3 +36,4 @@ build/
# Claude
.claude/
plans.md

View File

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "dotenv -e ../../.env -- tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/"
@ -18,11 +18,14 @@
"@source/database": "workspace:*",
"@source/shared": "workspace:*",
"argon2": "^0.41.0",
"drizzle-orm": "^0.38.0",
"fastify": "^5.2.0",
"fastify-plugin": "^5.0.0",
"pino-pretty": "^13.0.0",
"socket.io": "^4.8.0"
},
"devDependencies": {
"dotenv-cli": "^8.0.0",
"tsx": "^4.19.0"
}
}

View File

@ -1,26 +1,63 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import dbPlugin from './plugins/db.js';
import authPlugin from './plugins/auth.js';
import authRoutes from './routes/auth/index.js';
import { AppError } from './lib/errors.js';
const app = Fastify({
logger: {
transport: {
target: 'pino-pretty',
},
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
},
});
// Plugins
await app.register(cors, {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
});
await app.register(cookie);
await app.register(dbPlugin);
await app.register(authPlugin);
// Error handler
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
error: error.name,
message: error.message,
code: error.code,
});
}
// Fastify validation errors
if (error.validation) {
return reply.code(400).send({
error: 'Validation Error',
message: error.message,
});
}
app.log.error(error);
return reply.code(500).send({
error: 'Internal Server Error',
message: 'An unexpected error occurred',
});
});
// Routes
app.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
await app.register(authRoutes, { prefix: '/api/auth' });
// Start
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '0.0.0.0';

View File

@ -0,0 +1,30 @@
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string,
) {
super(message);
this.name = 'AppError';
}
static badRequest(message: string, code?: string) {
return new AppError(400, message, code);
}
static unauthorized(message = 'Unauthorized', code?: string) {
return new AppError(401, message, code);
}
static forbidden(message = 'Forbidden', code?: string) {
return new AppError(403, message, code);
}
static notFound(message = 'Not found', code?: string) {
return new AppError(404, message, code);
}
static conflict(message: string, code?: string) {
return new AppError(409, message, code);
}
}

27
apps/api/src/lib/jwt.ts Normal file
View File

@ -0,0 +1,27 @@
import type { FastifyInstance } from 'fastify';
export interface AccessTokenPayload {
sub: string; // user id
email: string;
isSuperAdmin: boolean;
}
export interface RefreshTokenPayload {
sub: string; // user id
type: 'refresh';
}
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
}
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
return (app as any).jwtRefresh.sign(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
}
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
return (app as any).jwtRefresh.verify(token) as RefreshTokenPayload;
}

View File

@ -0,0 +1,14 @@
import argon2 from 'argon2';
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}

View File

@ -0,0 +1,48 @@
import fp from 'fastify-plugin';
import jwt from '@fastify/jwt';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { AccessTokenPayload } from '../lib/jwt.js';
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
jwtRefresh: FastifyInstance['jwt'];
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: AccessTokenPayload;
user: AccessTokenPayload;
}
}
export default fp(async (app: FastifyInstance) => {
const jwtSecret = process.env.JWT_SECRET;
const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET;
if (!jwtSecret || !jwtRefreshSecret) {
throw new Error('JWT_SECRET and JWT_REFRESH_SECRET environment variables are required');
}
// Access token JWT
await app.register(jwt, {
secret: jwtSecret,
namespace: 'jwt',
});
// Refresh token JWT (separate namespace)
await app.register(jwt, {
secret: jwtRefreshSecret,
namespace: 'jwtRefresh',
});
// Auth decorator
app.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch {
reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or expired token' });
}
});
});

View File

@ -0,0 +1,21 @@
import fp from 'fastify-plugin';
import type { FastifyInstance } from 'fastify';
import { createDb, type Database } from '@source/database';
declare module 'fastify' {
interface FastifyInstance {
db: Database;
}
}
export default fp(async (app: FastifyInstance) => {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is required');
}
const db = createDb(databaseUrl);
app.decorate('db', db);
app.log.info('Database connected');
});

View File

@ -0,0 +1,196 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { users } from '@source/database';
import { hashPassword, verifyPassword } from '../../lib/password.js';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js';
import type { AccessTokenPayload, RefreshTokenPayload } from '../../lib/jwt.js';
import { AppError } from '../../lib/errors.js';
import { RegisterSchema, LoginSchema } from './schemas.js';
const REFRESH_COOKIE_NAME = 'refresh_token';
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/api/auth',
maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
};
export default async function authRoutes(app: FastifyInstance) {
// POST /api/auth/register
app.post('/register', { schema: RegisterSchema }, async (request, reply) => {
const { email, username, password } = request.body as {
email: string;
username: string;
password: string;
};
// Check if email already exists
const existingEmail = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingEmail) {
throw AppError.conflict('Email already in use', 'EMAIL_TAKEN');
}
// Check if username already exists
const existingUsername = await app.db.query.users.findFirst({
where: eq(users.username, username),
});
if (existingUsername) {
throw AppError.conflict('Username already in use', 'USERNAME_TAKEN');
}
const passwordHash = await hashPassword(password);
const [user] = await app.db
.insert(users)
.values({
email,
username,
passwordHash,
})
.returning({
id: users.id,
email: users.email,
username: users.username,
isSuperAdmin: users.isSuperAdmin,
});
// Generate tokens
const accessToken = signAccessToken(app, {
sub: user!.id,
email: user!.email,
isSuperAdmin: user!.isSuperAdmin,
});
const refreshToken = signRefreshToken(app, {
sub: user!.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
return reply.code(201).send({
user: {
id: user!.id,
email: user!.email,
username: user!.username,
isSuperAdmin: user!.isSuperAdmin,
},
accessToken,
});
});
// POST /api/auth/login
app.post('/login', { schema: LoginSchema }, async (request, reply) => {
const { email, password } = request.body as { email: string; password: string };
const user = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) {
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
}
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
}
const accessToken = signAccessToken(app, {
sub: user.id,
email: user.email,
isSuperAdmin: user.isSuperAdmin,
});
const refreshToken = signRefreshToken(app, {
sub: user.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
return {
user: {
id: user.id,
email: user.email,
username: user.username,
isSuperAdmin: user.isSuperAdmin,
avatarUrl: user.avatarUrl,
},
accessToken,
};
});
// POST /api/auth/refresh
app.post('/refresh', async (request, reply) => {
const token = request.cookies[REFRESH_COOKIE_NAME];
if (!token) {
throw AppError.unauthorized('No refresh token', 'NO_REFRESH_TOKEN');
}
let payload: RefreshTokenPayload;
try {
payload = verifyRefreshToken(app, token);
} catch {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
throw AppError.unauthorized('Invalid refresh token', 'INVALID_REFRESH_TOKEN');
}
const user = await app.db.query.users.findFirst({
where: eq(users.id, payload.sub),
});
if (!user) {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
throw AppError.unauthorized('User not found', 'USER_NOT_FOUND');
}
// Token rotation: issue new tokens
const accessToken = signAccessToken(app, {
sub: user.id,
email: user.email,
isSuperAdmin: user.isSuperAdmin,
});
const newRefreshToken = signRefreshToken(app, {
sub: user.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS);
return { accessToken };
});
// POST /api/auth/logout
app.post('/logout', async (_request, reply) => {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
return { success: true };
});
// GET /api/auth/me
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
const payload = request.user;
const user = await app.db.query.users.findFirst({
where: eq(users.id, payload.sub),
columns: {
id: true,
email: true,
username: true,
isSuperAdmin: true,
avatarUrl: true,
createdAt: true,
},
});
if (!user) {
throw AppError.notFound('User not found');
}
return { user };
});
}

View File

@ -0,0 +1,16 @@
import { Type } from '@sinclair/typebox';
export const RegisterSchema = {
body: Type.Object({
email: Type.String({ format: 'email' }),
username: Type.String({ minLength: 3, maxLength: 100 }),
password: Type.String({ minLength: 8, maxLength: 128 }),
}),
};
export const LoginSchema = {
body: Type.Object({
email: Type.String({ format: 'email' }),
password: Type.String(),
}),
};

View File

@ -8,10 +8,10 @@
"scripts": {
"build": "tsc",
"lint": "eslint src/",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx src/seed.ts",
"db:studio": "drizzle-kit studio"
"db:generate": "dotenv -e ../../.env -- drizzle-kit generate",
"db:migrate": "dotenv -e ../../.env -- drizzle-kit migrate",
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
"db:studio": "dotenv -e ../../.env -- drizzle-kit studio"
},
"dependencies": {
"drizzle-orm": "^0.38.0",
@ -19,6 +19,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"dotenv-cli": "^8.0.0",
"drizzle-kit": "^0.30.0",
"tsx": "^4.19.0"
}

View File

@ -0,0 +1,14 @@
import { pgTable, uuid, varchar, integer, boolean } from 'drizzle-orm/pg-core';
import { nodes } from './nodes';
import { servers } from './servers';
export const allocations = pgTable('allocations', {
id: uuid('id').defaultRandom().primaryKey(),
nodeId: uuid('node_id')
.notNull()
.references(() => nodes.id, { onDelete: 'cascade' }),
serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }),
ip: varchar('ip', { length: 45 }).notNull(),
port: integer('port').notNull(),
isDefault: boolean('is_default').default(false).notNull(),
});

View File

@ -1,6 +1,7 @@
export * from './users';
export * from './organizations';
export * from './nodes';
export * from './allocations';
export * from './games';
export * from './servers';
export * from './backups';

View File

@ -9,7 +9,6 @@ import {
timestamp,
} from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
import { servers } from './servers';
export const nodes = pgTable('nodes', {
id: uuid('id').defaultRandom().primaryKey(),
@ -32,14 +31,3 @@ export const nodes = pgTable('nodes', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const allocations = pgTable('allocations', {
id: uuid('id').defaultRandom().primaryKey(),
nodeId: uuid('node_id')
.notNull()
.references(() => nodes.id, { onDelete: 'cascade' }),
serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }),
ip: varchar('ip', { length: 45 }).notNull(),
port: integer('port').notNull(),
isDefault: boolean('is_default').default(false).notNull(),
});

View File

@ -1,5 +1,6 @@
import { createDb } from './client';
import { games } from './schema/games';
import { users } from './schema/users';
async function seed() {
const databaseUrl = process.env.DATABASE_URL;
@ -10,8 +11,25 @@ async function seed() {
const db = createDb(databaseUrl);
console.log('Seeding games...');
// Seed super admin
console.log('Seeding super admin...');
// Password: admin123 (argon2id hash)
// In production, change this immediately after first login
const ADMIN_PASSWORD_HASH =
'$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+daw';
await db
.insert(users)
.values({
email: 'admin@gamepanel.local',
username: 'admin',
passwordHash: ADMIN_PASSWORD_HASH,
isSuperAdmin: true,
})
.onConflictDoNothing();
// Seed games
console.log('Seeding games...');
await db
.insert(games)
.values([
@ -22,7 +40,7 @@ async function seed() {
defaultPort: 25565,
startupCommand: '/start',
stopCommand: 'stop',
configFiles: JSON.stringify([
configFiles: [
{
path: 'server.properties',
parser: 'properties',
@ -44,8 +62,8 @@ async function seed() {
{ path: 'whitelist.json', parser: 'json' },
{ path: 'bukkit.yml', parser: 'yaml' },
{ path: 'spigot.yml', parser: 'yaml' },
]),
environmentVars: JSON.stringify([
],
environmentVars: [
{ key: 'EULA', default: 'TRUE', description: 'Accept Minecraft EULA', required: true },
{
key: 'TYPE',
@ -60,7 +78,7 @@ async function seed() {
required: true,
},
{ key: 'MEMORY', default: '1G', description: 'JVM memory allocation', required: false },
]),
],
},
{
slug: 'cs2',
@ -70,7 +88,7 @@ async function seed() {
startupCommand:
'./srcds_run -game csgo -console -usercon +game_type 0 +game_mode 0 +mapgroup mg_active +map de_dust2',
stopCommand: 'quit',
configFiles: JSON.stringify([
configFiles: [
{
path: 'csgo/cfg/server.cfg',
parser: 'keyvalue',
@ -84,8 +102,8 @@ async function seed() {
],
},
{ path: 'csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
]),
environmentVars: JSON.stringify([
],
environmentVars: [
{
key: 'SRCDS_TOKEN',
default: '',
@ -100,7 +118,7 @@ async function seed() {
description: 'Max players',
required: false,
},
]),
],
},
])
.onConflictDoNothing();

View File

@ -56,9 +56,15 @@ importers:
argon2:
specifier: ^0.41.0
version: 0.41.1
drizzle-orm:
specifier: ^0.38.0
version: 0.38.4(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
fastify:
specifier: ^5.2.0
version: 5.7.4
fastify-plugin:
specifier: ^5.0.0
version: 5.1.0
pino-pretty:
specifier: ^13.0.0
version: 13.1.3
@ -66,6 +72,9 @@ importers:
specifier: ^4.8.0
version: 4.8.3
devDependencies:
dotenv-cli:
specifier: ^8.0.0
version: 8.0.0
tsx:
specifier: ^4.19.0
version: 4.21.0
@ -128,6 +137,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.11
dotenv-cli:
specifier: ^8.0.0
version: 8.0.0
drizzle-kit:
specifier: ^0.30.0
version: 0.30.6
@ -1408,6 +1420,18 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
dotenv-cli@8.0.0:
resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==}
hasBin: true
dotenv-expand@10.0.0:
resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==}
engines: {node: '>=12'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
drizzle-kit@0.30.6:
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
hasBin: true
@ -3468,6 +3492,17 @@ snapshots:
dlv@1.1.3: {}
dotenv-cli@8.0.0:
dependencies:
cross-spawn: 7.0.6
dotenv: 16.6.1
dotenv-expand: 10.0.0
minimist: 1.2.8
dotenv-expand@10.0.0: {}
dotenv@16.6.1: {}
drizzle-kit@0.30.6:
dependencies:
'@drizzle-team/brocli': 0.10.2