diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b75b980..139d98c 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,20 +1,418 @@ import { dotenvLoad } from "dotenv-mono"; const dotenv = dotenvLoad(); -import { Elysia } from "elysia"; +import { Elysia, error, t } from "elysia"; import { swagger } from "@elysiajs/swagger"; -import { prisma } from "@minikura/db"; +import { prisma, ServerType } from "@minikura/db"; + +import { ServerService } from "./services/server"; +import { UserService } from "./services/user"; +import argon2 from "argon2"; +import { SessionService } from "./services/session"; + +enum ReturnError { + INVALID_USERNAME_OR_PASSWORD = "INVALID_USERNAME_OR_PASSWORD", + MISSING_TOKEN = "MISSING_TOKEN", + REVOKED_TOKEN = "REVOKED_TOKEN", + EXPIRED_TOKEN = "EXPIRED_TOKEN", + INVALID_TOKEN = "INVALID_TOKEN", + SERVER_NAME_IN_USE = "SERVER_NAME_IN_USE", + SERVER_NOT_FOUND = "SERVER_NOT_FOUND", +} + +const bootstrap = async () => { + const users = await prisma.user.findMany(); + if (users.length !== 0) { + return; + } + + await prisma.user.create({ + data: { + username: "admin", + password: await argon2.hash("admin"), + }, + }); + + console.log("Default user created"); +}; const app = new Elysia() .use(swagger()) - .get("/", async () => { - return "Hello Elysia"; + .ws("/ws", { + body: t.Object({ + message: t.String(), + }), + // query: t.Object({ + // id: t.String(), + // }), + message(ws, { message }) { + const { id } = ws.data.query; + ws.send({ + id, + message, + time: Date.now(), + }); + }, + }) + .post( + "/login", + async ({ body, cookie: { session_token } }) => { + const user = await UserService.getUserByUsername(body.username); + + if (!user) { + // Fake hash to prevent timing attacks + const FAKE_HASH = + "$argon2id$v=19$m=65536,t=3,p=4$PMqZArEBmfkHPbPv7Z50KQ$miV6tIIXU6w2WWOGLbWrdan8TGMTUsS1A1hy9RrpS9Y"; + await argon2.verify(FAKE_HASH, "fake"); + + return error("Unauthorized", { + success: false, + message: ReturnError.INVALID_USERNAME_OR_PASSWORD, + }); + } + + const valid = await argon2.verify(user.password, body.password); + if (!valid) { + return error("Unauthorized", { + success: false, + message: ReturnError.INVALID_USERNAME_OR_PASSWORD, + }); + } + + const session = await SessionService.create(user.id); + + session_token.httpOnly = true; + session_token.value = session.token; + + return { + success: true, + }; + }, + { + body: t.Object({ + username: t.String({ minLength: 1 }), + password: t.String({ minLength: 1 }), + }), + } + ) + .derive(async ({ headers, cookie: { session_token } }) => { + const auth = session_token.value; + const token = headers.authorization?.split(" ")[1]; + + if (!auth && !token) + return error("Unauthorized", { + success: false, + message: ReturnError.MISSING_TOKEN, + }); + + if (auth) { + const session = await SessionService.validate(auth); + if (session.status === SessionService.SESSION_STATUS.REVOKED) { + return error("Unauthorized", { + success: false, + message: ReturnError.REVOKED_TOKEN, + }); + } + if (session.status === SessionService.SESSION_STATUS.EXPIRED) { + return error("Unauthorized", { + success: false, + message: ReturnError.EXPIRED_TOKEN, + }); + } + if ( + session.status === SessionService.SESSION_STATUS.INVALID || + session.status !== SessionService.SESSION_STATUS.VALID || + !session.session + ) { + return error("Unauthorized", { + success: false, + message: ReturnError.INVALID_TOKEN, + }); + } + + return { + server: null, + session: session.session, + }; + } + + if (token) { + const session = await SessionService.validateApiKey(token); + if (session.status === SessionService.SESSION_STATUS.INVALID) { + return error("Unauthorized", { + success: false, + message: ReturnError.INVALID_TOKEN, + }); + } + if ( + session.status === SessionService.SESSION_STATUS.VALID && + session.server + ) { + return { + session: null, + server: session.server, + }; + } + } + + // Should never reach here + return error("Unauthorized", { + success: false, + message: ReturnError.INVALID_TOKEN, + }); + }) + .post("/logout", async ({ session, cookie: { session_token } }) => { + if (!session) return { success: true }; + + await SessionService.revoke(session.token); + + session_token.remove(); + + return { + success: true, + }; + }) + .get("/servers", async ({ session }) => { + return await ServerService.getAllServers(!session); + }) + .get("/servers/:id", async ({ session, params: { id } }) => { + return await ServerService.getServerById(id, !session); + }) + .post( + "/servers", + async ({ body, error }) => { + // Must be a-z, A-Z, 0-9, and -_ only + if (!/^[a-zA-Z0-9-_]+$/.test(body.name)) { + return error("Bad Request", "Name must be a-z, A-Z, 0-9, and -_ only"); + } + + const _server = await ServerService.getServerByName(body.name); + if (_server) { + return error("Conflict", { + success: false, + message: ReturnError.SERVER_NAME_IN_USE, + }); + } + + const server = await ServerService.createServer({ + name: body.name, + description: body.description, + address: body.address, + port: body.port, + type: body.type, + join_priority: body.join_priority, + }); + + return { + success: true, + data: { + server, + }, + }; + }, + { + body: t.Object({ + name: t.String({ minLength: 1 }), + description: t.Nullable(t.String({ minLength: 1 })), + address: t.String({ minLength: 1 }), + port: t.Integer({ minimum: 1, maximum: 65535 }), + type: t.Enum(ServerType), + join_priority: t.Nullable(t.Integer({ minimum: 0 })), + }), + } + ) + .patch( + "/servers/:id", + async ({ session, params: { id }, body }) => { + const server = await ServerService.getServerById(id); + if (!server) { + return error("Not Found", { + success: false, + message: ReturnError.SERVER_NOT_FOUND, + }); + } + + if (body.name) { + const _server = await ServerService.getServerByName(body.name); + if (_server && _server.id !== id) { + return error("Conflict", { + success: false, + message: ReturnError.SERVER_NAME_IN_USE, + }); + } + } + + const data = { + name: body.name, + description: body.description, + address: body.address, + port: body.port, + join_priority: body.join_priority, + }; + + await prisma.server.update({ + where: { id }, + data, + }); + + const newServer = await ServerService.getServerById(id, !session); + + return { + success: true, + data: { + server: newServer, + }, + }; + }, + { + body: t.Object({ + name: t.Optional(t.String({ minLength: 1 })), + description: t.Optional(t.Nullable(t.String({ minLength: 1 }))), + address: t.Optional(t.String({ minLength: 1 })), + port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })), + join_priority: t.Optional(t.Integer({ minimum: 0 })), + }), + } + ) + .delete("/servers/:id", async ({ params: { id } }) => { + const server = await ServerService.getServerById(id); + if (!server) { + return error("Not Found", { + success: false, + message: ReturnError.SERVER_NOT_FOUND, + }); + } + + await prisma.server.delete({ + where: { id }, + }); + + return { + success: true, + }; + }) + .get("/reverse_proxy_servers", async ({ session }) => { + return await ServerService.getAllReverseProxyServers(!session); + }) + .post( + "/reverse_proxy_servers", + async ({ body, error }) => { + // Must be a-z, A-Z, 0-9, and -_ only + if (!/^[a-zA-Z0-9-_]+$/.test(body.name)) { + return error("Bad Request", "Name must be a-z, A-Z, 0-9, and -_ only"); + } + + const _server = await ServerService.getReverseProxyServerByName( + body.name + ); + if (_server) { + return error("Conflict", { + success: false, + message: ReturnError.SERVER_NAME_IN_USE, + }); + } + + const server = await ServerService.createReverseProxyServer({ + name: body.name, + address: body.address, + port: body.port, + description: body.description, + }); + + return { + success: true, + data: { + server, + }, + }; + }, + { + body: t.Object({ + name: t.String({ minLength: 1 }), + description: t.Nullable(t.String({ minLength: 1 })), + address: t.String({ minLength: 1 }), + port: t.Integer({ minimum: 1, maximum: 65535 }), + }), + } + ) + .patch( + "/reverse_proxy_servers/:id", + async ({ session, params: { id }, body }) => { + const server = await prisma.reverseProxyServer.findUnique({ + where: { id }, + }); + if (!server) { + return error("Not Found", { + success: false, + message: ReturnError.SERVER_NOT_FOUND, + }); + } + + if (body.name) { + const _server = await ServerService.getServerByName(body.name); + if (_server && _server.id !== id) { + return error("Conflict", { + success: false, + message: ReturnError.SERVER_NAME_IN_USE, + }); + } + } + + const data = { + name: body.name, + description: body.description, + address: body.address, + port: body.port, + }; + + await prisma.reverseProxyServer.update({ + where: { id }, + data, + }); + + const newServer = await ServerService.getReverseProxyServerById( + id, + !session + ); + + return { + success: true, + data: { + server: newServer, + }, + }; + }, + { + body: t.Object({ + name: t.Optional(t.String({ minLength: 1 })), + description: t.Optional(t.Nullable(t.String({ minLength: 1 }))), + address: t.Optional(t.String({ minLength: 1 })), + port: t.Optional(t.Integer({ minimum: 1, maximum: 65535 })), + }), + } + ) + .delete("/reverse_proxy_servers/:id", async ({ params: { id } }) => { + const server = await prisma.reverseProxyServer.findUnique({ + where: { id }, + }); + if (!server) { + return error("Not Found", { + success: false, + message: ReturnError.SERVER_NOT_FOUND, + }); + } + + await prisma.reverseProxyServer.delete({ + where: { id }, + }); + + return { + success: true, + }; }) - .get("/hello", "Do you miss me?") .listen(3000, async () => { console.log("Server is running on port 3000"); - const result = await prisma.server.findMany(); - console.log(result); + bootstrap(); }); export type App = typeof app; diff --git a/apps/backend/src/services/server.ts b/apps/backend/src/services/server.ts new file mode 100644 index 0000000..01447f7 --- /dev/null +++ b/apps/backend/src/services/server.ts @@ -0,0 +1,127 @@ +import { prisma } from "@minikura/db"; +import type { ServerType } from "@minikura/db"; +import crypto from "node:crypto"; + +export namespace ServerService { + export async function getAllServers(omitSensitive = false) { + return await prisma.server.findMany({ + omit: { + api_key: omitSensitive, + }, + }); + } + + export async function getAllReverseProxyServers(omitSensitive = false) { + return await prisma.reverseProxyServer.findMany({ + omit: { + api_key: omitSensitive, + }, + }); + } + + export async function getServerById(id: string, omitSensitive = false) { + return await prisma.server.findUnique({ + where: { id }, + omit: { + api_key: omitSensitive, + }, + }); + } + + export async function getReverseProxyServerById( + id: string, + omitSensitive = false + ) { + return await prisma.reverseProxyServer.findUnique({ + where: { id }, + omit: { + api_key: omitSensitive, + }, + }); + } + + export async function getServerByName(name: string, omitSensitive = false) { + return await prisma.server.findUnique({ + where: { name }, + omit: { + api_key: omitSensitive, + }, + }); + } + + export async function getReverseProxyServerByName( + name: string, + omitSensitive = false + ) { + return await prisma.reverseProxyServer.findUnique({ + where: { name }, + omit: { + api_key: omitSensitive, + }, + }); + } + + export async function createReverseProxyServer({ + name, + description, + address, + port, + }: { + name: string; + description: string | null; + address: string; + port: number; + }) { + let token = crypto.randomBytes(64).toString("hex"); + token = token + .split("") + .map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char)) + .join(""); + token = `minikura_reverse_proxy_server_api_key_${token}`; + + return await prisma.reverseProxyServer.create({ + data: { + name, + description, + address, + port, + api_key: token, + }, + }); + } + + export async function createServer({ + name, + description, + address, + port, + type, + join_priority, + }: { + name: string; + description: string | null; + address: string; + port: number; + type: ServerType; + join_priority: number | null; + }) { + let token = crypto.randomBytes(64).toString("hex"); + token = token + .split("") + .map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char)) + .join(""); + token = `minikura_server_api_key_${token}`; + + return await prisma.server.create({ + data: { + name, + description, + address, + port, + type, + api_key: token, + join_priority, + }, + }); + } +} diff --git a/apps/backend/src/services/session.ts b/apps/backend/src/services/session.ts new file mode 100644 index 0000000..17f7aa0 --- /dev/null +++ b/apps/backend/src/services/session.ts @@ -0,0 +1,126 @@ +import { prisma } from "@minikura/db"; +import crypto from "node:crypto"; + +export namespace SessionService { + export enum SESSION_STATUS { + VALID = "VALID", + INVALID = "INVALID", + REVOKED = "REVOKED", + EXPIRED = "EXPIRED", + } + + export async function validate(token: string) { + const session = await prisma.session.findUnique({ + where: { + token, + }, + }); + + if (!session) { + return { + status: SESSION_STATUS.INVALID, + session: null, + }; + } + + if (session.revoked) { + return { + status: SESSION_STATUS.REVOKED, + session, + }; + } + + if (session.expires_at < new Date()) { + return { + status: SESSION_STATUS.EXPIRED, + session, + }; + } + + return { + status: SESSION_STATUS.VALID, + session, + }; + } + + export async function validateApiKey(apiKey: string) { + // If starts with "minikura_reverse_proxy_server_api_key_" + if (apiKey.startsWith("minikura_reverse_proxy_server_api_key_")) { + const reverseProxyServer = await prisma.reverseProxyServer.findUnique({ + where: { + api_key: apiKey, + }, + }); + + if (!reverseProxyServer) { + return { + status: SESSION_STATUS.INVALID, + session: null, + }; + } + + return { + status: SESSION_STATUS.VALID, + server: reverseProxyServer, + }; + } + + if (apiKey.startsWith("minikura_server_api_key_")) { + const server = await prisma.server.findUnique({ + where: { + api_key: apiKey, + }, + }); + + if (!server) { + return { + status: SESSION_STATUS.INVALID, + session: null, + }; + } + + return { + status: SESSION_STATUS.VALID, + server: server, + }; + } + + return { + status: SESSION_STATUS.INVALID, + session: null, + }; + } + + export async function create(userId: string) { + let token = crypto.randomBytes(64).toString("hex"); + token = token + .split("") + .map((char) => (Math.random() > 0.5 ? char.toUpperCase() : char)) + .join(""); + token = `minikura_user_session_${token}`; + + return await prisma.session.create({ + data: { + token, + user: { + connect: { + id: userId, + }, + }, + // Expires in 48 hours + expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000), + }, + }); + } + + export async function revoke(token: string) { + return await prisma.session.update({ + where: { + token, + }, + data: { + revoked: true, + }, + }); + } +} diff --git a/apps/backend/src/services/user.ts b/apps/backend/src/services/user.ts new file mode 100644 index 0000000..f2f94cc --- /dev/null +++ b/apps/backend/src/services/user.ts @@ -0,0 +1,9 @@ +import { prisma } from "@minikura/db"; + +export namespace UserService { + export async function getUserByUsername(username: string) { + return await prisma.user.findUnique({ + where: { username }, + }); + } +} diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 5957fbb..64a747f 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -5,7 +5,8 @@ // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["omitApi"] } datasource db {