diff --git a/.env.example b/.env.example index 7b62b23..f381dcb 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# ============================================================================= +# ============================================================================= # samdev-pulse — Environment Configuration # ============================================================================= # Copy this file to .env and fill in your values: @@ -38,4 +38,4 @@ MONGODB_URI=mongodb://localhost:27017/samdev-pulse # --- Admin ------------------------------------------------------------------- # API key for protected admin endpoints (optional — /api/cache/stats requires this) # If not set, cache stats are publicly accessible -# ADMIN_API_KEY=your_admin_key_here +# ADMIN_API_KEY=your_admin_key_here \ No newline at end of file diff --git a/README.md b/README.md index 3299eb7..9d9e5d9 100644 --- a/README.md +++ b/README.md @@ -227,8 +227,14 @@ Create a `.env` file: ```env GITHUB_TOKEN=your_github_personal_access_token +DEFAULT_USERNAME=SamXop123 PORT=3000 NODE_ENV=development +ADMIN_API_KEY= +MONGODB_URI= +MONGODB_DB= +ANALYTICS_DISABLED=false +CACHE_MAX_SIZE=1000 ``` --- diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..3b6ad6c --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,142 @@ +import dotenv from 'dotenv'; + +dotenv.config({ quiet: true }); + +const TRUE_VALUES = new Set(['true', '1', 'yes']); +const DEFAULT_PORT = 3000; +const DEFAULT_CACHE_MAX_SIZE = 1000; +const PRODUCTION_CACHE_TTL_MS = 1800000; +const DEVELOPMENT_CACHE_TTL_MS = 300000; +const KNOWN_NODE_ENVS = new Set(['development', 'production', 'test']); + +function readString(env, key) { + const value = env[key]; + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function readBoolean(env, key) { + return TRUE_VALUES.has(readString(env, key).toLowerCase()); +} + +function normalizePort(rawPort, errors) { + if (!rawPort) { + return DEFAULT_PORT; + } + + const port = Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + errors.push(`PORT must be an integer between 1 and 65535. Received: ${rawPort}`); + return DEFAULT_PORT; + } + + return port; +} + +function normalizePositiveInteger(rawValue, defaultValue, key, warnings) { + if (!rawValue) { + return defaultValue; + } + + const value = parseInt(rawValue, 10); + if (!Number.isInteger(value) || value < 1) { + warnings.push(`${key} is invalid; using default ${defaultValue}.`); + return defaultValue; + } + + return value; +} + +export function loadConfig(env = process.env) { + const errors = []; + const warnings = []; + const nodeEnv = readString(env, 'NODE_ENV') || 'development'; + const isProduction = nodeEnv === 'production'; + const githubToken = readString(env, 'GITHUB_TOKEN'); + const mongoUri = readString(env, 'MONGODB_URI'); + const analyticsDisabled = readBoolean(env, 'ANALYTICS_DISABLED') || readBoolean(env, 'DISABLE_ANALYTICS'); + const analyticsEnabled = !analyticsDisabled && Boolean(mongoUri); + const cacheMaxSize = normalizePositiveInteger( + readString(env, 'CACHE_MAX_SIZE'), + DEFAULT_CACHE_MAX_SIZE, + 'CACHE_MAX_SIZE', + warnings + ); + + if (!KNOWN_NODE_ENVS.has(nodeEnv)) { + warnings.push(`NODE_ENV is '${nodeEnv}'. Expected development, production, or test.`); + } + + return { + env: nodeEnv, + isProduction, + port: normalizePort(readString(env, 'PORT'), errors), + github: { + token: githubToken || null, + enabled: Boolean(githubToken), + }, + admin: { + apiKey: readString(env, 'ADMIN_API_KEY') || null, + cacheStatsProtected: Boolean(readString(env, 'ADMIN_API_KEY')), + }, + analytics: { + disabled: analyticsDisabled, + enabled: analyticsEnabled, + mongoUri: mongoUri || null, + dbName: readString(env, 'MONGODB_DB') || undefined, + }, + cache: { + maxSize: cacheMaxSize, + defaultTtlMs: isProduction ? PRODUCTION_CACHE_TTL_MS : DEVELOPMENT_CACHE_TTL_MS, + }, + defaults: { + username: readString(env, 'DEFAULT_USERNAME') || 'SamXop123', + }, + diagnostics: { + warnings, + errors, + missingOptional: [ + ...(!githubToken ? ['GITHUB_TOKEN'] : []), + ...(!analyticsDisabled && !mongoUri ? ['MONGODB_URI'] : []), + ...(!readString(env, 'ADMIN_API_KEY') ? ['ADMIN_API_KEY'] : []), + ], + features: { + githubToken: Boolean(githubToken), + analytics: analyticsEnabled, + cacheStatsAuth: Boolean(readString(env, 'ADMIN_API_KEY')), + }, + }, + }; +} + +export function validateConfig(config) { + if (config.diagnostics.errors.length > 0) { + throw new Error(`Invalid configuration:\n${config.diagnostics.errors.map(error => `- ${error}`).join('\n')}`); + } +} + +export function logStartupDiagnostics(config, logger = console) { + const enabled = []; + const disabled = []; + + if (config.github.enabled) enabled.push('GitHub token auth'); + else disabled.push('GitHub token auth'); + + if (config.analytics.enabled) enabled.push('analytics'); + else disabled.push('analytics'); + + if (config.admin.cacheStatsProtected) enabled.push('cache stats auth'); + else disabled.push('cache stats auth'); + + logger.info(`Features enabled: ${enabled.length ? enabled.join(', ') : 'none'}.`); + logger.info(`Optional features disabled: ${disabled.join(', ')}.`); + + if (config.diagnostics.missingOptional.length > 0) { + logger.info(`Missing optional config: ${config.diagnostics.missingOptional.join(', ')}.`); + } + + config.diagnostics.warnings.forEach(warning => logger.warn(`Configuration warning: ${warning}`)); +} + +const config = loadConfig(); + +export default config; diff --git a/src/config/index.test.js b/src/config/index.test.js new file mode 100644 index 0000000..fc8bf7f --- /dev/null +++ b/src/config/index.test.js @@ -0,0 +1,109 @@ +import { describe, expect, test } from '@jest/globals'; +import { loadConfig, validateConfig } from './index.js'; + +describe('config', () => { + test('loads defaults for optional runtime settings', () => { + const config = loadConfig({}); + + expect(config).toMatchObject({ + env: 'development', + isProduction: false, + port: 3000, + github: { + token: null, + enabled: false, + }, + admin: { + apiKey: null, + cacheStatsProtected: false, + }, + analytics: { + disabled: false, + enabled: false, + mongoUri: null, + }, + cache: { + maxSize: 1000, + defaultTtlMs: 300000, + }, + defaults: { + username: 'SamXop123', + }, + }); + expect(config.diagnostics.missingOptional).toEqual([ + 'GITHUB_TOKEN', + 'MONGODB_URI', + 'ADMIN_API_KEY', + ]); + }); + + test('enables optional features from environment values', () => { + const config = loadConfig({ + NODE_ENV: 'production', + PORT: '8080', + GITHUB_TOKEN: 'ghp_test', + ADMIN_API_KEY: 'secret', + MONGODB_URI: 'mongodb://localhost:27017/samdev', + MONGODB_DB: 'samdev_test', + CACHE_MAX_SIZE: '42', + DEFAULT_USERNAME: 'octocat', + }); + + expect(config).toMatchObject({ + env: 'production', + isProduction: true, + port: 8080, + github: { + token: 'ghp_test', + enabled: true, + }, + admin: { + apiKey: 'secret', + cacheStatsProtected: true, + }, + analytics: { + disabled: false, + enabled: true, + mongoUri: 'mongodb://localhost:27017/samdev', + dbName: 'samdev_test', + }, + cache: { + maxSize: 42, + defaultTtlMs: 1800000, + }, + defaults: { + username: 'octocat', + }, + }); + expect(config.diagnostics.missingOptional).toEqual([]); + }); + + test('reports invalid ports as startup errors', () => { + const config = loadConfig({ PORT: 'not-a-port' }); + + expect(config.port).toBe(3000); + expect(config.diagnostics.errors).toEqual([ + 'PORT must be an integer between 1 and 65535. Received: not-a-port', + ]); + expect(() => validateConfig(config)).toThrow('Invalid configuration'); + }); + + test('warns and falls back for invalid cache sizes', () => { + const config = loadConfig({ CACHE_MAX_SIZE: '0' }); + + expect(config.cache.maxSize).toBe(1000); + expect(config.diagnostics.warnings).toContain('CACHE_MAX_SIZE is invalid; using default 1000.'); + }); + + test('keeps analytics disabled when explicitly requested', () => { + const config = loadConfig({ + DISABLE_ANALYTICS: 'yes', + MONGODB_URI: 'mongodb://localhost:27017/samdev', + }); + + expect(config.analytics.disabled).toBe(true); + expect(config.analytics.enabled).toBe(false); + expect(config.diagnostics.features.analytics).toBe(false); + expect(config.diagnostics.missingOptional).not.toContain('MONGODB_URI'); + }); +}); diff --git a/src/server.js b/src/server.js index eb31432..3903d9c 100644 --- a/src/server.js +++ b/src/server.js @@ -1,52 +1,38 @@ import express from 'express'; -import dotenv from 'dotenv'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { inject } from '@vercel/analytics'; +import config, { logStartupDiagnostics, validateConfig } from './config/index.js'; import profileRoute from './routes/profile.route.js'; import themeComparisonRoute from './routes/theme-comparison.route.js'; import { initializeAnalytics } from './services/analytics.service.js'; import { githubCache } from './utils/cache.js'; -dotenv.config(); inject(); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); -const PORT = process.env.PORT || 3000; -const IS_PRODUCTION = process.env.NODE_ENV === 'production'; +const PORT = config.port; // render html file app.use(express.static(join(__dirname, '..', 'public'))); -// validates env on startup -function validateEnv() { - const warnings = []; - - if (!process.env.GITHUB_TOKEN) { - warnings.push('GITHUB_TOKEN not set - streak stats will be unavailable'); - } - - if (warnings.length > 0) { - warnings.forEach(w => console.warn(`⚠️ ${w}`)); - } -} - -validateEnv(); +validateConfig(config); +logStartupDiagnostics(config); void initializeAnalytics(); app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// Cache stats endpoint — restricted to callers that supply the configured +// Cache stats endpoint - restricted to callers that supply the configured // ADMIN_API_KEY in the Authorization header (Bearer scheme). This is an // internal operations endpoint; exposing hit/miss/eviction counts publicly // leaks implementation details useful for timing attacks. app.get('/api/cache/stats', (req, res) => { - const adminKey = process.env.ADMIN_API_KEY; + const adminKey = config.admin.apiKey; if (adminKey) { const authHeader = req.headers['authorization'] || ''; @@ -68,7 +54,7 @@ app.get('/theme-comparison', (req, res) => { }); const server = app.listen(PORT, () => { - if (!IS_PRODUCTION) { + if (!config.isProduction) { console.log(`Server running on http://localhost:${PORT}`); } }); diff --git a/src/services/analytics.service.js b/src/services/analytics.service.js index aec1317..499ca28 100644 --- a/src/services/analytics.service.js +++ b/src/services/analytics.service.js @@ -1,15 +1,9 @@ import mongoose from 'mongoose'; - -const ENABLED_VALUES = new Set(['true', '1', 'yes']); +import { loadConfig } from '../config/index.js'; let analyticsState = 'uninitialized'; let connectingPromise = null; -function analyticsDisabledByEnv() { - return ENABLED_VALUES.has(String(process.env.ANALYTICS_DISABLED || '').toLowerCase()) - || ENABLED_VALUES.has(String(process.env.DISABLE_ANALYTICS || '').toLowerCase()); -} - function getQueryValue(query, key) { const value = query?.[key]; if (Array.isArray(value)) { @@ -86,20 +80,22 @@ export async function initializeAnalytics() { return isAnalyticsAvailable(); } - if (analyticsDisabledByEnv()) { + const config = loadConfig(); + + if (config.analytics.disabled) { analyticsState = 'disabled'; console.info('Analytics disabled by configuration.'); return false; } - const mongoUri = process.env.MONGODB_URI; + const mongoUri = config.analytics.mongoUri; if (!mongoUri) { analyticsState = 'disabled'; console.info('Analytics disabled: MONGODB_URI is not configured.'); return false; } - const dbName = process.env.MONGODB_DB || undefined; + const dbName = config.analytics.dbName; analyticsState = 'connecting'; connectingPromise = mongoose.connect(mongoUri, { diff --git a/src/services/github-graphql.service.js b/src/services/github-graphql.service.js index e2988ec..991749a 100644 --- a/src/services/github-graphql.service.js +++ b/src/services/github-graphql.service.js @@ -1,19 +1,21 @@ // GitHub GraphQL API Service - Contribution Calendar & Streaks import { githubCache } from "../utils/cache.js"; +import { loadConfig } from "../config/index.js"; import { HttpErrorCode, httpRequest } from "../utils/http-client.js"; const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"; /* authorization headers for GraphQL API*/ function getHeaders() { + const config = loadConfig(); const headers = { "Content-Type": "application/json", "User-Agent": "samdev-pulse", }; - if (process.env.GITHUB_TOKEN) { - headers["Authorization"] = `Bearer ${process.env.GITHUB_TOKEN}`; + if (config.github.token) { + headers["Authorization"] = `Bearer ${config.github.token}`; } return headers; @@ -51,7 +53,7 @@ query($username: String!) { /* fetch contribution data from GitHub GraphQL API */ async function fetchContributionData(username) { - if (!process.env.GITHUB_TOKEN) { + if (!loadConfig().github.enabled) { throw new Error("GITHUB_TOKEN required for contribution data"); } diff --git a/src/services/github.service.js b/src/services/github.service.js index c81b9ac..72f2a6d 100644 --- a/src/services/github.service.js +++ b/src/services/github.service.js @@ -1,6 +1,7 @@ // GitHub REST API Service import { githubCache } from '../utils/cache.js'; +import { loadConfig } from '../config/index.js'; import { HttpErrorCode, httpRequest } from '../utils/http-client.js'; const GITHUB_API_BASE = 'https://api.github.com'; @@ -49,13 +50,14 @@ function errorFromStatus(status) { /* function to get authorization headers once to use it everywhere */ function getHeaders() { + const config = loadConfig(); const headers = { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'samdev-pulse', }; - if (process.env.GITHUB_TOKEN) { - headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`; + if (config.github.token) { + headers['Authorization'] = `Bearer ${config.github.token}`; } return headers; diff --git a/src/utils/cache.js b/src/utils/cache.js index 14b5e26..2901fd1 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -1,9 +1,8 @@ // TTL-based in-memory cache with LRU eviction +import config from '../config/index.js'; -// Production cache TTL: 30 minutes (1800000ms) -// Development cache TTL: 5 minutes (300000ms) -const DEFAULT_CACHE_TTL = process.env.NODE_ENV === 'production' ? 1800000 : 300000; -const DEFAULT_MAX_SIZE = parseInt(process.env.CACHE_MAX_SIZE, 10) || 1000; +const DEFAULT_CACHE_TTL = config.cache.defaultTtlMs; +const DEFAULT_MAX_SIZE = config.cache.maxSize; class Cache { constructor(defaultTTL = DEFAULT_CACHE_TTL, maxSize = DEFAULT_MAX_SIZE) { diff --git a/src/utils/query-validation.js b/src/utils/query-validation.js index b2b77bb..68f9538 100644 --- a/src/utils/query-validation.js +++ b/src/utils/query-validation.js @@ -1,4 +1,5 @@ import { SUPPORTED_THEME_NAMES } from '../renderers/svg.renderer.js'; +import config from '../config/index.js'; const DEFAULT_THEME = 'dark'; const DEFAULT_ALIGN = 'left'; @@ -81,7 +82,7 @@ export function normalizeHexColor(value) { } export function normalizeProfileQuery(query) { - const usernameResult = normalizeGitHubUsername(query.username); + const usernameResult = normalizeGitHubUsername(query.username || config.defaults.username); const leetcode = normalizeCPHandle(query.leetcode); const codeforces = normalizeCPHandle(query.codeforces); const codechef = normalizeCPHandle(query.codechef); diff --git a/src/utils/query-validation.test.js b/src/utils/query-validation.test.js index c1b5e06..4c92c13 100644 --- a/src/utils/query-validation.test.js +++ b/src/utils/query-validation.test.js @@ -81,13 +81,13 @@ describe('query-validation.js', () => { }); }); - test('normalizeProfileQuery rejects missing username', () => { + test('normalizeProfileQuery falls back to the configured default username', () => { const result = normalizeProfileQuery( {} ); - expect(result.isUsernameValid).toBe(false); - expect(result.username).toBe(''); + expect(result.isUsernameValid).toBe(true); + expect(result.username).toBe('SamXop123'); }); test('normalizeProfileQuery rejects invalid platform handles securely', () => {