protections

This commit is contained in:
Michael Freno
2025-12-20 23:41:50 -05:00
parent 268841fb4d
commit 89e9a2ee45
8 changed files with 1014 additions and 388 deletions

View File

@@ -1,4 +1,4 @@
import { Show, untrack, createEffect, on } from "solid-js"; import { Show, untrack, createEffect, on, createSignal } from "solid-js";
import { createTiptapEditor, useEditorHTML } from "solid-tiptap"; import { createTiptapEditor, useEditorHTML } from "solid-tiptap";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
@@ -104,6 +104,13 @@ export interface TextEditorProps {
export default function TextEditor(props: TextEditorProps) { export default function TextEditor(props: TextEditorProps) {
let editorRef!: HTMLDivElement; let editorRef!: HTMLDivElement;
let bubbleMenuRef!: HTMLDivElement;
const [showBubbleMenu, setShowBubbleMenu] = createSignal(false);
const [bubbleMenuPosition, setBubbleMenuPosition] = createSignal({
top: 0,
left: 0
});
const editor = createTiptapEditor(() => ({ const editor = createTiptapEditor(() => ({
element: editorRef, element: editorRef,
@@ -126,6 +133,26 @@ export default function TextEditor(props: TextEditorProps) {
untrack(() => { untrack(() => {
props.updateContent(editor.getHTML()); props.updateContent(editor.getHTML());
}); });
},
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection;
const hasSelection = from !== to;
if (hasSelection && !editor.state.selection.empty) {
setShowBubbleMenu(true);
// Position the bubble menu
const { view } = editor;
const start = view.coordsAtPos(from);
const end = view.coordsAtPos(to);
const left = Math.max((start.left + end.left) / 2, 0);
const top = Math.max(start.top - 10, 0);
setBubbleMenuPosition({ top, left });
} else {
setShowBubbleMenu(false);
}
} }
})); }));
@@ -191,13 +218,17 @@ export default function TextEditor(props: TextEditorProps) {
{(instance) => ( {(instance) => (
<> <>
{/* Bubble Menu - appears when text is selected */} {/* Bubble Menu - appears when text is selected */}
<Show when={showBubbleMenu()}>
<div <div
class="tiptap-bubble-menu" ref={bubbleMenuRef}
class="bg-mantle text-text fixed z-50 w-fit rounded p-2 text-sm whitespace-nowrap shadow-lg"
style={{ style={{
display: "none" // Will be shown by Tiptap when text is selected top: `${bubbleMenuPosition().top}px`,
left: `${bubbleMenuPosition().left}px`,
transform: "translate(-50%, -100%)",
"margin-top": "-8px"
}} }}
> >
<div class="bg-mantle text-text mt-4 w-fit rounded p-2 text-sm whitespace-nowrap shadow-lg">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<button <button
type="button" type="button"
@@ -315,9 +346,8 @@ export default function TextEditor(props: TextEditorProps) {
</button> </button>
</div> </div>
</div> </div>
</div> </Show>
{/* Toolbar - always visible */}
<div class="border-surface2 mb-2 flex flex-wrap gap-1 border-b pb-2"> <div class="border-surface2 mb-2 flex flex-wrap gap-1 border-b pb-2">
<button <button
type="button" type="button"

View File

@@ -3,41 +3,36 @@
* Uploads files to S3 using pre-signed URLs from tRPC * Uploads files to S3 using pre-signed URLs from tRPC
*/ */
import { api } from "~/lib/api";
export default async function AddImageToS3( export default async function AddImageToS3(
file: Blob | File, file: Blob | File,
title: string, title: string,
type: string, type: string
): Promise<string | undefined> { ): Promise<string | undefined> {
try { try {
const filename = (file as File).name;
// Get pre-signed URL from tRPC endpoint // Get pre-signed URL from tRPC endpoint
const getPreSignedResponse = await fetch("/api/trpc/misc.getPreSignedURL", { const { uploadURL, key } = await api.misc.getPreSignedURL.mutate({
method: "POST", type,
headers: { title,
"Content-Type": "application/json", filename
},
body: JSON.stringify({
type: type,
title: title,
filename: (file as File).name,
}),
}); });
if (!getPreSignedResponse.ok) {
throw new Error("Failed to get pre-signed URL");
}
const responseData = await getPreSignedResponse.json();
const { uploadURL, key } = responseData.result.data as {
uploadURL: string;
key: string;
};
console.log("url: " + uploadURL, "key: " + key); console.log("url: " + uploadURL, "key: " + key);
// Extract content type from filename extension
const ext = /^.+\.([^.]+)$/.exec(filename);
const contentType = ext ? `image/${ext[1]}` : "application/octet-stream";
// Upload file to S3 using pre-signed URL // Upload file to S3 using pre-signed URL
const uploadResponse = await fetch(uploadURL, { const uploadResponse = await fetch(uploadURL, {
method: "PUT", method: "PUT",
body: file as File, headers: {
"Content-Type": contentType
},
body: file as File
}); });
if (!uploadResponse.ok) { if (!uploadResponse.ok) {

View File

@@ -7,6 +7,14 @@ import { ConnectionFactory, hashPassword, checkPassword } from "~/server/utils";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { setCookie, getCookie } from "vinxi/http"; import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/types/user"; import type { User } from "~/types/user";
import {
fetchWithTimeout,
checkResponse,
fetchWithRetry,
NetworkError,
TimeoutError,
APIError
} from "~/server/fetch-utils";
// Helper to create JWT token // Helper to create JWT token
async function createJWT( async function createJWT(
@@ -21,7 +29,7 @@ async function createJWT(
return token; return token;
} }
// Helper to send email via Brevo/SendInBlue // Helper to send email via Brevo/SendInBlue with retry logic
async function sendEmail(to: string, subject: string, htmlContent: string) { async function sendEmail(to: string, subject: string, htmlContent: string) {
const apiKey = env.SENDINBLUE_KEY; const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email"; const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
@@ -36,21 +44,27 @@ async function sendEmail(to: string, subject: string, htmlContent: string) {
subject subject
}; };
const response = await fetch(apiUrl, { return fetchWithRetry(
async () => {
const response = await fetchWithTimeout(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
accept: "application/json", accept: "application/json",
"api-key": apiKey, "api-key": apiKey,
"content-type": "application/json" "content-type": "application/json"
}, },
body: JSON.stringify(sendinblueData) body: JSON.stringify(sendinblueData),
timeout: 15000
}); });
if (!response.ok) { await checkResponse(response);
throw new Error("Failed to send email");
}
return response; return response;
},
{
maxRetries: 2,
retryDelay: 1000
}
);
} }
export const authRouter = createTRPCRouter({ export const authRouter = createTRPCRouter({
@@ -61,8 +75,8 @@ export const authRouter = createTRPCRouter({
const { code } = input; const { code } = input;
try { try {
// Exchange code for access token // Exchange code for access token with timeout
const tokenResponse = await fetch( const tokenResponse = await fetchWithTimeout(
"https://github.com/login/oauth/access_token", "https://github.com/login/oauth/access_token",
{ {
method: "POST", method: "POST",
@@ -74,18 +88,33 @@ export const authRouter = createTRPCRouter({
client_id: env.VITE_GITHUB_CLIENT_ID, client_id: env.VITE_GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET, client_secret: env.GITHUB_CLIENT_SECRET,
code code
}) }),
timeout: 15000
} }
); );
await checkResponse(tokenResponse);
const { access_token } = await tokenResponse.json(); const { access_token } = await tokenResponse.json();
// Fetch user info from GitHub if (!access_token) {
const userResponse = await fetch("https://api.github.com/user", { throw new TRPCError({
code: "UNAUTHORIZED",
message: "Failed to get access token from GitHub"
});
}
// Fetch user info from GitHub with timeout
const userResponse = await fetchWithTimeout(
"https://api.github.com/user",
{
headers: { headers: {
Authorization: `token ${access_token}` Authorization: `token ${access_token}`
},
timeout: 15000
} }
}); );
await checkResponse(userResponse);
const user = await userResponse.json(); const user = await userResponse.json();
const login = user.login; const login = user.login;
const conn = ConnectionFactory(); const conn = ConnectionFactory();
@@ -128,6 +157,31 @@ export const authRouter = createTRPCRouter({
redirectTo: "/account" redirectTo: "/account"
}; };
} catch (error) { } catch (error) {
if (error instanceof TRPCError) {
throw error;
}
// Provide specific error messages for different failure types
if (error instanceof TimeoutError) {
console.error("GitHub API timeout:", error.message);
throw new TRPCError({
code: "TIMEOUT",
message: "GitHub authentication timed out. Please try again."
});
} else if (error instanceof NetworkError) {
console.error("GitHub API network error:", error.message);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to connect to GitHub. Please try again later."
});
} else if (error instanceof APIError) {
console.error("GitHub API error:", error.status, error.statusText);
throw new TRPCError({
code: "BAD_REQUEST",
message: "GitHub authentication failed. Please try again."
});
}
console.error("GitHub authentication failed:", error); console.error("GitHub authentication failed:", error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
@@ -143,8 +197,8 @@ export const authRouter = createTRPCRouter({
const { code } = input; const { code } = input;
try { try {
// Exchange code for access token // Exchange code for access token with timeout
const tokenResponse = await fetch( const tokenResponse = await fetchWithTimeout(
"https://oauth2.googleapis.com/token", "https://oauth2.googleapis.com/token",
{ {
method: "POST", method: "POST",
@@ -157,22 +211,33 @@ export const authRouter = createTRPCRouter({
client_secret: env.GOOGLE_CLIENT_SECRET, client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${env.VITE_DOMAIN || "https://freno.me"}/api/auth/callback/google`, redirect_uri: `${env.VITE_DOMAIN || "https://freno.me"}/api/auth/callback/google`,
grant_type: "authorization_code" grant_type: "authorization_code"
}) }),
timeout: 15000
} }
); );
await checkResponse(tokenResponse);
const { access_token } = await tokenResponse.json(); const { access_token } = await tokenResponse.json();
// Fetch user info from Google if (!access_token) {
const userResponse = await fetch( throw new TRPCError({
code: "UNAUTHORIZED",
message: "Failed to get access token from Google"
});
}
// Fetch user info from Google with timeout
const userResponse = await fetchWithTimeout(
"https://www.googleapis.com/oauth2/v3/userinfo", "https://www.googleapis.com/oauth2/v3/userinfo",
{ {
headers: { headers: {
Authorization: `Bearer ${access_token}` Authorization: `Bearer ${access_token}`
} },
timeout: 15000
} }
); );
await checkResponse(userResponse);
const userData = await userResponse.json(); const userData = await userResponse.json();
const name = userData.name; const name = userData.name;
const image = userData.picture; const image = userData.picture;
@@ -227,6 +292,31 @@ export const authRouter = createTRPCRouter({
redirectTo: "/account" redirectTo: "/account"
}; };
} catch (error) { } catch (error) {
if (error instanceof TRPCError) {
throw error;
}
// Provide specific error messages for different failure types
if (error instanceof TimeoutError) {
console.error("Google API timeout:", error.message);
throw new TRPCError({
code: "TIMEOUT",
message: "Google authentication timed out. Please try again."
});
} else if (error instanceof NetworkError) {
console.error("Google API network error:", error.message);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to connect to Google. Please try again later."
});
} else if (error instanceof APIError) {
console.error("Google API error:", error.status, error.statusText);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Google authentication failed. Please try again."
});
}
console.error("Google authentication failed:", error); console.error("Google authentication failed:", error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
@@ -484,6 +574,7 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email, rememberMe } = input; const { email, rememberMe } = input;
try {
// Check rate limiting // Check rate limiting
const requested = getCookie( const requested = getCookie(
ctx.event.nativeEvent, ctx.event.nativeEvent,
@@ -578,6 +669,30 @@ export const authRouter = createTRPCRouter({
); );
return { success: true, message: "email sent" }; return { success: true, message: "email sent" };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
// Handle email sending failures gracefully
if (
error instanceof TimeoutError ||
error instanceof NetworkError ||
error instanceof APIError
) {
console.error("Failed to send login email:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email. Please try again later."
});
}
console.error("Email login link request failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred. Please try again."
});
}
}), }),
// Request password reset // Request password reset
@@ -586,6 +701,7 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email } = input; const { email } = input;
try {
// Check rate limiting // Check rate limiting
const requested = getCookie( const requested = getCookie(
ctx.event.nativeEvent, ctx.event.nativeEvent,
@@ -677,6 +793,30 @@ export const authRouter = createTRPCRouter({
); );
return { success: true, message: "email sent" }; return { success: true, message: "email sent" };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
// Handle email sending failures gracefully
if (
error instanceof TimeoutError ||
error instanceof NetworkError ||
error instanceof APIError
) {
console.error("Failed to send password reset email:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email. Please try again later."
});
}
console.error("Password reset request failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred. Please try again."
});
}
}), }),
// Reset password with token // Reset password with token
@@ -747,6 +887,7 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { email } = input; const { email } = input;
try {
// Check rate limiting // Check rate limiting
const requested = getCookie( const requested = getCookie(
ctx.event.nativeEvent, ctx.event.nativeEvent,
@@ -760,7 +901,8 @@ export const authRouter = createTRPCRouter({
if (difference < 15) { if (difference < 15) {
throw new TRPCError({ throw new TRPCError({
code: "TOO_MANY_REQUESTS", code: "TOO_MANY_REQUESTS",
message: "Please wait before requesting another verification email" message:
"Please wait before requesting another verification email"
}); });
} }
} }
@@ -836,6 +978,30 @@ export const authRouter = createTRPCRouter({
); );
return { success: true, message: "Verification email sent" }; return { success: true, message: "Verification email sent" };
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
// Handle email sending failures gracefully
if (
error instanceof TimeoutError ||
error instanceof NetworkError ||
error instanceof APIError
) {
console.error("Failed to send verification email:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email. Please try again later."
});
}
console.error("Email verification request failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred. Please try again."
});
}
}), }),
// Sign out // Sign out
@@ -852,4 +1018,3 @@ export const authRouter = createTRPCRouter({
return { success: true }; return { success: true };
}) })
}); });

View File

@@ -1,7 +1,14 @@
import { z } from "zod"; 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 { withCache } from "~/server/cache"; import { withCacheAndStale } from "~/server/cache";
import {
fetchWithTimeout,
checkResponse,
NetworkError,
TimeoutError,
APIError
} from "~/server/fetch-utils";
// Types for commits // Types for commits
interface GitCommit { interface GitCommit {
@@ -23,28 +30,23 @@ export const gitActivityRouter = createTRPCRouter({
getGitHubCommits: publicProcedure getGitHubCommits: publicProcedure
.input(z.object({ limit: z.number().default(3) })) .input(z.object({ limit: z.number().default(3) }))
.query(async ({ input }) => { .query(async ({ input }) => {
return withCache( return withCacheAndStale(
`github-commits-${input.limit}`, `github-commits-${input.limit}`,
10 * 60 * 1000, // 10 minutes 10 * 60 * 1000, // 10 minutes
async () => { async () => {
try {
// Get user's repositories sorted by most recently pushed // Get user's repositories sorted by most recently pushed
const reposResponse = await fetch( const reposResponse = await fetchWithTimeout(
`https://api.github.com/users/MikeFreno/repos?sort=pushed&per_page=10`, `https://api.github.com/users/MikeFreno/repos?sort=pushed&per_page=10`,
{ {
headers: { headers: {
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
} }
); );
if (!reposResponse.ok) { await checkResponse(reposResponse);
throw new Error(
`GitHub repos API error: ${reposResponse.statusText}`
);
}
const repos = await reposResponse.json(); const repos = await reposResponse.json();
const allCommits: GitCommit[] = []; const allCommits: GitCommit[] = [];
@@ -53,13 +55,14 @@ export const gitActivityRouter = createTRPCRouter({
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
try { try {
const commitsResponse = await fetch( const commitsResponse = await fetchWithTimeout(
`https://api.github.com/repos/${repo.full_name}/commits?per_page=5`, `https://api.github.com/repos/${repo.full_name}/commits?per_page=5`,
{ {
headers: { headers: {
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: 10000
} }
); );
@@ -74,15 +77,13 @@ export const gitActivityRouter = createTRPCRouter({
allCommits.push({ allCommits.push({
sha: commit.sha?.substring(0, 7) || "unknown", sha: commit.sha?.substring(0, 7) || "unknown",
message: message:
commit.commit?.message?.split("\n")[0] || commit.commit?.message?.split("\n")[0] || "No message",
"No message",
author: author:
commit.commit?.author?.name || commit.commit?.author?.name ||
commit.author?.login || commit.author?.login ||
"Unknown", "Unknown",
date: date:
commit.commit?.author?.date || commit.commit?.author?.date || new Date().toISOString(),
new Date().toISOString(),
repo: repo.full_name, repo: repo.full_name,
url: `https://github.com/${repo.full_name}/commit/${commit.sha}` url: `https://github.com/${repo.full_name}/commit/${commit.sha}`
}); });
@@ -90,12 +91,22 @@ export const gitActivityRouter = createTRPCRouter({
} }
} }
} catch (error) { } catch (error) {
// Log individual repo failures but continue with others
if (
error instanceof NetworkError ||
error instanceof TimeoutError
) {
console.warn(
`Network error fetching commits for ${repo.full_name}, skipping`
);
} else {
console.error( console.error(
`Error fetching commits for ${repo.full_name}:`, `Error fetching commits for ${repo.full_name}:`,
error error
); );
} }
} }
}
// Sort by date and return the most recent // Sort by date and return the most recent
allCommits.sort( allCommits.sort(
@@ -103,40 +114,46 @@ export const gitActivityRouter = createTRPCRouter({
); );
return allCommits.slice(0, input.limit); return allCommits.slice(0, input.limit);
} catch (error) { },
console.error("Error fetching GitHub commits:", error); { maxStaleMs: 24 * 60 * 60 * 1000 } // Accept stale data up to 24 hours old
return []; ).catch((error) => {
} // Final fallback - return empty array if everything fails
} if (error instanceof NetworkError) {
console.error("GitHub API unavailable (network error)");
} else if (error instanceof TimeoutError) {
console.error(`GitHub API timeout after ${error.timeoutMs}ms`);
} else if (error instanceof APIError) {
console.error(
`GitHub API error: ${error.status} ${error.statusText}`
); );
} else {
console.error("Unexpected error fetching GitHub commits:", error);
}
return [];
});
}), }),
// Get recent commits from Gitea // Get recent commits from Gitea
getGiteaCommits: publicProcedure getGiteaCommits: publicProcedure
.input(z.object({ limit: z.number().default(3) })) .input(z.object({ limit: z.number().default(3) }))
.query(async ({ input }) => { .query(async ({ input }) => {
return withCache( return withCacheAndStale(
`gitea-commits-${input.limit}`, `gitea-commits-${input.limit}`,
10 * 60 * 1000, // 10 minutes 10 * 60 * 1000, // 10 minutes
async () => { async () => {
try {
// First, get user's repositories // First, get user's repositories
const reposResponse = await fetch( const reposResponse = await fetchWithTimeout(
`${env.GITEA_URL}/api/v1/users/Mike/repos?limit=100`, `${env.GITEA_URL}/api/v1/users/Mike/repos?limit=100`,
{ {
headers: { headers: {
Authorization: `token ${env.GITEA_TOKEN}`, Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json" Accept: "application/json"
} },
timeout: 15000
} }
); );
if (!reposResponse.ok) { await checkResponse(reposResponse);
throw new Error(
`Gitea repos API error: ${reposResponse.statusText}`
);
}
const repos = await reposResponse.json(); const repos = await reposResponse.json();
const allCommits: GitCommit[] = []; const allCommits: GitCommit[] = [];
@@ -145,13 +162,14 @@ export const gitActivityRouter = createTRPCRouter({
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
try { try {
const commitsResponse = await fetch( const commitsResponse = await fetchWithTimeout(
`${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`, `${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`,
{ {
headers: { headers: {
Authorization: `token ${env.GITEA_TOKEN}`, Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json" Accept: "application/json"
} },
timeout: 10000
} }
); );
@@ -170,12 +188,10 @@ export const gitActivityRouter = createTRPCRouter({
allCommits.push({ allCommits.push({
sha: commit.sha?.substring(0, 7) || "unknown", sha: commit.sha?.substring(0, 7) || "unknown",
message: message:
commit.commit?.message?.split("\n")[0] || commit.commit?.message?.split("\n")[0] || "No message",
"No message",
author: commit.commit?.author?.name || repo.owner.login, author: commit.commit?.author?.name || repo.owner.login,
date: date:
commit.commit?.author?.date || commit.commit?.author?.date || new Date().toISOString(),
new Date().toISOString(),
repo: repo.full_name, repo: repo.full_name,
url: `${env.GITEA_URL}/${repo.full_name}/commit/${commit.sha}` url: `${env.GITEA_URL}/${repo.full_name}/commit/${commit.sha}`
}); });
@@ -183,12 +199,22 @@ export const gitActivityRouter = createTRPCRouter({
} }
} }
} catch (error) { } catch (error) {
// Log individual repo failures but continue with others
if (
error instanceof NetworkError ||
error instanceof TimeoutError
) {
console.warn(
`Network error fetching commits for ${repo.name}, skipping`
);
} else {
console.error( console.error(
`Error fetching commits for ${repo.name}:`, `Error fetching commits for ${repo.name}:`,
error error
); );
} }
} }
}
// Sort by date and return the most recent // Sort by date and return the most recent
allCommits.sort( allCommits.sort(
@@ -196,18 +222,29 @@ export const gitActivityRouter = createTRPCRouter({
); );
return allCommits.slice(0, input.limit); return allCommits.slice(0, input.limit);
} catch (error) { },
console.error("Error fetching Gitea commits:", error); { maxStaleMs: 24 * 60 * 60 * 1000 }
).catch((error) => {
// Final fallback - return empty array if everything fails
if (error instanceof NetworkError) {
console.error("Gitea API unavailable (network error)");
} else if (error instanceof TimeoutError) {
console.error(`Gitea API timeout after ${error.timeoutMs}ms`);
} else if (error instanceof APIError) {
console.error(`Gitea API error: ${error.status} ${error.statusText}`);
} else {
console.error("Unexpected error fetching Gitea commits:", error);
}
return []; return [];
} });
}
);
}), }),
// Get GitHub contribution activity (for heatmap) // Get GitHub contribution activity (for heatmap)
getGitHubActivity: publicProcedure.query(async () => { getGitHubActivity: publicProcedure.query(async () => {
return withCache("github-activity", 10 * 60 * 1000, async () => { return withCacheAndStale(
try { "github-activity",
10 * 60 * 1000,
async () => {
// Use GitHub GraphQL API for contribution data // Use GitHub GraphQL API for contribution data
const query = ` const query = `
query($userName: String!) { query($userName: String!) {
@@ -226,7 +263,9 @@ export const gitActivityRouter = createTRPCRouter({
} }
`; `;
const response = await fetch("https://api.github.com/graphql", { const response = await fetchWithTimeout(
"https://api.github.com/graphql",
{
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`, Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
@@ -235,18 +274,17 @@ export const gitActivityRouter = createTRPCRouter({
body: JSON.stringify({ body: JSON.stringify({
query, query,
variables: { userName: "MikeFreno" } variables: { userName: "MikeFreno" }
}) }),
}); timeout: 15000
if (!response.ok) {
throw new Error(`GitHub GraphQL API error: ${response.statusText}`);
} }
);
await checkResponse(response);
const data = await response.json(); const data = await response.json();
if (data.errors) { if (data.errors) {
console.error("GitHub GraphQL errors:", data.errors); console.error("GitHub GraphQL errors:", data.errors);
throw new Error("GraphQL query failed"); throw new APIError("GraphQL query failed", 500, "GraphQL Error");
} }
// Extract contribution days from the response // Extract contribution days from the response
@@ -265,32 +303,43 @@ export const gitActivityRouter = createTRPCRouter({
} }
return contributions; return contributions;
} catch (error) { },
console.error("Error fetching GitHub activity:", error); { maxStaleMs: 24 * 60 * 60 * 1000 }
return []; ).catch((error) => {
if (error instanceof NetworkError) {
console.error("GitHub GraphQL API unavailable (network error)");
} else if (error instanceof TimeoutError) {
console.error(`GitHub GraphQL API timeout after ${error.timeoutMs}ms`);
} else if (error instanceof APIError) {
console.error(
`GitHub GraphQL API error: ${error.status} ${error.statusText}`
);
} else {
console.error("Unexpected error fetching GitHub activity:", error);
} }
return [];
}); });
}), }),
// Get Gitea contribution activity (for heatmap) // Get Gitea contribution activity (for heatmap)
getGiteaActivity: publicProcedure.query(async () => { getGiteaActivity: publicProcedure.query(async () => {
return withCache("gitea-activity", 10 * 60 * 1000, async () => { return withCacheAndStale(
try { "gitea-activity",
10 * 60 * 1000,
async () => {
// Get user's repositories // Get user's repositories
const reposResponse = await fetch( const reposResponse = await fetchWithTimeout(
`${env.GITEA_URL}/api/v1/user/repos?limit=100`, `${env.GITEA_URL}/api/v1/user/repos?limit=100`,
{ {
headers: { headers: {
Authorization: `token ${env.GITEA_TOKEN}`, Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json" Accept: "application/json"
} },
timeout: 15000
} }
); );
if (!reposResponse.ok) { await checkResponse(reposResponse);
throw new Error(`Gitea repos API error: ${reposResponse.statusText}`);
}
const repos = await reposResponse.json(); const repos = await reposResponse.json();
const contributionsByDay = new Map<string, number>(); const contributionsByDay = new Map<string, number>();
@@ -300,13 +349,14 @@ export const gitActivityRouter = createTRPCRouter({
for (const repo of repos) { for (const repo of repos) {
try { try {
const commitsResponse = await fetch( const commitsResponse = await fetchWithTimeout(
`${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100`, `${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100`,
{ {
headers: { headers: {
Authorization: `token ${env.GITEA_TOKEN}`, Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json" Accept: "application/json"
} },
timeout: 10000
} }
); );
@@ -323,9 +373,19 @@ export const gitActivityRouter = createTRPCRouter({
} }
} }
} catch (error) { } catch (error) {
// Log individual repo failures but continue with others
if (
error instanceof NetworkError ||
error instanceof TimeoutError
) {
console.warn(
`Network error fetching commits for ${repo.name}, skipping`
);
} else {
console.error(`Error fetching commits for ${repo.name}:`, error); console.error(`Error fetching commits for ${repo.name}:`, error);
} }
} }
}
// Convert to array format // Convert to array format
const contributions: ContributionDay[] = Array.from( const contributions: ContributionDay[] = Array.from(
@@ -333,10 +393,19 @@ export const gitActivityRouter = createTRPCRouter({
).map(([date, count]) => ({ date, count })); ).map(([date, count]) => ({ date, count }));
return contributions; return contributions;
} catch (error) { },
console.error("Error fetching Gitea activity:", error); { maxStaleMs: 24 * 60 * 60 * 1000 }
return []; ).catch((error) => {
if (error instanceof NetworkError) {
console.error("Gitea API unavailable (network error)");
} else if (error instanceof TimeoutError) {
console.error(`Gitea API timeout after ${error.timeoutMs}ms`);
} else if (error instanceof APIError) {
console.error(`Gitea API error: ${error.status} ${error.statusText}`);
} else {
console.error("Unexpected error fetching Gitea activity:", error);
} }
return [];
}); });
}) })
}); });

View File

@@ -89,7 +89,19 @@ export const miscRouter = createTRPCRouter({
credentials: credentials credentials: credentials
}); });
const Key = `${input.type}/${input.title}/${input.filename}`; // Sanitize the title and filename for S3 key (replace spaces with hyphens, remove special chars)
const sanitizeForS3 = (str: string) => {
return str
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/[^\w\-\.]/g, "") // Remove special characters except hyphens, dots, and word chars
.replace(/\-+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
};
const sanitizedTitle = sanitizeForS3(input.title);
const sanitizedFilename = sanitizeForS3(input.filename);
const Key = `${input.type}/${sanitizedTitle}/${sanitizedFilename}`;
const ext = /^.+\.([^.]+)$/.exec(input.filename); const ext = /^.+\.([^.]+)$/.exec(input.filename);
const s3params = { const s3params = {
@@ -105,7 +117,7 @@ export const miscRouter = createTRPCRouter({
return { uploadURL: signedUrl, key: Key }; return { uploadURL: signedUrl, key: Key };
} catch (error) { } catch (error) {
console.error(error); console.error("Failed to generate pre-signed URL:", error);
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate pre-signed URL" message: "Failed to generate pre-signed URL"
@@ -124,13 +136,19 @@ export const miscRouter = createTRPCRouter({
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const credentials = {
accessKeyId: env._AWS_ACCESS_KEY,
secretAccessKey: env._AWS_SECRET_KEY
};
const s3params = { const s3params = {
Bucket: env.AWS_S3_BUCKET_NAME, Bucket: env.AWS_S3_BUCKET_NAME,
Key: input.key Key: input.key
}; };
const client = new S3Client({ const client = new S3Client({
region: env.AWS_REGION region: env.AWS_REGION,
credentials: credentials
}); });
const command = new DeleteObjectCommand(s3params); const command = new DeleteObjectCommand(s3params);
@@ -157,13 +175,19 @@ export const miscRouter = createTRPCRouter({
.input(z.object({ key: z.string() })) .input(z.object({ key: z.string() }))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const credentials = {
accessKeyId: env._AWS_ACCESS_KEY,
secretAccessKey: env._AWS_SECRET_KEY
};
const s3params = { const s3params = {
Bucket: env.AWS_S3_BUCKET_NAME, Bucket: env.AWS_S3_BUCKET_NAME,
Key: input.key Key: input.key
}; };
const client = new S3Client({ const client = new S3Client({
region: env.AWS_REGION region: env.AWS_REGION,
credentials: credentials
}); });
const command = new DeleteObjectCommand(s3params); const command = new DeleteObjectCommand(s3params);

View File

@@ -19,6 +19,21 @@ class SimpleCache {
return entry.data as T; return entry.data as T;
} }
/**
* Get cached data even if expired (for stale-while-revalidate)
*/
getStale<T>(key: string): T | null {
const entry = this.cache.get(key);
return entry ? (entry.data as T) : null;
}
/**
* Check if cache entry exists (regardless of expiration)
*/
has(key: string): boolean {
return this.cache.has(key);
}
set<T>(key: string, data: T): void { set<T>(key: string, data: T): void {
this.cache.set(key, { this.cache.set(key, {
data, data,
@@ -52,3 +67,56 @@ export async function withCache<T>(
cache.set(key, result); cache.set(key, result);
return result; return result;
} }
/**
* Cache wrapper with stale-while-revalidate support
* Returns stale data if fetch fails, with optional stale time limit
*/
export async function withCacheAndStale<T>(
key: string,
ttlMs: number,
fn: () => Promise<T>,
options: {
maxStaleMs?: number; // Maximum age of stale data to return (default: 7 days)
logErrors?: boolean; // Whether to log errors (default: true)
} = {}
): Promise<T> {
const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options;
// Try to get fresh cached data
const cached = cache.get<T>(key, ttlMs);
if (cached !== null) {
return cached;
}
// Try to fetch new data
try {
const result = await fn();
cache.set(key, result);
return result;
} catch (error) {
if (logErrors) {
console.error(`Error fetching data for cache key "${key}":`, error);
}
// If fetch fails, try to serve stale data
const stale = cache.getStale<T>(key);
if (stale !== null) {
// Check if stale data is within acceptable age
const entry = (cache as any).cache.get(key);
const age = Date.now() - entry.timestamp;
if (age <= maxStaleMs) {
if (logErrors) {
console.log(
`Serving stale data for cache key "${key}" (age: ${Math.round(age / 1000 / 60)}m)`
);
}
return stale;
}
}
// No stale data available or too old, re-throw the error
throw error;
}
}

View File

@@ -0,0 +1,119 @@
// Manual test file for fetch-utils error handling
// Run with: bun run src/server/fetch-utils.test.ts
import {
fetchWithTimeout,
checkResponse,
NetworkError,
TimeoutError,
APIError,
fetchWithRetry
} from "./fetch-utils";
async function testTimeoutError() {
console.log("\n=== Testing Timeout Error ===");
try {
// This should timeout after 1ms
await fetchWithTimeout("https://httpbin.org/delay/10", { timeout: 1 });
console.log("❌ Should have thrown TimeoutError");
} catch (error) {
if (error instanceof TimeoutError) {
console.log("✅ TimeoutError caught correctly");
console.log(` Message: ${error.message}`);
console.log(` Timeout: ${error.timeoutMs}ms`);
} else {
console.log("❌ Wrong error type:", error);
}
}
}
async function testNetworkError() {
console.log("\n=== Testing Network Error ===");
try {
// This should fail to connect
await fetchWithTimeout(
"https://invalid-domain-that-does-not-exist-12345.com"
);
console.log("❌ Should have thrown NetworkError");
} catch (error) {
if (error instanceof NetworkError) {
console.log("✅ NetworkError caught correctly");
console.log(` Message: ${error.message}`);
} else {
console.log("❌ Wrong error type:", error);
}
}
}
async function testAPIError() {
console.log("\n=== Testing API Error ===");
try {
// This should return 404
const response = await fetchWithTimeout("https://httpbin.org/status/404");
await checkResponse(response);
console.log("❌ Should have thrown APIError");
} catch (error) {
if (error instanceof APIError) {
console.log("✅ APIError caught correctly");
console.log(` Message: ${error.message}`);
console.log(` Status: ${error.status}`);
} else {
console.log("❌ Wrong error type:", error);
}
}
}
async function testSuccessfulRequest() {
console.log("\n=== Testing Successful Request ===");
try {
const response = await fetchWithTimeout("https://httpbin.org/get", {
timeout: 10000
});
await checkResponse(response);
const data = await response.json();
console.log("✅ Successful request");
console.log(` URL: ${data.url}`);
} catch (error) {
console.log("❌ Should not have thrown error:", error);
}
}
async function testRetryLogic() {
console.log("\n=== Testing Retry Logic ===");
let attempts = 0;
try {
await fetchWithRetry(
async () => {
attempts++;
console.log(` Attempt ${attempts}`);
throw new NetworkError("Simulated network error");
},
{
maxRetries: 2,
retryDelay: 100
}
);
console.log("❌ Should have thrown error after retries");
} catch (error) {
if (error instanceof NetworkError && attempts === 3) {
console.log("✅ Retry logic worked correctly");
console.log(` Total attempts: ${attempts}`);
} else {
console.log("❌ Wrong behavior:", { error, attempts });
}
}
}
async function runTests() {
console.log("Starting fetch-utils tests...\n");
await testTimeoutError();
await testNetworkError();
await testAPIError();
await testSuccessfulRequest();
await testRetryLogic();
console.log("\n=== All tests completed ===\n");
}
runTests();

156
src/server/fetch-utils.ts Normal file
View File

@@ -0,0 +1,156 @@
// Error types for better error classification
export class NetworkError extends Error {
constructor(
message: string,
public readonly cause?: unknown
) {
super(message);
this.name = "NetworkError";
}
}
export class TimeoutError extends Error {
constructor(
message: string,
public readonly timeoutMs: number
) {
super(message);
this.name = "TimeoutError";
}
}
export class APIError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly statusText: string
) {
super(message);
this.name = "APIError";
}
}
interface FetchWithTimeoutOptions extends RequestInit {
timeout?: number;
}
/**
* Fetch wrapper with timeout support and proper error classification
*/
export async function fetchWithTimeout(
url: string,
options: FetchWithTimeoutOptions = {}
): Promise<Response> {
const { timeout = 10000, ...fetchOptions } = options;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal
});
clearTimeout(timeoutId);
return response;
} catch (error: unknown) {
clearTimeout(timeoutId);
// Classify the error for better handling
if (error instanceof Error) {
// Check for abort/timeout
if (error.name === "AbortError") {
throw new TimeoutError(
`Request to ${url} timed out after ${timeout}ms`,
timeout
);
}
// Check for connection errors (various runtime-specific errors)
if (
error.message.includes("fetch failed") ||
error.message.includes("ECONNREFUSED") ||
error.message.includes("ENOTFOUND") ||
error.message.includes("ETIMEDOUT") ||
error.message.includes("UND_ERR_CONNECT_TIMEOUT") ||
error.name === "FailedToOpenSocket" ||
error.message.includes("Was there a typo")
) {
throw new NetworkError(
`Failed to connect to ${url}: ${error.message}`,
error
);
}
}
// Re-throw unknown errors
throw error;
}
}
/**
* Helper to check response status and throw APIError if not ok
*/
export async function checkResponse(response: Response): Promise<Response> {
if (!response.ok) {
throw new APIError(
`API request failed: ${response.statusText}`,
response.status,
response.statusText
);
}
return response;
}
/**
* Safe JSON parse that handles errors gracefully
*/
export async function safeJsonParse<T>(response: Response): Promise<T | null> {
try {
return await response.json();
} catch (error) {
console.error("Failed to parse JSON response:", error);
return null;
}
}
/**
* Retry logic with exponential backoff
*/
export async function fetchWithRetry<T>(
fn: () => Promise<T>,
options: {
maxRetries?: number;
retryDelay?: number;
retryableErrors?: (error: unknown) => boolean;
} = {}
): Promise<T> {
const {
maxRetries = 2,
retryDelay = 1000,
retryableErrors = (error) =>
error instanceof TimeoutError || error instanceof NetworkError
} = options;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Don't retry if it's the last attempt or error is not retryable
if (attempt === maxRetries || !retryableErrors(error)) {
throw error;
}
// Exponential backoff
const delay = retryDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}