config centralized
This commit is contained in:
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
324
src/config.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}%`,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: "/"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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: "/"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user