chat-ui / src /hooks.server.ts
coyotte508
support PKCE
46d21da
import { config, ready } from "$lib/server/config";
import type { Handle, HandleServerError, ServerInit, HandleFetch } from "@sveltejs/kit";
import { collections } from "$lib/server/database";
import { base } from "$app/paths";
import {
authenticateRequest,
loginEnabled,
refreshSessionCookie,
triggerOauthFlow,
} from "$lib/server/auth";
import { ERROR_MESSAGES } from "$lib/stores/errors";
import { addWeeks } from "date-fns";
import { checkAndRunMigrations } from "$lib/migrations/migrations";
import { building, dev } from "$app/environment";
import { logger } from "$lib/server/logger";
import { AbortedGenerations } from "$lib/server/abortedGenerations";
import { initExitHandler } from "$lib/server/exitHandler";
import { refreshConversationStats } from "$lib/jobs/refresh-conversation-stats";
import { adminTokenManager } from "$lib/server/adminToken";
import { isHostLocalhost } from "$lib/server/isURLLocal";
import { MetricsServer } from "$lib/server/metrics";
export const init: ServerInit = async () => {
// Wait for config to be fully loaded
await ready;
// TODO: move this code on a started server hook, instead of using a "building" flag
if (!building) {
// Ensure legacy env expected by some libs: map OPENAI_API_KEY -> HF_TOKEN if absent
const canonicalToken = config.OPENAI_API_KEY || config.HF_TOKEN;
if (canonicalToken) {
process.env.HF_TOKEN ??= canonicalToken;
}
// Warn if legacy-only var is used
if (!config.OPENAI_API_KEY && config.HF_TOKEN) {
logger.warn(
"HF_TOKEN is deprecated in favor of OPENAI_API_KEY. Please migrate to OPENAI_API_KEY."
);
}
logger.info("Starting server...");
initExitHandler();
if (config.METRICS_ENABLED === "true") {
MetricsServer.getInstance();
}
checkAndRunMigrations();
refreshConversationStats();
// Init AbortedGenerations refresh process
AbortedGenerations.getInstance();
adminTokenManager.displayToken();
if (config.EXPOSE_API) {
logger.warn(
"The EXPOSE_API flag has been deprecated. The API is now required for chat-ui to work."
);
}
}
};
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
// handle 404
if (building) {
throw error;
}
if (event.route.id === null) {
return {
message: `Page ${event.url.pathname} not found`,
};
}
const errorId = crypto.randomUUID();
logger.error({
locals: event.locals,
url: event.request.url,
params: event.params,
request: event.request,
message,
error,
errorId,
status,
stack: error instanceof Error ? error.stack : undefined,
});
return {
message: "An error occurred",
errorId,
};
};
export const handle: Handle = async ({ event, resolve }) => {
await ready.then(() => {
config.checkForUpdates();
});
logger.debug({
locals: event.locals,
url: event.url.pathname,
params: event.params,
request: event.request,
});
function errorResponse(status: number, message: string) {
const sendJson =
event.request.headers.get("accept")?.includes("application/json") ||
event.request.headers.get("content-type")?.includes("application/json");
return new Response(sendJson ? JSON.stringify({ error: message }) : message, {
status,
headers: {
"content-type": sendJson ? "application/json" : "text/plain",
},
});
}
if (event.url.pathname.startsWith(`${base}/admin/`) || event.url.pathname === `${base}/admin`) {
const ADMIN_SECRET = config.ADMIN_API_SECRET || config.PARQUET_EXPORT_SECRET;
if (!ADMIN_SECRET) {
return errorResponse(500, "Admin API is not configured");
}
if (event.request.headers.get("Authorization") !== `Bearer ${ADMIN_SECRET}`) {
return errorResponse(401, "Unauthorized");
}
}
const auth = await authenticateRequest(
{ type: "svelte", value: event.request.headers },
{ type: "svelte", value: event.cookies },
event.url
);
event.locals.sessionId = auth.sessionId;
if (loginEnabled && !auth.user && !event.url.pathname.startsWith(`${base}/.well-known/`)) {
if (config.AUTOMATIC_LOGIN === "true") {
// AUTOMATIC_LOGIN: always redirect to OAuth flow (unless already on login or healthcheck pages)
if (
!event.url.pathname.startsWith(`${base}/login`) &&
!event.url.pathname.startsWith(`${base}/healthcheck`)
) {
// To get the same CSRF token after callback
refreshSessionCookie(event.cookies, auth.secretSessionId);
return await triggerOauthFlow(event);
}
} else {
// Redirect to OAuth flow unless on the authorized pages (home, shared conversation, login, healthcheck, model thumbnails)
if (
event.url.pathname !== `${base}/` &&
event.url.pathname !== `${base}` &&
!event.url.pathname.startsWith(`${base}/login`) &&
!event.url.pathname.startsWith(`${base}/login/callback`) &&
!event.url.pathname.startsWith(`${base}/healthcheck`) &&
!event.url.pathname.startsWith(`${base}/r/`) &&
!event.url.pathname.startsWith(`${base}/conversation/`) &&
!event.url.pathname.startsWith(`${base}/models/`) &&
!event.url.pathname.startsWith(`${base}/api`)
) {
refreshSessionCookie(event.cookies, auth.secretSessionId);
return triggerOauthFlow(event);
}
}
}
event.locals.user = auth.user || undefined;
event.locals.token = auth.token;
event.locals.isAdmin =
event.locals.user?.isAdmin || adminTokenManager.isAdmin(event.locals.sessionId);
// CSRF protection
const requestContentType = event.request.headers.get("content-type")?.split(";")[0] ?? "";
/** https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-enctype */
const nativeFormContentTypes = [
"multipart/form-data",
"application/x-www-form-urlencoded",
"text/plain",
];
if (event.request.method === "POST") {
if (nativeFormContentTypes.includes(requestContentType)) {
const origin = event.request.headers.get("origin");
if (!origin) {
return errorResponse(403, "Non-JSON form requests need to have an origin");
}
const validOrigins = [
new URL(event.request.url).host,
...(config.PUBLIC_ORIGIN ? [new URL(config.PUBLIC_ORIGIN).host] : []),
];
if (!validOrigins.includes(new URL(origin).host)) {
return errorResponse(403, "Invalid referer for POST request");
}
}
}
if (
event.request.method === "POST" ||
event.url.pathname.startsWith(`${base}/login`) ||
event.url.pathname.startsWith(`${base}/login/callback`)
) {
// if the request is a POST request or login-related we refresh the cookie
refreshSessionCookie(event.cookies, auth.secretSessionId);
await collections.sessions.updateOne(
{ sessionId: auth.sessionId },
{ $set: { updatedAt: new Date(), expiresAt: addWeeks(new Date(), 2) } }
);
}
if (
loginEnabled &&
!event.locals.user &&
!event.url.pathname.startsWith(`${base}/login`) &&
!event.url.pathname.startsWith(`${base}/admin`) &&
!event.url.pathname.startsWith(`${base}/settings`) &&
!["GET", "OPTIONS", "HEAD"].includes(event.request.method)
) {
return errorResponse(401, ERROR_MESSAGES.authOnly);
}
let replaced = false;
const response = await resolve(event, {
transformPageChunk: (chunk) => {
// For some reason, Sveltekit doesn't let us load env variables from .env in the app.html template
if (replaced || !chunk.html.includes("%gaId%")) {
return chunk.html;
}
replaced = true;
return chunk.html.replace("%gaId%", config.PUBLIC_GOOGLE_ANALYTICS_ID);
},
filterSerializedResponseHeaders: (header) => {
return header.includes("content-type");
},
});
// Add CSP header to disallow framing if ALLOW_IFRAME is not "true"
if (config.ALLOW_IFRAME !== "true") {
response.headers.append("Content-Security-Policy", "frame-ancestors 'none';");
}
if (
event.url.pathname.startsWith(`${base}/login/callback`) ||
event.url.pathname.startsWith(`${base}/login`)
) {
response.headers.append("Cache-Control", "no-store");
}
if (event.url.pathname.startsWith(`${base}/api/`)) {
// get origin from the request
const requestOrigin = event.request.headers.get("origin");
// get origin from the config if its defined
let allowedOrigin = config.PUBLIC_ORIGIN ? new URL(config.PUBLIC_ORIGIN).origin : undefined;
if (
dev || // if we're in dev mode
!requestOrigin || // or the origin is null (SSR)
isHostLocalhost(new URL(requestOrigin).hostname) // or the origin is localhost
) {
allowedOrigin = "*"; // allow all origins
} else if (allowedOrigin === requestOrigin) {
allowedOrigin = requestOrigin; // echo back the caller
}
if (allowedOrigin) {
response.headers.set("Access-Control-Allow-Origin", allowedOrigin);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
);
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
}
return response;
};
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
if (isHostLocalhost(new URL(request.url).hostname)) {
const cookieHeader = event.request.headers.get("cookie");
if (cookieHeader) {
const headers = new Headers(request.headers);
headers.set("cookie", cookieHeader);
return fetch(new Request(request, { headers }));
}
}
return fetch(request);
};