update osm
This commit is contained in:
+168
-1
@@ -49,6 +49,11 @@ const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WIND
|
||||
const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000)
|
||||
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000)
|
||||
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120)
|
||||
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || ''
|
||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
const TURNSTILE_VERIFY_TIMEOUT_MS = Number(process.env.TURNSTILE_VERIFY_TIMEOUT_MS || 5000)
|
||||
const TURNSTILE_MAX_TOKEN_LENGTH = 2048
|
||||
const TURNSTILE_TOKEN_MAX_AGE_MS = 5 * 60 * 1000
|
||||
const DIST_DIR = path.join(__dirname, 'dist')
|
||||
const MAX_TEAM_NAME_LENGTH = 80
|
||||
const MAX_CACHE_ENTRIES = 200
|
||||
@@ -531,6 +536,130 @@ function readJsonBody(req) {
|
||||
})
|
||||
}
|
||||
|
||||
function callTurnstileSiteverify(token, remoteIp, idempotencyKey) {
|
||||
return new Promise((resolve) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('secret', TURNSTILE_SECRET_KEY)
|
||||
params.set('response', token)
|
||||
if (remoteIp && remoteIp !== 'unknown') params.set('remoteip', remoteIp)
|
||||
if (idempotencyKey) params.set('idempotency_key', idempotencyKey)
|
||||
const payload = params.toString()
|
||||
|
||||
const verifyUrl = new URL(TURNSTILE_VERIFY_URL)
|
||||
const request = https.request(
|
||||
{
|
||||
method: 'POST',
|
||||
hostname: verifyUrl.hostname,
|
||||
path: verifyUrl.pathname,
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
},
|
||||
timeout: TURNSTILE_VERIFY_TIMEOUT_MS,
|
||||
},
|
||||
(response) => {
|
||||
const chunks = []
|
||||
response.on('data', (chunk) => chunks.push(chunk))
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
resolve({ ok: true, data })
|
||||
} catch {
|
||||
resolve({ ok: false, error: 'Invalid Turnstile response' })
|
||||
}
|
||||
})
|
||||
response.on('error', (error) => {
|
||||
resolve({ ok: false, error: error.message || 'Turnstile read failed' })
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy(new Error('Turnstile verification timed out'))
|
||||
})
|
||||
request.on('error', (error) => {
|
||||
resolve({ ok: false, error: error.message || 'Turnstile request failed' })
|
||||
})
|
||||
|
||||
request.write(payload)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyTurnstileToken(token, remoteIp, options = {}) {
|
||||
if (!TURNSTILE_SECRET_KEY) {
|
||||
return { success: true, data: { skipped: true } }
|
||||
}
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { success: false, error: 'Missing Turnstile token', codes: ['missing-input-response'] }
|
||||
}
|
||||
if (token.length > TURNSTILE_MAX_TOKEN_LENGTH) {
|
||||
return { success: false, error: 'Turnstile token too long', codes: ['invalid-input-response'] }
|
||||
}
|
||||
|
||||
const { expectedAction, expectedHostname, maxRetries = 2 } = options
|
||||
const idempotencyKey = crypto.randomUUID()
|
||||
|
||||
let attempt = 0
|
||||
let lastResult = null
|
||||
while (attempt < maxRetries) {
|
||||
attempt += 1
|
||||
const call = await callTurnstileSiteverify(token, remoteIp, idempotencyKey)
|
||||
lastResult = call
|
||||
|
||||
if (call.ok) {
|
||||
const data = call.data || {}
|
||||
if (!data.success) {
|
||||
const codes = Array.isArray(data['error-codes']) ? data['error-codes'] : []
|
||||
const transient = codes.includes('internal-error')
|
||||
if (transient && attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
|
||||
continue
|
||||
}
|
||||
return { success: false, error: 'Turnstile verification failed', codes }
|
||||
}
|
||||
|
||||
if (expectedAction && data.action && data.action !== expectedAction) {
|
||||
console.warn(`Turnstile action mismatch: expected ${expectedAction}, got ${data.action}`)
|
||||
return { success: false, error: 'Action mismatch', codes: ['action-mismatch'] }
|
||||
}
|
||||
|
||||
if (expectedHostname && data.hostname && data.hostname !== expectedHostname) {
|
||||
console.warn(`Turnstile hostname mismatch: expected ${expectedHostname}, got ${data.hostname}`)
|
||||
return { success: false, error: 'Hostname mismatch', codes: ['hostname-mismatch'] }
|
||||
}
|
||||
|
||||
if (data.challenge_ts) {
|
||||
const ageMs = Date.now() - Date.parse(data.challenge_ts)
|
||||
if (Number.isFinite(ageMs) && ageMs > TURNSTILE_TOKEN_MAX_AGE_MS) {
|
||||
console.warn(`Turnstile token age ${(ageMs / 1000).toFixed(1)}s exceeds limit`)
|
||||
return { success: false, error: 'Token expired', codes: ['stale-token'] }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 250))
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Turnstile verification failed after retries:', lastResult?.error)
|
||||
return { success: false, error: 'Turnstile verification unavailable', codes: ['internal-error'] }
|
||||
}
|
||||
|
||||
function expectedTurnstileHostname() {
|
||||
if (!PUBLIC_ORIGIN) return ''
|
||||
const first = PUBLIC_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)[0]
|
||||
if (!first) return ''
|
||||
try {
|
||||
return new URL(first).hostname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function purgeOldAnalytics(db) {
|
||||
const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
|
||||
const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()
|
||||
@@ -1318,7 +1447,15 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
readJsonBody(req)
|
||||
.then((payload) => {
|
||||
.then(async (payload) => {
|
||||
const verification = await verifyTurnstileToken(payload.turnstile_token, clientIp(req), {
|
||||
expectedAction: 'analytics-delete',
|
||||
expectedHostname: expectedTurnstileHostname(),
|
||||
})
|
||||
if (!verification.success) {
|
||||
sendJson(res, 403, { error: 'Turnstile verification required', detail: verification.error })
|
||||
return
|
||||
}
|
||||
const result = deleteViewerData(payload)
|
||||
sendJson(res, 200, result)
|
||||
})
|
||||
@@ -1326,6 +1463,33 @@ const server = http.createServer((req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/turnstile/verify') {
|
||||
if (!isSameOriginRequest(req)) {
|
||||
sendJson(res, 403, { error: 'Turnstile verification is restricted to this site' })
|
||||
return
|
||||
}
|
||||
|
||||
if (isRateLimited(req)) {
|
||||
sendJson(res, 429, { error: 'Too many verification attempts' })
|
||||
return
|
||||
}
|
||||
|
||||
readJsonBody(req)
|
||||
.then(async (payload) => {
|
||||
const verification = await verifyTurnstileToken(payload.token, clientIp(req), {
|
||||
expectedAction: typeof payload.action === 'string' ? payload.action : undefined,
|
||||
expectedHostname: expectedTurnstileHostname(),
|
||||
})
|
||||
if (!verification.success) {
|
||||
sendJson(res, 403, { error: 'Turnstile verification failed', detail: verification.error })
|
||||
return
|
||||
}
|
||||
sendJson(res, 200, { success: true })
|
||||
})
|
||||
.catch((error) => sendJson(res, 400, { error: error.message }))
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS' && req.url.startsWith('/api/')) {
|
||||
sendJson(res, 403, { error: 'CORS requests are not allowed' })
|
||||
return
|
||||
@@ -1345,5 +1509,8 @@ server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`sampling uptime every ${Math.round(UPTIME_SAMPLE_INTERVAL_MS / 60000)} minutes`)
|
||||
console.log(`storing uptime snapshots in ${path.join(uptimeStoragePath(), UPTIME_DATABASE_FILE)}`)
|
||||
console.log(`storing viewer analytics in ${path.join(uptimeStoragePath(), ANALYTICS_DATABASE_FILE)}`)
|
||||
if (!TURNSTILE_SECRET_KEY) {
|
||||
console.warn('TURNSTILE_SECRET_KEY is not set — Turnstile verification is disabled and gated endpoints will accept any request')
|
||||
}
|
||||
startUptimeSampler()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user