Telegram Bot + Mini App for city walking quests. - React 19 + TypeScript + Vite 6 frontend - Express 5 + PostgreSQL backend - grammY Telegram bot with DeepSeek AI - GitLab CI/CD: lint, build, deploy to production Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { ApiRequest, ApiResponse } from './types.js'
|
|
|
|
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
|
|
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || 'dev-internal-key'
|
|
|
|
const ALLOWED_ORIGINS = [
|
|
'https://web.telegram.org',
|
|
process.env.APP_URL || null,
|
|
process.env.NODE_ENV === 'development' ? 'http://localhost:5173' : null,
|
|
].filter(Boolean) as string[]
|
|
|
|
// Validate Telegram Mini App initData
|
|
// https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
|
export function validateTelegramInitData(initData: string): { id: number; username?: string; first_name?: string; last_name?: string; language_code?: string } | null {
|
|
if (!initData || !TELEGRAM_BOT_TOKEN) return null
|
|
|
|
try {
|
|
const params = new URLSearchParams(initData)
|
|
const hash = params.get('hash')
|
|
if (!hash) return null
|
|
|
|
params.delete('hash')
|
|
|
|
// Sort and create data-check-string
|
|
const dataCheckString = Array.from(params.entries())
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join('\n')
|
|
|
|
// Create secret key
|
|
const secretKey = crypto
|
|
.createHmac('sha256', 'WebAppData')
|
|
.update(TELEGRAM_BOT_TOKEN)
|
|
.digest()
|
|
|
|
// Compute HMAC
|
|
const computedHash = crypto
|
|
.createHmac('sha256', secretKey)
|
|
.update(dataCheckString)
|
|
.digest('hex')
|
|
|
|
if (computedHash !== hash) return null
|
|
|
|
// Check auth_date is not too old (24 hours)
|
|
const authDate = params.get('auth_date')
|
|
if (authDate) {
|
|
const authTimestamp = parseInt(authDate) * 1000
|
|
const now = Date.now()
|
|
if (now - authTimestamp > 24 * 60 * 60 * 1000) return null
|
|
}
|
|
|
|
// Parse user data
|
|
const userStr = params.get('user')
|
|
if (!userStr) return null
|
|
|
|
return JSON.parse(userStr)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Middleware: authenticate via Telegram initData
|
|
export function withTelegramAuth(handler: Function) {
|
|
return async (req: ApiRequest, res: ApiResponse) => {
|
|
const initData = req.headers['x-telegram-init-data'] as string
|
|
const user = validateTelegramInitData(initData)
|
|
|
|
if (!user) {
|
|
// In development, allow bypass with query param
|
|
if (process.env.NODE_ENV === 'development' && req.query.telegram_id) {
|
|
req.telegramUser = {
|
|
id: parseInt(req.query.telegram_id as string),
|
|
first_name: 'Dev',
|
|
language_code: 'ru',
|
|
}
|
|
return handler(req, res)
|
|
}
|
|
|
|
return res.status(401).json({ error: 'Invalid Telegram authentication' })
|
|
}
|
|
|
|
req.telegramUser = user
|
|
return handler(req, res)
|
|
}
|
|
}
|
|
|
|
// Middleware: authenticate internal bot-to-API calls
|
|
export function withInternalAuth(handler: Function) {
|
|
return async (req: ApiRequest, res: ApiResponse) => {
|
|
const apiKey = req.headers['x-internal-api-key'] as string
|
|
|
|
if (apiKey !== INTERNAL_API_KEY) {
|
|
return res.status(401).json({ error: 'Invalid internal API key' })
|
|
}
|
|
|
|
return handler(req, res)
|
|
}
|
|
}
|
|
|
|
// CORS handler
|
|
export function cors(req: ApiRequest, res: ApiResponse) {
|
|
const origin = req.headers.origin
|
|
|
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
} else if (process.env.NODE_ENV === 'development' && origin) {
|
|
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
|
}
|
|
}
|
|
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Telegram-Init-Data, X-Internal-Api-Key')
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true')
|
|
}
|