fixing things

This commit is contained in:
Michael Freno
2026-01-07 16:50:10 -05:00
parent 0a0c0e313e
commit 5b0f6dba0f
7 changed files with 123 additions and 48 deletions

View File

@@ -9,14 +9,47 @@ const checkAdmin = query(async (): Promise<boolean> => {
const userState = await getUserState(); const userState = await getUserState();
if (userState.privilegeLevel !== "admin") { if (userState.privilegeLevel !== "admin") {
console.log("redirect");
throw redirect("/"); throw redirect("/");
} }
return true; return true;
}, "checkAdminAccess"); }, "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 = { 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 { interface PerformanceTarget {
@@ -88,39 +121,14 @@ export default function AnalyticsPage() {
const [selectedPath, setSelectedPath] = createSignal<string | null>(null); const [selectedPath, setSelectedPath] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
const summary = createAsync(async () => { const summary = createAsync(() => getSummaryData(timeWindow()));
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 performanceStats = createAsync(async () => { const performanceStats = createAsync(() => getPerformanceData(timeWindow()));
try {
return await api.analytics.getPerformanceStats.query({
days: timeWindow()
});
} catch (e) {
console.error("Failed to load performance stats:", e);
return null;
}
});
const pathStats = createAsync(async () => { const pathStats = createAsync(() => {
const path = selectedPath(); const path = selectedPath();
if (!path) return null; if (!path) return Promise.resolve(null);
try { return getPathData(path, timeWindow());
return await api.analytics.getPathStats.query({
path,
days: timeWindow()
});
} catch (e) {
console.error("Failed to load path stats:", e);
return null;
}
}); });
return ( return (

View File

@@ -9,7 +9,8 @@ import { blogRouter } from "./routers/blog";
import { gitActivityRouter } from "./routers/git-activity"; import { gitActivityRouter } from "./routers/git-activity";
import { postHistoryRouter } from "./routers/post-history"; import { postHistoryRouter } from "./routers/post-history";
import { infillRouter } from "./routers/infill"; import { infillRouter } from "./routers/infill";
import { createTRPCRouter } from "./utils"; import { createTRPCRouter, createTRPCContext } from "./utils";
import type { H3Event } from "h3";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
auth: authRouter, auth: authRouter,
@@ -26,3 +27,13 @@ export const appRouter = createTRPCRouter({
}); });
export type AppRouter = typeof appRouter; 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);
};

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../utils"; import { createTRPCRouter, publicProcedure } from "../utils";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { withCacheAndStale } from "~/server/cache"; import { withCacheAndStale } from "~/server/cache";
import { CACHE_CONFIG } from "~/config"; import { CACHE_CONFIG, NETWORK_CONFIG } from "~/config";
import { import {
fetchWithTimeout, fetchWithTimeout,
checkResponse, checkResponse,
@@ -40,7 +40,7 @@ export const gitActivityRouter = createTRPCRouter({
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`, Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
Accept: "application/vnd.github.v3+json" 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}`, Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json" Accept: "application/json"
}, },
timeout: 15000 timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS
} }
); );
@@ -229,7 +229,7 @@ export const gitActivityRouter = createTRPCRouter({
getGitHubActivity: publicProcedure.query(async () => { getGitHubActivity: publicProcedure.query(async () => {
return withCacheAndStale( return withCacheAndStale(
"github-activity", "github-activity",
10 * 60 * 1000, CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
async () => { async () => {
const query = ` const query = `
query($userName: String!) { query($userName: String!) {
@@ -288,7 +288,7 @@ export const gitActivityRouter = createTRPCRouter({
return contributions; return contributions;
}, },
{ maxStaleMs: 24 * 60 * 60 * 1000 } { maxStaleMs: CACHE_CONFIG.GIT_ACTIVITY_MAX_STALE_MS }
).catch((error) => { ).catch((error) => {
if (error instanceof NetworkError) { if (error instanceof NetworkError) {
console.error("GitHub GraphQL API unavailable (network error)"); console.error("GitHub GraphQL API unavailable (network error)");
@@ -308,7 +308,7 @@ export const gitActivityRouter = createTRPCRouter({
getGiteaActivity: publicProcedure.query(async () => { getGiteaActivity: publicProcedure.query(async () => {
return withCacheAndStale( return withCacheAndStale(
"gitea-activity", "gitea-activity",
10 * 60 * 1000, CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
async () => { async () => {
const reposResponse = await fetchWithTimeout( const reposResponse = await fetchWithTimeout(
`${env.GITEA_URL}/api/v1/user/repos?limit=100`, `${env.GITEA_URL}/api/v1/user/repos?limit=100`,
@@ -373,7 +373,7 @@ export const gitActivityRouter = createTRPCRouter({
return contributions; return contributions;
}, },
{ maxStaleMs: 24 * 60 * 60 * 1000 } { maxStaleMs: CACHE_CONFIG.GIT_ACTIVITY_MAX_STALE_MS }
).catch((error) => { ).catch((error) => {
if (error instanceof NetworkError) { if (error instanceof NetworkError) {
console.error("Gitea API unavailable (network error)"); console.error("Gitea API unavailable (network error)");

View File

@@ -11,6 +11,7 @@
import { createClient } from "redis"; import { createClient } from "redis";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { CACHE_CONFIG } from "~/config";
let redisClient: ReturnType<typeof createClient> | null = null; let redisClient: ReturnType<typeof createClient> | null = null;
let isConnecting = false; let isConnecting = false;
@@ -171,7 +172,8 @@ export async function withCacheAndStale<T>(
logErrors?: boolean; logErrors?: boolean;
} = {} } = {}
): Promise<T> { ): Promise<T> {
const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options; const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } =
options;
// Try fresh cache // Try fresh cache
const cached = await cache.get<T>(key); const cached = await cache.get<T>(key);

View File

@@ -3,7 +3,6 @@ import { createClient as createAPIClient } from "@tursodatabase/api";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { env } from "~/env/server"; import { env } from "~/env/server";
import type { H3Event } from "vinxi/http"; import type { H3Event } from "vinxi/http";
import { getUserID } from "./auth";
import { import {
fetchWithTimeout, fetchWithTimeout,
checkResponse, checkResponse,
@@ -179,6 +178,8 @@ export async function getUserBasicInfo(event: H3Event): Promise<{
email: string | null; email: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
} | null> { } | null> {
// Lazy import to avoid circular dependency
const { getUserID } = await import("./auth");
const userId = await getUserID(event); const userId = await getUserID(event);
if (!userId) { if (!userId) {

View File

@@ -1,6 +1,6 @@
import type { SessionConfig } from "vinxi/http"; import type { SessionConfig } from "vinxi/http";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { AUTH_CONFIG } from "~/config"; import { AUTH_CONFIG, expiryToSeconds } from "~/config";
/** /**
* Session data stored in encrypted cookie * Session data stored in encrypted cookie
@@ -45,7 +45,7 @@ export function getSessionCookieOptions(rememberMe: boolean) {
return { return {
...sessionConfig.cookieOptions, ...sessionConfig.cookieOptions,
maxAge: rememberMe maxAge: rememberMe
? 90 * 24 * 60 * 60 // 90 days ? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG)
: undefined // Session cookie (expires on browser close) : undefined // Session cookie (expires on browser close)
}; };
} }

View File

@@ -5,7 +5,8 @@ import {
useSession, useSession,
updateSession, updateSession,
clearSession, clearSession,
getSession getSession,
getCookie
} from "vinxi/http"; } from "vinxi/http";
import { ConnectionFactory } from "./database"; import { ConnectionFactory } from "./database";
import { env } from "~/env/server"; import { env } from "~/env/server";
@@ -162,12 +163,47 @@ export async function createAuthSession(
/** /**
* Get current session from Vinxi and validate against database * Get current session from Vinxi and validate against database
* @param event - H3Event * @param event - H3Event
* @param skipUpdate - If true, don't update the session cookie (for SSR contexts)
* @returns Session data or null if invalid/expired * @returns Session data or null if invalid/expired
*/ */
export async function getAuthSession( export async function getAuthSession(
event: H3Event event: H3Event,
skipUpdate = false
): Promise<SessionData | null> { ): Promise<SessionData | null> {
try { 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<SessionData>(
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<SessionData>(event, sessionConfig); const session = await getSession<SessionData>(event, sessionConfig);
if (!session.data || !session.data.userId || !session.data.sessionId) { if (!session.data || !session.data.userId || !session.data.sessionId) {
@@ -182,13 +218,30 @@ export async function getAuthSession(
); );
if (!isValid) { if (!isValid) {
// Clear invalid session // Clear invalid session - wrap in try/catch for headers-sent error
try {
await clearSession(event, sessionConfig); 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 null;
} }
return session.data; 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); console.error("Error getting auth session:", error);
return null; return null;
} }