243 lines
6.6 KiB
JavaScript
243 lines
6.6 KiB
JavaScript
const crypto = require('node:crypto')
|
|
const fs = require('node:fs')
|
|
const http = require('node:http')
|
|
const https = require('node:https')
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
const { spawn } = require('node:child_process')
|
|
|
|
function loadEnvFile() {
|
|
const envPath = path.join(__dirname, '.env')
|
|
if (!fs.existsSync(envPath)) return
|
|
|
|
const lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/)
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed || trimmed.startsWith('#')) continue
|
|
|
|
const separatorIndex = trimmed.indexOf('=')
|
|
if (separatorIndex === -1) continue
|
|
|
|
const key = trimmed.slice(0, separatorIndex).trim()
|
|
let value = trimmed.slice(separatorIndex + 1).trim()
|
|
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1)
|
|
}
|
|
|
|
if (key && (!process.env[key] || process.env[key] === '')) {
|
|
process.env[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
loadEnvFile()
|
|
|
|
const PORT = Number(process.env.WEBHOOK_PORT || 3011)
|
|
const SECRET = process.env.GITHUB_WEBHOOK_SECRET || ''
|
|
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL || ''
|
|
const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web')
|
|
.split(',')
|
|
.map((target) => target.trim())
|
|
.filter(Boolean)
|
|
const RESTART_AFTER_MS = 24 * 60 * 60 * 1000
|
|
|
|
let deploying = false
|
|
|
|
function json(res, status, body) {
|
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
res.end(JSON.stringify(body))
|
|
}
|
|
|
|
function verifySignature(rawBody, signature) {
|
|
if (!SECRET) return true
|
|
if (!signature || !signature.startsWith('sha256=')) return false
|
|
|
|
const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
|
|
const signatureBuffer = Buffer.from(signature)
|
|
const expectedBuffer = Buffer.from(expected)
|
|
|
|
return (
|
|
signatureBuffer.length === expectedBuffer.length &&
|
|
crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
|
|
)
|
|
}
|
|
|
|
function commandFor(command) {
|
|
if (process.platform !== 'win32') return command
|
|
if (command === 'npm') return 'npm.cmd'
|
|
if (command === 'pm2') return 'pm2.cmd'
|
|
return command
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const label = [command, ...args].join(' ')
|
|
console.log(`deploy step started: ${label}`)
|
|
|
|
const child = spawn(commandFor(command), args, {
|
|
cwd: __dirname,
|
|
env: { ...process.env, ...options.env },
|
|
shell: process.platform === 'win32',
|
|
stdio: 'inherit',
|
|
})
|
|
|
|
child.on('error', reject)
|
|
child.on('exit', (code) => {
|
|
if (code === 0) {
|
|
console.log(`deploy step completed: ${label}`)
|
|
resolve()
|
|
} else {
|
|
reject(new Error(`${label} exited with ${code}`))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async function ensureBuildDependencies() {
|
|
await run('npm', ['install', '--production=false', '--include=dev', '--omit=optional'], {
|
|
env: {
|
|
NODE_ENV: 'development',
|
|
npm_config_include: 'dev',
|
|
npm_config_omit: 'optional',
|
|
npm_config_production: 'false',
|
|
},
|
|
})
|
|
|
|
try {
|
|
require.resolve('@vitejs/plugin-react')
|
|
} catch {
|
|
throw new Error('@vitejs/plugin-react is missing after npm install; dev dependencies were not installed')
|
|
}
|
|
}
|
|
|
|
function postDiscordWebhook(payload) {
|
|
if (!DISCORD_WEBHOOK_URL) return Promise.resolve()
|
|
|
|
return new Promise((resolve) => {
|
|
let url
|
|
try {
|
|
url = new URL(DISCORD_WEBHOOK_URL)
|
|
} catch (error) {
|
|
console.error('Discord restart webhook URL is invalid:', error.message)
|
|
resolve()
|
|
return
|
|
}
|
|
|
|
const body = Buffer.from(JSON.stringify(payload))
|
|
const client = url.protocol === 'http:' ? http : https
|
|
const req = client.request(
|
|
url,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'content-length': body.length,
|
|
'user-agent': 'tssbot-webhook',
|
|
},
|
|
timeout: 5000,
|
|
},
|
|
(res) => {
|
|
res.resume()
|
|
res.on('end', resolve)
|
|
},
|
|
)
|
|
|
|
req.on('timeout', () => {
|
|
req.destroy(new Error('Discord restart webhook timed out'))
|
|
})
|
|
req.on('error', (error) => {
|
|
console.error('Discord restart webhook failed:', error.message)
|
|
resolve()
|
|
})
|
|
req.end(body)
|
|
})
|
|
}
|
|
|
|
function notifyDiscordRestart() {
|
|
const startedAt = new Date()
|
|
|
|
postDiscordWebhook({
|
|
username: 'tssbot webhook',
|
|
embeds: [
|
|
{
|
|
title: 'Webhook listener restarted',
|
|
color: 0x00f2ff,
|
|
fields: [
|
|
{ name: 'Host', value: os.hostname(), inline: true },
|
|
{ name: 'Port', value: String(PORT), inline: true },
|
|
{ name: 'Restart targets', value: RESTART_TARGETS.join(', ') || 'none', inline: false },
|
|
],
|
|
timestamp: startedAt.toISOString(),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
async function deploy() {
|
|
await run('git', ['pull', '--ff-only'])
|
|
await ensureBuildDependencies()
|
|
await run('npm', ['run', 'build'])
|
|
|
|
for (const target of RESTART_TARGETS) {
|
|
await run('pm2', ['reload', target, '--update-env'])
|
|
}
|
|
}
|
|
|
|
http
|
|
.createServer((req, res) => {
|
|
if (req.method === 'GET' && req.url === '/health') {
|
|
json(res, 200, { ok: true, deploying, restart_targets: RESTART_TARGETS })
|
|
return
|
|
}
|
|
|
|
if (req.method !== 'POST' || req.url !== '/github') {
|
|
json(res, 404, { error: 'Not found' })
|
|
return
|
|
}
|
|
|
|
const chunks = []
|
|
req.on('data', (chunk) => chunks.push(chunk))
|
|
req.on('end', () => {
|
|
const rawBody = Buffer.concat(chunks)
|
|
|
|
if (!verifySignature(rawBody, req.headers['x-hub-signature-256'])) {
|
|
json(res, 401, { error: 'Invalid signature' })
|
|
return
|
|
}
|
|
|
|
if (req.headers['x-github-event'] !== 'push') {
|
|
json(res, 202, { skipped: true, reason: 'Only push events trigger deploys' })
|
|
return
|
|
}
|
|
|
|
if (deploying) {
|
|
json(res, 202, { queued: false, deploying: true })
|
|
return
|
|
}
|
|
|
|
deploying = true
|
|
json(res, 202, { accepted: true, restart_targets: RESTART_TARGETS })
|
|
|
|
deploy()
|
|
.then(() => console.log('GitHub push deploy completed'))
|
|
.catch((error) => console.error('GitHub push deploy failed:', error))
|
|
.finally(() => {
|
|
deploying = false
|
|
})
|
|
})
|
|
})
|
|
.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`tssbot webhook listening on http://localhost:${PORT}/github`)
|
|
console.log(`restart targets: ${RESTART_TARGETS.join(', ')}`)
|
|
notifyDiscordRestart()
|
|
})
|
|
|
|
setTimeout(() => {
|
|
console.log('24 hour webhook refresh reached; exiting for PM2 restart')
|
|
process.exit(0)
|
|
}, RESTART_AFTER_MS).unref()
|