Files
website/server/src/utils/db.js
Claude 7a21cc636c Add Discord bot (moderation, filters, scheduling, roles, invites, site integration)
Standalone bot/ service (its own package.json/Dockerfile) managed entirely
through a new admin-only Discord Bot panel — token stored encrypted in the
DB and pushed to the bot process in-memory, never an env var. Built in
phases, each independently verified against a live Discord guild:

- Bot skeleton: gateway connection, internal shared-secret API, self-heals
  on its own restart by pulling config from the site
- Moderation core: /ban /kick /mute /warn /warnings + mod-log channel
- Word/invite/spam filtering with leetspeak-resistant normalization and a
  staff role/channel allowlist
- Scheduled messages: recurring (cron) and one-off channel posts
- Role assignment: button role menus, auto-role on join, temp roles,
  bulk role ops
- Auto-rotating primary invite with an audit log
- Site integration: news-publish -> Discord announce webhook, manual
  /announce, read-only /wiki search

Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb
driver defaulted to timezone 'local', silently mis-serializing bound Date
params by the host's local offset instead of the DB's UTC session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:54:41 -05:00

86 lines
2.7 KiB
JavaScript

const fs = require('fs')
const path = require('path')
const mariadb = require('mariadb')
require('dotenv').config()
const log = require('./logger')('db')
const pool = mariadb.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT) || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'uomysticmoon',
connectionLimit: 5,
// Return plain JS numbers, never BigInt — keeps JSON responses clean.
insertIdAsNumber: true,
bigIntAsNumber: true,
decimalAsNumber: true,
// The driver defaults to 'local' — silently serializing bound JS Date
// params using the HOST MACHINE's local offset instead of the DB session's
// timezone (discovered via the Discord bot's temp_roles.expires_at coming
// back hours off in dev). 'auto' negotiates the actual session timezone so
// Date round-trips correctly regardless of host TZ — affects any write of
// a JS Date param, e.g. botConfig.model.js's last_connected_at.
timezone: 'auto',
})
/**
* Run a parameterized query and release the connection.
* @param {string} sql
* @param {Array} [params]
*/
async function query(sql, params) {
const conn = await pool.getConnection()
try {
return await conn.query(sql, params)
} finally {
conn.release()
}
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
* Create tables if they do not exist. Idempotent. Retries while the DB is still
* coming up (important under docker-compose even with a healthcheck).
*/
async function ensureSchema({ retries = 10, delayMs = 2000 } = {}) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const conn = await pool.getConnection()
try {
const sql = fs.readFileSync(SCHEMA_PATH, 'utf8')
// Strip full-line comments first, then split — so a leading comment block
// doesn't get glued onto (and discard) the statement that follows it.
const statements = sql
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0)
for (const statement of statements) {
await conn.query(statement)
}
log.info('schema ensured')
return
} finally {
conn.release()
}
} catch (err) {
if (attempt === retries) throw err
log.warn(`database not ready, retrying (attempt ${attempt}/${retries})`, {
code: err.code || err.message,
})
await new Promise((r) => setTimeout(r, delayMs))
}
}
}
async function close() {
await pool.end()
}
module.exports = { pool, query, ensureSchema, close }