96 lines
4.3 KiB
JavaScript
96 lines
4.3 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { createRequire } from 'node:module'
|
|
|
|
export function dependency(name) {
|
|
const root = process.env.TANG_COS_TOOLS_DIR
|
|
return createRequire(root ? path.join(path.resolve(root), 'package.json') : import.meta.url)(name)
|
|
}
|
|
|
|
// Fail closed rather than evaluating PHP or guessing deployment settings.
|
|
export function configuredDatabase(serverDirectory, environment = process.env) {
|
|
const filename = path.join(serverDirectory, 'config/database.php')
|
|
const source = fs.readFileSync(filename, 'utf8')
|
|
if (fs.existsSync(path.join(serverDirectory, '.env'))) {
|
|
throw new Error('SERVER_ENV_REQUIRES_NATIVE_RUNTIME')
|
|
}
|
|
function value(key) {
|
|
const envKey = `DATABASE_${key.toUpperCase()}`
|
|
if (environment[envKey] !== undefined) return environment[envKey]
|
|
if (environment[`PHP_${envKey}`] !== undefined) return environment[`PHP_${envKey}`]
|
|
const match = source.match(new RegExp(`env\\('database\\.${key}',\\s*'((?:\\\\.|[^'\\\\])*)'\\)`))
|
|
if (!match) throw new Error('UNSUPPORTED_DATABASE_CONFIG')
|
|
return match[1].replace(/\\([\\'])/g, '$1')
|
|
}
|
|
const prefix = value('prefix')
|
|
const port = Number(value('hostport'))
|
|
if (!/^[a-zA-Z0-9_]+$/.test(prefix) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
throw new Error('INVALID_DATABASE_CONFIG')
|
|
}
|
|
const options = {
|
|
host: value('hostname'), port, database: value('database'), user: value('username'), password: value('password'),
|
|
connectTimeout: 8000, multipleStatements: false,
|
|
ssl: { rejectUnauthorized: true, verifyIdentity: true },
|
|
}
|
|
// Supply only a trusted CA obtained from the server administrator; never fetch and trust a peer certificate.
|
|
if (environment.TANG_DB_CA_FILE) options.ssl.ca = fs.readFileSync(environment.TANG_DB_CA_FILE, 'utf8')
|
|
return { options, prefix }
|
|
}
|
|
|
|
export function validateCosConfig(config, driver) {
|
|
if (driver !== 'qcloud') throw new Error('CONFIGURED_DRIVER_IS_NOT_COS')
|
|
if (!/^[a-z0-9][a-z0-9-]*-\d+$/.test(config.bucket || '') || !/^[a-z][a-z0-9-]+$/.test(config.region || '')) {
|
|
throw new Error('INVALID_COS_DESTINATION')
|
|
}
|
|
if (typeof config.access_key !== 'string' || !config.access_key || typeof config.secret_key !== 'string' || !config.secret_key) {
|
|
throw new Error('COS_CREDENTIALS_MISSING')
|
|
}
|
|
const rawDomain = String(config.domain || '').trim()
|
|
const base = new URL(rawDomain ? (/^https?:\/\//i.test(rawDomain) ? rawDomain : `https://${rawDomain}`)
|
|
: `https://${config.bucket}.cos.${config.region}.myqcloud.com`)
|
|
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) {
|
|
throw new Error('COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL')
|
|
}
|
|
return { ...config, baseUrl: base.href.replace(/\/+$/, '') }
|
|
}
|
|
|
|
export async function withDeadline(operation, milliseconds, abort, code) {
|
|
let timer
|
|
try {
|
|
return await Promise.race([operation, new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
try { abort() } catch {}
|
|
reject(new Error(code))
|
|
}, milliseconds)
|
|
})])
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
export async function readCosConfig(serverDirectory) {
|
|
const mysql = dependency('mysql2')
|
|
const { options, prefix } = configuredDatabase(serverDirectory)
|
|
let connection
|
|
let destroyed = false
|
|
const abort = () => { destroyed = true; try { connection?.destroy() } catch {} }
|
|
try {
|
|
connection = mysql.createConnection(options).promise()
|
|
await withDeadline(connection.connect(), 8000, abort, 'DATABASE_CONNECT_TIMEOUT')
|
|
const [rows] = await withDeadline(connection.execute(`SELECT name,value FROM \`${prefix}config\` WHERE type=? AND name IN (?,?)`,
|
|
['storage', 'default', 'qcloud']), 8000, abort, 'DATABASE_QUERY_TIMEOUT')
|
|
const config = JSON.parse(rows.find(row => row.name === 'qcloud')?.value || '{}')
|
|
return validateCosConfig(config, rows.find(row => row.name === 'default')?.value)
|
|
} finally {
|
|
if (connection && !destroyed) {
|
|
try { await withDeadline(connection.end(), 2000, abort, 'DATABASE_CLOSE_TIMEOUT') } catch { abort() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Never print raw MySQL/COS errors: they may contain connection values or signed request URLs.
|
|
export function safeError(error) {
|
|
const code = String(error?.code || error?.message || '')
|
|
return /^[A-Z][A-Z0-9_]{2,80}$/.test(code) ? code : 'REDACTED_OPERATION_ERROR'
|
|
}
|