Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.
- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
row), contact recipient = contact_email setting, mailto: fallback preserved,
plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
+ PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
115 lines
4.4 KiB
JavaScript
115 lines
4.4 KiB
JavaScript
require('dotenv').config()
|
|
const http = require('http')
|
|
|
|
const app = require('./app')
|
|
const internalApp = require('./internalApp')
|
|
const botScore = require('./middleware/botScore')
|
|
const { ensureSchema, close } = require('./utils/db')
|
|
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
|
const settings = require('./model/settings/settings.model')
|
|
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
|
const createLogger = require('./utils/logger')
|
|
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
|
const pkg = require('../package.json')
|
|
|
|
const log = createLogger('server')
|
|
const PORT = Number(process.env.PORT) || 3000
|
|
// Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off
|
|
// the public PORT so the decrypted-token route can't ride the listener Pangolin
|
|
// proxies to the world (issue #33). Must match the port in the bot's
|
|
// SITE_INTERNAL_URL (docker-compose.yml).
|
|
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
|
|
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it
|
|
|
|
async function start() {
|
|
log.info(`starting UOMysticmoon server v${pkg.version}`, {
|
|
node: process.version,
|
|
env: process.env.NODE_ENV || 'development',
|
|
logLevel: process.env.LOG_LEVEL || 'info',
|
|
logFile: createLogger.logFilePath || 'disabled (console only)',
|
|
db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'uomysticmoon'}`,
|
|
cookieSecure: process.env.COOKIE_SECURE || 'auto',
|
|
email: 'gmail-oauth2 (configured in admin → settings)',
|
|
})
|
|
|
|
// Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
|
|
// production (the /internal/bot-config route hands back the decrypted Discord
|
|
// token and this key is its only guard); a warning otherwise.
|
|
const keyCheck = evaluateBotInternalKey({
|
|
key: process.env.BOT_INTERNAL_KEY,
|
|
nodeEnv: process.env.NODE_ENV,
|
|
})
|
|
if (keyCheck.fatal) {
|
|
log.error(keyCheck.message)
|
|
process.exit(1)
|
|
} else if (!keyCheck.ok) {
|
|
log.warn(keyCheck.message)
|
|
}
|
|
|
|
log.info('ensuring database schema...')
|
|
await ensureSchema()
|
|
log.info('seeding defaults...')
|
|
await seedDefaults()
|
|
await createInitialAdminFromEnv()
|
|
|
|
// Clear out session-denylist rows whose token has already expired (dead weight).
|
|
// Best-effort — a prune failure must never block startup.
|
|
try {
|
|
const pruned = await revokedSessions.pruneExpired()
|
|
if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`)
|
|
} catch (err) {
|
|
log.warn('revoked-session prune failed', { error: err.message })
|
|
}
|
|
|
|
const mode = await settings.get('site_mode')
|
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
|
|
|
const server = http.createServer(app)
|
|
server.listen(PORT, HOST, () => {
|
|
log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`)
|
|
})
|
|
|
|
// Internal server<->bot API on a separate, unpublished port. NEVER expose this
|
|
// through Pangolin/the public reverse proxy — it serves the decrypted Discord
|
|
// bot token to the bot process over the private compose network only (#33).
|
|
const internalServer = http.createServer(internalApp)
|
|
internalServer.listen(INTERNAL_PORT, HOST, () => {
|
|
log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`)
|
|
})
|
|
|
|
setupShutdown(server, internalServer)
|
|
}
|
|
|
|
function setupShutdown(server, internalServer) {
|
|
let closing = false
|
|
const shutdown = async (signal) => {
|
|
if (closing) return
|
|
closing = true
|
|
log.warn(`${signal} received — shutting down gracefully`)
|
|
botScore.stopSweeper() // stop the bot-store cleanup interval
|
|
server.close(() => log.info('http server closed'))
|
|
if (internalServer) internalServer.close(() => log.info('internal http server closed'))
|
|
try {
|
|
await close()
|
|
log.info('database pool closed')
|
|
} catch (err) {
|
|
log.error('error closing database pool', err)
|
|
}
|
|
await createLogger.close() // flush the log file
|
|
process.exit(0)
|
|
}
|
|
|
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
|
|
process.on('uncaughtException', (err) => {
|
|
log.error('uncaughtException', err)
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
start().catch((err) => {
|
|
log.error('failed to start server', err)
|
|
process.exit(1)
|
|
})
|