upd
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 +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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user