140 lines
3.9 KiB
TypeScript
140 lines
3.9 KiB
TypeScript
import { redirect } from "@tanstack/react-router";
|
|
import UserRoles from "supertokens-node/recipe/userroles";
|
|
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
|
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
|
|
import { Logger } from "@/lib/logger";
|
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
|
import { PUBLIC_ROUTES } from "./supertokens";
|
|
import type { Player } from "@/features/players/types";
|
|
|
|
const logger = new Logger("Middleware");
|
|
|
|
const ROLES_METADATA_TTL_MS = 60 * 1000;
|
|
const rolesMetadataCache = new Map<
|
|
string,
|
|
{ roles: string[]; metadata: any; expiresAt: number }
|
|
>();
|
|
|
|
export const invalidateUserCache = (userAuthId: string) => {
|
|
rolesMetadataCache.delete(userAuthId);
|
|
};
|
|
|
|
export const fetchUserRoles = async (userAuthId: string) => {
|
|
const response = await UserRoles.getRolesForUser("public", userAuthId);
|
|
return response;
|
|
};
|
|
|
|
const getRolesAndMetadata = async (userAuthId: string) => {
|
|
const now = Date.now();
|
|
const cached = rolesMetadataCache.get(userAuthId);
|
|
if (cached && cached.expiresAt > now) {
|
|
return cached;
|
|
}
|
|
|
|
const [{ roles }, { metadata }] = await Promise.all([
|
|
fetchUserRoles(userAuthId),
|
|
UserMetadata.getUserMetadata(userAuthId),
|
|
]);
|
|
|
|
const entry = { roles, metadata, expiresAt: now + ROLES_METADATA_TTL_MS };
|
|
rolesMetadataCache.set(userAuthId, entry);
|
|
return entry;
|
|
};
|
|
|
|
export const updateUserMetadataFields = async (
|
|
userAuthId: string,
|
|
fields: Record<string, any>
|
|
) => {
|
|
await UserMetadata.updateUserMetadata(userAuthId, fields);
|
|
invalidateUserCache(userAuthId);
|
|
};
|
|
|
|
const verifySuperTokensSession = async (request: Request) => {
|
|
let session = await getSessionForStart(request, { sessionRequired: false });
|
|
|
|
if (session?.needsRefresh) {
|
|
logger.info("Session needs refresh - redirecting to client");
|
|
return { context: { session: { tryRefresh: true } } };
|
|
}
|
|
|
|
const userAuthId = session?.userId;
|
|
|
|
if (!userAuthId || !session) {
|
|
return { context: { userAuthId: null, roles: [] } };
|
|
}
|
|
|
|
const { roles, metadata } = await getRolesAndMetadata(userAuthId);
|
|
|
|
return {
|
|
context: {
|
|
userAuthId,
|
|
roles,
|
|
metadata,
|
|
phone: session.phone,
|
|
session: {
|
|
accessTokenPayload: session.accessTokenPayload,
|
|
sessionHandle: session.sessionHandle,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
const LAST_ACTIVITY_THROTTLE_MS = 5 * 60 * 1000;
|
|
const lastActivityWriteAt = new Map<string, number>();
|
|
|
|
export const getSessionContextCore = async (
|
|
request: Request,
|
|
options?: { isServerFunction?: boolean }
|
|
) => {
|
|
const session = await verifySuperTokensSession(request);
|
|
|
|
if (session.context.session?.tryRefresh) {
|
|
if (options?.isServerFunction) {
|
|
throw new Error("SESSION_REFRESH_REQUIRED");
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
|
|
if (PUBLIC_ROUTES.some((route) => url.pathname.startsWith(route))) {
|
|
throw new Error("Unauthenticated");
|
|
}
|
|
|
|
const from = url.pathname + url.search;
|
|
|
|
throw redirect({
|
|
to: "/refresh-session",
|
|
search: from === "/" ? {} : { redirect: from },
|
|
});
|
|
}
|
|
|
|
if (!session.context.userAuthId) {
|
|
throw new Error("Unauthenticated");
|
|
}
|
|
|
|
let player: Player | null = null;
|
|
|
|
try {
|
|
player = await pbAdmin.getPlayerByAuthId(session.context.userAuthId);
|
|
if (player) {
|
|
const now = Date.now();
|
|
const lastWrite = lastActivityWriteAt.get(session.context.userAuthId) ?? 0;
|
|
if (now - lastWrite > LAST_ACTIVITY_THROTTLE_MS) {
|
|
lastActivityWriteAt.set(session.context.userAuthId, now);
|
|
await pbAdmin.updatePlayer(player.id, {
|
|
last_activity: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error("Failed to update player last_activity", error);
|
|
}
|
|
|
|
return {
|
|
userAuthId: session.context.userAuthId,
|
|
roles: session.context.roles,
|
|
metadata: session.context.metadata,
|
|
phone: session.context.phone,
|
|
player,
|
|
};
|
|
};
|