config centralized

This commit is contained in:
Michael Freno
2026-01-01 02:22:33 -05:00
parent a6417c650f
commit 8e77727148
24 changed files with 519 additions and 143 deletions

View File

@@ -153,7 +153,7 @@ function AppLayout(props: { children: any }) {
const handleCenterTapRelease = (e: MouseEvent | TouchEvent) => { const handleCenterTapRelease = (e: MouseEvent | TouchEvent) => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const currentIsMobile = window.innerWidth < 768; const currentIsMobile = isMobile(window.innerWidth);
// Only hide left bar on mobile when it's visible // Only hide left bar on mobile when it's visible
if (currentIsMobile && leftBarVisible()) { if (currentIsMobile && leftBarVisible()) {

View File

@@ -18,6 +18,7 @@ import { DarkModeToggle } from "./DarkModeToggle";
import { SkeletonBox, SkeletonText } from "./SkeletonLoader"; import { SkeletonBox, SkeletonText } from "./SkeletonLoader";
import { env } from "~/env/client"; import { env } from "~/env/client";
import { A, useNavigate, useLocation } from "@solidjs/router"; import { A, useNavigate, useLocation } from "@solidjs/router";
import { BREAKPOINTS } from "~/config";
function formatDomainName(url: string): string { function formatDomainName(url: string): string {
const domain = url.split("://")[1]?.split(":")[0] ?? url; const domain = url.split("://")[1]?.split(":")[0] ?? url;
@@ -67,7 +68,10 @@ export function RightBarContent() {
const [loading, setLoading] = createSignal(true); const [loading, setLoading] = createSignal(true);
const handleLinkClick = () => { const handleLinkClick = () => {
if (typeof window !== "undefined" && window.innerWidth < 768) { if (
typeof window !== "undefined" &&
window.innerWidth < BREAKPOINTS.MOBILE
) {
setLeftBarVisible(false); setLeftBarVisible(false);
} }
}; };
@@ -210,7 +214,10 @@ export function LeftBar() {
const [getLostVisible, setGetLostVisible] = createSignal(false); const [getLostVisible, setGetLostVisible] = createSignal(false);
const handleLinkClick = () => { const handleLinkClick = () => {
if (typeof window !== "undefined" && window.innerWidth < 768) { if (
typeof window !== "undefined" &&
window.innerWidth < BREAKPOINTS.MOBILE
) {
setLeftBarVisible(false); setLeftBarVisible(false);
} }
}; };
@@ -311,7 +318,7 @@ export function LeftBar() {
if (ref) { if (ref) {
// Focus trap for accessibility on mobile // Focus trap for accessibility on mobile
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const isMobile = window.innerWidth < 768; const isMobile = window.innerWidth < BREAKPOINTS.MOBILE;
if (!isMobile || !leftBarVisible()) return; if (!isMobile || !leftBarVisible()) return;

View File

@@ -1,4 +1,5 @@
import { createSignal, onMount, onCleanup, Show } from "solid-js"; import { createSignal, onMount, onCleanup, Show } from "solid-js";
import { BREAKPOINTS } from "~/config";
interface BtopProps { interface BtopProps {
onClose: () => void; onClose: () => void;
@@ -39,10 +40,10 @@ export function Btop(props: BtopProps) {
onMount(() => { onMount(() => {
// Check if mobile // Check if mobile
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE);
const handleResize = () => { const handleResize = () => {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < BREAKPOINTS.MOBILE);
}; };
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
onCleanup(() => window.removeEventListener("resize", handleResize)); onCleanup(() => window.removeEventListener("resize", handleResize));

View File

@@ -164,14 +164,6 @@ export function TerminalErrorPage(props: TerminalErrorPageProps) {
{/* Main content */} {/* Main content */}
<div class="relative z-10 flex min-h-screen flex-col items-start justify-start px-4 py-16 lg:px-16"> <div class="relative z-10 flex min-h-screen flex-col items-start justify-start px-4 py-16 lg:px-16">
{/* Terminal header */} {/* Terminal header */}
<div class="mb-8 w-full max-w-4xl">
<div class="border-surface0 text-subtext0 flex items-center gap-2 border-b pb-2 font-mono text-sm">
<span class="text-green">freno@terminal</span>
<span class="text-subtext1">:</span>
<span class="text-blue">~</span>
<span class="text-subtext1">$</span>
</div>
</div>
{/* Error Content - passed as prop */} {/* Error Content - passed as prop */}
{props.errorContent} {props.errorContent}

View File

@@ -54,6 +54,7 @@ import ruby from "highlight.js/lib/languages/ruby";
import swift from "highlight.js/lib/languages/swift"; import swift from "highlight.js/lib/languages/swift";
import kotlin from "highlight.js/lib/languages/kotlin"; import kotlin from "highlight.js/lib/languages/kotlin";
import dockerfile from "highlight.js/lib/languages/dockerfile"; import dockerfile from "highlight.js/lib/languages/dockerfile";
import { BREAKPOINTS } from "~/config";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
@@ -1648,7 +1649,7 @@ export default function TextEditor(props: TextEditorProps) {
!hasSuggestion() || !hasSuggestion() ||
!isFullscreen() || !isFullscreen() ||
typeof window === "undefined" || typeof window === "undefined" ||
window.innerWidth >= 768 window.innerWidth >= BREAKPOINTS.MOBILE
) { ) {
return false; return false;
} }
@@ -1663,7 +1664,7 @@ export default function TextEditor(props: TextEditorProps) {
!hasSuggestion() || !hasSuggestion() ||
!isFullscreen() || !isFullscreen() ||
typeof window === "undefined" || typeof window === "undefined" ||
window.innerWidth >= 768 window.innerWidth >= BREAKPOINTS.MOBILE
) { ) {
return false; return false;
} }
@@ -1737,7 +1738,7 @@ export default function TextEditor(props: TextEditorProps) {
if (infillConfig() && !isInitialLoad && infillEnabled()) { if (infillConfig() && !isInitialLoad && infillEnabled()) {
const isMobileNotFullscreen = const isMobileNotFullscreen =
typeof window !== "undefined" && typeof window !== "undefined" &&
window.innerWidth < 768 && window.innerWidth < BREAKPOINTS.MOBILE &&
!isFullscreen(); !isFullscreen();
// Skip auto-infill on mobile when not in fullscreen // Skip auto-infill on mobile when not in fullscreen
@@ -4108,7 +4109,7 @@ export default function TextEditor(props: TextEditorProps) {
title={ title={
infillEnabled() infillEnabled()
? typeof window !== "undefined" && ? typeof window !== "undefined" &&
window.innerWidth < 768 window.innerWidth < BREAKPOINTS.MOBILE
? "AI Autocomplete: ON (swipe right to accept full)" ? "AI Autocomplete: ON (swipe right to accept full)"
: "AI Autocomplete: ON (Ctrl/Cmd+Space to trigger manually)" : "AI Autocomplete: ON (Ctrl/Cmd+Space to trigger manually)"
: "AI Autocomplete: OFF (Click to enable)" : "AI Autocomplete: OFF (Click to enable)"

324
src/config.ts Normal file
View File

@@ -0,0 +1,324 @@
/**
* Application Configuration
* Central location for all configurable values including timeouts, limits, durations, etc.
*/
// ============================================================
// AUTHENTICATION & SESSION
// ============================================================
export const AUTH_CONFIG = {
/** JWT token expiration for regular sessions (with remember me) */
JWT_EXPIRY: "14d" as const,
/** JWT token expiration for sessions without remember me */
JWT_EXPIRY_SHORT: "12h" as const,
/** Session cookie max age in seconds (14 days) */
SESSION_COOKIE_MAX_AGE: 60 * 60 * 24 * 14, // 14 days
/** Remember me cookie max age in seconds */
REMEMBER_ME_MAX_AGE: 60 * 60 * 24 * 14, // 14 days
/** CSRF token cookie max age in seconds (14 days) */
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14, // 14 days
/** Lineage JWT expiration for mobile game */
LINEAGE_JWT_EXPIRY: "14d" as const
} as const;
// ============================================================
// RATE LIMITING
// ============================================================
export const RATE_LIMITS = {
/** Login: 5 attempts per 15 minutes per IP */
LOGIN_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 },
/** Login: 3 attempts per hour per email */
LOGIN_EMAIL: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
/** Password reset: 3 attempts per hour per IP */
PASSWORD_RESET_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
/** Registration: 3 attempts per hour per IP */
REGISTRATION_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
/** Email verification: 5 attempts per 15 minutes per IP */
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
} as const;
/** Rate limit store cleanup interval (5 minutes) */
export const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
// ============================================================
// ACCOUNT SECURITY
// ============================================================
export const ACCOUNT_LOCKOUT = {
/** Maximum failed login attempts before account lockout */
MAX_FAILED_ATTEMPTS: 5,
/** Account lockout duration in milliseconds (5 minutes) */
LOCKOUT_DURATION_MS: 5 * 60 * 1000
} as const;
export const PASSWORD_RESET_CONFIG = {
/** Password reset token expiry (1 hour) */
TOKEN_EXPIRY_MS: 60 * 60 * 1000
} as const;
// ============================================================
// COOLDOWN TIMERS (CLIENT-SIDE COOKIES)
// ============================================================
export const COOLDOWN_TIMERS = {
/** Email login link cooldown (2 minutes) */
EMAIL_LOGIN_LINK_MS: 2 * 60 * 1000,
/** Email login link cookie max age in seconds */
EMAIL_LOGIN_LINK_COOKIE_MAX_AGE: 2 * 60,
/** Password reset request cooldown (5 minutes) */
PASSWORD_RESET_REQUEST_MS: 5 * 60 * 1000,
/** Password reset request cookie max age in seconds */
PASSWORD_RESET_REQUEST_COOKIE_MAX_AGE: 5 * 60,
/** Contact form request cooldown (1 minute) */
CONTACT_REQUEST_MS: 1 * 60 * 1000,
/** Contact form request cookie max age in seconds */
CONTACT_REQUEST_COOKIE_MAX_AGE: 1 * 60,
/** Email verification cooldown (15 minutes) */
EMAIL_VERIFICATION_MS: 15 * 60 * 1000,
/** Email verification cookie max age in seconds */
EMAIL_VERIFICATION_COOKIE_MAX_AGE: 15 * 60
} as const;
// ============================================================
// CACHE & DATA PERSISTENCE
// ============================================================
export const CACHE_CONFIG = {
/** Blog cache TTL (24 hours) */
BLOG_CACHE_TTL_MS: 24 * 60 * 60 * 1000,
/** Git activity cache TTL (10 minutes) */
GIT_ACTIVITY_CACHE_TTL_MS: 10 * 60 * 1000,
/** Blog posts list cache TTL (5 minutes) */
BLOG_POSTS_LIST_CACHE_TTL_MS: 5 * 60 * 1000,
/** Maximum stale data age (7 days) */
MAX_STALE_DATA_MS: 7 * 24 * 60 * 60 * 1000,
/** Git activity max stale age (24 hours) */
GIT_ACTIVITY_MAX_STALE_MS: 24 * 60 * 60 * 1000
} as const;
// ============================================================
// NETWORK & API
// ============================================================
export const NETWORK_CONFIG = {
/** Default API timeout for email service (15 seconds) */
EMAIL_API_TIMEOUT_MS: 15000,
/** Default API timeout for GitHub OAuth (15 seconds) */
GITHUB_API_TIMEOUT_MS: 15000,
/** Default API timeout for Google OAuth (15 seconds) */
GOOGLE_API_TIMEOUT_MS: 15000,
/** Maximum retry attempts for failed requests */
MAX_RETRIES: 2,
/** Retry delay between attempts (1 second) */
RETRY_DELAY_MS: 1000
} as const;
// ============================================================
// UI/UX - TYPEWRITER COMPONENT
// ============================================================
export const TYPEWRITER_CONFIG = {
/** Default typing speed (characters per second) */
DEFAULT_SPEED: 30,
/** Fast typing speed */
FAST_SPEED: 80,
/** Slow typing speed */
SLOW_SPEED: 10,
/** Very slow typing speed */
VERY_SLOW_SPEED: 100,
/** Extra slow typing speed */
EXTRA_SLOW_SPEED: 120,
/** Default keep alive duration (ms) */
DEFAULT_KEEP_ALIVE_MS: 2000,
/** Long keep alive duration (ms) */
LONG_KEEP_ALIVE_MS: 10000,
/** Default initial delay (ms) */
DEFAULT_DELAY_MS: 500,
/** Cursor fade delay after completion (1 second) */
CURSOR_FADE_DELAY_MS: 1000
} as const;
// ============================================================
// UI/UX - COUNTDOWN TIMER COMPONENT
// ============================================================
export const COUNTDOWN_CONFIG = {
/** Email login link countdown duration (2 minutes) */
EMAIL_LOGIN_LINK_DURATION_S: 120,
/** Password reset countdown duration (5 minutes) */
PASSWORD_RESET_DURATION_S: 300,
/** Contact form countdown duration (1 minute) */
CONTACT_FORM_DURATION_S: 60,
/** Password reset success redirect countdown (5 seconds) */
PASSWORD_RESET_SUCCESS_DURATION_S: 5,
/** Default timer size (pixels) */
DEFAULT_TIMER_SIZE_PX: 48,
/** Large timer size (pixels) */
LARGE_TIMER_SIZE_PX: 200,
/** Default stroke width */
DEFAULT_STROKE_WIDTH: 6,
/** Large stroke width */
LARGE_STROKE_WIDTH: 12
} as const;
// ============================================================
// UI/UX - RESPONSIVE BREAKPOINTS
// ============================================================
export const BREAKPOINTS = {
/** Mobile breakpoint (pixels) */
MOBILE_MAX_WIDTH: 768,
/** Tablet breakpoint (pixels) */
TABLET_MAX_WIDTH: 1024,
/** Desktop minimum width (pixels) */
DESKTOP_MIN_WIDTH: 1025
} as const;
// ============================================================
// UI/UX - ANIMATIONS & TRANSITIONS
// ============================================================
export const ANIMATION_CONFIG = {
/** Standard transition duration (ms) */
TRANSITION_DURATION_MS: 300,
/** Fast transition duration (ms) */
FAST_TRANSITION_MS: 200,
/** Slow transition duration (ms) */
SLOW_TRANSITION_MS: 500,
/** Extra slow transition duration (ms) */
EXTRA_SLOW_TRANSITION_MS: 600,
/** Sidebar toggle duration (ms) */
SIDEBAR_DURATION_MS: 500,
/** Menu typing effect delay (ms) */
MENU_TYPING_DELAY_MS: 140,
/** Menu initial delay (ms) */
MENU_INITIAL_DELAY_MS: 500,
/** Success message auto-hide duration (ms) */
SUCCESS_MESSAGE_DURATION_MS: 3000,
/** Error message auto-hide duration (ms) */
ERROR_MESSAGE_DURATION_MS: 5000,
/** Redirect delay after successful action (ms) */
REDIRECT_DELAY_MS: 500
} as const;
// ============================================================
// UI/UX - PDF VIEWER
// ============================================================
export const PDF_CONFIG = {
/** PDF rendering scale */
RENDER_SCALE: 1.5
} as const;
// ============================================================
// UI/UX - 401 ERROR PAGE
// ============================================================
export const ERROR_PAGE_CONFIG = {
/** Glitch effect interval (ms) */
GLITCH_INTERVAL_MS: 300,
/** Glitch effect duration (ms) */
GLITCH_DURATION_MS: 100,
/** Number of particles for background animation */
PARTICLE_COUNT: 45
} as const;
// ============================================================
// VALIDATION
// ============================================================
export const VALIDATION_CONFIG = {
/** Minimum password length (must match securePasswordSchema in schemas/user.ts) */
MIN_PASSWORD_LENGTH: 12,
/** Maximum message length for contact form */
MAX_CONTACT_MESSAGE_LENGTH: 500,
/** Minimum password confirmation match length before showing error */
MIN_PASSWORD_CONF_LENGTH_FOR_ERROR: 6
} as const;
// ============================================================
// LINEAGE GAME (MOBILE APP)
// ============================================================
export const LINEAGE_CONFIG = {
/** Database deletion grace period (24 hours) */
DELETION_GRACE_PERIOD_MS: 24 * 60 * 60 * 1000,
/** PvP opponents returned per query */
PVP_OPPONENTS_COUNT: 3
} as const;
// ============================================================
// AUDIT & LOGGING
// ============================================================
export const AUDIT_CONFIG = {
/** Default query limit for audit logs */
DEFAULT_QUERY_LIMIT: 100,
/** Maximum audit log retention (90 days) */
MAX_RETENTION_DAYS: 90
} as const;
// ============================================================
// HELPER FUNCTIONS
// ============================================================
/**
* Convert milliseconds to seconds
*/
export function msToSeconds(ms: number): number {
return Math.floor(ms / 1000);
}
/**
* Convert seconds to milliseconds
*/
export function secondsToMs(seconds: number): number {
return seconds * 1000;
}
/**
* Convert minutes to milliseconds
*/
export function minutesToMs(minutes: number): number {
return minutes * 60 * 1000;
}
/**
* Convert hours to milliseconds
*/
export function hoursToMs(hours: number): number {
return hours * 60 * 60 * 1000;
}
/**
* Convert days to milliseconds
*/
export function daysToMs(days: number): number {
return days * 24 * 60 * 60 * 1000;
}
/**
* Check if screen width is mobile
*/
export function isMobileWidth(width: number): boolean {
return width < BREAKPOINTS.MOBILE_MAX_WIDTH;
}
/**
* Check if screen width is tablet
*/
export function isTabletWidth(width: number): boolean {
return (
width >= BREAKPOINTS.MOBILE_MAX_WIDTH &&
width <= BREAKPOINTS.TABLET_MAX_WIDTH
);
}
/**
* Check if screen width is desktop
*/
export function isDesktopWidth(width: number): boolean {
return width >= BREAKPOINTS.DESKTOP_MIN_WIDTH;
}

View File

@@ -2,6 +2,8 @@
* Form validation utilities * Form validation utilities
*/ */
import { VALIDATION_CONFIG } from "~/config";
/** /**
* Validate email format * Validate email format
*/ */
@@ -36,9 +38,11 @@ export function validatePassword(password: string): {
} { } {
const errors: string[] = []; const errors: string[] = [];
// Minimum length: 12 characters // Minimum length from config
if (password.length < 12) { if (password.length < VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
errors.push("Password must be at least 12 characters long"); errors.push(
`Password must be at least ${VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} characters long`
);
} }
// Require uppercase letter // Require uppercase letter
@@ -93,7 +97,7 @@ export function validatePassword(password: string): {
strength = "strong"; strength = "strong";
} else if (password.length >= 16) { } else if (password.length >= 16) {
strength = "good"; strength = "good";
} else if (password.length >= 12) { } else if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
strength = "fair"; strength = "fair";
} }
} }

View File

@@ -2,6 +2,7 @@ import { Title, Meta } from "@solidjs/meta";
import { HttpStatusCode } from "@solidjs/start"; import { HttpStatusCode } from "@solidjs/start";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { createEffect, createSignal, For } from "solid-js"; import { createEffect, createSignal, For } from "solid-js";
import { ERROR_PAGE_CONFIG } from "~/config";
export default function Page_401() { export default function Page_401() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -24,15 +25,18 @@ export default function Page_401() {
} }
setGlitchText(glitched); setGlitchText(glitched);
setTimeout(() => setGlitchText(originalText), 100); setTimeout(
() => setGlitchText(originalText),
ERROR_PAGE_CONFIG.GLITCH_DURATION_MS
);
} }
}, 300); }, ERROR_PAGE_CONFIG.GLITCH_INTERVAL_MS);
return () => clearInterval(glitchInterval); return () => clearInterval(glitchInterval);
}); });
const createParticles = () => { const createParticles = () => {
return Array.from({ length: 45 }, (_, i) => ({ return Array.from({ length: ERROR_PAGE_CONFIG.PARTICLE_COUNT }, (_, i) => ({
id: i, id: i,
left: `${Math.random() * 100}%`, left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`, top: `${Math.random() * 100}%`,

View File

@@ -12,6 +12,7 @@ import Dropzone from "~/components/blog/Dropzone";
import AddImageToS3 from "~/lib/s3upload"; import AddImageToS3 from "~/lib/s3upload";
import { validatePassword, isValidEmail } from "~/lib/validation"; import { validatePassword, isValidEmail } from "~/lib/validation";
import { TerminalSplash } from "~/components/TerminalSplash"; import { TerminalSplash } from "~/components/TerminalSplash";
import { VALIDATION_CONFIG } from "~/config";
import type { UserProfile } from "~/types/user"; import type { UserProfile } from "~/types/user";
@@ -806,7 +807,7 @@ export default function AccountPage() {
ref={oldPasswordRef} ref={oldPasswordRef}
type={showOldPasswordInput() ? "text" : "password"} type={showOldPasswordInput() ? "text" : "password"}
required required
minlength="8" minlength={VALIDATION_CONFIG.MIN_PASSWORD_LENGTH}
disabled={passwordChangeLoading()} disabled={passwordChangeLoading()}
placeholder=" " placeholder=" "
title="Password must be at least 8 characters" title="Password must be at least 8 characters"
@@ -997,7 +998,7 @@ export default function AccountPage() {
ref={deleteAccountPasswordRef} ref={deleteAccountPasswordRef}
type="password" type="password"
required required
minlength="8" minlength={VALIDATION_CONFIG.MIN_PASSWORD_LENGTH}
disabled={deleteAccountButtonLoading()} disabled={deleteAccountButtonLoading()}
placeholder=" " placeholder=" "
title="Enter your password to confirm account deletion" title="Enter your password to confirm account deletion"

View File

@@ -8,6 +8,7 @@ import TagSelector from "~/components/blog/TagSelector";
import PostSorting from "~/components/blog/PostSorting"; import PostSorting from "~/components/blog/PostSorting";
import PublishStatusToggle from "~/components/blog/PublishStatusToggle"; import PublishStatusToggle from "~/components/blog/PublishStatusToggle";
import { TerminalSplash } from "~/components/TerminalSplash"; import { TerminalSplash } from "~/components/TerminalSplash";
import { CACHE_CONFIG } from "~/config";
const getPosts = query(async () => { const getPosts = query(async () => {
"use server"; "use server";
@@ -17,7 +18,10 @@ const getPosts = query(async () => {
const event = getRequestEvent()!; const event = getRequestEvent()!;
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent); const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
return withCache(`posts-${privilegeLevel}`, 5 * 60 * 1000, async () => { return withCache(
`posts-${privilegeLevel}`,
CACHE_CONFIG.BLOG_POSTS_LIST_CACHE_TTL_MS,
async () => {
const conn = ConnectionFactory(); const conn = ConnectionFactory();
// Fetch all posts with aggregated data // Fetch all posts with aggregated data
@@ -69,7 +73,8 @@ const getPosts = query(async () => {
}); });
return { posts, tags, tagMap, privilegeLevel }; return { posts, tags, tagMap, privilegeLevel };
}); }
);
}, "posts"); }, "posts");
export default function BlogIndex() { export default function BlogIndex() {

View File

@@ -26,6 +26,12 @@ import {
TimeoutError, TimeoutError,
APIError APIError
} from "~/server/fetch-utils"; } from "~/server/fetch-utils";
import {
NETWORK_CONFIG,
COOLDOWN_TIMERS,
VALIDATION_CONFIG,
COUNTDOWN_CONFIG
} from "~/config";
const getContactData = query(async () => { const getContactData = query(async () => {
"use server"; "use server";
@@ -52,7 +58,7 @@ const sendContactEmail = action(async (formData: FormData) => {
message: z message: z
.string() .string()
.min(1, "Message is required") .min(1, "Message is required")
.max(500, "Message too long") .max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH, "Message too long")
}); });
try { try {
@@ -99,20 +105,20 @@ const sendContactEmail = action(async (formData: FormData) => {
"content-type": "application/json" "content-type": "application/json"
}, },
body: JSON.stringify(sendinblueData), body: JSON.stringify(sendinblueData),
timeout: 15000 timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
}); });
await checkResponse(response); await checkResponse(response);
return response; return response;
}, },
{ {
maxRetries: 2, maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: 1000 retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
} }
); );
// Set cooldown cookie // Set cooldown cookie
const exp = new Date(Date.now() + 1 * 60 * 1000); const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
setCookie("contactRequestSent", exp.toUTCString(), { setCookie("contactRequestSent", exp.toUTCString(), {
expires: exp, expires: exp,
path: "/" path: "/"
@@ -418,7 +424,7 @@ export default function ContactPage() {
title="Please enter your message" title="Please enter your message"
class="underlinedInput w-full bg-transparent" class="underlinedInput w-full bg-transparent"
rows={4} rows={4}
maxlength={500} maxlength={VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH}
/> />
<span class="bar" /> <span class="bar" />
<label class="underlinedInputLabel">Message</label> <label class="underlinedInputLabel">Message</label>
@@ -456,7 +462,7 @@ export default function ContactPage() {
} }
> >
<CountdownCircleTimer <CountdownCircleTimer
duration={60} duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
initialRemainingTime={countDown()} initialRemainingTime={countDown()}
size={48} size={48}
strokeWidth={6} strokeWidth={6}

View File

@@ -16,6 +16,7 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import { isValidEmail, validatePassword } from "~/lib/validation"; import { isValidEmail, validatePassword } from "~/lib/validation";
import { getClientCookie } from "~/lib/cookies.client"; import { getClientCookie } from "~/lib/cookies.client";
import { env } from "~/env/client"; import { env } from "~/env/client";
import { VALIDATION_CONFIG, COUNTDOWN_CONFIG } from "~/config";
const checkAuth = query(async () => { const checkAuth = query(async () => {
"use server"; "use server";
@@ -325,7 +326,7 @@ export default function LoginPage() {
}; };
const checkPasswordLength = (password: string) => { const checkPasswordLength = (password: string) => {
if (password.length >= 8) { if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
setPasswordLengthSufficient(true); setPasswordLengthSufficient(true);
setShowPasswordLengthWarning(false); setShowPasswordLengthWarning(false);
} else { } else {
@@ -628,7 +629,7 @@ export default function LoginPage() {
} }
> >
<CountdownCircleTimer <CountdownCircleTimer
duration={120} duration={COUNTDOWN_CONFIG.EMAIL_LOGIN_LINK_DURATION_S}
initialRemainingTime={countDown()} initialRemainingTime={countDown()}
size={48} size={48}
strokeWidth={6} strokeWidth={6}

View File

@@ -6,6 +6,7 @@ import Eye from "~/components/icons/Eye";
import EyeSlash from "~/components/icons/EyeSlash"; import EyeSlash from "~/components/icons/EyeSlash";
import { validatePassword } from "~/lib/validation"; import { validatePassword } from "~/lib/validation";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { VALIDATION_CONFIG, COUNTDOWN_CONFIG } from "~/config";
export default function PasswordResetPage() { export default function PasswordResetPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -98,7 +99,7 @@ export default function PasswordResetPage() {
}; };
const checkPasswordLength = (password: string) => { const checkPasswordLength = (password: string) => {
if (password.length >= 8) { if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
setPasswordLengthSufficient(true); setPasswordLengthSufficient(true);
setShowPasswordLengthWarning(false); setShowPasswordLengthWarning(false);
} else { } else {
@@ -223,7 +224,8 @@ export default function PasswordResetPage() {
showPasswordLengthWarning() ? "" : "opacity-0 select-none" showPasswordLengthWarning() ? "" : "opacity-0 select-none"
} text-red text-center transition-opacity duration-200 ease-in-out`} } text-red text-center transition-opacity duration-200 ease-in-out`}
> >
Password too short! Min Length: 8 Password too short! Min Length:{" "}
{VALIDATION_CONFIG.MIN_PASSWORD_LENGTH}
</div> </div>
{/* Password Confirmation Input */} {/* Password Confirmation Input */}
@@ -279,7 +281,8 @@ export default function PasswordResetPage() {
!passwordsMatch() && !passwordsMatch() &&
passwordLengthSufficient() && passwordLengthSufficient() &&
newPasswordConfRef && newPasswordConfRef &&
newPasswordConfRef.value.length >= 6 newPasswordConfRef.value.length >=
VALIDATION_CONFIG.MIN_PASSWORD_CONF_LENGTH_FOR_ERROR
? "" ? ""
: "opacity-0 select-none" : "opacity-0 select-none"
} text-red text-center transition-opacity duration-200 ease-in-out`} } text-red text-center transition-opacity duration-200 ease-in-out`}
@@ -307,7 +310,7 @@ export default function PasswordResetPage() {
<div class="mx-auto pt-4"> <div class="mx-auto pt-4">
<CountdownCircleTimer <CountdownCircleTimer
isPlaying={countDown()} isPlaying={countDown()}
duration={5} duration={COUNTDOWN_CONFIG.PASSWORD_RESET_SUCCESS_DURATION_S}
size={200} size={200}
strokeWidth={12} strokeWidth={12}
colors="var(--color-blue)" colors="var(--color-blue)"

View File

@@ -4,6 +4,7 @@ import { Title, Meta } from "@solidjs/meta";
import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import { isValidEmail } from "~/lib/validation"; import { isValidEmail } from "~/lib/validation";
import { getClientCookie } from "~/lib/cookies.client"; import { getClientCookie } from "~/lib/cookies.client";
import { COUNTDOWN_CONFIG } from "~/config";
export default function RequestPasswordResetPage() { export default function RequestPasswordResetPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -176,7 +177,7 @@ export default function RequestPasswordResetPage() {
<div class="mx-auto pt-4"> <div class="mx-auto pt-4">
<CountdownCircleTimer <CountdownCircleTimer
isPlaying={true} isPlaying={true}
duration={300} duration={COUNTDOWN_CONFIG.PASSWORD_RESET_DURATION_S}
initialRemainingTime={countDown()} initialRemainingTime={countDown()}
size={48} size={48}
strokeWidth={6} strokeWidth={6}

View File

@@ -2,6 +2,7 @@ import { Title, Meta } from "@solidjs/meta";
import { createSignal, onMount, Show, For } from "solid-js"; import { createSignal, onMount, Show, For } from "solid-js";
import { isServer } from "solid-js/web"; import { isServer } from "solid-js/web";
import { TerminalSplash } from "~/components/TerminalSplash"; import { TerminalSplash } from "~/components/TerminalSplash";
import { PDF_CONFIG } from "~/config";
export default function Resume() { export default function Resume() {
const [pages, setPages] = createSignal<HTMLCanvasElement[]>([]); const [pages, setPages] = createSignal<HTMLCanvasElement[]>([]);
@@ -26,7 +27,7 @@ export default function Resume() {
// Render each page // Render each page
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum); const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.5 }); const viewport = page.getViewport({ scale: PDF_CONFIG.RENDER_SCALE });
// Create canvas // Create canvas
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");

View File

@@ -46,6 +46,7 @@ import {
import { logAuditEvent } from "~/server/audit"; import { logAuditEvent } from "~/server/audit";
import type { H3Event } from "vinxi/http"; import type { H3Event } from "vinxi/http";
import type { Context } from "../utils"; import type { Context } from "../utils";
import { AUTH_CONFIG, NETWORK_CONFIG, COOLDOWN_TIMERS } from "~/config";
/** /**
* Safely extract H3Event from Context * Safely extract H3Event from Context
@@ -70,7 +71,7 @@ function getH3Event(ctx: Context): H3Event {
async function createJWT( async function createJWT(
userId: string, userId: string,
sessionId: string, sessionId: string,
expiresIn: string = "14d" expiresIn: string = AUTH_CONFIG.JWT_EXPIRY
): Promise<string> { ): Promise<string> {
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({
@@ -173,15 +174,15 @@ async function sendEmail(to: string, subject: string, htmlContent: string) {
"content-type": "application/json" "content-type": "application/json"
}, },
body: JSON.stringify(sendinblueData), body: JSON.stringify(sendinblueData),
timeout: 15000 timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
}); });
await checkResponse(response); await checkResponse(response);
return response; return response;
}, },
{ {
maxRetries: 2, maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: 1000 retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
} }
); );
} }
@@ -206,7 +207,7 @@ export const authRouter = createTRPCRouter({
client_secret: env.GITHUB_CLIENT_SECRET, client_secret: env.GITHUB_CLIENT_SECRET,
code code
}), }),
timeout: 15000 timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS
} }
); );
@@ -226,7 +227,7 @@ export const authRouter = createTRPCRouter({
headers: { headers: {
Authorization: `token ${access_token}` Authorization: `token ${access_token}`
}, },
timeout: 15000 timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS
} }
); );
@@ -241,7 +242,7 @@ export const authRouter = createTRPCRouter({
headers: { headers: {
Authorization: `token ${access_token}` Authorization: `token ${access_token}`
}, },
timeout: 15000 timeout: NETWORK_CONFIG.GITHUB_API_TIMEOUT_MS
} }
); );
@@ -319,7 +320,7 @@ export const authRouter = createTRPCRouter({
const userAgent = getUserAgent(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx));
const sessionId = await createSession( const sessionId = await createSession(
userId, userId,
"14d", AUTH_CONFIG.JWT_EXPIRY,
clientIP, clientIP,
userAgent userAgent
); );
@@ -327,7 +328,7 @@ export const authRouter = createTRPCRouter({
const token = await createJWT(userId, sessionId); const token = await createJWT(userId, sessionId);
setCookie(getH3Event(ctx), "userIDToken", token, { setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: AUTH_CONFIG.SESSION_COOKIE_MAX_AGE,
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
@@ -417,7 +418,7 @@ export const authRouter = createTRPCRouter({
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 timeout: NETWORK_CONFIG.GOOGLE_API_TIMEOUT_MS
} }
); );
@@ -437,7 +438,7 @@ export const authRouter = createTRPCRouter({
headers: { headers: {
Authorization: `Bearer ${access_token}` Authorization: `Bearer ${access_token}`
}, },
timeout: 15000 timeout: NETWORK_CONFIG.GOOGLE_API_TIMEOUT_MS
} }
); );
@@ -501,7 +502,7 @@ export const authRouter = createTRPCRouter({
const userAgent = getUserAgent(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx));
const sessionId = await createSession( const sessionId = await createSession(
userId, userId,
"14d", AUTH_CONFIG.JWT_EXPIRY,
clientIP, clientIP,
userAgent userAgent
); );
@@ -509,7 +510,7 @@ export const authRouter = createTRPCRouter({
const token = await createJWT(userId, sessionId); const token = await createJWT(userId, sessionId);
setCookie(getH3Event(ctx), "userIDToken", token, { setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: AUTH_CONFIG.SESSION_COOKIE_MAX_AGE,
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
@@ -618,7 +619,9 @@ export const authRouter = createTRPCRouter({
// Create session with client info // Create session with client info
const clientIP = getClientIP(getH3Event(ctx)); const clientIP = getClientIP(getH3Event(ctx));
const userAgent = getUserAgent(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx));
const expiresIn = rememberMe ? "14d" : "12h"; const expiresIn = rememberMe
? AUTH_CONFIG.JWT_EXPIRY
: AUTH_CONFIG.JWT_EXPIRY_SHORT;
const sessionId = await createSession( const sessionId = await createSession(
userId, userId,
expiresIn, expiresIn,
@@ -636,7 +639,7 @@ export const authRouter = createTRPCRouter({
}; };
if (rememberMe) { if (rememberMe) {
cookieOptions.maxAge = 60 * 60 * 24 * 14; cookieOptions.maxAge = AUTH_CONFIG.REMEMBER_ME_MAX_AGE;
} }
setCookie(getH3Event(ctx), "userIDToken", userToken, cookieOptions); setCookie(getH3Event(ctx), "userIDToken", userToken, cookieOptions);
@@ -790,7 +793,7 @@ export const authRouter = createTRPCRouter({
const userAgent = getUserAgent(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx));
const sessionId = await createSession( const sessionId = await createSession(
userId, userId,
"14d", AUTH_CONFIG.JWT_EXPIRY,
clientIP, clientIP,
userAgent userAgent
); );
@@ -798,7 +801,7 @@ export const authRouter = createTRPCRouter({
const token = await createJWT(userId, sessionId); const token = await createJWT(userId, sessionId);
setCookie(getH3Event(ctx), "userIDToken", token, { setCookie(getH3Event(ctx), "userIDToken", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days maxAge: AUTH_CONFIG.SESSION_COOKIE_MAX_AGE,
path: "/", path: "/",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
@@ -958,7 +961,9 @@ export const authRouter = createTRPCRouter({
// Reset failed attempts on successful login // Reset failed attempts on successful login
await resetFailedAttempts(user.id); await resetFailedAttempts(user.id);
const expiresIn = rememberMe ? "14d" : "12h"; const expiresIn = rememberMe
? AUTH_CONFIG.JWT_EXPIRY
: AUTH_CONFIG.JWT_EXPIRY_SHORT;
// Create session with client info (reuse clientIP from rate limiting) // Create session with client info (reuse clientIP from rate limiting)
const userAgent = getUserAgent(getH3Event(ctx)); const userAgent = getUserAgent(getH3Event(ctx));
@@ -979,7 +984,7 @@ export const authRouter = createTRPCRouter({
}; };
if (rememberMe) { if (rememberMe) {
cookieOptions.maxAge = 60 * 60 * 24 * 14; // 14 days cookieOptions.maxAge = AUTH_CONFIG.REMEMBER_ME_MAX_AGE;
} }
setCookie(getH3Event(ctx), "userIDToken", token, cookieOptions); setCookie(getH3Event(ctx), "userIDToken", token, cookieOptions);
@@ -1110,13 +1115,13 @@ export const authRouter = createTRPCRouter({
await sendEmail(email, "freno.me login link", htmlContent); await sendEmail(email, "freno.me login link", htmlContent);
const exp = new Date(Date.now() + 2 * 60 * 1000); const exp = new Date(Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS);
setCookie( setCookie(
getH3Event(ctx), getH3Event(ctx),
"emailLoginLinkRequested", "emailLoginLinkRequested",
exp.toUTCString(), exp.toUTCString(),
{ {
maxAge: 2 * 60, maxAge: COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_COOKIE_MAX_AGE,
path: "/" path: "/"
} }
); );
@@ -1228,13 +1233,15 @@ export const authRouter = createTRPCRouter({
await sendEmail(email, "password reset", htmlContent); await sendEmail(email, "password reset", htmlContent);
const exp = new Date(Date.now() + 5 * 60 * 1000); const exp = new Date(
Date.now() + COOLDOWN_TIMERS.PASSWORD_RESET_REQUEST_MS
);
setCookie( setCookie(
getH3Event(ctx), getH3Event(ctx),
"passwordResetRequested", "passwordResetRequested",
exp.toUTCString(), exp.toUTCString(),
{ {
maxAge: 5 * 60, maxAge: COOLDOWN_TIMERS.PASSWORD_RESET_REQUEST_COOKIE_MAX_AGE,
path: "/" path: "/"
} }
); );
@@ -1417,9 +1424,9 @@ export const authRouter = createTRPCRouter({
if (requested) { if (requested) {
const time = parseInt(requested); const time = parseInt(requested);
const currentTime = Date.now(); const currentTime = Date.now();
const difference = (currentTime - time) / (1000 * 60); const difference = (currentTime - time) / 1000;
if (difference < 15) { if (difference * 1000 < COOLDOWN_TIMERS.EMAIL_VERIFICATION_MS) {
throw new TRPCError({ throw new TRPCError({
code: "TOO_MANY_REQUESTS", code: "TOO_MANY_REQUESTS",
message: message:
@@ -1492,7 +1499,7 @@ export const authRouter = createTRPCRouter({
"emailVerificationRequested", "emailVerificationRequested",
Date.now().toString(), Date.now().toString(),
{ {
maxAge: 15 * 60, maxAge: COOLDOWN_TIMERS.EMAIL_VERIFICATION_COOKIE_MAX_AGE,
path: "/" path: "/"
} }
); );

View File

@@ -3,8 +3,9 @@ import { ConnectionFactory } from "~/server/utils";
import { withCacheAndStale } from "~/server/cache"; import { withCacheAndStale } from "~/server/cache";
import { incrementPostReadSchema } from "../schemas/blog"; import { incrementPostReadSchema } from "../schemas/blog";
import type { PostWithCommentsAndLikes } from "~/db/types"; import type { PostWithCommentsAndLikes } from "~/db/types";
import { CACHE_CONFIG } from "~/config";
const BLOG_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS;
// Shared cache function for all blog posts // Shared cache function for all blog posts
const getAllPostsData = async (privilegeLevel: string) => { const getAllPostsData = async (privilegeLevel: string) => {

View File

@@ -31,8 +31,9 @@ import {
updateUserImageSchema, updateUserImageSchema,
updateUserEmailSchema updateUserEmailSchema
} from "../schemas/database"; } from "../schemas/database";
import { CACHE_CONFIG } from "~/config";
const BLOG_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours const BLOG_CACHE_TTL = CACHE_CONFIG.BLOG_CACHE_TTL_MS;
export const databaseRouter = createTRPCRouter({ export const databaseRouter = createTRPCRouter({
getCommentReactions: publicProcedure getCommentReactions: publicProcedure

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../utils"; import { createTRPCRouter, publicProcedure } from "../utils";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { withCacheAndStale } from "~/server/cache"; import { withCacheAndStale } from "~/server/cache";
import { CACHE_CONFIG } from "~/config";
import { import {
fetchWithTimeout, fetchWithTimeout,
checkResponse, checkResponse,
@@ -30,7 +31,7 @@ export const gitActivityRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return withCacheAndStale( return withCacheAndStale(
`github-commits-${input.limit}`, `github-commits-${input.limit}`,
10 * 60 * 1000, // 10 minutes CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
async () => { async () => {
const reposResponse = await fetchWithTimeout( const reposResponse = await fetchWithTimeout(
`https://api.github.com/users/MikeFreno/repos?sort=pushed&per_page=10`, `https://api.github.com/users/MikeFreno/repos?sort=pushed&per_page=10`,
@@ -108,7 +109,7 @@ export const gitActivityRouter = createTRPCRouter({
return allCommits.slice(0, input.limit); return allCommits.slice(0, input.limit);
}, },
{ maxStaleMs: 24 * 60 * 60 * 1000 } // Accept stale data up to 24 hours old { maxStaleMs: CACHE_CONFIG.GIT_ACTIVITY_MAX_STALE_MS }
).catch((error) => { ).catch((error) => {
if (error instanceof NetworkError) { if (error instanceof NetworkError) {
console.error("GitHub API unavailable (network error)"); console.error("GitHub API unavailable (network error)");
@@ -130,7 +131,7 @@ export const gitActivityRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return withCacheAndStale( return withCacheAndStale(
`gitea-commits-${input.limit}`, `gitea-commits-${input.limit}`,
10 * 60 * 1000, // 10 minutes CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
async () => { async () => {
const reposResponse = await fetchWithTimeout( const reposResponse = await fetchWithTimeout(
`${env.GITEA_URL}/api/v1/users/Mike/repos?limit=100`, `${env.GITEA_URL}/api/v1/users/Mike/repos?limit=100`,
@@ -210,7 +211,7 @@ export const gitActivityRouter = createTRPCRouter({
return allCommits.slice(0, input.limit); return allCommits.slice(0, input.limit);
}, },
{ maxStaleMs: 24 * 60 * 60 * 1000 } { maxStaleMs: CACHE_CONFIG.GIT_ACTIVITY_MAX_STALE_MS }
).catch((error) => { ).catch((error) => {
if (error instanceof NetworkError) { if (error instanceof NetworkError) {
console.error("Gitea API unavailable (network error)"); console.error("Gitea API unavailable (network error)");

View File

@@ -20,6 +20,7 @@ import {
TimeoutError, TimeoutError,
APIError APIError
} from "~/server/fetch-utils"; } from "~/server/fetch-utils";
import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG } from "~/config";
const assets: Record<string, string> = { const assets: Record<string, string> = {
"shapes-with-abigail": "shapes-with-abigail.apk", "shapes-with-abigail": "shapes-with-abigail.apk",
"magic-delve": "magic-delve.apk", "magic-delve": "magic-delve.apk",
@@ -257,7 +258,10 @@ export const miscRouter = createTRPCRouter({
z.object({ z.object({
name: z.string().min(1), name: z.string().min(1),
email: z.string().email(), email: z.string().email(),
message: z.string().min(1).max(500) message: z
.string()
.min(1)
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH)
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -300,19 +304,19 @@ export const miscRouter = createTRPCRouter({
"content-type": "application/json" "content-type": "application/json"
}, },
body: JSON.stringify(sendinblueData), body: JSON.stringify(sendinblueData),
timeout: 15000 timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
}); });
await checkResponse(response); await checkResponse(response);
return response; return response;
}, },
{ {
maxRetries: 2, maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: 1000 retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
} }
); );
const exp = new Date(Date.now() + 1 * 60 * 1000); const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
setCookie("contactRequestSent", exp.toUTCString(), { setCookie("contactRequestSent", exp.toUTCString(), {
expires: exp, expires: exp,
path: "/" path: "/"
@@ -415,12 +419,15 @@ export const miscRouter = createTRPCRouter({
"content-type": "application/json" "content-type": "application/json"
}, },
body: JSON.stringify(sendinblueMyData), body: JSON.stringify(sendinblueMyData),
timeout: 15000 timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
}); });
await checkResponse(response); await checkResponse(response);
return response; return response;
}, },
{ maxRetries: 2, retryDelay: 1000 } {
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
}
), ),
fetchWithRetry( fetchWithRetry(
async () => { async () => {
@@ -432,16 +439,19 @@ export const miscRouter = createTRPCRouter({
"content-type": "application/json" "content-type": "application/json"
}, },
body: JSON.stringify(sendinblueUserData), body: JSON.stringify(sendinblueUserData),
timeout: 15000 timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
}); });
await checkResponse(response); await checkResponse(response);
return response; return response;
}, },
{ maxRetries: 2, retryDelay: 1000 } {
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
}
) )
]); ]);
const exp = new Date(Date.now() + 1 * 60 * 1000); const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
setCookie("deletionRequestSent", exp.toUTCString(), { setCookie("deletionRequestSent", exp.toUTCString(), {
expires: exp, expires: exp,
path: "/" path: "/"

View File

@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { validatePassword } from "~/lib/validation"; import { validatePassword } from "~/lib/validation";
import { VALIDATION_CONFIG } from "~/config";
/** /**
* User API Validation Schemas * User API Validation Schemas
@@ -14,11 +15,14 @@ import { validatePassword } from "~/lib/validation";
/** /**
* Secure password validation with strength requirements * Secure password validation with strength requirements
* Minimum 12 characters, uppercase, lowercase, number, and special character * Minimum length from config, uppercase, lowercase, number, and special character
*/ */
const securePasswordSchema = z const securePasswordSchema = z
.string() .string()
.min(12, "Password must be at least 12 characters") .min(
VALIDATION_CONFIG.MIN_PASSWORD_LENGTH,
`Password must be at least ${VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} characters`
)
.refine( .refine(
(password) => { (password) => {
const result = validatePassword(password); const result = validatePassword(password);
@@ -44,7 +48,7 @@ export const registerUserSchema = z
.object({ .object({
email: z.string().email(), email: z.string().email(),
password: securePasswordSchema, password: securePasswordSchema,
passwordConfirmation: z.string().min(12) passwordConfirmation: z.string().min(VALIDATION_CONFIG.MIN_PASSWORD_LENGTH)
}) })
.refine((data) => data.password === data.passwordConfirmation, { .refine((data) => data.password === data.passwordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",
@@ -100,7 +104,9 @@ export const changePasswordSchema = z
.object({ .object({
oldPassword: z.string().min(1, "Current password is required"), oldPassword: z.string().min(1, "Current password is required"),
newPassword: securePasswordSchema, newPassword: securePasswordSchema,
newPasswordConfirmation: z.string().min(12) newPasswordConfirmation: z
.string()
.min(VALIDATION_CONFIG.MIN_PASSWORD_LENGTH)
}) })
.refine((data) => data.newPassword === data.newPasswordConfirmation, { .refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",
@@ -117,7 +123,9 @@ export const changePasswordSchema = z
export const setPasswordSchema = z export const setPasswordSchema = z
.object({ .object({
newPassword: securePasswordSchema, newPassword: securePasswordSchema,
newPasswordConfirmation: z.string().min(12) newPasswordConfirmation: z
.string()
.min(VALIDATION_CONFIG.MIN_PASSWORD_LENGTH)
}) })
.refine((data) => data.newPassword === data.newPasswordConfirmation, { .refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",
@@ -138,7 +146,9 @@ export const resetPasswordSchema = z
.object({ .object({
token: z.string().min(1), token: z.string().min(1),
newPassword: securePasswordSchema, newPassword: securePasswordSchema,
newPasswordConfirmation: z.string().min(12) newPasswordConfirmation: z
.string()
.min(VALIDATION_CONFIG.MIN_PASSWORD_LENGTH)
}) })
.refine((data) => data.newPassword === data.newPasswordConfirmation, { .refine((data) => data.newPassword === data.newPasswordConfirmation, {
message: "Passwords do not match", message: "Passwords do not match",

View File

@@ -1,3 +1,5 @@
import { CACHE_CONFIG } from "~/config";
interface CacheEntry<T> { interface CacheEntry<T> {
data: T; data: T;
timestamp: number; timestamp: number;
@@ -80,7 +82,8 @@ export async function withCacheAndStale<T>(
logErrors?: boolean; logErrors?: boolean;
} = {} } = {}
): Promise<T> { ): Promise<T> {
const { maxStaleMs = 7 * 24 * 60 * 60 * 1000, logErrors = true } = options; const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } =
options;
const cached = cache.get<T>(key, ttlMs); const cached = cache.get<T>(key, ttlMs);
if (cached !== null) { if (cached !== null) {

View File

@@ -1,7 +1,8 @@
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { env } from "~/env/server"; import { env } from "~/env/server";
import { AUTH_CONFIG } from "~/config";
export const LINEAGE_JWT_EXPIRY = "14d"; export const LINEAGE_JWT_EXPIRY = AUTH_CONFIG.LINEAGE_JWT_EXPIRY;
export async function sendEmailVerification(userEmail: string): Promise<{ export async function sendEmailVerification(userEmail: string): Promise<{
success: boolean; success: boolean;

View File

@@ -4,6 +4,13 @@ import type { H3Event } from "vinxi/http";
import { t } from "~/server/api/utils"; import { t } from "~/server/api/utils";
import { logAuditEvent } from "~/server/audit"; import { logAuditEvent } from "~/server/audit";
import { env } from "~/env/server"; import { env } from "~/env/server";
import {
AUTH_CONFIG,
RATE_LIMITS as CONFIG_RATE_LIMITS,
RATE_LIMIT_CLEANUP_INTERVAL_MS,
ACCOUNT_LOCKOUT as CONFIG_ACCOUNT_LOCKOUT,
PASSWORD_RESET_CONFIG as CONFIG_PASSWORD_RESET
} from "~/config";
/** /**
* Extract cookie value from H3Event (works in both production and tests) * Extract cookie value from H3Event (works in both production and tests)
@@ -106,7 +113,7 @@ export function generateCSRFToken(): string {
export function setCSRFToken(event: H3Event): string { export function setCSRFToken(event: H3Event): string {
const token = generateCSRFToken(); const token = generateCSRFToken();
setCookieValue(event, "csrf-token", token, { setCookieValue(event, "csrf-token", token, {
maxAge: 60 * 60 * 24 * 14, // 14 days - same as session maxAge: AUTH_CONFIG.CSRF_TOKEN_MAX_AGE,
path: "/", path: "/",
httpOnly: false, // Must be readable by client JS httpOnly: false, // Must be readable by client JS
secure: env.NODE_ENV === "production", secure: env.NODE_ENV === "production",
@@ -207,17 +214,14 @@ export function clearRateLimitStore(): void {
/** /**
* Cleanup expired rate limit entries every 5 minutes * Cleanup expired rate limit entries every 5 minutes
*/ */
setInterval( setInterval(() => {
() => {
const now = Date.now(); const now = Date.now();
for (const [key, record] of rateLimitStore.entries()) { for (const [key, record] of rateLimitStore.entries()) {
if (now > record.resetAt) { if (now > record.resetAt) {
rateLimitStore.delete(key); rateLimitStore.delete(key);
} }
} }
}, }, RATE_LIMIT_CLEANUP_INTERVAL_MS);
5 * 60 * 1000
);
/** /**
* Get client IP address from request headers * Get client IP address from request headers
@@ -320,19 +324,9 @@ export function checkRateLimit(
/** /**
* Rate limit configuration for different operations * Rate limit configuration for different operations
* Re-exported from config for backward compatibility
*/ */
export const RATE_LIMITS = { export const RATE_LIMITS = CONFIG_RATE_LIMITS;
// Login: 5 attempts per 15 minutes per IP
LOGIN_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 },
// Login: 3 attempts per hour per email
LOGIN_EMAIL: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
// Password reset: 3 attempts per hour per IP
PASSWORD_RESET_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
// Registration: 3 attempts per hour per IP
REGISTRATION_IP: { maxAttempts: 3, windowMs: 60 * 60 * 1000 },
// Email verification: 5 attempts per 15 minutes per IP
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
} as const;
/** /**
* Rate limiting middleware for login operations * Rate limiting middleware for login operations
@@ -405,11 +399,9 @@ export function rateLimitEmailVerification(
/** /**
* Account lockout configuration * Account lockout configuration
* Re-exported from config for backward compatibility
*/ */
export const ACCOUNT_LOCKOUT = { export const ACCOUNT_LOCKOUT = CONFIG_ACCOUNT_LOCKOUT;
MAX_FAILED_ATTEMPTS: 5,
LOCKOUT_DURATION_MS: 5 * 60 * 1000 // 5 minutes
} as const;
/** /**
* Check if an account is locked * Check if an account is locked
@@ -527,10 +519,9 @@ export async function resetFailedAttempts(userId: string): Promise<void> {
/** /**
* Password reset token configuration * Password reset token configuration
* Re-exported from config for backward compatibility
*/ */
export const PASSWORD_RESET_CONFIG = { export const PASSWORD_RESET_CONFIG = CONFIG_PASSWORD_RESET;
TOKEN_EXPIRY_MS: 60 * 60 * 1000 // 1 hour
} as const;
/** /**
* Create a password reset token * Create a password reset token