protections
This commit is contained in:
@@ -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 */}
|
||||||
<div
|
<Show when={showBubbleMenu()}>
|
||||||
class="tiptap-bubble-menu"
|
<div
|
||||||
style={{
|
ref={bubbleMenuRef}
|
||||||
display: "none" // Will be shown by Tiptap when text is selected
|
class="bg-mantle text-text fixed z-50 w-fit rounded p-2 text-sm whitespace-nowrap shadow-lg"
|
||||||
}}
|
style={{
|
||||||
>
|
top: `${bubbleMenuPosition().top}px`,
|
||||||
<div class="bg-mantle text-text mt-4 w-fit rounded p-2 text-sm whitespace-nowrap shadow-lg">
|
left: `${bubbleMenuPosition().left}px`,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
"margin-top": "-8px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
<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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
method: "POST",
|
async () => {
|
||||||
headers: {
|
const response = await fetchWithTimeout(apiUrl, {
|
||||||
accept: "application/json",
|
method: "POST",
|
||||||
"api-key": apiKey,
|
headers: {
|
||||||
"content-type": "application/json"
|
accept: "application/json",
|
||||||
|
"api-key": apiKey,
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(sendinblueData),
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkResponse(response);
|
||||||
|
return response;
|
||||||
},
|
},
|
||||||
body: JSON.stringify(sendinblueData)
|
{
|
||||||
});
|
maxRetries: 2,
|
||||||
|
retryDelay: 1000
|
||||||
if (!response.ok) {
|
}
|
||||||
throw new Error("Failed to send email");
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
headers: {
|
code: "UNAUTHORIZED",
|
||||||
Authorization: `token ${access_token}`
|
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: {
|
||||||
|
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,48 +574,49 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email, rememberMe } = input;
|
const { email, rememberMe } = input;
|
||||||
|
|
||||||
// Check rate limiting
|
try {
|
||||||
const requested = getCookie(
|
// Check rate limiting
|
||||||
ctx.event.nativeEvent,
|
const requested = getCookie(
|
||||||
"emailLoginLinkRequested"
|
ctx.event.nativeEvent,
|
||||||
);
|
"emailLoginLinkRequested"
|
||||||
if (requested) {
|
);
|
||||||
const expires = new Date(requested);
|
if (requested) {
|
||||||
const remaining = expires.getTime() - Date.now();
|
const expires = new Date(requested);
|
||||||
if (remaining > 0) {
|
const remaining = expires.getTime() - Date.now();
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: "countdown not expired"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: "SELECT * FROM User WHERE email = ?",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "TOO_MANY_REQUESTS",
|
code: "NOT_FOUND",
|
||||||
message: "countdown not expired"
|
message: "User not found"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
// Create JWT token for email link (15min expiry)
|
||||||
const res = await conn.execute({
|
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
sql: "SELECT * FROM User WHERE email = ?",
|
const token = await new SignJWT({
|
||||||
args: [email]
|
email,
|
||||||
});
|
rememberMe: rememberMe ?? false
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setExpirationTime("15m")
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
if (res.rows.length === 0) {
|
// Send email
|
||||||
throw new TRPCError({
|
const domain = env.VITE_DOMAIN || "https://freno.me";
|
||||||
code: "NOT_FOUND",
|
const htmlContent = `<html>
|
||||||
message: "User not found"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create JWT token for email link (15min expiry)
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const token = await new SignJWT({
|
|
||||||
email,
|
|
||||||
rememberMe: rememberMe ?? false
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("15m")
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
const domain = env.VITE_DOMAIN || "https://freno.me";
|
|
||||||
const htmlContent = `<html>
|
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.center {
|
.center {
|
||||||
@@ -563,21 +654,45 @@ export const authRouter = createTRPCRouter({
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
await sendEmail(email, "freno.me login link", htmlContent);
|
await sendEmail(email, "freno.me login link", htmlContent);
|
||||||
|
|
||||||
// Set rate limit cookie (2 minutes)
|
// Set rate limit cookie (2 minutes)
|
||||||
const exp = new Date(Date.now() + 2 * 60 * 1000);
|
const exp = new Date(Date.now() + 2 * 60 * 1000);
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.event.nativeEvent,
|
ctx.event.nativeEvent,
|
||||||
"emailLoginLinkRequested",
|
"emailLoginLinkRequested",
|
||||||
exp.toUTCString(),
|
exp.toUTCString(),
|
||||||
{
|
{
|
||||||
maxAge: 2 * 60,
|
maxAge: 2 * 60,
|
||||||
path: "/"
|
path: "/"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, message: "email sent" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true, message: "email sent" };
|
// 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,45 +701,46 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// Check rate limiting
|
try {
|
||||||
const requested = getCookie(
|
// Check rate limiting
|
||||||
ctx.event.nativeEvent,
|
const requested = getCookie(
|
||||||
"passwordResetRequested"
|
ctx.event.nativeEvent,
|
||||||
);
|
"passwordResetRequested"
|
||||||
if (requested) {
|
);
|
||||||
const expires = new Date(requested);
|
if (requested) {
|
||||||
const remaining = expires.getTime() - Date.now();
|
const expires = new Date(requested);
|
||||||
if (remaining > 0) {
|
const remaining = expires.getTime() - Date.now();
|
||||||
throw new TRPCError({
|
if (remaining > 0) {
|
||||||
code: "TOO_MANY_REQUESTS",
|
throw new TRPCError({
|
||||||
message: "countdown not expired"
|
code: "TOO_MANY_REQUESTS",
|
||||||
});
|
message: "countdown not expired"
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const res = await conn.execute({
|
const res = await conn.execute({
|
||||||
sql: "SELECT * FROM User WHERE email = ?",
|
sql: "SELECT * FROM User WHERE email = ?",
|
||||||
args: [email]
|
args: [email]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.rows.length === 0) {
|
if (res.rows.length === 0) {
|
||||||
// Don't reveal if user exists
|
// Don't reveal if user exists
|
||||||
return { success: true, message: "email sent" };
|
return { success: true, message: "email sent" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = res.rows[0] as unknown as User;
|
const user = res.rows[0] as unknown as User;
|
||||||
|
|
||||||
// Create JWT token with user ID (15min expiry)
|
// Create JWT token with user ID (15min expiry)
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
const token = await new SignJWT({ id: user.id })
|
const token = await new SignJWT({ id: user.id })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setExpirationTime("15m")
|
.setExpirationTime("15m")
|
||||||
.sign(secret);
|
.sign(secret);
|
||||||
|
|
||||||
// Send email
|
// Send email
|
||||||
const domain = env.VITE_DOMAIN || "https://freno.me";
|
const domain = env.VITE_DOMAIN || "https://freno.me";
|
||||||
const htmlContent = `<html>
|
const htmlContent = `<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.center {
|
.center {
|
||||||
@@ -662,21 +778,45 @@ export const authRouter = createTRPCRouter({
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
await sendEmail(email, "password reset", htmlContent);
|
await sendEmail(email, "password reset", htmlContent);
|
||||||
|
|
||||||
// Set rate limit cookie (5 minutes)
|
// Set rate limit cookie (5 minutes)
|
||||||
const exp = new Date(Date.now() + 5 * 60 * 1000);
|
const exp = new Date(Date.now() + 5 * 60 * 1000);
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.event.nativeEvent,
|
ctx.event.nativeEvent,
|
||||||
"passwordResetRequested",
|
"passwordResetRequested",
|
||||||
exp.toUTCString(),
|
exp.toUTCString(),
|
||||||
{
|
{
|
||||||
maxAge: 5 * 60,
|
maxAge: 5 * 60,
|
||||||
path: "/"
|
path: "/"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, message: "email sent" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true, message: "email sent" };
|
// 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,47 +887,49 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// Check rate limiting
|
try {
|
||||||
const requested = getCookie(
|
// Check rate limiting
|
||||||
ctx.event.nativeEvent,
|
const requested = getCookie(
|
||||||
"emailVerificationRequested"
|
ctx.event.nativeEvent,
|
||||||
);
|
"emailVerificationRequested"
|
||||||
if (requested) {
|
);
|
||||||
const time = parseInt(requested);
|
if (requested) {
|
||||||
const currentTime = Date.now();
|
const time = parseInt(requested);
|
||||||
const difference = (currentTime - time) / (1000 * 60);
|
const currentTime = Date.now();
|
||||||
|
const difference = (currentTime - time) / (1000 * 60);
|
||||||
|
|
||||||
if (difference < 15) {
|
if (difference < 15) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message:
|
||||||
|
"Please wait before requesting another verification email"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = ConnectionFactory();
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: "SELECT * FROM User WHERE email = ?",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "TOO_MANY_REQUESTS",
|
code: "NOT_FOUND",
|
||||||
message: "Please wait before requesting another verification email"
|
message: "User not found"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
// Create JWT token (15min expiry)
|
||||||
const res = await conn.execute({
|
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
sql: "SELECT * FROM User WHERE email = ?",
|
const token = await new SignJWT({ email })
|
||||||
args: [email]
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
});
|
.setExpirationTime("15m")
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
if (res.rows.length === 0) {
|
// Send email
|
||||||
throw new TRPCError({
|
const domain = env.VITE_DOMAIN || "https://freno.me";
|
||||||
code: "NOT_FOUND",
|
const htmlContent = `<html>
|
||||||
message: "User not found"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create JWT token (15min expiry)
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
const token = await new SignJWT({ email })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("15m")
|
|
||||||
.sign(secret);
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
const domain = env.VITE_DOMAIN || "https://freno.me";
|
|
||||||
const htmlContent = `<html>
|
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.center {
|
.center {
|
||||||
@@ -822,20 +964,44 @@ export const authRouter = createTRPCRouter({
|
|||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
await sendEmail(email, "freno.me email verification", htmlContent);
|
await sendEmail(email, "freno.me email verification", htmlContent);
|
||||||
|
|
||||||
// Set rate limit cookie
|
// Set rate limit cookie
|
||||||
setCookie(
|
setCookie(
|
||||||
ctx.event.nativeEvent,
|
ctx.event.nativeEvent,
|
||||||
"emailVerificationRequested",
|
"emailVerificationRequested",
|
||||||
Date.now().toString(),
|
Date.now().toString(),
|
||||||
{
|
{
|
||||||
maxAge: 15 * 60,
|
maxAge: 15 * 60,
|
||||||
path: "/"
|
path: "/"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, message: "Verification email sent" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true, message: "Verification email sent" };
|
// 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 };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,191 +30,221 @@ 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 fetchWithTimeout(
|
||||||
const reposResponse = await fetch(
|
`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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await checkResponse(reposResponse);
|
||||||
|
const repos = await reposResponse.json();
|
||||||
|
const allCommits: GitCommit[] = [];
|
||||||
|
|
||||||
|
// Fetch recent commits from each repo
|
||||||
|
for (const repo of repos) {
|
||||||
|
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commitsResponse = await fetchWithTimeout(
|
||||||
|
`https://api.github.com/repos/${repo.full_name}/commits?per_page=5`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
|
||||||
|
Accept: "application/vnd.github.v3+json"
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (commitsResponse.ok) {
|
||||||
|
const commits = await commitsResponse.json();
|
||||||
|
for (const commit of commits) {
|
||||||
|
// Filter for commits by the authenticated user
|
||||||
|
if (
|
||||||
|
commit.author?.login === "MikeFreno" ||
|
||||||
|
commit.commit?.author?.email?.includes("mike")
|
||||||
|
) {
|
||||||
|
allCommits.push({
|
||||||
|
sha: commit.sha?.substring(0, 7) || "unknown",
|
||||||
|
message:
|
||||||
|
commit.commit?.message?.split("\n")[0] || "No message",
|
||||||
|
author:
|
||||||
|
commit.commit?.author?.name ||
|
||||||
|
commit.author?.login ||
|
||||||
|
"Unknown",
|
||||||
|
date:
|
||||||
|
commit.commit?.author?.date || new Date().toISOString(),
|
||||||
|
repo: repo.full_name,
|
||||||
|
url: `https://github.com/${repo.full_name}/commit/${commit.sha}`
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
} catch (error) {
|
||||||
|
// Log individual repo failures but continue with others
|
||||||
if (!reposResponse.ok) {
|
if (
|
||||||
throw new Error(
|
error instanceof NetworkError ||
|
||||||
`GitHub repos API error: ${reposResponse.statusText}`
|
error instanceof TimeoutError
|
||||||
);
|
) {
|
||||||
}
|
console.warn(
|
||||||
|
`Network error fetching commits for ${repo.full_name}, skipping`
|
||||||
const repos = await reposResponse.json();
|
|
||||||
const allCommits: GitCommit[] = [];
|
|
||||||
|
|
||||||
// Fetch recent commits from each repo
|
|
||||||
for (const repo of repos) {
|
|
||||||
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
|
|
||||||
|
|
||||||
try {
|
|
||||||
const commitsResponse = await fetch(
|
|
||||||
`https://api.github.com/repos/${repo.full_name}/commits?per_page=5`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
|
|
||||||
Accept: "application/vnd.github.v3+json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
if (commitsResponse.ok) {
|
|
||||||
const commits = await commitsResponse.json();
|
|
||||||
for (const commit of commits) {
|
|
||||||
// Filter for commits by the authenticated user
|
|
||||||
if (
|
|
||||||
commit.author?.login === "MikeFreno" ||
|
|
||||||
commit.commit?.author?.email?.includes("mike")
|
|
||||||
) {
|
|
||||||
allCommits.push({
|
|
||||||
sha: commit.sha?.substring(0, 7) || "unknown",
|
|
||||||
message:
|
|
||||||
commit.commit?.message?.split("\n")[0] ||
|
|
||||||
"No message",
|
|
||||||
author:
|
|
||||||
commit.commit?.author?.name ||
|
|
||||||
commit.author?.login ||
|
|
||||||
"Unknown",
|
|
||||||
date:
|
|
||||||
commit.commit?.author?.date ||
|
|
||||||
new Date().toISOString(),
|
|
||||||
repo: repo.full_name,
|
|
||||||
url: `https://github.com/${repo.full_name}/commit/${commit.sha}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
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
|
|
||||||
allCommits.sort(
|
|
||||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
return allCommits.slice(0, input.limit);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching GitHub commits:", error);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by date and return the most recent
|
||||||
|
allCommits.sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return allCommits.slice(0, input.limit);
|
||||||
|
},
|
||||||
|
{ maxStaleMs: 24 * 60 * 60 * 1000 } // Accept stale data up to 24 hours old
|
||||||
|
).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 fetchWithTimeout(
|
||||||
const reposResponse = await fetch(
|
`${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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await checkResponse(reposResponse);
|
||||||
|
const repos = await reposResponse.json();
|
||||||
|
const allCommits: GitCommit[] = [];
|
||||||
|
|
||||||
|
// Fetch recent commits from each repo
|
||||||
|
for (const repo of repos) {
|
||||||
|
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commitsResponse = await fetchWithTimeout(
|
||||||
|
`${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${env.GITEA_TOKEN}`,
|
||||||
|
Accept: "application/json"
|
||||||
|
},
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (commitsResponse.ok) {
|
||||||
|
const commits = await commitsResponse.json();
|
||||||
|
for (const commit of commits) {
|
||||||
|
if (
|
||||||
|
(commit.commit?.author?.email &&
|
||||||
|
commit.commit.author.email.includes(
|
||||||
|
"michael@freno.me"
|
||||||
|
)) ||
|
||||||
|
commit.commit.author.email.includes(
|
||||||
|
"michaelt.freno@gmail.com"
|
||||||
|
) // Filter for your commits
|
||||||
|
) {
|
||||||
|
allCommits.push({
|
||||||
|
sha: commit.sha?.substring(0, 7) || "unknown",
|
||||||
|
message:
|
||||||
|
commit.commit?.message?.split("\n")[0] || "No message",
|
||||||
|
author: commit.commit?.author?.name || repo.owner.login,
|
||||||
|
date:
|
||||||
|
commit.commit?.author?.date || new Date().toISOString(),
|
||||||
|
repo: repo.full_name,
|
||||||
|
url: `${env.GITEA_URL}/${repo.full_name}/commit/${commit.sha}`
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
} catch (error) {
|
||||||
|
// Log individual repo failures but continue with others
|
||||||
if (!reposResponse.ok) {
|
if (
|
||||||
throw new Error(
|
error instanceof NetworkError ||
|
||||||
`Gitea repos API error: ${reposResponse.statusText}`
|
error instanceof TimeoutError
|
||||||
);
|
) {
|
||||||
}
|
console.warn(
|
||||||
|
`Network error fetching commits for ${repo.name}, skipping`
|
||||||
const repos = await reposResponse.json();
|
|
||||||
const allCommits: GitCommit[] = [];
|
|
||||||
|
|
||||||
// Fetch recent commits from each repo
|
|
||||||
for (const repo of repos) {
|
|
||||||
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
|
|
||||||
|
|
||||||
try {
|
|
||||||
const commitsResponse = await fetch(
|
|
||||||
`${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${env.GITEA_TOKEN}`,
|
|
||||||
Accept: "application/json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
if (commitsResponse.ok) {
|
|
||||||
const commits = await commitsResponse.json();
|
|
||||||
for (const commit of commits) {
|
|
||||||
if (
|
|
||||||
(commit.commit?.author?.email &&
|
|
||||||
commit.commit.author.email.includes(
|
|
||||||
"michael@freno.me"
|
|
||||||
)) ||
|
|
||||||
commit.commit.author.email.includes(
|
|
||||||
"michaelt.freno@gmail.com"
|
|
||||||
) // Filter for your commits
|
|
||||||
) {
|
|
||||||
allCommits.push({
|
|
||||||
sha: commit.sha?.substring(0, 7) || "unknown",
|
|
||||||
message:
|
|
||||||
commit.commit?.message?.split("\n")[0] ||
|
|
||||||
"No message",
|
|
||||||
author: commit.commit?.author?.name || repo.owner.login,
|
|
||||||
date:
|
|
||||||
commit.commit?.author?.date ||
|
|
||||||
new Date().toISOString(),
|
|
||||||
repo: repo.full_name,
|
|
||||||
url: `${env.GITEA_URL}/${repo.full_name}/commit/${commit.sha}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
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
|
|
||||||
allCommits.sort(
|
|
||||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
return allCommits.slice(0, input.limit);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching Gitea commits:", error);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by date and return the most recent
|
||||||
|
allCommits.sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return allCommits.slice(0, input.limit);
|
||||||
|
},
|
||||||
|
{ 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 [];
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// 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,27 +263,28 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const response = await fetch("https://api.github.com/graphql", {
|
const response = await fetchWithTimeout(
|
||||||
method: "POST",
|
"https://api.github.com/graphql",
|
||||||
headers: {
|
{
|
||||||
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
|
method: "POST",
|
||||||
"Content-Type": "application/json"
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
|
||||||
body: JSON.stringify({
|
"Content-Type": "application/json"
|
||||||
query,
|
},
|
||||||
variables: { userName: "MikeFreno" }
|
body: JSON.stringify({
|
||||||
})
|
query,
|
||||||
});
|
variables: { userName: "MikeFreno" }
|
||||||
|
}),
|
||||||
if (!response.ok) {
|
timeout: 15000
|
||||||
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,7 +373,17 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching commits for ${repo.name}:`, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 [];
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
119
src/server/fetch-utils.test.ts
Normal file
119
src/server/fetch-utils.test.ts
Normal 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
156
src/server/fetch-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user