fixing things
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
await clearSession(event, sessionConfig);
|
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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user