// // Verification of the scraper's schema and privileges. Issues NO DDL. // // This module used to converge the schema at boot with CREATE TABLE / ALTER // TABLE. That worked only because dev connects as root (shell.nix exports // DB_USER=root); on production, which runs as a least-privilege `app` user, it // failed with: // // CREATE command denied to user 'app'@'localhost' for table scrape_cache // // The restricted account is correct - the server ingests untrusted URLs and // should not be able to reshape its own tables. So DDL moved out of the // application entirely: an admin runs ./migrateDatabase.sh once, and this // module only checks that the result is present and usable. // // Note on why the DDL is REMOVED rather than guarded by an existence check: // MariaDB evaluates the CREATE privilege before IF NOT EXISTS, so // `CREATE TABLE IF NOT EXISTS` would keep erroring for a DML-only user on every // boot even once the table exists. // const db = require('@config/database') let Schema = module.exports = {} const REQUIRED_TABLE = 'scrape_cache' const REQUIRED_COLUMNS = ['scrape_state', 'scrape_attempts', 'enqueued_at'] const REQUIRED_INDEX = 'attachment_scrape_state' // // The FULLTEXT index backing Attachment.textSearch's MATCH..AGAINST. // // Checked by TYPE, not just existence: Prisma's `@@fulltext` is a preview // feature and MariaDB is not among its documented databases, so a future // migration could plausibly emit a plain BTREE index of the same name. That // would make attachment search return nothing at all, with no error anywhere - // so it is worth asserting the type explicitly on every boot. // const REQUIRED_FULLTEXT = { table: 'attachment', index: 'attachment_text_index' } // The scraper writes and deletes, not just reads. DELETE is genuinely needed: // terminal failures drop the shell attachment row (ScrapeQueue), and // ScrapeCache.purgeExpired removes expired rows. const REQUIRED_PRIVILEGES = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] // Set by verifyScrapeSchema so the per-request paths can check a flag rather // than querying information_schema on every note save. let ready = false let lastResult = null Schema.isScrapeSchemaReady = () => ready Schema.lastVerification = () => lastResult // Test seam. Schema.__setReady = (value) => { ready = !!value } Schema.__reset = () => { ready = false; lastResult = null } const tableExists = async (table) => { const [rows] = await db.promise().query( `SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1`, [table] ) return rows.length > 0 } const columnExists = async (table, column) => { const [rows] = await db.promise().query( `SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`, [table, column] ) return rows.length > 0 } // // Does this index exist AND is it actually FULLTEXT? // const fulltextIndexExists = async (table, index) => { const [rows] = await db.promise().query( `SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? AND INDEX_TYPE = 'FULLTEXT' LIMIT 1`, [table, index] ) return rows.length > 0 } const indexExists = async (table, index) => { const [rows] = await db.promise().query( `SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1`, [table, index] ) return rows.length > 0 } Schema.__tableExists = tableExists Schema.__columnExists = columnExists Schema.__indexExists = indexExists Schema.__fulltextIndexExists = fulltextIndexExists // // Parse SHOW GRANTS output into the set of privileges that apply to this // database. Any user may run SHOW GRANTS FOR CURRENT_USER(), so this needs no // privileges of its own. // // Grant lines look like: // GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost` WITH GRANT OPTION // GRANT SELECT, INSERT, UPDATE, DELETE ON `application`.* TO `app`@`localhost` // GRANT SELECT ON `application`.`attachment` TO `app`@`localhost` // GRANT USAGE ON *.* TO `app`@`localhost` // const PRIVILEGE_PATTERN = /^GRANT\s+(.+?)\s+ON\s+(\S+)\s+TO\s/i Schema.parseGrants = (lines, database, tables) => { const granted = new Set() // Scope strings that cover our tables. Backticks are optional in output. const strip = (value) => value.replace(/`/g, '') const relevantScopes = new Set([ '*.*', `${database}.*`, ...tables.map(table => `${database}.${table}`), ]) for (const line of lines) { const match = String(line).match(PRIVILEGE_PATTERN) if (!match) { continue } const scope = strip(match[2]) if (!relevantScopes.has(scope)) { continue } // Drop any column-level qualifiers, e.g. "SELECT (id, url)". const privileges = match[1].replace(/\s*\([^)]*\)/g, '') for (const privilege of privileges.split(',')) { const name = privilege.trim().toUpperCase() if (!name || name === 'USAGE') { continue } if (name === 'ALL PRIVILEGES' || name === 'ALL') { REQUIRED_PRIVILEGES.forEach(required => granted.add(required)) continue } granted.add(name) } } return granted } const checkPrivileges = async () => { let lines = [] try { const [rows] = await db.promise().query('SHOW GRANTS FOR CURRENT_USER()') // Each row is an object with a single, server-named column. lines = rows.map(row => Object.values(row)[0]) } catch (error) { // If we cannot read our own grants, do not block on a guess - the // object checks above are the real gate. console.log('[schema] could not read grants, skipping privilege check:', error.message) return { missing: [], database: null, currentUser: null } } const [dbRows] = await db.promise().query('SELECT DATABASE() AS name, CURRENT_USER() AS who') const database = dbRows[0] && dbRows[0].name const currentUser = dbRows[0] && dbRows[0].who const granted = Schema.parseGrants(lines, database, ['attachment', REQUIRED_TABLE]) return { missing: REQUIRED_PRIVILEGES.filter(privilege => !granted.has(privilege)), database, currentUser, } } // 'app@localhost' -> "'app'@'localhost'", so the remedy is copy-pasteable. const quoteUser = (currentUser) => { if (!currentUser) { return '' } const [name, host] = String(currentUser).split('@') return `'${name}'@'${host || 'localhost'}'` } // // Check that everything the scraper needs is present. Never mutates anything. // // Returns { ok, missingObjects, missingPrivileges, remedy }. // Schema.verifyScrapeSchema = async () => { const missingObjects = [] if (!await tableExists(REQUIRED_TABLE)) { missingObjects.push(`table ${REQUIRED_TABLE}`) } for (const column of REQUIRED_COLUMNS) { if (!await columnExists('attachment', column)) { missingObjects.push(`attachment.${column}`) } } if (!await indexExists('attachment', REQUIRED_INDEX)) { missingObjects.push(`attachment index ${REQUIRED_INDEX}`) } if (!await fulltextIndexExists(REQUIRED_FULLTEXT.table, REQUIRED_FULLTEXT.index)) { missingObjects.push(`FULLTEXT index ${REQUIRED_FULLTEXT.table}.${REQUIRED_FULLTEXT.index}`) } const privileges = await checkPrivileges() const missingPrivileges = privileges.missing || [] const remedy = [] if (missingObjects.length) { remedy.push('run ./migrateDatabase.sh as a database admin') } if (missingPrivileges.length) { // Name the real database and user so this can be pasted straight into // a mysql prompt rather than decoded first. const target = `${privileges.database || ''}.*` remedy.push( `GRANT ${missingPrivileges.join(', ')} ON ${target} TO ${quoteUser(privileges.currentUser)}` ) } lastResult = { ok: missingObjects.length === 0 && missingPrivileges.length === 0, missingObjects, missingPrivileges, remedy: remedy.join('; '), } ready = lastResult.ok return lastResult }