This commit is contained in:
Michael Freno
2025-12-23 10:23:43 -05:00
parent ee1de16c9e
commit 236555e41e
12 changed files with 20 additions and 210 deletions

View File

@@ -7,9 +7,6 @@ export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>
fullWidth?: boolean; fullWidth?: boolean;
} }
/**
* Reusable button component with variants and loading state
*/
export default function Button(props: ButtonProps) { export default function Button(props: ButtonProps) {
const [local, others] = splitProps(props, [ const [local, others] = splitProps(props, [
"variant", "variant",
@@ -18,13 +15,14 @@ export default function Button(props: ButtonProps) {
"fullWidth", "fullWidth",
"class", "class",
"children", "children",
"disabled", "disabled"
]); ]);
const variant = () => local.variant || "primary"; const variant = () => local.variant || "primary";
const size = () => local.size || "md"; const size = () => local.size || "md";
const baseClasses = "flex justify-center items-center rounded font-semibold transition-all duration-300 ease-out disabled:opacity-50 disabled:cursor-not-allowed"; const baseClasses =
"flex justify-center items-center rounded font-semibold transition-all duration-300 ease-out disabled:opacity-50 disabled:cursor-not-allowed";
const variantClasses = () => { const variantClasses = () => {
switch (variant()) { switch (variant()) {
@@ -64,7 +62,7 @@ export default function Button(props: ButtonProps) {
> >
<Show when={local.loading} fallback={local.children}> <Show when={local.loading} fallback={local.children}>
<svg <svg
class="animate-spin h-5 w-5 mr-2" class="mr-2 h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -6,12 +6,13 @@ export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
helperText?: string; helperText?: string;
} }
/**
* Reusable input component with label and error handling
* Styled to match Next.js migration source (underlined input style)
*/
export default function Input(props: InputProps) { export default function Input(props: InputProps) {
const [local, others] = splitProps(props, ["label", "error", "helperText", "class"]); const [local, others] = splitProps(props, [
"label",
"error",
"helperText",
"class"
]);
return ( return (
<div class="input-group"> <div class="input-group">
@@ -23,22 +24,18 @@ export default function Input(props: InputProps) {
aria-describedby={local.error ? `${others.id}-error` : undefined} aria-describedby={local.error ? `${others.id}-error` : undefined}
/> />
<span class="bar"></span> <span class="bar"></span>
{local.label && ( {local.label && <label class="underlinedInputLabel">{local.label}</label>}
<label class="underlinedInputLabel">{local.label}</label>
)}
{local.error && ( {local.error && (
<span <span
id={`${others.id}-error`} id={`${others.id}-error`}
class="text-xs text-red-500 mt-1 block" class="mt-1 block text-xs text-red-500"
role="alert" role="alert"
> >
{local.error} {local.error}
</span> </span>
)} )}
{local.helperText && !local.error && ( {local.helperText && !local.error && (
<span class="text-xs text-gray-500 mt-1 block"> <span class="mt-1 block text-xs text-gray-500">{local.helperText}</span>
{local.helperText}
</span>
)} )}
</div> </div>
); );

View File

@@ -48,7 +48,7 @@ export default function EditPost() {
subtitle: p.subtitle || "", subtitle: p.subtitle || "",
body: p.body || "", body: p.body || "",
banner_photo: p.banner_photo || "", banner_photo: p.banner_photo || "",
published: p.published || false, published: Boolean(p.published),
tags: tagValues, tags: tagValues,
attachments: p.attachments attachments: p.attachments
}; };

View File

@@ -16,7 +16,6 @@ import {
APIError APIError
} from "~/server/fetch-utils"; } from "~/server/fetch-utils";
// Helper to create JWT token
async function createJWT( async function createJWT(
userId: string, userId: string,
expiresIn: string = "14d" expiresIn: string = "14d"
@@ -29,7 +28,6 @@ async function createJWT(
return token; return token;
} }
// 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";
@@ -68,14 +66,12 @@ async function sendEmail(to: string, subject: string, htmlContent: string) {
} }
export const authRouter = createTRPCRouter({ export const authRouter = createTRPCRouter({
// GitHub callback route
githubCallback: publicProcedure githubCallback: publicProcedure
.input(z.object({ code: z.string() })) .input(z.object({ code: z.string() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { code } = input; const { code } = input;
try { try {
// Exchange code for access token with timeout
const tokenResponse = await fetchWithTimeout( const tokenResponse = await fetchWithTimeout(
"https://github.com/login/oauth/access_token", "https://github.com/login/oauth/access_token",
{ {
@@ -103,7 +99,6 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Fetch user info from GitHub with timeout
const userResponse = await fetchWithTimeout( const userResponse = await fetchWithTimeout(
"https://api.github.com/user", "https://api.github.com/user",
{ {
@@ -119,7 +114,6 @@ export const authRouter = createTRPCRouter({
const login = user.login; const login = user.login;
const icon = user.avatar_url; const icon = user.avatar_url;
// Fetch primary email from GitHub emails endpoint
const emailsResponse = await fetchWithTimeout( const emailsResponse = await fetchWithTimeout(
"https://api.github.com/user/emails", "https://api.github.com/user/emails",
{ {
@@ -133,7 +127,6 @@ export const authRouter = createTRPCRouter({
await checkResponse(emailsResponse); await checkResponse(emailsResponse);
const emails = await emailsResponse.json(); const emails = await emailsResponse.json();
// Find primary verified email
const primaryEmail = emails.find( const primaryEmail = emails.find(
(e: { primary: boolean; verified: boolean; email: string }) => (e: { primary: boolean; verified: boolean; email: string }) =>
e.primary && e.verified e.primary && e.verified
@@ -143,7 +136,6 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Check if user exists
const query = `SELECT * FROM User WHERE provider = ? AND display_name = ?`; const query = `SELECT * FROM User WHERE provider = ? AND display_name = ?`;
const params = ["github", login]; const params = ["github", login];
const res = await conn.execute({ sql: query, args: params }); const res = await conn.execute({ sql: query, args: params });
@@ -151,7 +143,6 @@ export const authRouter = createTRPCRouter({
let userId: string; let userId: string;
if (res.rows[0]) { if (res.rows[0]) {
// User exists - update email and image if changed
userId = (res.rows[0] as unknown as User).id; userId = (res.rows[0] as unknown as User).id;
try { try {
@@ -160,7 +151,6 @@ export const authRouter = createTRPCRouter({
args: [email, emailVerified ? 1 : 0, icon, userId] args: [email, emailVerified ? 1 : 0, icon, userId]
}); });
} catch (updateError: any) { } catch (updateError: any) {
// Handle unique constraint error on email
if ( if (
updateError.code === "SQLITE_CONSTRAINT" && updateError.code === "SQLITE_CONSTRAINT" &&
updateError.message?.includes("User.email") updateError.message?.includes("User.email")
@@ -174,7 +164,6 @@ export const authRouter = createTRPCRouter({
throw updateError; throw updateError;
} }
} else { } else {
// Create new user
userId = uuidV4(); userId = uuidV4();
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`; const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
@@ -190,7 +179,6 @@ export const authRouter = createTRPCRouter({
try { try {
await conn.execute({ sql: insertQuery, args: insertParams }); await conn.execute({ sql: insertQuery, args: insertParams });
} catch (insertError: any) { } catch (insertError: any) {
// Handle unique constraint error on email
if ( if (
insertError.code === "SQLITE_CONSTRAINT" && insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email") insertError.message?.includes("User.email")
@@ -205,10 +193,8 @@ export const authRouter = createTRPCRouter({
} }
} }
// Create JWT token
const token = await createJWT(userId); const token = await createJWT(userId);
// Set cookie
setCookie(ctx.event.nativeEvent, "userIDToken", token, { setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/", path: "/",
@@ -226,7 +212,6 @@ export const authRouter = createTRPCRouter({
throw error; throw error;
} }
// Provide specific error messages for different failure types
if (error instanceof TimeoutError) { if (error instanceof TimeoutError) {
console.error("GitHub API timeout:", error.message); console.error("GitHub API timeout:", error.message);
throw new TRPCError({ throw new TRPCError({
@@ -255,14 +240,12 @@ export const authRouter = createTRPCRouter({
} }
}), }),
// Google callback route
googleCallback: publicProcedure googleCallback: publicProcedure
.input(z.object({ code: z.string() })) .input(z.object({ code: z.string() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { code } = input; const { code } = input;
try { try {
// Exchange code for access token with timeout
const tokenResponse = await fetchWithTimeout( const tokenResponse = await fetchWithTimeout(
"https://oauth2.googleapis.com/token", "https://oauth2.googleapis.com/token",
{ {
@@ -291,7 +274,6 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Fetch user info from Google with timeout
const userResponse = await fetchWithTimeout( const userResponse = await fetchWithTimeout(
"https://www.googleapis.com/oauth2/v3/userinfo", "https://www.googleapis.com/oauth2/v3/userinfo",
{ {
@@ -311,7 +293,6 @@ export const authRouter = createTRPCRouter({
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Check if user exists
const query = `SELECT * FROM User WHERE provider = ? AND email = ?`; const query = `SELECT * FROM User WHERE provider = ? AND email = ?`;
const params = ["google", email]; const params = ["google", email];
const res = await conn.execute({ sql: query, args: params }); const res = await conn.execute({ sql: query, args: params });
@@ -319,16 +300,13 @@ export const authRouter = createTRPCRouter({
let userId: string; let userId: string;
if (res.rows[0]) { if (res.rows[0]) {
// User exists - update email, email_verified, display_name, and image if changed
userId = (res.rows[0] as unknown as User).id; userId = (res.rows[0] as unknown as User).id;
// No need to catch constraint error here since we're updating the same user's record
await conn.execute({ await conn.execute({
sql: `UPDATE User SET email = ?, email_verified = ?, display_name = ?, image = ? WHERE id = ?`, sql: `UPDATE User SET email = ?, email_verified = ?, display_name = ?, image = ? WHERE id = ?`,
args: [email, email_verified ? 1 : 0, name, image, userId] args: [email, email_verified ? 1 : 0, name, image, userId]
}); });
} else { } else {
// Create new user
userId = uuidV4(); userId = uuidV4();
const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`; const insertQuery = `INSERT INTO User (id, email, email_verified, display_name, provider, image) VALUES (?, ?, ?, ?, ?, ?)`;
@@ -347,7 +325,6 @@ export const authRouter = createTRPCRouter({
args: insertParams args: insertParams
}); });
} catch (insertError: any) { } catch (insertError: any) {
// Handle unique constraint error on email
if ( if (
insertError.code === "SQLITE_CONSTRAINT" && insertError.code === "SQLITE_CONSTRAINT" &&
insertError.message?.includes("User.email") insertError.message?.includes("User.email")
@@ -362,10 +339,8 @@ export const authRouter = createTRPCRouter({
} }
} }
// Create JWT token
const token = await createJWT(userId); const token = await createJWT(userId);
// Set cookie
setCookie(ctx.event.nativeEvent, "userIDToken", token, { setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/", path: "/",
@@ -383,7 +358,6 @@ export const authRouter = createTRPCRouter({
throw error; throw error;
} }
// Provide specific error messages for different failure types
if (error instanceof TimeoutError) { if (error instanceof TimeoutError) {
console.error("Google API timeout:", error.message); console.error("Google API timeout:", error.message);
throw new TRPCError({ throw new TRPCError({
@@ -412,7 +386,6 @@ export const authRouter = createTRPCRouter({
} }
}), }),
// Email login route
emailLogin: publicProcedure emailLogin: publicProcedure
.input( .input(
z.object({ z.object({
@@ -425,11 +398,9 @@ export const authRouter = createTRPCRouter({
const { email, token, rememberMe } = input; const { email, token, rememberMe } = input;
try { try {
// Verify JWT token
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret); const { payload } = await jwtVerify(token, secret);
// Check if email matches
if (payload.email !== email) { if (payload.email !== email) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -451,10 +422,8 @@ export const authRouter = createTRPCRouter({
const userId = (res.rows[0] as unknown as User).id; const userId = (res.rows[0] as unknown as User).id;
// Create JWT token
const userToken = await createJWT(userId); const userToken = await createJWT(userId);
// Set cookie based on rememberMe flag
const cookieOptions: any = { const cookieOptions: any = {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
@@ -490,7 +459,6 @@ export const authRouter = createTRPCRouter({
} }
}), }),
// Email verification route
emailVerification: publicProcedure emailVerification: publicProcedure
.input( .input(
z.object({ z.object({
@@ -502,11 +470,9 @@ export const authRouter = createTRPCRouter({
const { email, token } = input; const { email, token } = input;
try { try {
// Verify JWT token
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY); const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(token, secret); const { payload } = await jwtVerify(token, secret);
// Check if email matches
if (payload.email !== email) { if (payload.email !== email) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -535,7 +501,6 @@ export const authRouter = createTRPCRouter({
} }
}), }),
// Email/password registration
emailRegistration: publicProcedure emailRegistration: publicProcedure
.input( .input(
z.object({ z.object({
@@ -564,10 +529,8 @@ export const authRouter = createTRPCRouter({
args: [userId, email, passwordHash, "email"] args: [userId, email, passwordHash, "email"]
}); });
// Create JWT token
const token = await createJWT(userId); const token = await createJWT(userId);
// Set cookie
setCookie(ctx.event.nativeEvent, "userIDToken", token, { setCookie(ctx.event.nativeEvent, "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: 60 * 60 * 24 * 14, // 14 days
path: "/", path: "/",
@@ -586,7 +549,6 @@ export const authRouter = createTRPCRouter({
} }
}), }),
// Email/password login
emailPasswordLogin: publicProcedure emailPasswordLogin: publicProcedure
.input( .input(
z.object({ z.object({
@@ -640,11 +602,9 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Create JWT token with appropriate expiry
const expiresIn = rememberMe ? "14d" : "12h"; const expiresIn = rememberMe ? "14d" : "12h";
const token = await createJWT(user.id, expiresIn); const token = await createJWT(user.id, expiresIn);
// Set cookie
const cookieOptions: any = { const cookieOptions: any = {
path: "/", path: "/",
httpOnly: true, httpOnly: true,
@@ -661,7 +621,6 @@ export const authRouter = createTRPCRouter({
return { success: true, message: "success" }; return { success: true, message: "success" };
}), }),
// Request email login link
requestEmailLinkLogin: publicProcedure requestEmailLinkLogin: publicProcedure
.input( .input(
z.object({ z.object({
@@ -673,7 +632,6 @@ export const authRouter = createTRPCRouter({
const { email, rememberMe } = input; const { email, rememberMe } = input;
try { try {
// Check rate limiting
const requested = getCookie( const requested = getCookie(
ctx.event.nativeEvent, ctx.event.nativeEvent,
"emailLoginLinkRequested" "emailLoginLinkRequested"
@@ -702,7 +660,6 @@ export const authRouter = createTRPCRouter({
}); });
} }
// Create JWT token for email link (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({ const token = await new SignJWT({
email, email,
@@ -754,7 +711,6 @@ export const authRouter = createTRPCRouter({
await sendEmail(email, "freno.me login link", htmlContent); await sendEmail(email, "freno.me login link", htmlContent);
// 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,
@@ -772,7 +728,6 @@ export const authRouter = createTRPCRouter({
throw error; throw error;
} }
// Handle email sending failures gracefully
if ( if (
error instanceof TimeoutError || error instanceof TimeoutError ||
error instanceof NetworkError || error instanceof NetworkError ||
@@ -793,7 +748,6 @@ export const authRouter = createTRPCRouter({
} }
}), }),
// Request password reset
requestPasswordReset: publicProcedure requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() })) .input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -896,7 +850,6 @@ export const authRouter = createTRPCRouter({
throw error; throw error;
} }
// Handle email sending failures gracefully
if ( if (
error instanceof TimeoutError || error instanceof TimeoutError ||
error instanceof NetworkError || error instanceof NetworkError ||
@@ -1108,7 +1061,6 @@ export const authRouter = createTRPCRouter({
throw error; throw error;
} }
// Handle email sending failures gracefully
if ( if (
error instanceof TimeoutError || error instanceof TimeoutError ||
error instanceof NetworkError || error instanceof NetworkError ||

View File

@@ -12,10 +12,6 @@ import { cache, withCacheAndStale } from "~/server/cache";
const BLOG_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours const BLOG_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
export const databaseRouter = createTRPCRouter({ export const databaseRouter = createTRPCRouter({
// ============================================================
// Comment Reactions Routes
// ============================================================
getCommentReactions: publicProcedure getCommentReactions: publicProcedure
.input(z.object({ commentID: z.string() })) .input(z.object({ commentID: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
@@ -105,14 +101,9 @@ export const databaseRouter = createTRPCRouter({
} }
}), }),
// ============================================================
// Comments Routes
// ============================================================
getAllComments: publicProcedure.query(async () => { getAllComments: publicProcedure.query(async () => {
try { try {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Join with Post table to get post titles along with comments
const query = ` const query = `
SELECT c.*, p.title as post_title SELECT c.*, p.title as post_title
FROM Comment c FROM Comment c
@@ -148,7 +139,6 @@ export const databaseRouter = createTRPCRouter({
privilegeLevel: ctx.privilegeLevel privilegeLevel: ctx.privilegeLevel
}); });
// Get the comment to check ownership
const commentQuery = await conn.execute({ const commentQuery = await conn.execute({
sql: "SELECT * FROM Comment WHERE id = ?", sql: "SELECT * FROM Comment WHERE id = ?",
args: [input.commentID] args: [input.commentID]
@@ -162,7 +152,6 @@ export const databaseRouter = createTRPCRouter({
}); });
} }
// Authorization checks
const isOwner = comment.commenter_id === ctx.userId; const isOwner = comment.commenter_id === ctx.userId;
const isAdmin = ctx.privilegeLevel === "admin"; const isAdmin = ctx.privilegeLevel === "admin";

View File

@@ -19,17 +19,11 @@ 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 { getStale<T>(key: string): T | null {
const entry = this.cache.get(key); const entry = this.cache.get(key);
return entry ? (entry.data as T) : null; return entry ? (entry.data as T) : null;
} }
/**
* Check if cache entry exists (regardless of expiration)
*/
has(key: string): boolean { has(key: string): boolean {
return this.cache.has(key); return this.cache.has(key);
} }
@@ -49,9 +43,6 @@ class SimpleCache {
this.cache.delete(key); this.cache.delete(key);
} }
/**
* Delete all keys starting with a prefix
*/
deleteByPrefix(prefix: string): void { deleteByPrefix(prefix: string): void {
for (const key of this.cache.keys()) { for (const key of this.cache.keys()) {
if (key.startsWith(prefix)) { if (key.startsWith(prefix)) {
@@ -62,8 +53,6 @@ class SimpleCache {
} }
export const cache = new SimpleCache(); export const cache = new SimpleCache();
// Helper function to wrap async operations with caching
export async function withCache<T>( export async function withCache<T>(
key: string, key: string,
ttlMs: number, ttlMs: number,
@@ -80,7 +69,6 @@ export async function withCache<T>(
} }
/** /**
* Cache wrapper with stale-while-revalidate support
* Returns stale data if fetch fails, with optional stale time limit * Returns stale data if fetch fails, with optional stale time limit
*/ */
export async function withCacheAndStale<T>( export async function withCacheAndStale<T>(
@@ -88,19 +76,17 @@ export async function withCacheAndStale<T>(
ttlMs: number, ttlMs: number,
fn: () => Promise<T>, fn: () => Promise<T>,
options: { options: {
maxStaleMs?: number; // Maximum age of stale data to return (default: 7 days) maxStaleMs?: number;
logErrors?: boolean; // Whether to log errors (default: true) logErrors?: boolean;
} = {} } = {}
): Promise<T> { ): Promise<T> {
const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options; const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options;
// Try to get fresh cached data
const cached = cache.get<T>(key, ttlMs); const cached = cache.get<T>(key, ttlMs);
if (cached !== null) { if (cached !== null) {
return cached; return cached;
} }
// Try to fetch new data
try { try {
const result = await fn(); const result = await fn();
cache.set(key, result); cache.set(key, result);
@@ -110,10 +96,8 @@ export async function withCacheAndStale<T>(
console.error(`Error fetching data for cache key "${key}":`, error); console.error(`Error fetching data for cache key "${key}":`, error);
} }
// If fetch fails, try to serve stale data
const stale = cache.getStale<T>(key); const stale = cache.getStale<T>(key);
if (stale !== null) { if (stale !== null) {
// Check if stale data is within acceptable age
const entry = (cache as any).cache.get(key); const entry = (cache as any).cache.get(key);
const age = Date.now() - entry.timestamp; const age = Date.now() - entry.timestamp;
@@ -127,7 +111,6 @@ export async function withCacheAndStale<T>(
} }
} }
// No stale data available or too old, re-throw the error
throw error; throw error;
} }
} }

View File

@@ -1,17 +1,7 @@
/**
* Server-side conditional parser for blog content
* Evaluates conditional blocks and returns processed HTML
*/
/**
* Get safe environment variables for conditional evaluation
* Only exposes non-sensitive variables that are safe to use in content conditionals
*/
export function getSafeEnvVariables(): Record<string, string | undefined> { export function getSafeEnvVariables(): Record<string, string | undefined> {
return { return {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
VERCEL_ENV: process.env.VERCEL_ENV VERCEL_ENV: process.env.VERCEL_ENV
// Add other safe, non-sensitive env vars here as needed
// DO NOT expose API keys, secrets, database URLs, etc. // DO NOT expose API keys, secrets, database URLs, etc.
}; };
} }
@@ -33,12 +23,6 @@ interface ConditionalBlock {
content: string; content: string;
} }
/**
* Parse HTML and evaluate conditional blocks (both block and inline)
* @param html - Raw HTML from database
* @param context - Evaluation context (user, date, features)
* @returns Processed HTML with conditionals evaluated
*/
export function parseConditionals( export function parseConditionals(
html: string, html: string,
context: ConditionalContext context: ConditionalContext
@@ -47,40 +31,29 @@ export function parseConditionals(
let processedHtml = html; let processedHtml = html;
// First, process block-level conditionals (div elements)
processedHtml = processBlockConditionals(processedHtml, context); processedHtml = processBlockConditionals(processedHtml, context);
// Then, process inline conditionals (span elements)
processedHtml = processInlineConditionals(processedHtml, context); processedHtml = processInlineConditionals(processedHtml, context);
return processedHtml; return processedHtml;
} }
/**
* Process block-level conditional divs
*/
function processBlockConditionals( function processBlockConditionals(
html: string, html: string,
context: ConditionalContext context: ConditionalContext
): string { ): string {
// More flexible regex that handles attributes in any order
// Match div with class="conditional-block" and capture the full tag
const divRegex = const divRegex =
/<div\s+([^>]*class="[^"]*conditional-block[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi; /<div\s+([^>]*class="[^"]*conditional-block[^"]*"[^>]*)>([\s\S]*?)<\/div>/gi;
let processedHtml = html; let processedHtml = html;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
// Reset regex lastIndex
divRegex.lastIndex = 0; divRegex.lastIndex = 0;
// Collect all matches first to avoid regex state issues
const matches: ConditionalBlock[] = []; const matches: ConditionalBlock[] = [];
while ((match = divRegex.exec(html)) !== null) { while ((match = divRegex.exec(html)) !== null) {
const attributes = match[1]; const attributes = match[1];
const content = match[2]; const content = match[2];
// Extract individual attributes
const typeMatch = /data-condition-type="([^"]+)"/.exec(attributes); const typeMatch = /data-condition-type="([^"]+)"/.exec(attributes);
const valueMatch = /data-condition-value="([^"]+)"/.exec(attributes); const valueMatch = /data-condition-value="([^"]+)"/.exec(attributes);
const showWhenMatch = /data-show-when="(true|false)"/.exec(attributes); const showWhenMatch = /data-show-when="(true|false)"/.exec(attributes);
@@ -96,7 +69,6 @@ function processBlockConditionals(
} }
} }
// Process each conditional block
for (const block of matches) { for (const block of matches) {
const shouldShow = evaluateCondition( const shouldShow = evaluateCondition(
block.conditionType, block.conditionType,
@@ -106,8 +78,6 @@ function processBlockConditionals(
); );
if (shouldShow) { if (shouldShow) {
// Keep content, but remove conditional wrapper
// Extract content from inner <div class="conditional-content">
const innerContentRegex = const innerContentRegex =
/<div\s+class="conditional-content">([\s\S]*?)<\/div>/i; /<div\s+class="conditional-content">([\s\S]*?)<\/div>/i;
const innerMatch = block.fullMatch.match(innerContentRegex); const innerMatch = block.fullMatch.match(innerContentRegex);
@@ -115,7 +85,6 @@ function processBlockConditionals(
processedHtml = processedHtml.replace(block.fullMatch, innerContent); processedHtml = processedHtml.replace(block.fullMatch, innerContent);
} else { } else {
// Remove entire block
processedHtml = processedHtml.replace(block.fullMatch, ""); processedHtml = processedHtml.replace(block.fullMatch, "");
} }
} }
@@ -123,31 +92,23 @@ function processBlockConditionals(
return processedHtml; return processedHtml;
} }
/**
* Process inline conditional spans
*/
function processInlineConditionals( function processInlineConditionals(
html: string, html: string,
context: ConditionalContext context: ConditionalContext
): string { ): string {
// More flexible regex that handles attributes in any order
// Match span with class="conditional-inline" and capture the full tag
const spanRegex = const spanRegex =
/<span\s+([^>]*class="[^"]*conditional-inline[^"]*"[^>]*)>([\s\S]*?)<\/span>/gi; /<span\s+([^>]*class="[^"]*conditional-inline[^"]*"[^>]*)>([\s\S]*?)<\/span>/gi;
let processedHtml = html; let processedHtml = html;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
// Reset regex lastIndex
spanRegex.lastIndex = 0; spanRegex.lastIndex = 0;
// Collect all matches first
const matches: ConditionalBlock[] = []; const matches: ConditionalBlock[] = [];
while ((match = spanRegex.exec(html)) !== null) { while ((match = spanRegex.exec(html)) !== null) {
const attributes = match[1]; const attributes = match[1];
const content = match[2]; const content = match[2];
// Extract individual attributes
const typeMatch = /data-condition-type="([^"]+)"/.exec(attributes); const typeMatch = /data-condition-type="([^"]+)"/.exec(attributes);
const valueMatch = /data-condition-value="([^"]+)"/.exec(attributes); const valueMatch = /data-condition-value="([^"]+)"/.exec(attributes);
const showWhenMatch = /data-show-when="(true|false)"/.exec(attributes); const showWhenMatch = /data-show-when="(true|false)"/.exec(attributes);
@@ -163,7 +124,6 @@ function processInlineConditionals(
} }
} }
// Process each inline conditional
for (const inline of matches) { for (const inline of matches) {
const shouldShow = evaluateCondition( const shouldShow = evaluateCondition(
inline.conditionType, inline.conditionType,
@@ -173,10 +133,8 @@ function processInlineConditionals(
); );
if (shouldShow) { if (shouldShow) {
// Keep content, remove span wrapper
processedHtml = processedHtml.replace(inline.fullMatch, inline.content); processedHtml = processedHtml.replace(inline.fullMatch, inline.content);
} else { } else {
// Remove entire inline span
processedHtml = processedHtml.replace(inline.fullMatch, ""); processedHtml = processedHtml.replace(inline.fullMatch, "");
} }
} }
@@ -184,9 +142,6 @@ function processInlineConditionals(
return processedHtml; return processedHtml;
} }
/**
* Evaluate a single condition
*/
function evaluateCondition( function evaluateCondition(
conditionType: string, conditionType: string,
conditionValue: string, conditionValue: string,
@@ -212,18 +167,12 @@ function evaluateCondition(
conditionMet = evaluateEnvCondition(conditionValue, context); conditionMet = evaluateEnvCondition(conditionValue, context);
break; break;
default: default:
// Unknown condition type - default to hiding content for safety
conditionMet = false; conditionMet = false;
} }
// Apply showWhen logic: if showWhen is true, show when condition is met
// If showWhen is false, show when condition is NOT met
return showWhen ? conditionMet : !conditionMet; return showWhen ? conditionMet : !conditionMet;
} }
/**
* Evaluate authentication condition
*/
function evaluateAuthCondition( function evaluateAuthCondition(
value: string, value: string,
context: ConditionalContext context: ConditionalContext
@@ -238,9 +187,6 @@ function evaluateAuthCondition(
} }
} }
/**
* Evaluate privilege level condition
*/
function evaluatePrivilegeCondition( function evaluatePrivilegeCondition(
value: string, value: string,
context: ConditionalContext context: ConditionalContext
@@ -249,7 +195,6 @@ function evaluatePrivilegeCondition(
} }
/** /**
* Evaluate date-based condition
* Supports: "before:YYYY-MM-DD", "after:YYYY-MM-DD", "between:YYYY-MM-DD,YYYY-MM-DD" * Supports: "before:YYYY-MM-DD", "after:YYYY-MM-DD", "between:YYYY-MM-DD,YYYY-MM-DD"
*/ */
function evaluateDateCondition( function evaluateDateCondition(
@@ -287,9 +232,6 @@ function evaluateDateCondition(
} }
} }
/**
* Evaluate feature flag condition
*/
function evaluateFeatureCondition( function evaluateFeatureCondition(
value: string, value: string,
context: ConditionalContext context: ConditionalContext
@@ -298,7 +240,6 @@ function evaluateFeatureCondition(
} }
/** /**
* Evaluate environment variable condition
* Format: "ENV_VAR_NAME:expected_value" or "ENV_VAR_NAME:*" for any truthy value * Format: "ENV_VAR_NAME:expected_value" or "ENV_VAR_NAME:*" for any truthy value
*/ */
function evaluateEnvCondition( function evaluateEnvCondition(
@@ -306,7 +247,6 @@ function evaluateEnvCondition(
context: ConditionalContext context: ConditionalContext
): boolean { ): boolean {
try { try {
// Parse format: "VAR_NAME:expected_value"
const colonIndex = value.indexOf(":"); const colonIndex = value.indexOf(":");
if (colonIndex === -1) return false; if (colonIndex === -1) return false;
@@ -315,12 +255,10 @@ function evaluateEnvCondition(
const actualValue = context.env[varName]; const actualValue = context.env[varName];
// If expected value is "*", check if variable exists and is truthy
if (expectedValue === "*") { if (expectedValue === "*") {
return !!actualValue; return !!actualValue;
} }
// Otherwise, check for exact match
return actualValue === expectedValue; return actualValue === expectedValue;
} catch (error) { } catch (error) {
console.error("Error parsing env condition:", error); console.error("Error parsing env condition:", error);

View File

@@ -92,7 +92,6 @@ export async function dumpAndSendDB({
reason?: string; reason?: string;
}> { }> {
try { try {
// Fetch database dump with timeout
const res = await fetchWithTimeout( const res = await fetchWithTimeout(
`https://${dbName}-mikefreno.turso.io/dump`, `https://${dbName}-mikefreno.turso.io/dump`,
{ {
@@ -100,7 +99,7 @@ export async function dumpAndSendDB({
headers: { headers: {
Authorization: `Bearer ${dbToken}` Authorization: `Bearer ${dbToken}`
}, },
timeout: 30000 // 30s for database dump timeout: 30000
} }
); );
@@ -132,7 +131,6 @@ export async function dumpAndSendDB({
] ]
}; };
// Send email with retry logic
await fetchWithRetry( await fetchWithRetry(
async () => { async () => {
const sendRes = await fetchWithTimeout(apiUrl, { const sendRes = await fetchWithTimeout(apiUrl, {
@@ -143,7 +141,7 @@ export async function dumpAndSendDB({
"content-type": "application/json" "content-type": "application/json"
}, },
body: JSON.stringify(emailPayload), body: JSON.stringify(emailPayload),
timeout: 20000 // 20s for email with attachment timeout: 20000
}); });
await checkResponse(sendRes); await checkResponse(sendRes);
@@ -157,7 +155,6 @@ export async function dumpAndSendDB({
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
// Log specific error types for debugging
if (error instanceof TimeoutError) { if (error instanceof TimeoutError) {
console.error("Database dump timeout:", error.message); console.error("Database dump timeout:", error.message);
return { success: false, reason: "Database dump timed out" }; return { success: false, reason: "Database dump timed out" };

View File

@@ -1,8 +1,3 @@
/**
* Feature flag system for conditional content
* Centralized configuration for feature toggles
*/
export interface FeatureFlags { export interface FeatureFlags {
[key: string]: boolean; [key: string]: boolean;
} }

View File

@@ -1,4 +1,3 @@
// Error types for better error classification
export class NetworkError extends Error { export class NetworkError extends Error {
constructor( constructor(
message: string, message: string,
@@ -34,9 +33,6 @@ interface FetchWithTimeoutOptions extends RequestInit {
timeout?: number; timeout?: number;
} }
/**
* Fetch wrapper with timeout support and proper error classification
*/
export async function fetchWithTimeout( export async function fetchWithTimeout(
url: string, url: string,
options: FetchWithTimeoutOptions = {} options: FetchWithTimeoutOptions = {}
@@ -57,9 +53,7 @@ export async function fetchWithTimeout(
} catch (error: unknown) { } catch (error: unknown) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
// Classify the error for better handling
if (error instanceof Error) { if (error instanceof Error) {
// Check for abort/timeout
if (error.name === "AbortError") { if (error.name === "AbortError") {
throw new TimeoutError( throw new TimeoutError(
`Request to ${url} timed out after ${timeout}ms`, `Request to ${url} timed out after ${timeout}ms`,
@@ -67,7 +61,6 @@ export async function fetchWithTimeout(
); );
} }
// Check for connection errors (various runtime-specific errors)
if ( if (
error.message.includes("fetch failed") || error.message.includes("fetch failed") ||
error.message.includes("ECONNREFUSED") || error.message.includes("ECONNREFUSED") ||
@@ -84,14 +77,10 @@ export async function fetchWithTimeout(
} }
} }
// Re-throw unknown errors
throw error; throw error;
} }
} }
/**
* Helper to check response status and throw APIError if not ok
*/
export async function checkResponse(response: Response): Promise<Response> { export async function checkResponse(response: Response): Promise<Response> {
if (!response.ok) { if (!response.ok) {
throw new APIError( throw new APIError(
@@ -103,9 +92,6 @@ export async function checkResponse(response: Response): Promise<Response> {
return response; return response;
} }
/**
* Safe JSON parse that handles errors gracefully
*/
export async function safeJsonParse<T>(response: Response): Promise<T | null> { export async function safeJsonParse<T>(response: Response): Promise<T | null> {
try { try {
return await response.json(); return await response.json();
@@ -115,9 +101,6 @@ export async function safeJsonParse<T>(response: Response): Promise<T | null> {
} }
} }
/**
* Retry logic with exponential backoff
*/
export async function fetchWithRetry<T>( export async function fetchWithRetry<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
options: { options: {
@@ -141,12 +124,10 @@ export async function fetchWithRetry<T>(
} catch (error) { } catch (error) {
lastError = error; lastError = error;
// Don't retry if it's the last attempt or error is not retryable
if (attempt === maxRetries || !retryableErrors(error)) { if (attempt === maxRetries || !retryableErrors(error)) {
throw error; throw error;
} }
// Exponential backoff
const delay = retryDelay * Math.pow(2, attempt); const delay = retryDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }

View File

@@ -1,7 +1,6 @@
/** /**
* Comment System Type Definitions * Comment System Type Definitions
* *
* Types for the blog comment system including:
* - Comment and CommentReaction models * - Comment and CommentReaction models
* - WebSocket message types * - WebSocket message types
* - User data structures * - User data structures

View File

@@ -1,11 +1,7 @@
/**
* User type definitions matching database schema
*/
export interface User { export interface User {
id: string; id: string;
email: string | null; email: string | null;
email_verified: number; // SQLite boolean (0 or 1) email_verified: number;
password_hash: string | null; password_hash: string | null;
display_name: string | null; display_name: string | null;
provider: "email" | "google" | "github" | null; provider: "email" | "google" | "github" | null;
@@ -19,9 +15,6 @@ export interface User {
updated_at: string; updated_at: string;
} }
/**
* Client-safe user data (excludes sensitive fields)
*/
export interface UserProfile { export interface UserProfile {
id: string; id: string;
email?: string; email?: string;
@@ -32,9 +25,6 @@ export interface UserProfile {
hasPassword: boolean; hasPassword: boolean;
} }
/**
* Convert database User to client-safe UserProfile
*/
export function toUserProfile(user: User): UserProfile { export function toUserProfile(user: User): UserProfile {
return { return {
id: user.id, id: user.id,
@@ -47,24 +37,15 @@ export function toUserProfile(user: User): UserProfile {
}; };
} }
/**
* JWT payload for session tokens
*/
export interface SessionPayload { export interface SessionPayload {
id: string; // user ID id: string;
email?: string; email?: string;
} }
/**
* JWT payload for email verification
*/
export interface EmailVerificationPayload { export interface EmailVerificationPayload {
email: string; email: string;
} }
/**
* JWT payload for password reset
*/
export interface PasswordResetPayload { export interface PasswordResetPayload {
email: string; email: string;
} }