This commit is contained in:
2025-10-10 19:23:53 +05:00
parent b5dc953d6b
commit 7bb50a4ee5
13 changed files with 961 additions and 79 deletions
+114 -33
View File
@@ -1,9 +1,11 @@
import { Elysia, t } from "elysia";
import { authMiddleware } from "../middlewares/auth";
import { optionalAuthMiddleware } from "../middlewares/optionalAuth";
import { eq } from "drizzle-orm";
import db from "../db";
import { apps } from "../db/schema/apps";
import { serverSessionService } from "../services/serverSession";
import { serverService } from "../services/server";
export const sessionController = new Elysia({ prefix: "/sessions" })
// PATCH /sessions/:id/status - обновить статус сессии (публичный endpoint для сессионного сервера)
@@ -78,45 +80,17 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
}),
}
)
// Все роуты требуют авторизации
.use(authMiddleware)
// GET /sessions - получить список сессий пользователя
.get("/", async ({ currentUser, query }) => {
const { status, mode } = query as {
status?: "starting" | "started" | "ending" | "ended";
mode?: "stream" | "local";
};
const sessions = await serverSessionService.findByUserId(currentUser.id, {
status,
mode,
});
return { sessions };
})
// GET /sessions/:id - получить информацию о конкретной сессии
.get("/:id", async ({ params, currentUser, status }) => {
const { id } = params;
const session = await serverSessionService.findByIdForUser(
id,
currentUser.id
);
if (!session) {
return status(404, "Session not found");
}
return { session };
})
// POST /sessions - создать новую сессию
// Endpoints с optional auth (доступны для неавторизованных пользователей)
.use(optionalAuthMiddleware)
// POST /sessions - создать новую сессию (с optional auth для demo серверов)
.post(
"/",
async ({ body, currentUser, status }) => {
const { appId, mode, serverId } = body as {
const { appId, mode, serverId, tier } = body as {
appId: string;
mode: "stream" | "local";
serverId?: string;
tier?: "demo" | "prod";
};
// Проверить, что приложение существует
@@ -128,6 +102,53 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
return status(404, "App not found");
}
// Если пользователь не авторизован
if (!currentUser) {
// Проверяем, что режим - stream (только stream поддерживает demo)
if (mode !== "stream") {
return status(401, "Authorization required for local sessions");
}
// Неавторизованные пользователи могут использовать только demo-серверы
if (tier && tier !== "demo") {
return status(
403,
"Unauthorized users can only use demo tier servers"
);
}
// Проверяем, что есть доступные demo-серверы
const demoServers = await serverService.findAvailableStreamServers(
"demo"
);
if (demoServers.length === 0) {
return status(
503,
"No available demo servers. Please login to use production servers."
);
}
// Создаем сессию без userId (для неавторизованных пользователей)
try {
const newSession = await serverSessionService.create({
appId,
// userId не передаем - будет undefined для неавторизованных пользователей
mode,
serverId,
tier: "demo", // Всегда demo для неавторизованных
});
return { session: newSession };
} catch (error) {
if (error instanceof Error) {
return status(503, error.message);
}
return status(500, "Failed to create session");
}
}
// Если пользователь авторизован - используем стандартную логику
// Проверить, что пользователь не имеет активных сессий этого приложения
const hasActive = await serverSessionService.hasActiveSession(
currentUser.id,
@@ -138,6 +159,16 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
return status(409, "User already has an active session for this app");
}
// Для режима stream - проверяем наличие серверов нужного tier
if (mode === "stream" && tier) {
const availableServers = await serverService.findAvailableStreamServers(
tier
);
if (availableServers.length === 0) {
return status(503, `No available ${tier} servers`);
}
}
// Создать сессию
try {
const newSession = await serverSessionService.create({
@@ -145,6 +176,7 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
userId: currentUser.id,
mode,
serverId,
tier,
});
return { session: newSession };
@@ -160,9 +192,58 @@ export const sessionController = new Elysia({ prefix: "/sessions" })
appId: t.String({ format: "uuid" }),
mode: t.Union([t.Literal("stream"), t.Literal("local")]),
serverId: t.Optional(t.String({ format: "uuid" })),
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])),
}),
}
)
// GET /sessions/:id - получить информацию о конкретной сессии (optional auth)
.get("/:id", async ({ params, currentUser, status }) => {
const { id } = params;
// Для авторизованных пользователей - проверяем ownership
if (currentUser) {
const session = await serverSessionService.findByIdForUser(
id,
currentUser.id
);
if (!session) {
return status(404, "Session not found");
}
return { session };
}
// Для неавторизованных - просто находим сессию по ID
const session = await serverSessionService.findById(id);
if (!session) {
return status(404, "Session not found");
}
// Проверяем, что это сессия без userId (неавторизованная)
if (session.userId) {
return status(403, "This session belongs to an authenticated user");
}
return { session };
})
// Все остальные роуты требуют авторизации
.use(authMiddleware)
// GET /sessions - получить список сессий пользователя
.get("/", async ({ currentUser, query }) => {
const { status, mode } = query as {
status?: "starting" | "started" | "ending" | "ended";
mode?: "stream" | "local";
};
const sessions = await serverSessionService.findByUserId(currentUser.id, {
status,
mode,
});
return { sessions };
})
// PATCH /sessions/:id - обновить статус сессии
.patch(
"/:id",
+9
View File
@@ -6,3 +6,12 @@ export const roleNameEnum = pgEnum("role_name", [
"director",
"manager",
]);
// Enum для типов серверов
export const serverTypeEnum = pgEnum("server_type", ["stream", "local"]);
// Enum для местоположения серверов
export const serverLocationEnum = pgEnum("server_location", ["ru1", "uae1"]);
// Enum для tier серверов
export const serverTierEnum = pgEnum("server_tier", ["demo", "prod"]);
+4 -6
View File
@@ -4,6 +4,7 @@ import { apps } from "./apps";
import { users } from "./users";
import { relations } from "drizzle-orm";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { serverTierEnum } from "./enums";
// Enums
export const sessionModeEnum = pgEnum("session_mode", ["stream", "local"]);
@@ -20,16 +21,13 @@ export const serverSessions = pgTable("server_sessions", {
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
userId: uuid("user_id")
.notNull()
.references(() => users.id),
startAt: timestamp("start_at", { withTimezone: true })
.defaultNow()
.notNull(),
userId: uuid("user_id").references(() => users.id), // Nullable - для неавторизованных пользователей на demo-серверах
startAt: timestamp("start_at", { withTimezone: true }).defaultNow().notNull(),
endAt: timestamp("end_at", { withTimezone: true }), // Default 30 minutes from start_at
appPid: integer("app_pid"),
cirrusPid: integer("cirrus_pid"),
mode: sessionModeEnum("mode").notNull(), // stream, local
tier: serverTierEnum("tier"), // demo, prod (только для stream, nullable)
status: sessionStatusEnum("status").notNull(), // starting, started, ending, ended
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
+3 -5
View File
@@ -2,7 +2,6 @@ import {
pgTable,
uuid,
varchar,
pgEnum,
timestamp,
integer,
} from "drizzle-orm/pg-core";
@@ -10,11 +9,10 @@ import { relations } from "drizzle-orm";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { serverSessions } from "./serverSessions";
import { branches } from "./branches";
import { serverLocationEnum, serverTypeEnum, serverTierEnum } from "./enums";
// Enums
export const serverLocationEnum = pgEnum("server_location", ["ru1", "uae1"]);
export const serverTypeEnum = pgEnum("server_type", ["stream", "local"]);
export const serverTierEnum = pgEnum("server_tier", ["demo", "prod"]);
// Re-export enums for backward compatibility
export { serverLocationEnum, serverTypeEnum, serverTierEnum };
export const servers = pgTable("servers", {
id: uuid("id").primaryKey().defaultRandom(),
+173
View File
@@ -0,0 +1,173 @@
import { Elysia } from "elysia";
import { AuthSession, authSessions } from "../db/schema/authSessions";
import { eq, isNull, and } from "drizzle-orm";
import db from "../db";
import { jwtVerify } from "jose";
import { userService } from "../services/auth/user";
import { protectedRoutes } from "../db/schema";
import { RoleName } from "../services/auth";
// JWT секрет (должен совпадать с session.service.ts)
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
/**
* Optional auth middleware - проверяет авторизацию если токен предоставлен,
* но не требует его обязательно
*/
export const optionalAuthMiddleware = new Elysia().derive(
{ as: "scoped" },
async ({ request }) => {
const { headers } = request;
const authHeader = headers.get("Authorization");
// Если нет заголовка авторизации, продолжаем без пользователя
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return {
authSession: null,
currentUser: null,
};
}
const accessToken = authHeader.split(" ")[1];
if (!accessToken) {
return {
authSession: null,
currentUser: null,
};
}
// 2. Верифицировать JWT (проверка подписи и срока действия)
let sessionId: string;
try {
const { payload } = await jwtVerify<{ id: string }>(
accessToken,
JWT_SECRET
);
sessionId = payload.id;
if (!sessionId) {
return {
authSession: null,
currentUser: null,
};
}
} catch (err) {
return {
authSession: null,
currentUser: null,
};
}
// 3. Получить сессию из БД и проверить её валидность
let authSession: AuthSession;
try {
authSession = (
await db
.select()
.from(authSessions)
.where(
and(
eq(authSessions.id, sessionId),
isNull(authSessions.revokedAt) // Сессия не отозвана
)
)
.limit(1)
)[0];
if (!authSession) {
return {
authSession: null,
currentUser: null,
};
}
} catch (err) {
console.error("Database error in optional auth middleware:", err);
return {
authSession: null,
currentUser: null,
};
}
// 4. Проверить срок действия сессии
if (authSession.expiresAt && authSession.expiresAt < new Date()) {
return {
authSession: null,
currentUser: null,
};
}
// 5. Верифицировать bcrypt hash токена
try {
const verified = await Bun.password.verify(
accessToken,
authSession.accessTokenHash
);
if (!verified) {
return {
authSession: null,
currentUser: null,
};
}
} catch (err) {
console.error("Token verification error:", err);
return {
authSession: null,
currentUser: null,
};
}
// 6. Получить пользователя
const user = await userService.findById(authSession.userId);
if (!user) {
return {
authSession: null,
currentUser: null,
};
}
// 7. Проверить доступ к маршруту на основе ролей (если маршрут защищен)
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Получить маршрут из БД
const route = await db
.select({
methods: protectedRoutes.methods,
roles: protectedRoutes.roles,
})
.from(protectedRoutes)
.where(eq(protectedRoutes.path, path))
.limit(1);
// Если маршрут защищен, проверить права доступа
if (route.length > 0) {
const allowedMethods = route[0].methods;
const allowedRoles = route[0].roles;
// Проверить, что метод входит в список разрешенных
if (allowedMethods.includes(method)) {
// Проверить, есть ли роль пользователя среди разрешенных
if (!allowedRoles.includes(user.role as RoleName)) {
return {
authSession: null,
currentUser: null,
};
}
}
}
// 8. Всё ОК - вернуть сессию и санитизированного пользователя (без пароля)
return {
authSession,
currentUser: userService.sanitize(user),
};
}
);
+15 -6
View File
@@ -9,9 +9,10 @@ export type SessionStatus = "starting" | "started" | "ending" | "ended";
export interface CreateSessionParams {
appId: string;
userId: string;
userId?: string; // Optional для неавторизованных пользователей на demo-серверах
mode: SessionMode;
serverId?: string;
tier?: "demo" | "prod"; // Предпочитаемый tier для stream-сессий
}
export interface UpdateSessionParams {
@@ -197,7 +198,12 @@ export const serverSessionService = {
/**
* Проверить, есть ли у пользователя активная сессия для данного приложения
*/
async hasActiveSession(userId: string, appId: string) {
async hasActiveSession(userId: string | undefined, appId: string) {
// Для неавторизованных пользователей не проверяем активные сессии
if (!userId) {
return false;
}
const session = await db.query.serverSessions.findFirst({
where: and(
eq(serverSessions.userId, userId),
@@ -233,7 +239,7 @@ export const serverSessionService = {
* Создать новую сессию
*/
async create(params: CreateSessionParams) {
const { appId, userId, mode, serverId } = params;
const { appId, userId, mode, serverId, tier } = params;
// Для local-сессий выбираем сервер сразу
// Для stream-сессий сервер будет назначен динамически при запуске
@@ -257,6 +263,7 @@ export const serverSessionService = {
appId,
userId,
mode,
tier, // Предпочитаемый tier (для stream-сессий)
status: "starting",
endAt,
})
@@ -362,12 +369,14 @@ export const serverSessionService = {
if (session.mode === "stream") {
// Для stream-сессий выбираем сервер с максимальной свободной памятью
// Ищем среди всех tier (prod и demo)
const availableServers = await serverService.findAvailableStreamServers();
// Приоритет: tier из сессии > demo для неавторизованных > все серверы для авторизованных
const tier = session.tier || (session.userId ? undefined : "demo");
const availableServers = await serverService.findAvailableStreamServers(tier);
if (availableServers.length === 0) {
const serverType = tier === "demo" ? "demo " : "";
throw new Error(
"No available stream servers (check that stream servers are registered)"
`No available ${serverType}stream servers (check that stream servers are registered)`
);
}