diff --git a/client/bun.lock b/client/bun.lock index 333136a..964685f 100644 --- a/client/bun.lock +++ b/client/bun.lock @@ -4,6 +4,7 @@ "": { "name": "client", "dependencies": { + "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": "^0.1.1", "@tanstack/react-query": "^5.90.2", "@uidotdev/usehooks": "^2.4.1", "clsx": "^2.1.1", @@ -36,6 +37,10 @@ "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@epicgames-ps/lib-pixelstreamingcommon-ue5.7": ["@epicgames-ps/lib-pixelstreamingcommon-ue5.7@0.1.0", "", { "dependencies": { "@protobuf-ts/runtime": "^2.9.4", "@types/ws": "^8.5.14", "ws": "^8.18.0" } }, "sha512-8+6l0G6Ei6e8U5C9gxFD2DTJazkfR7fEXVfy19C4kOp1tn1/7l1wet6Y5UWvmj82h62i9xRzyaCOF0BxN1ZeGg=="], + + "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": ["@epicgames-ps/lib-pixelstreamingfrontend-ue5.7@0.1.1", "", { "dependencies": { "@epicgames-ps/lib-pixelstreamingcommon-ue5.7": "^0.1.0", "sdp": "^3.2.0" } }, "sha512-iGSb5eZRipjyc0A0513zNtgYX/+5U0+OoSTXh/dxWNhG6atnhmAvlrZFc/mx6cmlHbdhcpyN2o6TL8xnej2Kxg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="], @@ -132,6 +137,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@protobuf-ts/runtime": ["@protobuf-ts/runtime@2.11.1", "", {}, "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.35", "", {}, "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.3", "", { "os": "android", "cpu": "arm" }, "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw=="], @@ -218,6 +225,8 @@ "@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.45.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/type-utils": "8.45.0", "@typescript-eslint/utils": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.45.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ=="], @@ -538,6 +547,8 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "sdp": ["sdp@3.2.1", "", {}, "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], @@ -604,6 +615,8 @@ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], diff --git a/client/package.json b/client/package.json index 9ca998f..77f0268 100644 --- a/client/package.json +++ b/client/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": "^0.1.1", "@tanstack/react-query": "^5.90.2", "@uidotdev/usehooks": "^2.4.1", "clsx": "^2.1.1", diff --git a/client/src/components/PixelStreamingWrapper.tsx b/client/src/components/PixelStreamingWrapper.tsx new file mode 100644 index 0000000..2cfe4d9 --- /dev/null +++ b/client/src/components/PixelStreamingWrapper.tsx @@ -0,0 +1,93 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +// Copyright Epic Games, Inc. All Rights Reserved. + +import { useEffect, useRef, useState } from "react"; +import { + Config, + PixelStreaming, +} from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7"; +import type { AllSettings } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7"; + +export interface PixelStreamingWrapperProps { + initialSettings?: Partial; +} + +export const PixelStreamingWrapper = ({ + initialSettings, +}: PixelStreamingWrapperProps) => { + // A reference to parent div element that the Pixel Streaming library attaches into: + const videoParent = useRef(null); + + // Pixel streaming library instance is stored into this state variable after initialization: + const [pixelStreaming, setPixelStreaming] = useState(); + + // A boolean state variable that determines if the Click to play overlay is shown: + const [clickToPlayVisible, setClickToPlayVisible] = useState(false); + + // Run on component mount: + useEffect(() => { + if (videoParent.current) { + // Attach Pixel Streaming library to videoParent element: + const config = new Config({ initialSettings }); + const streaming = new PixelStreaming(config, { + videoElementParent: videoParent.current, + }); + + // register a playStreamRejected handler to show Click to play overlay if needed: + streaming.addEventListener("playStreamRejected", () => { + setClickToPlayVisible(true); + }); + + // Save the library instance into component state so that it can be accessed later: + setPixelStreaming(streaming); + + // Clean up on component unmount: + return () => { + try { + streaming.disconnect(); + } catch { + // + } + }; + } + }, []); + + return ( +
+
+ {clickToPlayVisible && ( +
{ + pixelStreaming?.play(); + setClickToPlayVisible(false); + }} + > +
Click to play
+
+ )} +
+ ); +}; diff --git a/client/src/main.tsx b/client/src/main.tsx index 580b8e9..3a89c24 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -5,6 +5,7 @@ import { createBrowserRouter, RouterProvider } from "react-router"; import SessionPage from "./pages/SessionPage"; import LoginPage from "./pages/LoginPage"; import RegisterPage from "./pages/RegisterPage"; +import TestPage from "./pages/TestPage"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./lib/queryClient"; import ProtectedRoute from "./components/ProtectedRoute"; @@ -37,13 +38,13 @@ const router = createBrowserRouter([ ), }, + { + path: "/test", + element: , + }, { path: "/sessions/:id", - element: ( - - - - ), + element: , }, ]); diff --git a/client/src/pages/SessionPage.tsx b/client/src/pages/SessionPage.tsx index fd7fb85..9432933 100644 --- a/client/src/pages/SessionPage.tsx +++ b/client/src/pages/SessionPage.tsx @@ -1,9 +1,411 @@ -import { useParams } from "react-router"; +import { useParams, useNavigate } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import LoaderIcon from "../components/icons/LoaderIcon"; +import CheckIcon from "../components/icons/CheckIcon"; +import ClockIcon from "../components/icons/ClockIcon"; +import WarningIcon from "../components/icons/WarningIcon"; +import StartIcon from "../components/icons/StartIcon"; +import Button from "../components/ui/Button"; +import clsx from "clsx"; +import { useEffect } from "react"; +import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper"; + +interface Session { + id: string; + appId: string; + userId: string | null; + mode: "stream" | "local"; + status: "starting" | "started" | "ending" | "ended"; + tier: "demo" | "prod" | null; + serverId: string | null; + appPid: number | null; + cirrusPid: number | null; + startAt: string; + endAt: string | null; + createdAt: string; + updatedAt: string; + app?: { + id: string; + name: string; + title: string; + gpuLimitMb: number | null; + psVersion: number | null; + }; + server?: { + id: string; + localIp: string; + hostname: string; + type: "stream" | "local"; + tier: "demo" | "prod" | null; + location: "ru1" | "uae1" | null; + } | null; + user?: { + id: string; + email: string; + role: string; + } | null; +} function SessionPage() { const { id } = useParams(); + const navigate = useNavigate(); - return
SessionPage {id}
; + const { + data: sessionData, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ["session", id], + queryFn: async () => { + const response = await api.get(`sessions/${id}`).json<{ + session: Session; + }>(); + return response; + }, + refetchInterval: (query) => { + // Автоматически обновляем каждые 2 секунды, если сессия в процессе запуска + const data = query.state.data; + if ( + data?.session.status === "starting" || + data?.session.status === "ending" + ) { + return 2000; + } + return false; + }, + }); + + const session = sessionData?.session; + + // Перенаправление на тестовую страницу при завершении сессии + useEffect(() => { + if (session?.status === "ended") { + const timer = setTimeout(() => { + navigate("/test"); + }, 5000); + return () => clearTimeout(timer); + } + }, [session?.status, navigate]); + + if (isLoading) { + return ( +
+
+
+ +
+

+ Загрузка информации о сессии... +

+
+
+ ); + } + + if (error || !session) { + return ( +
+
+
+
+ +
+
+

Сессия не найдена

+

+ {error instanceof Error + ? error.message + : "Не удалось загрузить информацию о сессии"} +

+ +
+
+
+
+ ); + } + + return ( +
+
+ {/* Header с названием приложения */} +
+
+

+ {session.app?.title || "Приложение"} +

+

+ ID сессии: {session.id.slice(0, 8)}... +

+
+ +
+ + {/* Pixel Streaming Player - показывается когда сессия запущена */} + {session.status === "started" && ( +
+ setIsVideoInitialized(true)} + /> +
+ )} + + {/* Основная карточка с информацией */} +
+ {/* Информация о сессии */} +
+

+
+ +
+ Информация о сессии +

+
+ + {session.tier && ( + + )} + + {session.endAt && ( + + )} + {session.appPid && ( + + )} + {session.cirrusPid && ( + + )} +
+
+ + {/* Информация о сервере */} + {session.server && ( +
+

+
+ +
+ Сервер +

+
+ + + + {session.server.location && ( + + )} + {session.server.tier && ( + + )} +
+
+ )} + + {/* Если сервер еще не назначен */} + {!session.server && session.status === "starting" && ( +
+

+
+ +
+ Сервер +

+

+ Подбирается подходящий сервер... +

+
+ )} + + {/* Информация о приложении */} + {session.app && ( +
+

+
+ +
+ Приложение +

+
+ + + {session.app.gpuLimitMb && ( + + )} + {session.app.psVersion && ( + + )} +
+
+ )} +
+ + {/* Статусное сообщение */} + + + {/* Кнопки управления */} +
+ + {(session.status === "starting" || session.status === "ending") && ( + + )} +
+
+
+ ); +} + +// Компоненты помощники +function StatusBadge({ status }: { status: Session["status"] }) { + const config = { + starting: { + label: "Запускается", + color: "bg-yellow-100 text-yellow-800", + icon: , + animate: true, + }, + started: { + label: "Запущена", + color: "bg-green-100 text-green-800", + icon: , + animate: false, + }, + ending: { + label: "Завершается", + color: "bg-orange-100 text-orange-800", + icon: , + animate: true, + }, + ended: { + label: "Завершена", + color: "bg-gray-100 text-gray-800", + icon: , + animate: false, + }, + }; + + const statusConfig = config[status]; + + return ( +
+
+ {statusConfig.icon} +
+ {statusConfig.label} +
+ ); +} + +function InfoRow({ label, value }: { label: string; value: string | number }) { + return ( +
+ {label}: + + {value} + +
+ ); +} + +function StatusMessage({ status }: { status: Session["status"] }) { + const messages = { + starting: { + text: "Сессия запускается. Пожалуйста, подождите...", + color: "bg-yellow-50 border-yellow-200 text-yellow-800", + icon: , + }, + started: { + text: "Сессия успешно запущена и готова к работе!", + color: "bg-green-50 border-green-200 text-green-800", + icon: , + }, + ending: { + text: "Сессия завершается...", + color: "bg-orange-50 border-orange-200 text-orange-800", + icon: , + }, + ended: { + text: "Сессия завершена. Через 5 секунд вы будете перенаправлены на главную страницу.", + color: "bg-gray-50 border-gray-200 text-gray-800", + icon: , + }, + }; + + const message = messages[status]; + + return ( +
+
{message.icon}
+

{message.text}

+
+ ); +} + +// Утилиты форматирования +function formatDateTime(dateString: string): string { + const date = new Date(dateString); + return new Intl.DateTimeFormat("ru-RU", { + dateStyle: "short", + timeStyle: "medium", + }).format(date); +} + +function getModeLabel(mode: "stream" | "local"): string { + return mode === "stream" ? "Стриминг" : "Локальный"; +} + +function getTierLabel(tier: "demo" | "prod"): string { + return tier === "demo" ? "Демо" : "Продакшн"; +} + +function getLocationLabel(location: "ru1" | "uae1"): string { + const labels = { + ru1: "Россия (ru1)", + uae1: "ОАЭ (uae1)", + }; + return labels[location] || location; } export default SessionPage; diff --git a/client/src/pages/TestPage.tsx b/client/src/pages/TestPage.tsx new file mode 100644 index 0000000..f6b9bdb --- /dev/null +++ b/client/src/pages/TestPage.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { api } from "../lib/api"; +import { useNavigate } from "react-router"; + +interface Session { + id: string; + appId: string; + userId: string | null; + mode: "stream" | "local"; + status: "starting" | "started" | "ending" | "ended"; + tier: "demo" | "prod"; + serverId: string | null; + appPid: number | null; + cirrusPid: number | null; + startAt: string; + endAt: string | null; +} + +function TestPage() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const handleStartSession = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await api + .post("sessions", { + json: { + appId: "2914d736-b928-461c-b58f-e5d35d8b605d", + mode: "stream", + tier: "demo", + }, + }) + .json<{ session: Session }>(); + + // Перенаправляем на страницу сессии + navigate(`/sessions/${response.session.id}`); + } catch (err) { + console.error("Failed to start session:", err); + setError( + err instanceof Error ? err.message : "Не удалось запустить приложение" + ); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ Тестовая страница +

+

+ Запустите демо-приложение без авторизации +

+ + + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} + +export default TestPage; diff --git a/server/src/controllers/session.ts b/server/src/controllers/session.ts index b39e679..a592f03 100644 --- a/server/src/controllers/session.ts +++ b/server/src/controllers/session.ts @@ -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", diff --git a/server/src/db/schema/enums.ts b/server/src/db/schema/enums.ts index e4fc910..32e758b 100644 --- a/server/src/db/schema/enums.ts +++ b/server/src/db/schema/enums.ts @@ -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"]); diff --git a/server/src/db/schema/serverSessions.ts b/server/src/db/schema/serverSessions.ts index ffc9177..3d4d02d 100644 --- a/server/src/db/schema/serverSessions.ts +++ b/server/src/db/schema/serverSessions.ts @@ -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() diff --git a/server/src/db/schema/servers.ts b/server/src/db/schema/servers.ts index abf49a5..52c322f 100644 --- a/server/src/db/schema/servers.ts +++ b/server/src/db/schema/servers.ts @@ -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(), diff --git a/server/src/middlewares/optionalAuth.ts b/server/src/middlewares/optionalAuth.ts new file mode 100644 index 0000000..fc825d2 --- /dev/null +++ b/server/src/middlewares/optionalAuth.ts @@ -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), + }; + } +); + diff --git a/server/src/services/serverSession/index.ts b/server/src/services/serverSession/index.ts index 18ea06c..55e1bee 100644 --- a/server/src/services/serverSession/index.ts +++ b/server/src/services/serverSession/index.ts @@ -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)` ); } diff --git a/session-server/src/index.ts b/session-server/src/index.ts index 866a833..413c9b5 100644 --- a/session-server/src/index.ts +++ b/session-server/src/index.ts @@ -377,13 +377,15 @@ async function startApplication(session: SessionData): Promise { try { console.log( - `[${new Date().toISOString()}] 🚀 Запуск приложения "${app.name}" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})` + `[${new Date().toISOString()}] 🚀 Запуск приложения "${ + app.name + }" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})` ); // Формируем путь к exe файлу приложения // Путь: C:\apps\{appName}\{appName}.exe const appPath = `C:\\apps\\${app.name}\\${app.name}.exe`; - + console.log( `[${new Date().toISOString()}] 📂 Путь к приложению: ${appPath}` ); @@ -397,12 +399,23 @@ async function startApplication(session: SessionData): Promise { // Запускаем exe приложение // Используем 'pipe' для stderr чтобы видеть ошибки, но 'ignore' для stdin/stdout - const appProcess = spawn(appPath, [], { - detached: false, - stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe - windowsHide: true, // Скрывать окно консоли на Windows - cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения - }); + const appProcess = spawn( + appPath, + [ + "-PixelStreamingURL=ws://127.0.0.1:8888", + "-ForceRes", + "-ResX=1920", + "-ResY=1080", + "-Unattended", + "-RenderOffScreen", + ], + { + detached: false, + stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe + windowsHide: true, // Скрывать окно консоли на Windows + cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения + } + ); const appPid = appProcess.pid; @@ -417,7 +430,9 @@ async function startApplication(session: SessionData): Promise { if (appProcess.stderr) { appProcess.stderr.on("data", (data) => { console.error( - `[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data.toString().trim()}` + `[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data + .toString() + .trim()}` ); }); } @@ -425,15 +440,17 @@ async function startApplication(session: SessionData): Promise { // Обработка завершения процесса appProcess.on("exit", async (code, signal) => { console.log( - `[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${signal ? ` (сигнал: ${signal})` : ""}` + `[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${ + signal ? ` (сигнал: ${signal})` : "" + }` ); - + if (code !== 0 && code !== null) { console.error( `[${new Date().toISOString()}] ⚠️ Приложение завершилось с ошибкой! Код выхода: ${code}` ); } - + activeProcesses.delete(sessionId); // Обновить статус на "ended" @@ -446,7 +463,7 @@ async function startApplication(session: SessionData): Promise { error ); activeProcesses.delete(sessionId); - + // Обновить статус на "ended" в случае ошибки await updateSessionStatus(sessionId, "ended"); }); @@ -455,7 +472,9 @@ async function startApplication(session: SessionData): Promise { await updateSessionStatus(sessionId, "started", appPid); console.log( - `[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${activeProcesses.size})` + `[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${ + activeProcesses.size + })` ); } catch (error) { console.error( @@ -518,7 +537,9 @@ async function stopApplication(session: SessionData): Promise { try { console.log( - `[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${appPid || appProcess?.pid || "неизвестен"})` + `[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${ + appPid || appProcess?.pid || "неизвестен" + })` ); // Используем PID из базы данных если он есть, иначе из процесса @@ -576,31 +597,35 @@ async function checkSessions(): Promise { const startAt = new Date(s.startAt); return startAt <= now; }); - + // Логировать запланированные сессии, которые ещё не пришло время запускать // Логируем не чаще раза в 10 секунд для каждой сессии, чтобы не спамить логами const scheduledSessions = allStartingSessions.filter((s) => { const startAt = new Date(s.startAt); return startAt > now; }); - + if (scheduledSessions.length > 0) { for (const session of scheduledSessions) { const lastLogTime = lastScheduledLogTime.get(session.id) || 0; const timeSinceLastLog = now.getTime() - lastLogTime; - + // Логируем только если прошло больше 10 секунд с последнего лога if (timeSinceLastLog > 10000) { const startAt = new Date(session.startAt); - const timeUntilStart = Math.round((startAt.getTime() - now.getTime()) / 1000); + const timeUntilStart = Math.round( + (startAt.getTime() - now.getTime()) / 1000 + ); console.log( - `[${new Date().toISOString()}] ⏰ Сессия ${session.id} (${session.app.name}) запланирована через ${timeUntilStart} сек` + `[${new Date().toISOString()}] ⏰ Сессия ${session.id} (${ + session.app.name + }) запланирована через ${timeUntilStart} сек` ); lastScheduledLogTime.set(session.id, now.getTime()); } } } - + // Очистить карту логирования для сессий, которые больше не запланированы const scheduledSessionIds = new Set(scheduledSessions.map((s) => s.id)); for (const sessionId of lastScheduledLogTime.keys()) { @@ -608,7 +633,7 @@ async function checkSessions(): Promise { lastScheduledLogTime.delete(sessionId); } } - + for (const session of startingSessions) { await startApplication(session); }