Refresh of the open-core distribution from the private tree. Included since the previous snapshot: - Mail module (ADR-158/159/160/169): composer, labels, scheduling, attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083 - Crawler-readable SSR for /blog and public spaces (ADR-190): blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages - Registration policy + referral/promo settings (ADR-183/188) - Message translation + language detection (ADR-185) - Reddit connector for the agent-tool surface Excised from the public distribution (unchanged policy): infrastructure topology and host config, internal ops scripts, DB cleanup snapshots, business documents, throwaway debug scripts, and two private product lines (SC-SIM simulator, personal one-off tools). Real host addresses are replaced with placeholders; credential-shaped literals are redacted. Frontend build verified green on this tree.
315 lines
9.8 KiB
JavaScript
315 lines
9.8 KiB
JavaScript
// system/settingsController.js — Rate limits, system info, settings, SMTP
|
|
|
|
import express from 'express';
|
|
import CryptoJS from 'crypto-js';
|
|
import { dbGet, dbRun, dbAll } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import SMTPService from '../../../services/SMTPService.js';
|
|
import { success, badRequest, serverError } from '../../../utils/response.js';
|
|
import { ownerOnly } from './helpers.js';
|
|
import { isPromoEnabled, PROMO_FLAG_KEY } from '../../../services/starter-pack/StarterPackService.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* GET /api/v3/system/promo-settings — ADR-183-A D6 (owner only).
|
|
* Read the promo kill-switch. Default ON when the row is absent (fail-open).
|
|
*/
|
|
router.get('/promo-settings', ownerOnly, async (req, res) => {
|
|
try {
|
|
return success(res, { promo_enabled: await isPromoEnabled() });
|
|
} catch (error) {
|
|
apiLogger.error('Error reading promo settings:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/v3/system/promo-settings — ADR-183-A D6 (owner only).
|
|
* Flip the promo kill-switch. Body: { promo_enabled: boolean }. Stored in
|
|
* _app_settings (JSONB), the same store StarterPackService reads.
|
|
*/
|
|
router.put('/promo-settings', ownerOnly, async (req, res) => {
|
|
try {
|
|
const { promo_enabled } = req.body;
|
|
if (typeof promo_enabled !== 'boolean') {
|
|
return badRequest(res, 'promo_enabled must be a boolean', 'VALIDATION_ERROR');
|
|
}
|
|
|
|
await dbRun(
|
|
`INSERT INTO _app_settings (key, value, updated_by, updated_at)
|
|
VALUES (?, ?::jsonb, ?, NOW())
|
|
ON CONFLICT (key) DO UPDATE
|
|
SET value = EXCLUDED.value,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = NOW()`,
|
|
[PROMO_FLAG_KEY, JSON.stringify(promo_enabled), req.user.id]
|
|
);
|
|
|
|
apiLogger.info({ userId: req.user.id, promo_enabled }, 'Promo settings updated');
|
|
return success(res, { promo_enabled });
|
|
} catch (error) {
|
|
apiLogger.error('Error updating promo settings:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
// In-memory storage for verification codes
|
|
const verificationCodes = new Map();
|
|
|
|
/**
|
|
* GET /api/v3/system/rate-limit-config
|
|
* Get current rate limit configuration
|
|
*/
|
|
router.get('/rate-limit-config', ownerOnly, async (req, res) => {
|
|
try {
|
|
// Get from system_settings table or return defaults
|
|
const setting = await dbGet(
|
|
"SELECT value FROM system_settings WHERE key = 'rate_limit_config'"
|
|
);
|
|
|
|
const config = setting?.value
|
|
? (typeof setting.value === 'string' ? JSON.parse(setting.value) : setting.value)
|
|
: {
|
|
auth_max_attempts: 10,
|
|
auth_window_minutes: 15,
|
|
global_max_requests: 1000,
|
|
global_window_minutes: 15
|
|
};
|
|
|
|
return success(res, config);
|
|
} catch (error) {
|
|
apiLogger.error('Error fetching rate limit config:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/v3/system/rate-limit-config
|
|
* Update rate limit configuration
|
|
*/
|
|
router.put('/rate-limit-config', ownerOnly, async (req, res) => {
|
|
try {
|
|
const { auth_max_attempts, auth_window_minutes, global_max_requests, global_window_minutes } = req.body;
|
|
|
|
// Validate
|
|
if (auth_max_attempts < 1 || auth_max_attempts > 100) {
|
|
return badRequest(res, 'auth_max_attempts must be between 1 and 100', 'VALIDATION_ERROR');
|
|
}
|
|
|
|
const config = {
|
|
auth_max_attempts: parseInt(auth_max_attempts) || 10,
|
|
auth_window_minutes: parseInt(auth_window_minutes) || 15,
|
|
global_max_requests: parseInt(global_max_requests) || 1000,
|
|
global_window_minutes: parseInt(global_window_minutes) || 15
|
|
};
|
|
|
|
// Ensure system_settings table exists
|
|
await dbRun(`
|
|
CREATE TABLE IF NOT EXISTS system_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
// Upsert setting
|
|
await dbRun(
|
|
`INSERT INTO system_settings (key, value, updated_at)
|
|
VALUES ('rate_limit_config', ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
|
[JSON.stringify(config)]
|
|
);
|
|
|
|
apiLogger.info({ userId: req.user.id, config }, 'Rate limit config updated');
|
|
|
|
return success(res, { ...config, message: 'Rate limit configuration updated. Restart server to apply changes.' });
|
|
} catch (error) {
|
|
apiLogger.error('Error updating rate limit config:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/system/info
|
|
* Get system info (owner only)
|
|
*/
|
|
router.get('/info', ownerOnly, async (req, res) => {
|
|
try {
|
|
const userCount = await dbGet('SELECT COUNT(*) as count FROM users');
|
|
const spaceCount = await dbGet('SELECT COUNT(*) as count FROM spaces');
|
|
const projectCount = await dbGet('SELECT COUNT(*) as count FROM projects');
|
|
|
|
return success(res, {
|
|
version: process.env.npm_package_version || '0.003.000',
|
|
node_version: process.version,
|
|
environment: process.env.NODE_ENV || 'development',
|
|
database: 'PostgreSQL',
|
|
stats: {
|
|
users: userCount?.count || 0,
|
|
spaces: spaceCount?.count || 0,
|
|
projects: projectCount?.count || 0
|
|
}
|
|
});
|
|
} catch (error) {
|
|
apiLogger.error('Error fetching system info:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/system/settings
|
|
* Get system settings (non-sensitive only)
|
|
*/
|
|
router.get('/settings', ownerOnly, async (req, res) => {
|
|
try {
|
|
const settings = await dbAll(
|
|
'SELECT key, value FROM system_settings WHERE key NOT LIKE ?',
|
|
['%password%']
|
|
);
|
|
|
|
const settingsObj = {};
|
|
for (const setting of settings) {
|
|
settingsObj[setting.key] = setting.value;
|
|
}
|
|
|
|
return success(res, settingsObj);
|
|
} catch (error) {
|
|
apiLogger.error('Error fetching system settings:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/system/smtp-settings
|
|
* Save SMTP configuration and send verification email
|
|
*/
|
|
router.post('/smtp-settings', ownerOnly, async (req, res) => {
|
|
try {
|
|
const { host, port, user, password, from } = req.body;
|
|
|
|
// Validate
|
|
if (!host || !port || !user || !password || !from) {
|
|
return badRequest(res, 'All SMTP fields are required', 'VALIDATION_ERROR');
|
|
}
|
|
|
|
const smtpConfig = { host, port: parseInt(port), user, password, from };
|
|
|
|
// Validate SMTP config
|
|
try {
|
|
SMTPService.validate(smtpConfig);
|
|
} catch (validationError) {
|
|
return badRequest(res, validationError.message, 'VALIDATION_ERROR');
|
|
}
|
|
|
|
// Generate verification code
|
|
const code = SMTPService.generateVerificationCode();
|
|
|
|
// Send test email
|
|
const emailResult = await SMTPService.sendTestEmail(
|
|
smtpConfig,
|
|
req.user.email,
|
|
code
|
|
);
|
|
|
|
if (!emailResult.success) {
|
|
return badRequest(res, 'Failed to send test email: ' + emailResult.error, 'SMTP_SEND_FAILED');
|
|
}
|
|
|
|
// Store verification code (10 minutes)
|
|
verificationCodes.set(req.user.id, {
|
|
code,
|
|
config: smtpConfig,
|
|
expiresAt: Date.now() + 10 * 60 * 1000
|
|
});
|
|
|
|
// Auto-cleanup
|
|
setTimeout(() => verificationCodes.delete(req.user.id), 10 * 60 * 1000);
|
|
|
|
apiLogger.info({ userId: req.user.id, host }, 'SMTP verification email sent');
|
|
|
|
return success(res, { message: 'Verification code sent to ' + req.user.email });
|
|
} catch (error) {
|
|
apiLogger.error('Error saving SMTP settings:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/system/smtp-verify
|
|
* Verify code and save SMTP configuration
|
|
*/
|
|
router.post('/smtp-verify', ownerOnly, async (req, res) => {
|
|
try {
|
|
const { code } = req.body;
|
|
|
|
if (!code) {
|
|
return badRequest(res, 'Verification code is required', 'CODE_REQUIRED');
|
|
}
|
|
|
|
const verificationData = verificationCodes.get(req.user.id);
|
|
|
|
if (!verificationData) {
|
|
return badRequest(res, 'Verification code expired or not found', 'CODE_EXPIRED');
|
|
}
|
|
|
|
if (Date.now() > verificationData.expiresAt) {
|
|
verificationCodes.delete(req.user.id);
|
|
return badRequest(res, 'Verification code has expired', 'CODE_EXPIRED');
|
|
}
|
|
|
|
if (code !== verificationData.code) {
|
|
return badRequest(res, 'Invalid verification code', 'INVALID_CODE');
|
|
}
|
|
|
|
// Encrypt password
|
|
const encryptionKey = process.env.ENCRYPTION_KEY || process.env.JWT_SECRET;
|
|
const encryptedPassword = CryptoJS.AES.encrypt(
|
|
verificationData.config.password,
|
|
encryptionKey
|
|
).toString();
|
|
|
|
// Save SMTP config
|
|
const smtpConfigJson = JSON.stringify({
|
|
host: verificationData.config.host,
|
|
port: verificationData.config.port,
|
|
user: verificationData.config.user,
|
|
password: encryptedPassword,
|
|
from: verificationData.config.from
|
|
});
|
|
|
|
// Ensure table exists
|
|
await dbRun(`
|
|
CREATE TABLE IF NOT EXISTS system_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT,
|
|
encrypted INTEGER DEFAULT 0,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
|
|
await dbRun(
|
|
`INSERT INTO system_settings (key, value, encrypted, updated_at)
|
|
VALUES ('smtp_config', ?, 1, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
|
|
[smtpConfigJson]
|
|
);
|
|
|
|
await dbRun(
|
|
`INSERT INTO system_settings (key, value, encrypted, updated_at)
|
|
VALUES ('smtp_configured', 'true', 0, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = CURRENT_TIMESTAMP`
|
|
);
|
|
|
|
verificationCodes.delete(req.user.id);
|
|
|
|
apiLogger.info({ userId: req.user.id }, 'SMTP configured successfully');
|
|
|
|
return success(res, { message: 'SMTP configured successfully' });
|
|
} catch (error) {
|
|
apiLogger.error('Error verifying SMTP:', error);
|
|
return serverError(res, error.message);
|
|
}
|
|
});
|
|
|
|
export default router;
|