Files
Piik/src/server/app.ts
T

414 lines
12 KiB
TypeScript

import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { fileURLToPath } from "node:url";
import sirv from "sirv";
import type { ViteDevServer } from "vite";
import type { CreateRoomResponse } from "../shared/protocol.js";
import { AccessSession } from "./access-session.js";
import { loadConfig, type ServerConfig } from "./config.js";
import { RoomDatabase } from "./room-database.js";
import { RoomStore, RoomStoreError } from "./room-store.js";
import { SignalingServer } from "./signaling.js";
export interface CreateServerOptions {
config?: ServerConfig;
roomStore?: RoomStore;
serveFrontend?: boolean;
staticDirectory?: string;
now?: () => number;
authenticationTimeoutMs?: number;
viewerDisconnectGraceMs?: number;
heartbeatIntervalMs?: number;
cleanupIntervalMs?: number;
maxSignalConnections?: number;
maxUnauthenticatedSignalConnections?: number;
accessSessionTtlSeconds?: number;
}
export interface ScreenerServer {
readonly httpServer: ReturnType<typeof createServer>;
readonly roomStore: RoomStore;
listen(port?: number, host?: string): Promise<number>;
close(): Promise<void>;
}
type FrontendHandler = (request: IncomingMessage, response: ServerResponse) => void;
export async function createScreenerServer(
options: CreateServerOptions = {},
): Promise<ScreenerServer> {
const config = options.config ?? loadConfig();
const now = options.now ?? Date.now;
const roomStore =
options.roomStore ??
new RoomStore({
ttlMs: config.roomTtlMs,
maxRooms: config.maxRooms,
maxViewersPerRoom: config.maxViewersPerRoom,
database: config.roomDatabasePath
? new RoomDatabase(config.roomDatabasePath)
: undefined,
now,
});
const accessSession = new AccessSession({
password: config.accessPassword,
secure:
config.nodeEnv === "production" &&
config.publicBaseUrl.protocol === "https:",
now,
ttlSeconds: options.accessSessionTtlSeconds,
});
const iceOptions = {
stunUrls: config.stunUrls,
turnUrls: config.turnUrls,
turnSharedSecret: config.turnSharedSecret,
credentialTtlSeconds: config.turnCredentialTtlSeconds,
};
let frontendHandler: FrontendHandler | undefined;
let vite: ViteDevServer | undefined;
const httpServer = createServer((request, response) => {
void handleRequest(
request,
response,
config,
roomStore,
accessSession,
() => frontendHandler,
).catch((error: unknown) => {
console.error("HTTP request failed", {
method: request.method,
path: safePathname(request.url),
error,
});
if (!response.headersSent) {
sendJson(response, 500, { error: "Internal server error" });
} else {
response.destroy();
}
});
});
const signaling = new SignalingServer({
server: httpServer,
roomStore,
peerAssistedMedia: config.peerAssistedMedia,
ice: iceOptions,
allowedOrigins: config.allowedOrigins,
authorizeUpgrade: (request) =>
accessSession.isAuthenticated(request.headers.cookie),
now,
authenticationTimeoutMs: options.authenticationTimeoutMs,
viewerDisconnectGraceMs: options.viewerDisconnectGraceMs,
heartbeatIntervalMs: options.heartbeatIntervalMs,
cleanupIntervalMs: options.cleanupIntervalMs,
maxConnections: options.maxSignalConnections,
maxUnauthenticatedConnections:
options.maxUnauthenticatedSignalConnections,
passThroughUnknownUpgrades:
config.nodeEnv === "development" && options.serveFrontend !== false,
});
if (options.serveFrontend !== false) {
if (config.nodeEnv === "development") {
const { createServer: createViteServer } = await import("vite");
vite = await createViteServer({
appType: "spa",
server: {
middlewareMode: true,
hmr: { server: httpServer },
},
});
frontendHandler = (request, response) => {
vite?.middlewares(request, response, () => {
sendJson(response, 404, { error: "Not found" });
});
};
} else if (config.nodeEnv === "production") {
const staticDirectory =
options.staticDirectory ??
fileURLToPath(new URL("../../client/", import.meta.url));
const serve = sirv(staticDirectory, {
single: true,
setHeaders(response) {
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Referrer-Policy", "no-referrer");
},
});
frontendHandler = (request, response) => {
serve(request, response, () => {
sendJson(response, 404, { error: "Not found" });
});
};
}
}
httpServer.requestTimeout = 10_000;
httpServer.headersTimeout = 15_000;
httpServer.keepAliveTimeout = 5_000;
return {
httpServer,
roomStore,
listen(port = config.port, host = config.listenHost) {
return new Promise<number>((resolve, reject) => {
const onError = (error: Error) => {
httpServer.off("listening", onListening);
reject(error);
};
const onListening = () => {
httpServer.off("error", onError);
const address = httpServer.address();
if (!address || typeof address === "string") {
reject(new Error("Server did not bind to a TCP port"));
return;
}
resolve(address.port);
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
httpServer.listen(port, host);
});
},
async close() {
try {
await signaling.close();
if (httpServer.listening) {
await new Promise<void>((resolve, reject) => {
httpServer.close((error) => (error ? reject(error) : resolve()));
});
}
await vite?.close();
} finally {
roomStore.close();
}
},
};
}
async function handleRequest(
request: IncomingMessage,
response: ServerResponse,
config: ServerConfig,
roomStore: RoomStore,
accessSession: AccessSession,
getFrontendHandler: () => FrontendHandler | undefined,
): Promise<void> {
const url = new URL(request.url ?? "/", config.publicBaseUrl);
if (url.pathname === "/healthz") {
response.setHeader("Cache-Control", "no-store");
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
sendJson(response, 405, { error: "Method not allowed" });
return;
}
sendJson(response, 200, { status: "ok" });
return;
}
if (url.pathname === "/api/session") {
handleSessionRequest(request, response, config, accessSession);
return;
}
if (url.pathname === "/api/rooms") {
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
sendJson(response, 405, { error: "Method not allowed" });
return;
}
if (!isAllowedRequestOrigin(request.headers.origin, config.allowedOrigins)) {
sendJson(response, 403, { error: "Forbidden" });
return;
}
if (hasRequestBody(request)) {
sendJson(response, 400, { error: "Request body is not accepted" });
return;
}
if (
!isRoomCreationAuthorized(
request,
accessSession,
isStrictlyAllowedRequestOrigin(
request.headers.origin,
config.allowedOrigins,
),
)
) {
sendJson(response, 401, { error: "Unauthorized" });
return;
}
try {
const room = roomStore.createRoom();
const inviteUrl = new URL(`/r/${room.roomId}`, config.publicBaseUrl);
const responseBody: CreateRoomResponse = {
roomId: room.roomId,
hostToken: room.hostToken,
inviteUrl: inviteUrl.toString(),
expiresAt: room.expiresAt,
};
sendJson(response, 201, responseBody);
} catch (error) {
if (error instanceof RoomStoreError && error.code === "ROOM_LIMIT") {
sendJson(response, 503, { error: "Room capacity reached" });
return;
}
throw error;
}
return;
}
if (url.pathname.startsWith("/api/")) {
response.setHeader("Cache-Control", "no-store");
sendJson(response, 404, { error: "Not found" });
return;
}
const frontendHandler = getFrontendHandler();
if (frontendHandler) {
frontendHandler(request, response);
return;
}
sendJson(response, 404, { error: "Not found" });
}
function handleSessionRequest(
request: IncomingMessage,
response: ServerResponse,
config: ServerConfig,
accessSession: AccessSession,
): void {
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method === "GET") {
sendJson(response, 200, sessionStatus(request, accessSession));
return;
}
if (request.method !== "POST") {
response.setHeader("Allow", "GET, POST");
sendJson(response, 405, { error: "Method not allowed" });
return;
}
if (
!isStrictlyAllowedRequestOrigin(
request.headers.origin,
config.allowedOrigins,
)
) {
sendJson(response, 403, { error: "Forbidden" });
return;
}
if (hasRequestBody(request)) {
sendJson(response, 400, { error: "Request body is not accepted" });
return;
}
if (!accessSession.required) {
sendJson(response, 200, { required: false, authenticated: true });
return;
}
if (!isBearerAuthorized(request, accessSession)) {
response.setHeader("WWW-Authenticate", "Bearer");
sendJson(response, 401, { error: "Unauthorized" });
return;
}
response.setHeader("Set-Cookie", accessSession.createCookie()!);
sendJson(response, 200, { required: true, authenticated: true });
}
function sessionStatus(
request: IncomingMessage,
accessSession: AccessSession,
): { required: boolean; authenticated: boolean } {
return {
required: accessSession.required,
authenticated: accessSession.isAuthenticated(request.headers.cookie),
};
}
function hasRequestBody(request: IncomingMessage): boolean {
const contentLength = request.headers["content-length"];
const parsedLength = contentLength === undefined ? 0 : Number(contentLength);
return (
request.headers["transfer-encoding"] !== undefined ||
!Number.isFinite(parsedLength) ||
parsedLength !== 0
);
}
function isAllowedRequestOrigin(
origin: string | undefined,
allowedOrigins: ReadonlySet<string>,
): boolean {
if (!origin) {
return true;
}
try {
return allowedOrigins.has(new URL(origin).origin);
} catch {
return false;
}
}
function isStrictlyAllowedRequestOrigin(
origin: string | undefined,
allowedOrigins: ReadonlySet<string>,
): boolean {
if (!origin) {
return false;
}
try {
const parsed = new URL(origin);
return parsed.origin === origin && allowedOrigins.has(origin);
} catch {
return false;
}
}
function isRoomCreationAuthorized(
request: IncomingMessage,
accessSession: AccessSession,
hasAllowedOrigin: boolean,
): boolean {
if (!accessSession.required) {
return true;
}
return hasAllowedOrigin && accessSession.isAuthenticated(request.headers.cookie);
}
function isBearerAuthorized(
request: IncomingMessage,
accessSession: AccessSession,
): boolean {
const authorization = request.headers.authorization;
if (!authorization?.startsWith("Bearer ")) {
return false;
}
const provided = authorization.slice("Bearer ".length);
return provided.length > 0 && accessSession.passwordMatches(provided);
}
function sendJson(response: ServerResponse, status: number, body: unknown): void {
const encoded = JSON.stringify(body);
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Content-Length", Buffer.byteLength(encoded));
response.end(encoded);
}
function safePathname(requestUrl: string | undefined): string {
try {
return new URL(requestUrl ?? "/", "http://localhost").pathname;
} catch {
return "<invalid>";
}
}