diff --git a/src/routes/api.ts b/src/routes/api.ts index 5aac01d..c01d83b 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -4,6 +4,7 @@ import { depositRouter } from './api/deposit'; import { chatRouter } from './api/chat'; import { contactRouter } from './api/contact'; import { metricsRouter } from './api/metrics'; +import { dashboardRouter } from './api/dashboard'; /** * API Router (Hono) @@ -49,5 +50,6 @@ api.route('/deposit', depositRouter); api.route('/', chatRouter); // /test, /chat api.route('/contact', contactRouter); api.route('/metrics', metricsRouter); +api.route('/', dashboardRouter); // /auth/telegram, /dashboard, /servers, /domains, /server/action export { api as apiRouter }; diff --git a/src/routes/api/dashboard.ts b/src/routes/api/dashboard.ts new file mode 100644 index 0000000..628409d --- /dev/null +++ b/src/routes/api/dashboard.ts @@ -0,0 +1,296 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { Env } from '../../types'; +import { createLogger } from '../../utils/logger'; + +const logger = createLogger('dashboard-api'); + +/** + * Dashboard API Router + * + * Endpoints: + * - POST /auth/telegram - Verify Telegram login + * - GET /dashboard - Get dashboard stats + * - GET /servers - Get user's servers + * - GET /domains - Get user's domains + * - GET /deposit/transactions - Get transaction history + */ +const dashboardRouter = new Hono<{ Bindings: Env }>(); + +// CORS for my.anvil.it.com +dashboardRouter.use('*', cors({ + origin: ['https://my.anvil.it.com', 'http://localhost:8788'], + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + credentials: true, +})); + +/** + * Verify Telegram Login Widget data + * https://core.telegram.org/widgets/login#checking-authorization + */ +dashboardRouter.post('/auth/telegram', async (c) => { + try { + const body = await c.req.json(); + const { id, first_name, last_name, username, photo_url, auth_date, hash } = body; + + if (!id || !auth_date || !hash) { + return c.json({ error: 'Missing required fields' }, 400); + } + + // Verify hash using BOT_TOKEN + const botToken = c.env.BOT_TOKEN; + if (!botToken) { + logger.error('BOT_TOKEN not configured'); + return c.json({ error: 'Server configuration error' }, 500); + } + + // Create data-check-string + const dataCheckArr = []; + if (auth_date) dataCheckArr.push(`auth_date=${auth_date}`); + if (first_name) dataCheckArr.push(`first_name=${first_name}`); + if (id) dataCheckArr.push(`id=${id}`); + if (last_name) dataCheckArr.push(`last_name=${last_name}`); + if (photo_url) dataCheckArr.push(`photo_url=${photo_url}`); + if (username) dataCheckArr.push(`username=${username}`); + dataCheckArr.sort(); + const dataCheckString = dataCheckArr.join('\n'); + + // Calculate secret key = SHA256(bot_token) + const encoder = new TextEncoder(); + const secretKey = await crypto.subtle.digest('SHA-256', encoder.encode(botToken)); + + // Calculate hash = HMAC-SHA256(data_check_string, secret_key) + const key = await crypto.subtle.importKey( + 'raw', + secretKey, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(dataCheckString)); + const calculatedHash = Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + if (calculatedHash !== hash) { + logger.warn('Invalid Telegram auth hash', { userId: id }); + return c.json({ error: 'Invalid authentication' }, 401); + } + + // Check auth_date is not too old (1 hour) + const authTime = parseInt(auth_date, 10) * 1000; + if (Date.now() - authTime > 60 * 60 * 1000) { + return c.json({ error: 'Authentication expired' }, 401); + } + + // Ensure user exists in DB + await c.env.DB.prepare(` + INSERT INTO users (telegram_id, username, first_name, last_name) + VALUES (?, ?, ?, ?) + ON CONFLICT(telegram_id) DO UPDATE SET + username = excluded.username, + first_name = excluded.first_name, + last_name = excluded.last_name + `).bind(id, username || null, first_name || null, last_name || null).run(); + + // Generate simple token (in production, use JWT) + const token = btoa(JSON.stringify({ id, ts: Date.now() })); + + logger.info('Telegram auth success', { userId: id, username }); + + return c.json({ + success: true, + token: token, + user: { id, first_name, last_name, username, photo_url }, + }); + } catch (error) { + logger.error('Telegram auth error', error as Error); + return c.json({ error: 'Authentication failed' }, 500); + } +}); + +/** + * Verify Authorization header and extract user_id + */ +async function verifyAuth(c: any): Promise { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return null; + } + + try { + const token = authHeader.slice(7); + const decoded = JSON.parse(atob(token)); + + // Check token age (24 hours) + if (Date.now() - decoded.ts > 24 * 60 * 60 * 1000) { + return null; + } + + return decoded.id; + } catch { + return null; + } +} + +/** + * Get dashboard stats + */ +dashboardRouter.get('/dashboard', async (c) => { + const userId = await verifyAuth(c); + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + // Get server count + const servers = await c.env.DB.prepare(` + SELECT COUNT(*) as count FROM server_orders + WHERE telegram_user_id = ? AND status IN ('active', 'stopped', 'provisioning') + `).bind(userId).first<{ count: number }>(); + + // Get domain count + const domains = await c.env.DB.prepare(` + SELECT COUNT(*) as count FROM user_domains + WHERE user_id = ? AND verified = 1 + `).bind(userId).first<{ count: number }>(); + + // Get deposit balance + const deposit = await c.env.DB.prepare(` + SELECT balance FROM user_deposits WHERE user_id = ? + `).bind(userId).first<{ balance: number }>(); + + return c.json({ + servers: servers?.count || 0, + domains: domains?.count || 0, + ddos: '미사용', + deposit: deposit?.balance || 0, + }); + } catch (error) { + logger.error('Dashboard stats error', error as Error); + return c.json({ error: 'Failed to load stats' }, 500); + } +}); + +/** + * Get user's servers + */ +dashboardRouter.get('/servers', async (c) => { + const userId = await verifyAuth(c); + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const servers = await c.env.DB.prepare(` + SELECT id, label, status, region, created_at + FROM server_orders + WHERE telegram_user_id = ? AND status IN ('active', 'stopped', 'provisioning') + ORDER BY created_at DESC + `).bind(userId).all(); + + return c.json({ servers: servers.results || [] }); + } catch (error) { + logger.error('Servers list error', error as Error); + return c.json({ error: 'Failed to load servers' }, 500); + } +}); + +/** + * Get user's domains + */ +dashboardRouter.get('/domains', async (c) => { + const userId = await verifyAuth(c); + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const domains = await c.env.DB.prepare(` + SELECT domain, created_at + FROM user_domains + WHERE user_id = ? AND verified = 1 + ORDER BY created_at DESC + `).bind(userId).all(); + + return c.json({ domains: domains.results || [] }); + } catch (error) { + logger.error('Domains list error', error as Error); + return c.json({ error: 'Failed to load domains' }, 500); + } +}); + +/** + * Get transaction history + */ +dashboardRouter.get('/deposit/transactions', async (c) => { + const userId = await verifyAuth(c); + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const transactions = await c.env.DB.prepare(` + SELECT id, amount, type, status, description, created_at + FROM deposit_transactions + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 50 + `).bind(userId).all(); + + return c.json({ transactions: transactions.results || [] }); + } catch (error) { + logger.error('Transactions list error', error as Error); + return c.json({ error: 'Failed to load transactions' }, 500); + } +}); + +/** + * Server action (start/stop/reboot) + */ +dashboardRouter.post('/server/action', async (c) => { + const userId = await verifyAuth(c); + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const { order_id, action } = await c.req.json(); + + if (!order_id || !['start', 'stop', 'reboot'].includes(action)) { + return c.json({ error: 'Invalid request' }, 400); + } + + // Verify ownership + const server = await c.env.DB.prepare(` + SELECT id FROM server_orders + WHERE id = ? AND telegram_user_id = ? + `).bind(order_id, userId).first(); + + if (!server) { + return c.json({ error: 'Server not found' }, 404); + } + + // Call Cloud Orchestrator + if (c.env.CLOUD_ORCHESTRATOR) { + const response = await c.env.CLOUD_ORCHESTRATOR.fetch( + `https://internal/api/provision/orders/${order_id}/${action}`, + { method: 'POST' } + ); + + if (!response.ok) { + throw new Error(`Orchestrator error: ${response.status}`); + } + } + + logger.info('Server action', { userId, orderId: order_id, action }); + + return c.json({ success: true }); + } catch (error) { + logger.error('Server action error', error as Error); + return c.json({ error: 'Action failed' }, 500); + } +}); + +export { dashboardRouter };