update osm
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)"
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm audit *)",
|
||||
"Bash(node --check server.cjs)",
|
||||
"Bash(node --check webhook.cjs)",
|
||||
"Bash(node --check ecosystem.config.cjs)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ locally and only proxies the API routes used by the app:
|
||||
|
||||
The proxy blocks cross-origin/API-navigation requests, strips CORS headers from
|
||||
the upstream response, rate limits callers, and caches successful GET responses
|
||||
briefly so public page traffic does not hammer the upstream API.
|
||||
briefly so public page traffic does not hammer the upstream API. All responses
|
||||
ship `X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy`,
|
||||
`Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
|
||||
HSTS (over HTTPS), and HTML responses include a Content Security Policy that
|
||||
allows only Cloudflare Turnstile and the CARTO basemap tiles.
|
||||
|
||||
Override the API target before starting PM2 if needed:
|
||||
|
||||
@@ -68,6 +72,22 @@ API_RATE_LIMIT_WINDOW_MS=60000
|
||||
API_RATE_LIMIT_MAX=120
|
||||
```
|
||||
|
||||
## Reverse proxy / Cloudflare
|
||||
|
||||
The server only honours `CF-Connecting-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`,
|
||||
and the Cloudflare geolocation headers when the immediate TCP peer is listed in
|
||||
`TRUSTED_UPSTREAM_IPS`. Bind the server to `127.0.0.1` (or leave it on `0.0.0.0`
|
||||
behind a firewall) and front it with nginx/Cloudflare for the configuration to
|
||||
take effect:
|
||||
|
||||
```sh
|
||||
TRUST_PROXY=cloudflare
|
||||
TRUSTED_UPSTREAM_IPS=127.0.0.1,::1,::ffff:127.0.0.1
|
||||
```
|
||||
|
||||
Set `TRUST_PROXY=none` if the server is exposed directly. Without it, an attacker
|
||||
that reaches the app port can spoof client-IP headers to bypass rate limiting.
|
||||
|
||||
## Uptime snapshots
|
||||
|
||||
The production server samples uptime every 30 minutes and exposes the history at
|
||||
@@ -120,14 +140,32 @@ setup, and actual data fields.
|
||||
The webhook process listens on port `3011` at `/github`. Configure GitHub to send
|
||||
push events there.
|
||||
|
||||
Set a webhook secret before starting PM2 if you want signature validation:
|
||||
A webhook secret is required — without `GITHUB_WEBHOOK_SECRET`, the webhook
|
||||
rejects every request:
|
||||
|
||||
```sh
|
||||
GITHUB_WEBHOOK_SECRET=your-secret pm2 start ecosystem.config.cjs
|
||||
```
|
||||
|
||||
On PowerShell, set `$env:GITHUB_WEBHOOK_SECRET = "your-secret"` before starting
|
||||
PM2, or put the value directly in `ecosystem.config.cjs`.
|
||||
PM2, or put the value in a `.env` file in the project root (recommended over
|
||||
inlining the secret in a shell command, which writes it to shell history).
|
||||
|
||||
The webhook only deploys pushes whose `ref` is in `GITHUB_WEBHOOK_REFS`
|
||||
(default `refs/heads/main`). Optionally pin the repository:
|
||||
|
||||
```sh
|
||||
GITHUB_WEBHOOK_REFS=refs/heads/main
|
||||
GITHUB_WEBHOOK_REPOSITORY=owner/repo
|
||||
```
|
||||
|
||||
Deploys run `npm ci` (not `npm install`) so an attacker who compromises a
|
||||
dependency cannot quietly add new packages — the lockfile is the source of
|
||||
truth.
|
||||
|
||||
Set `DISCORD_INCLUDE_PATCH=true` only if the Discord channel is private; by
|
||||
default the patch preview is omitted from Discord notifications to avoid
|
||||
leaking diff contents.
|
||||
|
||||
The default deploy flow is:
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ module.exports = {
|
||||
API_CACHE_TTL_MS: process.env.API_CACHE_TTL_MS || 15000,
|
||||
API_RATE_LIMIT_WINDOW_MS: process.env.API_RATE_LIMIT_WINDOW_MS || 60000,
|
||||
API_RATE_LIMIT_MAX: process.env.API_RATE_LIMIT_MAX || 120,
|
||||
TRUST_PROXY: process.env.TRUST_PROXY || 'cloudflare',
|
||||
TRUSTED_UPSTREAM_IPS: process.env.TRUSTED_UPSTREAM_IPS || '127.0.0.1,::1,::ffff:127.0.0.1',
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY || '',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -27,8 +30,11 @@ module.exports = {
|
||||
NODE_ENV: 'production',
|
||||
WEBHOOK_PORT: process.env.WEBHOOK_PORT || 3011,
|
||||
GITHUB_WEBHOOK_SECRET: process.env.GITHUB_WEBHOOK_SECRET || '',
|
||||
GITHUB_WEBHOOK_REFS: process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main',
|
||||
GITHUB_WEBHOOK_REPOSITORY: process.env.GITHUB_WEBHOOK_REPOSITORY || '',
|
||||
PM2_RESTART_TARGETS: process.env.PM2_RESTART_TARGETS || 'tssbot-web',
|
||||
DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL || '',
|
||||
DISCORD_INCLUDE_PATCH: process.env.DISCORD_INCLUDE_PATCH || 'false',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+14
@@ -14,12 +14,26 @@ API_CACHE_TTL_MS=15000
|
||||
API_RATE_LIMIT_WINDOW_MS=60000
|
||||
API_RATE_LIMIT_MAX=120
|
||||
|
||||
# Proxy trust. Set to "cloudflare" when the app is fronted by Cloudflare (possibly via nginx).
|
||||
# "generic" trusts X-Forwarded-* without CF-specific headers. "none" ignores all proxy headers.
|
||||
# Only honors trusted headers when the immediate TCP peer is in TRUSTED_UPSTREAM_IPS.
|
||||
TRUST_PROXY=cloudflare
|
||||
TRUSTED_UPSTREAM_IPS=127.0.0.1,::1,::ffff:127.0.0.1
|
||||
|
||||
WEBHOOK_PORT=3011
|
||||
GITHUB_WEBHOOK_SECRET=change-me
|
||||
# Comma-separated refs allowed to trigger a deploy. Defaults to refs/heads/main.
|
||||
GITHUB_WEBHOOK_REFS=refs/heads/main
|
||||
# Optional: refuse pushes whose repository.full_name does not match (e.g. "owner/repo").
|
||||
GITHUB_WEBHOOK_REPOSITORY=
|
||||
PM2_RESTART_TARGETS=tssbot-web
|
||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||
# Set to "true" only if the Discord channel is private. Default omits the patch preview
|
||||
# from Discord notifications to avoid leaking diffs (including any inline secrets).
|
||||
DISCORD_INCLUDE_PATCH=true
|
||||
|
||||
# Cloudflare Turnstile. VITE_TURNSTILE_SITE_KEY is the public site key baked into the client bundle by Vite.
|
||||
# TURNSTILE_SECRET_KEY is the server-only secret used to call the Siteverify endpoint.
|
||||
# If left blank, the gated deletion endpoint is open (no challenge enforced). Set both to enforce.
|
||||
VITE_TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
+206
-15
@@ -60,6 +60,85 @@ const MAX_CACHE_ENTRIES = 200
|
||||
const MAX_RATE_LIMIT_KEYS = 1000
|
||||
const MAX_ANALYTICS_BODY_BYTES = 16 * 1024
|
||||
|
||||
const TRUST_PROXY = (() => {
|
||||
const raw = String(process.env.TRUST_PROXY ?? 'cloudflare').trim().toLowerCase()
|
||||
if (raw === '' || raw === 'false' || raw === '0' || raw === 'none') return 'none'
|
||||
if (raw === 'cloudflare' || raw === 'cf') return 'cloudflare'
|
||||
if (raw === 'true' || raw === '1' || raw === 'any' || raw === 'generic') return 'generic'
|
||||
return 'none'
|
||||
})()
|
||||
|
||||
const TRUSTED_UPSTREAM_IPS = new Set(
|
||||
String(process.env.TRUSTED_UPSTREAM_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1')
|
||||
.split(',')
|
||||
.map((ip) => ip.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
function requestPeerIsTrusted(req) {
|
||||
if (TRUST_PROXY === 'none') return false
|
||||
if (!TRUSTED_UPSTREAM_IPS.size) return true
|
||||
const peer = req.socket.remoteAddress || ''
|
||||
return TRUSTED_UPSTREAM_IPS.has(peer)
|
||||
}
|
||||
|
||||
const ANALYTICS_METADATA_ALLOWED_KEYS = new Set([
|
||||
'preferences',
|
||||
'request',
|
||||
'screen',
|
||||
'viewport',
|
||||
'pixelRatio',
|
||||
'colorDepth',
|
||||
'network',
|
||||
'privacy',
|
||||
'capability',
|
||||
'capabilities',
|
||||
'memory',
|
||||
'cpu',
|
||||
'touch',
|
||||
'hardware',
|
||||
'locale',
|
||||
'referrer',
|
||||
])
|
||||
|
||||
const SECURITY_HEADERS_BASE = {
|
||||
'x-content-type-options': 'nosniff',
|
||||
'x-frame-options': 'DENY',
|
||||
'referrer-policy': 'strict-origin-when-cross-origin',
|
||||
'permissions-policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()',
|
||||
'cross-origin-opener-policy': 'same-origin',
|
||||
'cross-origin-resource-policy': 'same-origin',
|
||||
}
|
||||
|
||||
const CSP_DIRECTIVES = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"object-src 'none'",
|
||||
"script-src 'self' https://challenges.cloudflare.com",
|
||||
"script-src-elem 'self' https://challenges.cloudflare.com",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://challenges.cloudflare.com",
|
||||
"frame-src https://challenges.cloudflare.com",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
'upgrade-insecure-requests',
|
||||
].join('; ')
|
||||
|
||||
function securityHeaders(req, { html = false } = {}) {
|
||||
const headers = { ...SECURITY_HEADERS_BASE }
|
||||
if (isHttpsRequest(req)) {
|
||||
headers['strict-transport-security'] = 'max-age=31536000; includeSubDomains'
|
||||
}
|
||||
if (html) {
|
||||
headers['content-security-policy'] = CSP_DIRECTIVES
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
const mimeTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -75,7 +154,8 @@ const mimeTypes = {
|
||||
}
|
||||
|
||||
function send(res, status, body, headers = {}) {
|
||||
res.writeHead(status, headers)
|
||||
const merged = { ...securityHeaders(res.req || { socket: {}, headers: {} }), ...headers }
|
||||
res.writeHead(status, merged)
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
@@ -363,17 +443,57 @@ function startUptimeSampler() {
|
||||
}, UPTIME_SAMPLE_INTERVAL_MS).unref()
|
||||
}
|
||||
|
||||
function trustedHeaderFirst(req, name) {
|
||||
const value = req.headers[name]
|
||||
if (!value) return ''
|
||||
return String(Array.isArray(value) ? value[0] : value).split(',')[0].trim()
|
||||
}
|
||||
|
||||
function trustedForwardedProto(req) {
|
||||
if (!requestPeerIsTrusted(req)) {
|
||||
return req.socket.encrypted ? 'https' : 'http'
|
||||
}
|
||||
if (TRUST_PROXY === 'cloudflare') {
|
||||
const cfVisitor = trustedHeaderFirst(req, 'cf-visitor')
|
||||
if (cfVisitor) {
|
||||
try {
|
||||
const parsed = JSON.parse(cfVisitor)
|
||||
if (parsed && typeof parsed.scheme === 'string') return parsed.scheme.toLowerCase()
|
||||
} catch {
|
||||
// ignore malformed cf-visitor
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
trustedHeaderFirst(req, 'x-forwarded-proto').toLowerCase() ||
|
||||
(req.socket.encrypted ? 'https' : 'http')
|
||||
)
|
||||
}
|
||||
|
||||
function trustedForwardedHost(req) {
|
||||
if (requestPeerIsTrusted(req) && TRUST_PROXY === 'generic') {
|
||||
const value = trustedHeaderFirst(req, 'x-forwarded-host')
|
||||
if (value) return value
|
||||
}
|
||||
return trustedHeaderFirst(req, 'host')
|
||||
}
|
||||
|
||||
function isHttpsRequest(req) {
|
||||
if (req.socket.encrypted) return true
|
||||
return trustedForwardedProto(req) === 'https'
|
||||
}
|
||||
|
||||
function publicOrigins(req) {
|
||||
const origins = PUBLIC_ORIGIN.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!origins.length) {
|
||||
const host = req.headers['x-forwarded-host'] || req.headers.host
|
||||
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http')
|
||||
const host = trustedForwardedHost(req)
|
||||
const proto = trustedForwardedProto(req) || (req.socket.encrypted ? 'https' : 'http')
|
||||
|
||||
if (host) {
|
||||
origins.push(`${proto}://${String(host).split(',')[0].trim()}`)
|
||||
origins.push(`${proto}://${host}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +526,14 @@ function isSameOriginRequest(req) {
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
const forwardedFor = req.headers['x-forwarded-for']
|
||||
if (forwardedFor) return String(forwardedFor).split(',')[0].trim()
|
||||
if (requestPeerIsTrusted(req)) {
|
||||
if (TRUST_PROXY === 'cloudflare') {
|
||||
const cf = trustedHeaderFirst(req, 'cf-connecting-ip')
|
||||
if (cf) return cf
|
||||
}
|
||||
const forwarded = trustedHeaderFirst(req, 'x-forwarded-for')
|
||||
if (forwarded) return forwarded
|
||||
}
|
||||
return req.socket.remoteAddress || 'unknown'
|
||||
}
|
||||
|
||||
@@ -428,6 +554,7 @@ function headerValue(req, name, maxLength = 200) {
|
||||
}
|
||||
|
||||
function countryFromHeaders(req) {
|
||||
if (!requestPeerIsTrusted(req)) return ''
|
||||
const raw =
|
||||
headerValue(req, 'cf-ipcountry', 12) ||
|
||||
headerValue(req, 'x-vercel-ip-country', 12) ||
|
||||
@@ -445,6 +572,9 @@ function numberHeader(req, name, min, max) {
|
||||
}
|
||||
|
||||
function locationFromHeaders(req) {
|
||||
if (!requestPeerIsTrusted(req) || TRUST_PROXY !== 'cloudflare') {
|
||||
return { country: countryFromHeaders(req), region: '', city: '', latitude: null, longitude: null }
|
||||
}
|
||||
return {
|
||||
country: countryFromHeaders(req),
|
||||
region: headerValue(req, 'cf-region', 120),
|
||||
@@ -454,8 +584,46 @@ function locationFromHeaders(req) {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAnalyticsValue(value, depth = 0) {
|
||||
if (depth > 3) return null
|
||||
if (value == null) return null
|
||||
if (typeof value === 'string') return value.slice(0, 500)
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||
if (typeof value === 'boolean') return value
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 24).map((item) => sanitizeAnalyticsValue(item, depth + 1)).filter((item) => item !== null)
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const out = {}
|
||||
let kept = 0
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (kept >= 32) break
|
||||
const cleanKey = String(key).replace(/[^A-Za-z0-9_\-]/g, '').slice(0, 60)
|
||||
if (!cleanKey) continue
|
||||
const cleanVal = sanitizeAnalyticsValue(val, depth + 1)
|
||||
if (cleanVal === null) continue
|
||||
out[cleanKey] = cleanVal
|
||||
kept += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pruneAnalyticsMetadata(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
|
||||
const out = {}
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!ANALYTICS_METADATA_ALLOWED_KEYS.has(key)) continue
|
||||
const sanitized = sanitizeAnalyticsValue(value, 0)
|
||||
if (sanitized === null) continue
|
||||
out[key] = sanitized
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function analyticsMetadata(req, payload) {
|
||||
const metadata = payload.metadata && typeof payload.metadata === 'object' ? payload.metadata : {}
|
||||
const metadata = pruneAnalyticsMetadata(payload.metadata)
|
||||
const preferences = metadata.preferences && typeof metadata.preferences === 'object'
|
||||
? metadata.preferences
|
||||
: {}
|
||||
@@ -536,14 +704,18 @@ function readJsonBody(req) {
|
||||
})
|
||||
}
|
||||
|
||||
const TURNSTILE_SESSION_COOKIE = 'tssbot_turnstile'
|
||||
const TURNSTILE_SESSION_COOKIE_BASE = 'tssbot_turnstile'
|
||||
const TURNSTILE_SESSION_COOKIE_HOST = `__Host-${TURNSTILE_SESSION_COOKIE_BASE}`
|
||||
const TURNSTILE_SESSION_TTL_SECONDS = 30 * 60
|
||||
const TURNSTILE_SESSION_HMAC_KEY = crypto
|
||||
const TURNSTILE_SESSION_HMAC_KEY = TURNSTILE_SECRET_KEY
|
||||
? crypto
|
||||
.createHash('sha256')
|
||||
.update(`tssbot-turnstile-session|${TURNSTILE_SECRET_KEY}`)
|
||||
.digest()
|
||||
: null
|
||||
|
||||
function signTurnstileSession(expiresAt) {
|
||||
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
|
||||
return crypto
|
||||
.createHmac('sha256', TURNSTILE_SESSION_HMAC_KEY)
|
||||
.update(String(expiresAt))
|
||||
@@ -551,13 +723,14 @@ function signTurnstileSession(expiresAt) {
|
||||
}
|
||||
|
||||
function buildTurnstileSessionCookie(req) {
|
||||
if (!TURNSTILE_SESSION_HMAC_KEY) return ''
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + TURNSTILE_SESSION_TTL_SECONDS
|
||||
const signature = signTurnstileSession(expiresAt)
|
||||
const value = `${expiresAt}.${signature}`
|
||||
const proto = req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http')
|
||||
const isHttps = String(proto).split(',')[0].trim() === 'https'
|
||||
const isHttps = isHttpsRequest(req)
|
||||
const name = isHttps ? TURNSTILE_SESSION_COOKIE_HOST : TURNSTILE_SESSION_COOKIE_BASE
|
||||
const parts = [
|
||||
`${TURNSTILE_SESSION_COOKIE}=${value}`,
|
||||
`${name}=${value}`,
|
||||
'Path=/',
|
||||
`Max-Age=${TURNSTILE_SESSION_TTL_SECONDS}`,
|
||||
'HttpOnly',
|
||||
@@ -581,8 +754,10 @@ function parseRequestCookies(req) {
|
||||
|
||||
function isTurnstileSessionVerified(req) {
|
||||
if (!TURNSTILE_SECRET_KEY) return true
|
||||
if (!TURNSTILE_SESSION_HMAC_KEY) return false
|
||||
const cookies = parseRequestCookies(req)
|
||||
const cookie = cookies[TURNSTILE_SESSION_COOKIE]
|
||||
const cookie =
|
||||
cookies[TURNSTILE_SESSION_COOKIE_HOST] || cookies[TURNSTILE_SESSION_COOKIE_BASE]
|
||||
if (!cookie) return false
|
||||
const dot = cookie.indexOf('.')
|
||||
if (dot < 1) return false
|
||||
@@ -591,7 +766,7 @@ function isTurnstileSessionVerified(req) {
|
||||
const expiresAt = Number(expiresAtStr)
|
||||
if (!Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) return false
|
||||
const expected = signTurnstileSession(expiresAt)
|
||||
if (signature.length !== expected.length) return false
|
||||
if (!expected || signature.length !== expected.length) return false
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
||||
} catch {
|
||||
@@ -1372,12 +1547,15 @@ function proxyRequest(req, res) {
|
||||
(proxyRes) => {
|
||||
const headers = {
|
||||
...proxyRes.headers,
|
||||
...securityHeaders(req),
|
||||
'cache-control': 'private, max-age=15',
|
||||
'x-content-type-options': 'nosniff',
|
||||
}
|
||||
|
||||
delete headers['access-control-allow-origin']
|
||||
delete headers['access-control-allow-credentials']
|
||||
delete headers['access-control-allow-methods']
|
||||
delete headers['access-control-allow-headers']
|
||||
delete headers['access-control-expose-headers']
|
||||
|
||||
res.writeHead(proxyRes.statusCode || 502, headers)
|
||||
|
||||
@@ -1420,6 +1598,7 @@ function pagePublicOrigin(req) {
|
||||
function sendHtml(req, res, data, status = 200) {
|
||||
const html = data.toString('utf8').replaceAll('__PUBLIC_ORIGIN__', pagePublicOrigin(req))
|
||||
send(res, status, html, {
|
||||
...securityHeaders(req, { html: true }),
|
||||
'content-type': mimeTypes['.html'],
|
||||
'cache-control': 'no-cache',
|
||||
})
|
||||
@@ -1468,6 +1647,10 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/api/uptime') {
|
||||
if (isRateLimited(req)) {
|
||||
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
|
||||
return
|
||||
}
|
||||
uptimeHistory()
|
||||
.then((data) => sendJson(res, 200, data))
|
||||
.catch((error) => sendJson(res, 500, { error: 'Uptime history unavailable', detail: error.message }))
|
||||
@@ -1475,6 +1658,10 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/api/viewers') {
|
||||
if (isRateLimited(req)) {
|
||||
sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) })
|
||||
return
|
||||
}
|
||||
try {
|
||||
sendJson(res, 200, viewerDashboard())
|
||||
} catch (error) {
|
||||
@@ -1555,6 +1742,10 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/api/turnstile/session') {
|
||||
if (!isSameOriginRequest(req)) {
|
||||
sendJson(res, 403, { error: 'Turnstile session check is restricted to this site' })
|
||||
return
|
||||
}
|
||||
sendJson(res, 200, { verified: isTurnstileSessionVerified(req) })
|
||||
return
|
||||
}
|
||||
|
||||
+35
-3
@@ -39,10 +39,18 @@ 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 DISCORD_INCLUDE_PATCH = /^(1|true|yes)$/i.test(String(process.env.DISCORD_INCLUDE_PATCH || ''))
|
||||
const RESTART_TARGETS = (process.env.PM2_RESTART_TARGETS || 'tssbot-web')
|
||||
.split(',')
|
||||
.map((target) => target.trim())
|
||||
.filter(Boolean)
|
||||
const ALLOWED_REFS = new Set(
|
||||
(process.env.GITHUB_WEBHOOK_REFS || 'refs/heads/main')
|
||||
.split(',')
|
||||
.map((ref) => ref.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
const ALLOWED_REPOSITORY = (process.env.GITHUB_WEBHOOK_REPOSITORY || '').trim()
|
||||
const RESTART_AFTER_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
let deploying = false
|
||||
@@ -86,7 +94,10 @@ function validGitSha(value) {
|
||||
}
|
||||
|
||||
function verifySignature(rawBody, signature) {
|
||||
if (!SECRET) return true
|
||||
if (!SECRET) {
|
||||
console.error('GITHUB_WEBHOOK_SECRET is not set — rejecting webhook')
|
||||
return false
|
||||
}
|
||||
if (!signature || !signature.startsWith('sha256=')) return false
|
||||
|
||||
const expected = `sha256=${crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex')}`
|
||||
@@ -160,12 +171,14 @@ function runCapture(command, args, options = {}) {
|
||||
}
|
||||
|
||||
async function ensureBuildDependencies() {
|
||||
await run('npm', ['install', '--production=false', '--include=dev', '--include=optional'], {
|
||||
await run('npm', ['ci', '--include=dev', '--include=optional'], {
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
npm_config_include: 'dev,optional',
|
||||
npm_config_omit: '',
|
||||
npm_config_production: 'false',
|
||||
npm_config_fund: 'false',
|
||||
npm_config_audit: 'false',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -301,7 +314,7 @@ function diffFields(diff) {
|
||||
if (diff.summary) {
|
||||
fields.push({ name: 'Diff summary', value: codeBlock(diff.summary, 'diff'), inline: false })
|
||||
}
|
||||
if (diff.patch) {
|
||||
if (DISCORD_INCLUDE_PATCH && diff.patch) {
|
||||
fields.push({ name: 'Patch preview', value: codeBlock(diff.patch, 'diff'), inline: false })
|
||||
}
|
||||
|
||||
@@ -388,6 +401,25 @@ http
|
||||
return
|
||||
}
|
||||
|
||||
const pushRef = String(push?.ref || '')
|
||||
if (!ALLOWED_REFS.has(pushRef)) {
|
||||
json(res, 202, { skipped: true, reason: `Ignoring ref ${pushRef || '(missing)'}` })
|
||||
return
|
||||
}
|
||||
|
||||
if (ALLOWED_REPOSITORY) {
|
||||
const repoFullName = String(push?.repository?.full_name || '')
|
||||
if (repoFullName !== ALLOWED_REPOSITORY) {
|
||||
json(res, 202, { skipped: true, reason: `Ignoring repository ${repoFullName || '(missing)'}` })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (push?.deleted) {
|
||||
json(res, 202, { skipped: true, reason: 'Ignoring branch-delete push' })
|
||||
return
|
||||
}
|
||||
|
||||
if (deploying) {
|
||||
json(res, 202, { queued: false, deploying: true })
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user