pre-tiptap

This commit is contained in:
Michael Freno
2025-12-19 12:04:18 -05:00
parent 324141441b
commit 3d55dab7b5
13 changed files with 555 additions and 409 deletions

View File

@@ -9,7 +9,10 @@ import { setCookie, getCookie } from "vinxi/http";
import type { User } from "~/types/user";
// Helper to create JWT token
async function createJWT(userId: string, expiresIn: string = "14d"): Promise<string> {
async function createJWT(
userId: string,
expiresIn: string = "14d"
): Promise<string> {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({ id: userId })
.setProtectedHeader({ alg: "HS256" })
@@ -26,11 +29,11 @@ async function sendEmail(to: string, subject: string, htmlContent: string) {
const sendinblueData = {
sender: {
name: "freno.me",
email: "no_reply@freno.me",
email: "no_reply@freno.me"
},
to: [{ email: to }],
htmlContent,
subject,
subject
};
const response = await fetch(apiUrl, {
@@ -38,9 +41,9 @@ async function sendEmail(to: string, subject: string, htmlContent: string) {
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json",
"content-type": "application/json"
},
body: JSON.stringify(sendinblueData),
body: JSON.stringify(sendinblueData)
});
if (!response.ok) {
@@ -65,22 +68,22 @@ export const authRouter = createTRPCRouter({
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Accept: "application/json"
},
body: JSON.stringify({
client_id: env.VITE_GITHUB_CLIENT_ID || env.NEXT_PUBLIC_GITHUB_CLIENT_ID,
client_id: env.VITE_GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
}),
},
code
})
}
);
const { access_token } = await tokenResponse.json();
// Fetch user info from GitHub
const userResponse = await fetch("https://api.github.com/user", {
headers: {
Authorization: `token ${access_token}`,
},
Authorization: `token ${access_token}`
}
});
const user = await userResponse.json();
@@ -117,18 +120,18 @@ export const authRouter = createTRPCRouter({
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "lax"
});
return {
success: true,
redirectTo: "/account",
redirectTo: "/account"
};
} catch (error) {
console.error("GitHub authentication failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "GitHub authentication failed",
message: "GitHub authentication failed"
});
}
}),
@@ -141,19 +144,22 @@ export const authRouter = createTRPCRouter({
try {
// Exchange code for access token
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
code: code,
client_id: env.VITE_GOOGLE_CLIENT_ID || env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "",
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN}/api/auth/callback/google`,
grant_type: "authorization_code",
}),
});
const tokenResponse = await fetch(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
code: code,
client_id: env.VITE_GOOGLE_CLIENT_ID || "",
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: `${env.VITE_DOMAIN || "https://freno.me"}/api/auth/callback/google`,
grant_type: "authorization_code"
})
}
);
const { access_token } = await tokenResponse.json();
@@ -162,9 +168,9 @@ export const authRouter = createTRPCRouter({
"https://www.googleapis.com/oauth2/v3/userinfo",
{
headers: {
Authorization: `Bearer ${access_token}`,
},
},
Authorization: `Bearer ${access_token}`
}
}
);
const userData = await userResponse.json();
@@ -196,11 +202,11 @@ export const authRouter = createTRPCRouter({
email_verified,
name,
"google",
image,
image
];
await conn.execute({
sql: insertQuery,
args: insertParams,
args: insertParams
});
}
@@ -213,18 +219,18 @@ export const authRouter = createTRPCRouter({
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "lax"
});
return {
success: true,
redirectTo: "/account",
redirectTo: "/account"
};
} catch (error) {
console.error("Google authentication failed:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Google authentication failed",
message: "Google authentication failed"
});
}
}),
@@ -235,8 +241,8 @@ export const authRouter = createTRPCRouter({
z.object({
email: z.string().email(),
token: z.string(),
rememberMe: z.boolean().optional(),
}),
rememberMe: z.boolean().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { email, token, rememberMe } = input;
@@ -250,7 +256,7 @@ export const authRouter = createTRPCRouter({
if (payload.email !== email) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Email mismatch",
message: "Email mismatch"
});
}
@@ -262,7 +268,7 @@ export const authRouter = createTRPCRouter({
if (!res.rows[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
message: "User not found"
});
}
@@ -276,7 +282,7 @@ export const authRouter = createTRPCRouter({
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "lax"
};
if (rememberMe) {
@@ -284,11 +290,16 @@ export const authRouter = createTRPCRouter({
}
// If rememberMe is false, cookie will be session-only (no maxAge)
setCookie(ctx.event.nativeEvent, "userIDToken", userToken, cookieOptions);
setCookie(
ctx.event.nativeEvent,
"userIDToken",
userToken,
cookieOptions
);
return {
success: true,
redirectTo: "/account",
redirectTo: "/account"
};
} catch (error) {
if (error instanceof TRPCError) {
@@ -297,7 +308,7 @@ export const authRouter = createTRPCRouter({
console.error("Email login failed:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication failed",
message: "Authentication failed"
});
}
}),
@@ -307,8 +318,8 @@ export const authRouter = createTRPCRouter({
.input(
z.object({
email: z.string().email(),
token: z.string(),
}),
token: z.string()
})
)
.mutation(async ({ input }) => {
const { email, token } = input;
@@ -322,7 +333,7 @@ export const authRouter = createTRPCRouter({
if (payload.email !== email) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Email mismatch",
message: "Email mismatch"
});
}
@@ -333,7 +344,7 @@ export const authRouter = createTRPCRouter({
return {
success: true,
message: "Email verification success, you may close this window",
message: "Email verification success, you may close this window"
};
} catch (error) {
if (error instanceof TRPCError) {
@@ -342,7 +353,7 @@ export const authRouter = createTRPCRouter({
console.error("Email verification failed:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token",
message: "Invalid token"
});
}
}),
@@ -353,8 +364,8 @@ export const authRouter = createTRPCRouter({
z.object({
email: z.string().email(),
password: z.string().min(8),
passwordConfirmation: z.string().min(8),
}),
passwordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => {
const { email, password, passwordConfirmation } = input;
@@ -362,7 +373,7 @@ export const authRouter = createTRPCRouter({
if (password !== passwordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "passwordMismatch",
message: "passwordMismatch"
});
}
@@ -373,7 +384,7 @@ export const authRouter = createTRPCRouter({
try {
await conn.execute({
sql: "INSERT INTO User (id, email, password_hash, provider) VALUES (?, ?, ?, ?)",
args: [userId, email, passwordHash, "email"],
args: [userId, email, passwordHash, "email"]
});
// Create JWT token
@@ -385,7 +396,7 @@ export const authRouter = createTRPCRouter({
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "lax"
});
return { success: true, message: "success" };
@@ -393,7 +404,7 @@ export const authRouter = createTRPCRouter({
console.error("Registration error:", e);
throw new TRPCError({
code: "BAD_REQUEST",
message: "duplicate",
message: "duplicate"
});
}
}),
@@ -404,8 +415,8 @@ export const authRouter = createTRPCRouter({
z.object({
email: z.string().email(),
password: z.string(),
rememberMe: z.boolean().optional(),
}),
rememberMe: z.boolean().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { email, password, rememberMe } = input;
@@ -413,13 +424,13 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ? AND provider = ?",
args: [email, "email"],
args: [email, "email"]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
message: "no-match"
});
}
@@ -428,7 +439,7 @@ export const authRouter = createTRPCRouter({
if (!user.password_hash) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
message: "no-match"
});
}
@@ -437,7 +448,7 @@ export const authRouter = createTRPCRouter({
if (!passwordMatch) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "no-match",
message: "no-match"
});
}
@@ -450,7 +461,7 @@ export const authRouter = createTRPCRouter({
path: "/",
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
sameSite: "lax"
};
if (rememberMe) {
@@ -467,21 +478,24 @@ export const authRouter = createTRPCRouter({
.input(
z.object({
email: z.string().email(),
rememberMe: z.boolean().optional(),
}),
rememberMe: z.boolean().optional()
})
)
.mutation(async ({ input, ctx }) => {
const { email, rememberMe } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "emailLoginLinkRequested");
const requested = getCookie(
ctx.event.nativeEvent,
"emailLoginLinkRequested"
);
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired",
message: "countdown not expired"
});
}
}
@@ -489,25 +503,28 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
args: [email]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
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 })
const token = await new SignJWT({
email,
rememberMe: rememberMe ?? false
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const domain = env.VITE_DOMAIN || "https://freno.me";
const htmlContent = `<html>
<head>
<style>
@@ -550,10 +567,15 @@ export const authRouter = createTRPCRouter({
// Set rate limit cookie (2 minutes)
const exp = new Date(Date.now() + 2 * 60 * 1000);
setCookie(ctx.event.nativeEvent, "emailLoginLinkRequested", exp.toUTCString(), {
maxAge: 2 * 60,
path: "/",
});
setCookie(
ctx.event.nativeEvent,
"emailLoginLinkRequested",
exp.toUTCString(),
{
maxAge: 2 * 60,
path: "/"
}
);
return { success: true, message: "email sent" };
}),
@@ -565,14 +587,17 @@ export const authRouter = createTRPCRouter({
const { email } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "passwordResetRequested");
const requested = getCookie(
ctx.event.nativeEvent,
"passwordResetRequested"
);
if (requested) {
const expires = new Date(requested);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "countdown not expired",
message: "countdown not expired"
});
}
}
@@ -580,7 +605,7 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
args: [email]
});
if (res.rows.length === 0) {
@@ -598,7 +623,7 @@ export const authRouter = createTRPCRouter({
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const domain = env.VITE_DOMAIN || "https://freno.me";
const htmlContent = `<html>
<head>
<style>
@@ -641,10 +666,15 @@ export const authRouter = createTRPCRouter({
// Set rate limit cookie (5 minutes)
const exp = new Date(Date.now() + 5 * 60 * 1000);
setCookie(ctx.event.nativeEvent, "passwordResetRequested", exp.toUTCString(), {
maxAge: 5 * 60,
path: "/",
});
setCookie(
ctx.event.nativeEvent,
"passwordResetRequested",
exp.toUTCString(),
{
maxAge: 5 * 60,
path: "/"
}
);
return { success: true, message: "email sent" };
}),
@@ -655,8 +685,8 @@ export const authRouter = createTRPCRouter({
z.object({
token: z.string(),
newPassword: z.string().min(8),
newPasswordConfirmation: z.string().min(8),
}),
newPasswordConfirmation: z.string().min(8)
})
)
.mutation(async ({ input, ctx }) => {
const { token, newPassword, newPasswordConfirmation } = input;
@@ -664,7 +694,7 @@ export const authRouter = createTRPCRouter({
if (newPassword !== newPasswordConfirmation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Password Mismatch",
message: "Password Mismatch"
});
}
@@ -676,7 +706,7 @@ export const authRouter = createTRPCRouter({
if (!payload.id || typeof payload.id !== "string") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "bad token",
message: "bad token"
});
}
@@ -685,17 +715,17 @@ export const authRouter = createTRPCRouter({
await conn.execute({
sql: "UPDATE User SET password_hash = ? WHERE id = ?",
args: [passwordHash, payload.id],
args: [passwordHash, payload.id]
});
// Clear any session cookies
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
path: "/"
});
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
path: "/"
});
return { success: true, message: "success" };
@@ -706,7 +736,7 @@ export const authRouter = createTRPCRouter({
console.error("Password reset error:", error);
throw new TRPCError({
code: "UNAUTHORIZED",
message: "token expired",
message: "token expired"
});
}
}),
@@ -718,16 +748,19 @@ export const authRouter = createTRPCRouter({
const { email } = input;
// Check rate limiting
const requested = getCookie(ctx.event.nativeEvent, "emailVerificationRequested");
const requested = getCookie(
ctx.event.nativeEvent,
"emailVerificationRequested"
);
if (requested) {
const time = parseInt(requested);
const currentTime = Date.now();
const difference = (currentTime - time) / (1000 * 60);
if (difference < 15) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Please wait before requesting another verification email",
message: "Please wait before requesting another verification email"
});
}
}
@@ -735,13 +768,13 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory();
const res = await conn.execute({
sql: "SELECT * FROM User WHERE email = ?",
args: [email],
args: [email]
});
if (res.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
message: "User not found"
});
}
@@ -753,7 +786,7 @@ export const authRouter = createTRPCRouter({
.sign(secret);
// Send email
const domain = env.VITE_DOMAIN || env.NEXT_PUBLIC_DOMAIN;
const domain = env.VITE_DOMAIN || "https://freno.me";
const htmlContent = `<html>
<head>
<style>
@@ -792,10 +825,15 @@ export const authRouter = createTRPCRouter({
await sendEmail(email, "freno.me email verification", htmlContent);
// Set rate limit cookie
setCookie(ctx.event.nativeEvent, "emailVerificationRequested", Date.now().toString(), {
maxAge: 15 * 60,
path: "/",
});
setCookie(
ctx.event.nativeEvent,
"emailVerificationRequested",
Date.now().toString(),
{
maxAge: 15 * 60,
path: "/"
}
);
return { success: true, message: "Verification email sent" };
}),
@@ -804,13 +842,14 @@ export const authRouter = createTRPCRouter({
signOut: publicProcedure.mutation(async ({ ctx }) => {
setCookie(ctx.event.nativeEvent, "userIDToken", "", {
maxAge: 0,
path: "/",
path: "/"
});
setCookie(ctx.event.nativeEvent, "emailToken", "", {
maxAge: 0,
path: "/",
path: "/"
});
return { success: true };
}),
});
})
});

View File

@@ -1,5 +1,6 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { ConnectionFactory } from "~/server/utils";
import { withCache } from "~/server/cache";
// Simple in-memory cache for blog posts to reduce DB load
let cachedPosts: {
@@ -11,11 +12,12 @@ let cacheTimestamp: number = 0;
export const blogRouter = createTRPCRouter({
getRecentPosts: publicProcedure.query(async () => {
// Get database connection
const conn = ConnectionFactory();
return withCache("recent-posts", 10 * 60 * 1000, async () => {
// Get database connection
const conn = ConnectionFactory();
// Query for the 3 most recent published posts
const query = `
// Query for the 3 most recent published posts
const query = `
SELECT
p.id,
p.title,
@@ -37,8 +39,9 @@ export const blogRouter = createTRPCRouter({
LIMIT 3;
`;
const results = await conn.execute(query);
return results.rows;
const results = await conn.execute(query);
return results.rows;
});
}),
getPosts: publicProcedure.query(async ({ ctx }) => {

View File

@@ -264,7 +264,7 @@ export const databaseRouter = createTRPCRouter({
try {
const conn = ConnectionFactory();
const fullURL = input.banner_photo
? env.NEXT_PUBLIC_AWS_BUCKET_STRING + input.banner_photo
? env.VITE_AWS_BUCKET_STRING + input.banner_photo
: null;
const query = `
@@ -346,7 +346,7 @@ export const databaseRouter = createTRPCRouter({
if (input.banner_photo === "_DELETE_IMAGE_") {
params.push(null);
} else {
params.push(env.NEXT_PUBLIC_AWS_BUCKET_STRING + input.banner_photo);
params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo);
}
first = false;
}
@@ -593,7 +593,7 @@ export const databaseRouter = createTRPCRouter({
try {
const conn = ConnectionFactory();
const fullURL = input.imageURL
? env.NEXT_PUBLIC_AWS_BUCKET_STRING + input.imageURL
? env.VITE_AWS_BUCKET_STRING + input.imageURL
: null;
const query = `UPDATE User SET image = ? WHERE id = ?`;
await conn.execute({

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../utils";
import { env } from "~/env/server";
import { withCache } from "~/server/cache";
// Types for commits
interface GitCommit {
@@ -22,34 +23,14 @@ export const gitActivityRouter = createTRPCRouter({
getGitHubCommits: publicProcedure
.input(z.object({ limit: z.number().default(3) }))
.query(async ({ input }) => {
try {
// Get user's repositories sorted by most recently pushed
const reposResponse = await fetch(
`https://api.github.com/users/MikeFreno/repos?sort=pushed&per_page=10`,
{
headers: {
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
Accept: "application/vnd.github.v3+json"
}
}
);
if (!reposResponse.ok) {
throw new Error(
`GitHub repos API error: ${reposResponse.statusText}`
);
}
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
return withCache(
`github-commits-${input.limit}`,
10 * 60 * 1000, // 10 minutes
async () => {
try {
const commitsResponse = await fetch(
`https://api.github.com/repos/${repo.full_name}/commits?per_page=5`,
// Get user's repositories sorted by most recently pushed
const reposResponse = await fetch(
`https://api.github.com/users/MikeFreno/repos?sort=pushed&per_page=10`,
{
headers: {
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
@@ -58,80 +39,90 @@ export const gitActivityRouter = createTRPCRouter({
}
);
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}`
});
if (!reposResponse.ok) {
throw new Error(
`GitHub repos API error: ${reposResponse.statusText}`
);
}
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"
}
}
);
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(
`Error fetching commits for ${repo.full_name}:`,
error
);
}
}
} catch (error) {
console.error(
`Error fetching commits for ${repo.full_name}:`,
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);
} catch (error) {
console.error("Error fetching GitHub commits:", error);
return [];
}
);
}),
// Get recent commits from Gitea
getGiteaCommits: publicProcedure
.input(z.object({ limit: z.number().default(3) }))
.query(async ({ input }) => {
try {
// First, get user's repositories
const reposResponse = await fetch(
`${env.GITEA_URL}/api/v1/users/Mike/repos?limit=100`,
{
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json"
}
}
);
if (!reposResponse.ok) {
throw new Error(`Gitea repos API error: ${reposResponse.statusText}`);
}
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
return withCache(
`gitea-commits-${input.limit}`,
10 * 60 * 1000, // 10 minutes
async () => {
try {
const commitsResponse = await fetch(
`${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`,
// First, get user's repositories
const reposResponse = await fetch(
`${env.GITEA_URL}/api/v1/users/Mike/repos?limit=100`,
{
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
@@ -140,51 +131,85 @@ export const gitActivityRouter = createTRPCRouter({
}
);
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}`
});
if (!reposResponse.ok) {
throw new Error(
`Gitea repos API error: ${reposResponse.statusText}`
);
}
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"
}
}
);
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(
`Error fetching commits for ${repo.name}:`,
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 commits for ${repo.name}:`, 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);
} catch (error) {
console.error("Error fetching Gitea commits:", error);
return [];
}
);
}),
// Get GitHub contribution activity (for heatmap)
getGitHubActivity: publicProcedure.query(async () => {
try {
// Use GitHub GraphQL API for contribution data
const query = `
return withCache("github-activity", 10 * 60 * 1000, async () => {
try {
// Use GitHub GraphQL API for contribution data
const query = `
query($userName: String!) {
user(login: $userName) {
contributionsCollection {
@@ -201,114 +226,117 @@ export const gitActivityRouter = createTRPCRouter({
}
`;
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
query,
variables: { userName: "MikeFreno" }
})
});
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
query,
variables: { userName: "MikeFreno" }
})
});
if (!response.ok) {
throw new Error(`GitHub GraphQL API error: ${response.statusText}`);
}
const data = await response.json();
if (data.errors) {
console.error("GitHub GraphQL errors:", data.errors);
throw new Error("GraphQL query failed");
}
// Extract contribution days from the response
const contributions: ContributionDay[] = [];
const weeks =
data.data?.user?.contributionsCollection?.contributionCalendar?.weeks ||
[];
for (const week of weeks) {
for (const day of week.contributionDays) {
contributions.push({
date: day.date,
count: day.contributionCount
});
if (!response.ok) {
throw new Error(`GitHub GraphQL API error: ${response.statusText}`);
}
}
return contributions;
} catch (error) {
console.error("Error fetching GitHub activity:", error);
return [];
}
const data = await response.json();
if (data.errors) {
console.error("GitHub GraphQL errors:", data.errors);
throw new Error("GraphQL query failed");
}
// Extract contribution days from the response
const contributions: ContributionDay[] = [];
const weeks =
data.data?.user?.contributionsCollection?.contributionCalendar
?.weeks || [];
for (const week of weeks) {
for (const day of week.contributionDays) {
contributions.push({
date: day.date,
count: day.contributionCount
});
}
}
return contributions;
} catch (error) {
console.error("Error fetching GitHub activity:", error);
return [];
}
});
}),
// Get Gitea contribution activity (for heatmap)
getGiteaActivity: publicProcedure.query(async () => {
try {
// Get user's repositories
const reposResponse = await fetch(
`${env.GITEA_URL}/api/v1/user/repos?limit=100`,
{
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json"
return withCache("gitea-activity", 10 * 60 * 1000, async () => {
try {
// Get user's repositories
const reposResponse = await fetch(
`${env.GITEA_URL}/api/v1/user/repos?limit=100`,
{
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json"
}
}
);
if (!reposResponse.ok) {
throw new Error(`Gitea repos API error: ${reposResponse.statusText}`);
}
);
if (!reposResponse.ok) {
throw new Error(`Gitea repos API error: ${reposResponse.statusText}`);
}
const repos = await reposResponse.json();
const contributionsByDay = new Map<string, number>();
const repos = await reposResponse.json();
const contributionsByDay = new Map<string, number>();
// Get commits from each repo (last 3 months to avoid too many API calls)
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
// Get commits from each repo (last 3 months to avoid too many API calls)
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
for (const repo of repos) {
try {
const commitsResponse = await fetch(
`${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100`,
{
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json"
}
}
);
for (const repo of repos) {
try {
const commitsResponse = await fetch(
`${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100`,
{
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
Accept: "application/json"
if (commitsResponse.ok) {
const commits = await commitsResponse.json();
for (const commit of commits) {
const date = new Date(commit.commit.author.date)
.toISOString()
.split("T")[0];
contributionsByDay.set(
date,
(contributionsByDay.get(date) || 0) + 1
);
}
}
);
if (commitsResponse.ok) {
const commits = await commitsResponse.json();
for (const commit of commits) {
const date = new Date(commit.commit.author.date)
.toISOString()
.split("T")[0];
contributionsByDay.set(
date,
(contributionsByDay.get(date) || 0) + 1
);
}
} catch (error) {
console.error(`Error fetching commits for ${repo.name}:`, error);
}
} catch (error) {
console.error(`Error fetching commits for ${repo.name}:`, error);
}
// Convert to array format
const contributions: ContributionDay[] = Array.from(
contributionsByDay.entries()
).map(([date, count]) => ({ date, count }));
return contributions;
} catch (error) {
console.error("Error fetching Gitea activity:", error);
return [];
}
// Convert to array format
const contributions: ContributionDay[] = Array.from(
contributionsByDay.entries()
).map(([date, count]) => ({ date, count }));
return contributions;
} catch (error) {
console.error("Error fetching Gitea activity:", error);
return [];
}
});
})
});

View File

@@ -1,14 +1,14 @@
import { createTRPCRouter, publicProcedure } from "../../utils";
import { z } from "zod";
import {
LineageConnectionFactory,
validateLineageRequest,
dumpAndSendDB,
dumpAndSendDB
} from "~/server/utils";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { OAuth2Client } from "google-auth-library";
import { jwtVerify } from "jose";
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
export const lineageDatabaseRouter = createTRPCRouter({
credentials: publicProcedure
@@ -16,7 +16,7 @@ export const lineageDatabaseRouter = createTRPCRouter({
z.object({
email: z.string().email(),
provider: z.enum(["email", "google", "apple"]),
authToken: z.string(),
authToken: z.string()
})
)
.mutation(async ({ input }) => {
@@ -32,17 +32,17 @@ export const lineageDatabaseRouter = createTRPCRouter({
valid_request = true;
}
} else if (provider === "google") {
const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE || env.NEXT_PUBLIC_GOOGLE_CLIENT_ID_MAGIC_DELVE;
const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
if (!CLIENT_ID) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Google client ID not configured",
message: "Google client ID not configured"
});
}
const client = new OAuth2Client(CLIENT_ID);
const ticket = await client.verifyIdToken({
idToken: authToken,
audience: CLIENT_ID,
audience: CLIENT_ID
});
if (ticket.getPayload()?.email === email) {
valid_request = true;
@@ -67,25 +67,25 @@ export const lineageDatabaseRouter = createTRPCRouter({
return {
success: true,
db_name: user.database_name as string,
db_token: user.database_token as string,
db_token: user.database_token as string
};
}
throw new TRPCError({
code: "NOT_FOUND",
message: "No user found",
message: "No user found"
});
} else {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials",
message: "Invalid credentials"
});
}
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication failed",
message: "Authentication failed"
});
}
}),
@@ -98,35 +98,42 @@ export const lineageDatabaseRouter = createTRPCRouter({
db_token: z.string(),
authToken: z.string(),
skip_cron: z.boolean().optional(),
send_dump_target: z.string().email().optional(),
send_dump_target: z.string().email().optional()
})
)
.mutation(async ({ input }) => {
const { email, db_name, db_token, authToken, skip_cron, send_dump_target } = input;
const {
email,
db_name,
db_token,
authToken,
skip_cron,
send_dump_target
} = input;
const conn = LineageConnectionFactory();
const res = await conn.execute({
sql: `SELECT * FROM User WHERE email = ?`,
args: [email],
args: [email]
});
const userRow = res.rows[0];
if (!userRow) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
message: "User not found"
});
}
const valid = await validateLineageRequest({
auth_token: authToken,
userRow,
userRow
});
if (!valid) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Verification",
message: "Invalid Verification"
});
}
@@ -135,7 +142,7 @@ export const lineageDatabaseRouter = createTRPCRouter({
if (database_token !== db_token || database_name !== db_name) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Incorrect Verification",
message: "Incorrect Verification"
});
}
@@ -144,7 +151,7 @@ export const lineageDatabaseRouter = createTRPCRouter({
const dumpRes = await dumpAndSendDB({
dbName: db_name,
dbToken: db_token,
sendTarget: send_dump_target,
sendTarget: send_dump_target
});
if (dumpRes.success) {
@@ -153,31 +160,31 @@ export const lineageDatabaseRouter = createTRPCRouter({
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`,
},
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
}
}
);
if (deleteRes.ok) {
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email],
args: [email]
});
return {
ok: true,
status: 200,
message: `Account and Database deleted, db dump sent to email: ${send_dump_target}`,
message: `Account and Database deleted, db dump sent to email: ${send_dump_target}`
};
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete database",
message: "Failed to delete database"
});
}
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: dumpRes.reason || "Failed to dump database",
message: dumpRes.reason || "Failed to dump database"
});
}
} else {
@@ -186,44 +193,44 @@ export const lineageDatabaseRouter = createTRPCRouter({
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`,
},
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
}
}
);
if (deleteRes.ok) {
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email],
args: [email]
});
return {
ok: true,
status: 200,
message: `Account and Database deleted`,
message: `Account and Database deleted`
};
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete database",
message: "Failed to delete database"
});
}
}
} else {
const insertRes = await conn.execute({
sql: `INSERT INTO cron (email, db_name, db_token, send_dump_target) VALUES (?, ?, ?, ?)`,
args: [email, db_name, db_token, send_dump_target],
args: [email, db_name, db_token, send_dump_target]
});
if (insertRes.rowsAffected > 0) {
return {
ok: true,
status: 200,
message: `Deletion scheduled.`,
message: `Deletion scheduled.`
};
} else {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Deletion not scheduled, due to server failure`,
message: `Deletion not scheduled, due to server failure`
});
}
}
@@ -238,7 +245,7 @@ export const lineageDatabaseRouter = createTRPCRouter({
try {
const res = await conn.execute({
sql: `SELECT * FROM cron WHERE email = ?`,
args: [email],
args: [email]
});
const cronRow = res.rows[0];
@@ -249,12 +256,12 @@ export const lineageDatabaseRouter = createTRPCRouter({
return {
ok: true,
status: 200,
created_at: cronRow.created_at as string,
created_at: cronRow.created_at as string
};
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to check deletion status",
message: "Failed to check deletion status"
});
}
}),
@@ -263,7 +270,7 @@ export const lineageDatabaseRouter = createTRPCRouter({
.input(
z.object({
email: z.string().email(),
authToken: z.string(),
authToken: z.string()
})
)
.mutation(async ({ input }) => {
@@ -273,13 +280,13 @@ export const lineageDatabaseRouter = createTRPCRouter({
const resUser = await conn.execute({
sql: `SELECT * FROM User WHERE email = ?;`,
args: [email],
args: [email]
});
if (resUser.rows.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found.",
message: "User not found."
});
}
@@ -287,31 +294,31 @@ export const lineageDatabaseRouter = createTRPCRouter({
const valid = await validateLineageRequest({
auth_token: authToken,
userRow,
userRow
});
if (!valid) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials for cancelation.",
message: "Invalid credentials for cancelation."
});
}
const result = await conn.execute({
sql: `DELETE FROM cron WHERE email = ?;`,
args: [email],
args: [email]
});
if (result.rowsAffected > 0) {
return {
status: 200,
ok: true,
message: "Cron job(s) canceled successfully.",
message: "Cron job(s) canceled successfully."
};
} else {
throw new TRPCError({
code: "NOT_FOUND",
message: "No cron job found for the given email.",
message: "No cron job found for the given email."
});
}
}),
@@ -332,7 +339,7 @@ export const lineageDatabaseRouter = createTRPCRouter({
const dumpRes = await dumpAndSendDB({
dbName: db_name as string,
dbToken: db_token as string,
sendTarget: send_dump_target as string,
sendTarget: send_dump_target as string
});
if (dumpRes.success) {
@@ -341,15 +348,15 @@ export const lineageDatabaseRouter = createTRPCRouter({
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`,
},
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
}
}
);
if (deleteRes.ok) {
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email],
args: [email]
});
executed_ids.push(id as number);
}
@@ -360,15 +367,15 @@ export const lineageDatabaseRouter = createTRPCRouter({
{
method: "DELETE",
headers: {
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`,
},
Authorization: `Bearer ${env.TURSO_DB_API_TOKEN}`
}
}
);
if (deleteRes.ok) {
await conn.execute({
sql: `DELETE FROM User WHERE email = ?`,
args: [email],
args: [email]
});
executed_ids.push(id as number);
}
@@ -383,11 +390,11 @@ export const lineageDatabaseRouter = createTRPCRouter({
return {
status: 200,
message:
"Processed databases deleted and corresponding cron rows removed.",
"Processed databases deleted and corresponding cron rows removed."
};
}
}
return { status: 200, ok: true };
}),
})
});