diff --git a/src/routes/analytics.tsx b/src/routes/analytics.tsx index 03d4725..e1a44a1 100644 --- a/src/routes/analytics.tsx +++ b/src/routes/analytics.tsx @@ -9,14 +9,47 @@ const checkAdmin = query(async (): Promise => { const userState = await getUserState(); if (userState.privilegeLevel !== "admin") { + console.log("redirect"); throw redirect("/"); } return true; }, "checkAdminAccess"); +const getSummaryData = query(async (days: number) => { + "use server"; + const { createCaller } = await import("~/server/api/root"); + const { getEvent } = await import("vinxi/http"); + + const caller = await createCaller(getEvent()); + return await caller.analytics.getSummary({ days }); +}, "getSummaryData"); + +const getPerformanceData = query(async (days: number) => { + "use server"; + const { createCaller } = await import("~/server/api/root"); + const { getEvent } = await import("vinxi/http"); + + const caller = await createCaller(getEvent()); + return await caller.analytics.getPerformanceStats({ days }); +}, "getPerformanceData"); + +const getPathData = query(async (path: string, days: number) => { + "use server"; + const { createCaller } = await import("~/server/api/root"); + const { getEvent } = await import("vinxi/http"); + + const caller = await createCaller(getEvent()); + return await caller.analytics.getPathStats({ path, days }); +}, "getPathData"); + export const route = { - load: () => checkAdmin() + load: async () => { + await checkAdmin(); + // Preload initial data with default timeWindow of 7 days + void getSummaryData(7); + void getPerformanceData(7); + } }; interface PerformanceTarget { @@ -88,39 +121,14 @@ export default function AnalyticsPage() { const [selectedPath, setSelectedPath] = createSignal(null); const [error, setError] = createSignal(null); - const summary = createAsync(async () => { - try { - setError(null); - return await api.analytics.getSummary.query({ days: timeWindow() }); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load analytics"); - return null; - } - }); + const summary = createAsync(() => getSummaryData(timeWindow())); - const performanceStats = createAsync(async () => { - try { - return await api.analytics.getPerformanceStats.query({ - days: timeWindow() - }); - } catch (e) { - console.error("Failed to load performance stats:", e); - return null; - } - }); + const performanceStats = createAsync(() => getPerformanceData(timeWindow())); - const pathStats = createAsync(async () => { + const pathStats = createAsync(() => { const path = selectedPath(); - if (!path) return null; - try { - return await api.analytics.getPathStats.query({ - path, - days: timeWindow() - }); - } catch (e) { - console.error("Failed to load path stats:", e); - return null; - } + if (!path) return Promise.resolve(null); + return getPathData(path, timeWindow()); }); return ( diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 8192d6b..fc212e2 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -9,7 +9,8 @@ import { blogRouter } from "./routers/blog"; import { gitActivityRouter } from "./routers/git-activity"; import { postHistoryRouter } from "./routers/post-history"; import { infillRouter } from "./routers/infill"; -import { createTRPCRouter } from "./utils"; +import { createTRPCRouter, createTRPCContext } from "./utils"; +import type { H3Event } from "h3"; export const appRouter = createTRPCRouter({ auth: authRouter, @@ -26,3 +27,13 @@ export const appRouter = createTRPCRouter({ }); export type AppRouter = typeof appRouter; + +/** + * Create a server-side caller for tRPC procedures + * This allows calling tRPC procedures directly on the server with proper context + */ +export const createCaller = async (event: H3Event) => { + const apiEvent = { nativeEvent: event, request: event.node.req } as any; + const ctx = await createTRPCContext(apiEvent); + return appRouter.createCaller(ctx); +}; diff --git a/src/server/api/routers/git-activity.ts b/src/server/api/routers/git-activity.ts index bc718ef..ddb173d 100644 --- a/src/server/api/routers/git-activity.ts +++ b/src/server/api/routers/git-activity.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../utils"; import { env } from "~/env/server"; import { withCacheAndStale } from "~/server/cache"; -import { CACHE_CONFIG } from "~/config"; +import { CACHE_CONFIG, NETWORK_CONFIG } from "~/config"; import { fetchWithTimeout, checkResponse, @@ -40,7 +40,7 @@ export const gitActivityRouter = createTRPCRouter({ Authorization: `Bearer ${env.GITHUB_API_TOKEN}`, Accept: "application/vnd.github.v3+json" }, - timeout: 15000 // 15 second timeout + timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS } ); @@ -140,7 +140,7 @@ export const gitActivityRouter = createTRPCRouter({ Authorization: `token ${env.GITEA_TOKEN}`, Accept: "application/json" }, - timeout: 15000 + timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS } ); @@ -229,7 +229,7 @@ export const gitActivityRouter = createTRPCRouter({ getGitHubActivity: publicProcedure.query(async () => { return withCacheAndStale( "github-activity", - 10 * 60 * 1000, + CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS, async () => { const query = ` query($userName: String!) { @@ -288,7 +288,7 @@ export const gitActivityRouter = createTRPCRouter({ return contributions; }, - { maxStaleMs: 24 * 60 * 60 * 1000 } + { maxStaleMs: CACHE_CONFIG.GIT_ACTIVITY_MAX_STALE_MS } ).catch((error) => { if (error instanceof NetworkError) { console.error("GitHub GraphQL API unavailable (network error)"); @@ -308,7 +308,7 @@ export const gitActivityRouter = createTRPCRouter({ getGiteaActivity: publicProcedure.query(async () => { return withCacheAndStale( "gitea-activity", - 10 * 60 * 1000, + CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS, async () => { const reposResponse = await fetchWithTimeout( `${env.GITEA_URL}/api/v1/user/repos?limit=100`, @@ -373,7 +373,7 @@ export const gitActivityRouter = createTRPCRouter({ return contributions; }, - { maxStaleMs: 24 * 60 * 60 * 1000 } + { maxStaleMs: CACHE_CONFIG.GIT_ACTIVITY_MAX_STALE_MS } ).catch((error) => { if (error instanceof NetworkError) { console.error("Gitea API unavailable (network error)"); diff --git a/src/server/cache.ts b/src/server/cache.ts index 103ceb7..6e35268 100644 --- a/src/server/cache.ts +++ b/src/server/cache.ts @@ -11,6 +11,7 @@ import { createClient } from "redis"; import { env } from "~/env/server"; +import { CACHE_CONFIG } from "~/config"; let redisClient: ReturnType | null = null; let isConnecting = false; @@ -171,7 +172,8 @@ export async function withCacheAndStale( logErrors?: boolean; } = {} ): Promise { - const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options; + const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } = + options; // Try fresh cache const cached = await cache.get(key); diff --git a/src/server/database.ts b/src/server/database.ts index 06658c3..9c539c6 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -3,7 +3,6 @@ import { createClient as createAPIClient } from "@tursodatabase/api"; import { v4 as uuid } from "uuid"; import { env } from "~/env/server"; import type { H3Event } from "vinxi/http"; -import { getUserID } from "./auth"; import { fetchWithTimeout, checkResponse, @@ -179,6 +178,8 @@ export async function getUserBasicInfo(event: H3Event): Promise<{ email: string | null; isAuthenticated: boolean; } | null> { + // Lazy import to avoid circular dependency + const { getUserID } = await import("./auth"); const userId = await getUserID(event); if (!userId) { diff --git a/src/server/session-config.ts b/src/server/session-config.ts index 2799b04..58ac65e 100644 --- a/src/server/session-config.ts +++ b/src/server/session-config.ts @@ -1,6 +1,6 @@ import type { SessionConfig } from "vinxi/http"; import { env } from "~/env/server"; -import { AUTH_CONFIG } from "~/config"; +import { AUTH_CONFIG, expiryToSeconds } from "~/config"; /** * Session data stored in encrypted cookie @@ -45,7 +45,7 @@ export function getSessionCookieOptions(rememberMe: boolean) { return { ...sessionConfig.cookieOptions, maxAge: rememberMe - ? 90 * 24 * 60 * 60 // 90 days + ? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG) : undefined // Session cookie (expires on browser close) }; } diff --git a/src/server/session-helpers.ts b/src/server/session-helpers.ts index 20dc80a..4d12a86 100644 --- a/src/server/session-helpers.ts +++ b/src/server/session-helpers.ts @@ -5,7 +5,8 @@ import { useSession, updateSession, clearSession, - getSession + getSession, + getCookie } from "vinxi/http"; import { ConnectionFactory } from "./database"; import { env } from "~/env/server"; @@ -162,12 +163,47 @@ export async function createAuthSession( /** * Get current session from Vinxi and validate against database * @param event - H3Event + * @param skipUpdate - If true, don't update the session cookie (for SSR contexts) * @returns Session data or null if invalid/expired */ export async function getAuthSession( - event: H3Event + event: H3Event, + skipUpdate = false ): Promise { try { + // In SSR contexts where headers may already be sent, use unsealSession directly + if (skipUpdate) { + const { unsealSession } = await import("vinxi/http"); + const cookieValue = getCookie(event, sessionConfig.cookieName); + if (!cookieValue) { + return null; + } + + try { + const data = await unsealSession( + event, + sessionConfig, + cookieValue + ); + + if (!data || !data.userId || !data.sessionId) { + return null; + } + + // Validate session against database + const isValid = await validateSessionInDB( + data.sessionId, + data.userId, + data.refreshToken + ); + + return isValid ? data : null; + } catch { + return null; + } + } + + // Normal path - allow session updates const session = await getSession(event, sessionConfig); if (!session.data || !session.data.userId || !session.data.sessionId) { @@ -182,13 +218,30 @@ export async function getAuthSession( ); if (!isValid) { - // Clear invalid session - await clearSession(event, sessionConfig); + // Clear invalid session - wrap in try/catch for headers-sent error + try { + await clearSession(event, sessionConfig); + } catch (clearError: any) { + // If headers already sent, we can't clear the cookie, but that's OK + // The session is invalid in DB anyway + if (clearError?.code !== "ERR_HTTP_HEADERS_SENT") { + throw clearError; + } + } return null; } return session.data; - } catch (error) { + } catch (error: any) { + // If headers already sent, we can't read the session cookie properly + // This can happen in SSR when response streaming has started + if (error?.code === "ERR_HTTP_HEADERS_SENT") { + console.warn( + "Cannot access session - headers already sent, retrying with skipUpdate" + ); + // Retry with skipUpdate + return getAuthSession(event, true); + } console.error("Error getting auth session:", error); return null; }