Files
SolidScribe/server/helpers/Schema.js
2026-11-03 12:50:32 -07:00

240 lines
7.7 KiB
JavaScript

//
// 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 '<app user>' }
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 || '<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
}