Files
2026-09-09 14:47:29 +08:00

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