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' }