Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# =============================================================================
# =============================================================================
# samdev-pulse — Environment Configuration
# =============================================================================
# Copy this file to .env and fill in your values:
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand Down
142 changes: 142 additions & 0 deletions src/config/index.js
Original file line number Diff line number Diff line change
@@ -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;
109 changes: 109 additions & 0 deletions src/config/index.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
28 changes: 7 additions & 21 deletions src/server.js
Original file line number Diff line number Diff line change
@@ -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'] || '';
Expand All @@ -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}`);
}
});
Expand Down
16 changes: 6 additions & 10 deletions src/services/analytics.service.js
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down Expand Up @@ -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, {
Expand Down
Loading
Loading