mirror of
https://github.com/jetkvm/cloud-api.git
synced 2025-09-16 08:38:15 +00:00
209 lines
6.2 KiB
TypeScript
209 lines
6.2 KiB
TypeScript
import express from "express";
|
|
import cors from "cors";
|
|
import cookieSession from "cookie-session";
|
|
import * as jose from "jose";
|
|
import helmet from "helmet";
|
|
|
|
import * as Devices from "./devices";
|
|
import * as OIDC from "./oidc";
|
|
import * as Webrtc from "./webrtc";
|
|
import * as Releases from "./releases";
|
|
|
|
import { HttpError } from "./errors";
|
|
import { authenticated } from "./auth";
|
|
import { prisma } from "./db";
|
|
|
|
declare global {
|
|
namespace NodeJS {
|
|
interface ProcessEnv {
|
|
NODE_ENV: "development" | "production";
|
|
|
|
API_HOSTNAME: string;
|
|
APP_HOSTNAME: string;
|
|
COOKIE_SECRET: string;
|
|
|
|
// We use Google OIDC for authentication
|
|
GOOGLE_CLIENT_ID: string;
|
|
GOOGLE_CLIENT_SECRET: string;
|
|
|
|
// We use Cloudflare STUN & TURN server for cloud users
|
|
CLOUDFLARE_TURN_ID: string;
|
|
CLOUDFLARE_TURN_TOKEN: string;
|
|
|
|
// We use R2 for storing releases
|
|
R2_ENDPOINT: string;
|
|
R2_ACCESS_KEY_ID: string;
|
|
R2_SECRET_ACCESS_KEY: string;
|
|
R2_BUCKET: string;
|
|
R2_CDN_URL: string;
|
|
|
|
CORS_ORIGINS: string;
|
|
}
|
|
}
|
|
}
|
|
|
|
const app = express();
|
|
app.use(helmet());
|
|
app.disable("x-powered-by");
|
|
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use(
|
|
cors({
|
|
origin: process.env.CORS_ORIGINS?.split(",") || [
|
|
"https://app.jetkvm.com", "http://localhost:5173"
|
|
],
|
|
credentials: true,
|
|
}),
|
|
);
|
|
app.use(
|
|
cookieSession({
|
|
name: "session",
|
|
path: "/",
|
|
httpOnly: true,
|
|
keys: [process.env.COOKIE_SECRET],
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "strict",
|
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
}),
|
|
);
|
|
|
|
function asyncHandler(fn: any) {
|
|
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
return Promise.resolve(fn(req, res, next)).catch(next);
|
|
};
|
|
}
|
|
|
|
// express-session won't sent the cookie, as it's `secure` and `secureProxy` is set to true
|
|
// DO Apps doesn't send a X-Forwarded-Proto header, so we simply need to make a blanket trust
|
|
app.set("trust proxy", true);
|
|
|
|
const asyncAuthGuard = asyncHandler(authenticated);
|
|
app.get("/", (req, res) => {
|
|
return res.status(200).send("OK");
|
|
});
|
|
|
|
app.get(
|
|
"/me",
|
|
asyncAuthGuard,
|
|
asyncHandler(async (req: express.Request, res: express.Response) => {
|
|
const idToken = req.session?.id_token;
|
|
const { sub, iss, exp, aud, iat, jti, nbf } = jose.decodeJwt(idToken);
|
|
|
|
let user;
|
|
if (iss === "https://accounts.google.com") {
|
|
user = await prisma.user.findUnique({
|
|
where: { googleId: sub },
|
|
select: { picture: true, email: true },
|
|
});
|
|
}
|
|
|
|
return res.json({ ...user, sub });
|
|
}),
|
|
);
|
|
|
|
app.get("/releases", asyncHandler(Releases.Retrieve));
|
|
app.get(
|
|
"/releases/system_recovery/latest",
|
|
asyncHandler(Releases.RetrieveLatestSystemRecovery),
|
|
);
|
|
app.get("/releases/app/latest", asyncHandler(Releases.RetrieveLatestApp));
|
|
|
|
app.get("/devices", asyncAuthGuard, asyncHandler(Devices.List));
|
|
app.get("/devices/:id", asyncAuthGuard, asyncHandler(Devices.Retrieve));
|
|
app.post("/devices/token", asyncHandler(Devices.Token));
|
|
app.put("/devices/:id", asyncAuthGuard, asyncHandler(Devices.Update));
|
|
app.delete("/devices/:id", asyncHandler(Devices.Delete));
|
|
|
|
app.post("/webrtc/session", asyncAuthGuard, asyncHandler(Webrtc.CreateSession));
|
|
app.post("/webrtc/ice_config", asyncAuthGuard, asyncHandler(Webrtc.CreateIceCredentials));
|
|
app.post(
|
|
"/webrtc/turn_activity",
|
|
asyncAuthGuard,
|
|
asyncHandler(Webrtc.CreateTurnActivity),
|
|
);
|
|
|
|
app.post("/oidc/google", asyncHandler(OIDC.Google));
|
|
app.get("/oidc/callback_o", asyncHandler(OIDC.Callback));
|
|
app.get("/oidc/callback", (req, res) => {
|
|
/*
|
|
* We set the session cookie in the /oidc/google route as a part of 302 redirect to the OIDC login page
|
|
* When the OIDC provider redirects back to the /oidc/callback route, the session cookie won't be sent as it seen by the browser as a new session,
|
|
* and SameSite=Lax|Strict doesn't regard it as a same-site request.
|
|
*
|
|
* One solution, is to simply to use SameSite=None; Secure. Not nice for CSRF, and safari doesn't like it.
|
|
* Another solution is to simply return 200 and then redirect with HTML to the /oidc/callback_o route, which will have the session cookie.
|
|
* We went with the latter, and now we can have SameSite=Strict cookies:
|
|
* https://stackoverflow.com/questions/42216700/how-can-i-redirect-after-oauth2-with-samesite-strict-and-still-get-my-cookies
|
|
* */
|
|
const callbackUrl = req.url.replace("/oidc/callback", "/oidc/callback_o");
|
|
return res.send(
|
|
`<html>
|
|
<head>
|
|
<meta http-equiv="refresh" content="0; URL='${callbackUrl}'"/>
|
|
<script>
|
|
// Initial theme setup
|
|
document.documentElement.classList.toggle(
|
|
"dark",
|
|
localStorage.theme === "dark" ||
|
|
(!("theme" in localStorage) &&
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches),
|
|
);
|
|
|
|
// Listen for system theme changes
|
|
window
|
|
.matchMedia("(prefers-color-scheme: dark)")
|
|
.addEventListener("change", ({ matches }) => {
|
|
if (!("theme" in localStorage)) {
|
|
// Only auto-switch if user hasn't manually set a theme
|
|
document.documentElement.classList.toggle("dark", matches);
|
|
}
|
|
});
|
|
</script>
|
|
<style>
|
|
body {background-color: #0f172a;}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`,
|
|
);
|
|
});
|
|
|
|
app.post(
|
|
"/logout",
|
|
asyncHandler((req: express.Request, res: express.Response) => {
|
|
req.session = null;
|
|
return res.json({ message: "Logged out" });
|
|
}),
|
|
);
|
|
|
|
// Error-handling middleware
|
|
app.use(
|
|
(
|
|
err: HttpError | Error,
|
|
req: express.Request,
|
|
res: express.Response,
|
|
next: express.NextFunction,
|
|
) => {
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
const statusCode = err instanceof HttpError ? err.status : 500;
|
|
|
|
// Build the error response payload
|
|
const payload = {
|
|
name: err.name,
|
|
message: err.message,
|
|
...(isProduction ? {} : { stack: err.stack }),
|
|
};
|
|
|
|
console.error(err);
|
|
|
|
res.status(statusCode).json(payload);
|
|
},
|
|
);
|
|
|
|
const server = app.listen(3000, () => {
|
|
console.log("Server started on port 3000");
|
|
});
|
|
|
|
Webrtc.registerWebsocketServer(server);
|