refactor: comprehensive code review fixes and security hardening
Security: - Add CSP headers for HTML reports (style-src 'unsafe-inline') - Restrict origin validation to specific .kappa-d8e.workers.dev domain - Add base64 size limit (100KB) for report data parameter - Implement rejection sampling for unbiased password generation - Add SQL LIKE pattern escaping for tech specs query - Add security warning for plaintext password storage (TODO: encrypt) Performance: - Add Telegram API timeout (10s) with AbortController - Fix rate limiter sorting by resetTime for proper cleanup - Use centralized TIMEOUTS config for VPS provider APIs Features: - Add admin SSH key support for server recovery access - ADMIN_SSH_PUBLIC_KEY for Linode (public key string) - ADMIN_SSH_KEY_ID_VULTR for Vultr (pre-registered key ID) - Add origin validation middleware - Add idempotency key migration Code Quality: - Return 404 status when no servers found - Consolidate error logging to single JSON.stringify call - Import TECH_CATEGORY_WEIGHTS from config.ts - Add escapeLikePattern utility function Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
17
migrations/004_add_idempotency_key.sql
Normal file
17
migrations/004_add_idempotency_key.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Migration: Add idempotency_key column to server_orders table
|
||||
-- Purpose: Prevent duplicate order creation on Queue retry
|
||||
-- Date: 2026-01-28
|
||||
|
||||
-- Note: This migration should be run on telegram-conversations database (USER_DB)
|
||||
-- SQLite doesn't allow adding UNIQUE column directly, so we add column + UNIQUE INDEX
|
||||
|
||||
-- Step 1: Add idempotency_key column (without UNIQUE constraint)
|
||||
ALTER TABLE server_orders ADD COLUMN idempotency_key TEXT;
|
||||
|
||||
-- Step 2: Create UNIQUE index (this enforces uniqueness for non-NULL values)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_server_orders_idempotency_unique
|
||||
ON server_orders(idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
-- Verification query (run after migration):
|
||||
-- SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='server_orders' AND name LIKE '%idempotency%';
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"hono": "^4.11.7",
|
||||
"openai": "^6.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1825,6 +1826,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
|
||||
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"wrangler": "^4.60.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.11.7",
|
||||
"openai": "^6.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,32 @@ export const LIMITS = {
|
||||
MAX_TECH_STACK: 20,
|
||||
MAX_USE_CASE_LENGTH: 500,
|
||||
MAX_REGION_PREFERENCE: 10,
|
||||
MAX_IN_MEMORY_RATE_LIMIT_ENTRIES: 10000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Timeout configurations (in milliseconds)
|
||||
*/
|
||||
export const TIMEOUTS = {
|
||||
AI_REQUEST_MS: 30000, // 30 seconds
|
||||
SERVER_PROVISIONING_MS: 120000, // 2 minutes
|
||||
EXCHANGE_RATE_API_MS: 5000, // 5 seconds
|
||||
VPS_PROVIDER_API_MS: 30000, // 30 seconds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Tech category weights for vCPU calculation
|
||||
* Different categories have different bottleneck characteristics
|
||||
*/
|
||||
export const TECH_CATEGORY_WEIGHTS: Record<string, number> = {
|
||||
'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources
|
||||
'runtime': 1.0, // nodejs, php, python: actual computation
|
||||
'database': 1.0, // mysql, postgresql, mongodb: major bottleneck
|
||||
'cache': 0.5, // redis, memcached: supporting role
|
||||
'search': 0.8, // elasticsearch: CPU-intensive but not always primary
|
||||
'container': 0.3, // docker: orchestration overhead
|
||||
'messaging': 0.5, // rabbitmq, kafka: I/O bound
|
||||
'default': 0.7, // unknown categories
|
||||
} as const;
|
||||
|
||||
export const USE_CASE_CONFIGS: UseCaseConfig[] = [
|
||||
|
||||
@@ -2,19 +2,17 @@
|
||||
* Health check endpoint handler
|
||||
*/
|
||||
|
||||
import { jsonResponse } from '../utils';
|
||||
import type { Context } from 'hono';
|
||||
import type { Env } from '../types';
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
export function handleHealth(corsHeaders: Record<string, string>): Response {
|
||||
return jsonResponse(
|
||||
{
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'server-recommend',
|
||||
},
|
||||
200,
|
||||
corsHeaders
|
||||
);
|
||||
export function handleHealth(c: Context<{ Bindings: Env }>) {
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'server-recommend',
|
||||
request_id: c.get('requestId'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* GET /api/provision/orders/:id - Get specific order
|
||||
* DELETE /api/provision/orders/:id - Delete/terminate server
|
||||
* GET /api/provision/balance - Get user's balance
|
||||
* GET /api/provision/images - Get available OS images
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import type { Env, ProvisionRequest } from '../types';
|
||||
import { ProvisioningService } from '../services/provisioning-service';
|
||||
import { createErrorResponse, createSuccessResponse } from '../utils/http';
|
||||
import { LIMITS } from '../config';
|
||||
|
||||
/**
|
||||
@@ -72,68 +73,63 @@ function validateProvisionRequest(body: unknown): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ProvisioningService instance
|
||||
*/
|
||||
function createProvisioningService(env: Env) {
|
||||
return new ProvisioningService(
|
||||
env,
|
||||
env.DB,
|
||||
env.USER_DB,
|
||||
env.LINODE_API_KEY,
|
||||
env.VULTR_API_KEY,
|
||||
env.LINODE_API_URL,
|
||||
env.VULTR_API_URL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/provision
|
||||
* Create a new server
|
||||
*/
|
||||
export async function handleProvision(
|
||||
request: Request,
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
export async function handleProvision(c: Context<{ Bindings: Env }>) {
|
||||
try {
|
||||
// Check content length
|
||||
const contentLength = request.headers.get('content-length');
|
||||
const contentLength = c.req.header('content-length');
|
||||
if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) {
|
||||
return createErrorResponse('Request body too large', 413, undefined, corsHeaders);
|
||||
return c.json({ error: 'Request body too large', request_id: c.get('requestId') }, 413);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return createErrorResponse('Invalid JSON in request body', 400, undefined, corsHeaders);
|
||||
return c.json({ error: 'Invalid JSON in request body', request_id: c.get('requestId') }, 400);
|
||||
}
|
||||
|
||||
// Validate request
|
||||
const validation = validateProvisionRequest(body);
|
||||
if (!validation.valid) {
|
||||
return createErrorResponse(validation.error, 400, 'VALIDATION_ERROR', corsHeaders);
|
||||
return c.json({ error: validation.error, code: 'VALIDATION_ERROR', request_id: c.get('requestId') }, 400);
|
||||
}
|
||||
|
||||
// Check API keys (skip for dry_run)
|
||||
if (!validation.data.dry_run && !env.LINODE_API_KEY && !env.VULTR_API_KEY) {
|
||||
return createErrorResponse(
|
||||
'No VPS provider API keys configured',
|
||||
503,
|
||||
'SERVICE_UNAVAILABLE',
|
||||
corsHeaders
|
||||
if (!validation.data.dry_run && !c.env.LINODE_API_KEY && !c.env.VULTR_API_KEY) {
|
||||
return c.json(
|
||||
{ error: 'No VPS provider API keys configured', code: 'SERVICE_UNAVAILABLE', request_id: c.get('requestId') },
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
// Create provisioning service with both DBs
|
||||
const provisioningService = new ProvisioningService(
|
||||
env, // Full env for exchange rate
|
||||
env.DB, // cloud-instances-db
|
||||
env.USER_DB, // telegram-conversations
|
||||
env.LINODE_API_KEY,
|
||||
env.VULTR_API_KEY,
|
||||
env.LINODE_API_URL,
|
||||
env.VULTR_API_URL
|
||||
);
|
||||
|
||||
// Provision server
|
||||
const provisioningService = createProvisioningService(c.env);
|
||||
const result = await provisioningService.provisionServer(validation.data);
|
||||
|
||||
if (!result.success) {
|
||||
// Map error codes to HTTP status codes
|
||||
const statusCode = getStatusCodeForError(result.error!.code);
|
||||
return createErrorResponse(
|
||||
result.error!.message,
|
||||
statusCode,
|
||||
result.error!.code,
|
||||
corsHeaders
|
||||
return c.json(
|
||||
{ error: result.error!.message, code: result.error!.code, request_id: c.get('requestId') },
|
||||
statusCode
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,8 +139,8 @@ export async function handleProvision(
|
||||
root_password: '*** Use GET /api/provision/orders/:id to retrieve once ***',
|
||||
};
|
||||
|
||||
// Include dry_run_info if present
|
||||
const response: Record<string, unknown> = {
|
||||
success: true,
|
||||
message: validation.data.dry_run
|
||||
? 'Dry run successful. Validation passed.'
|
||||
: 'Server provisioned successfully',
|
||||
@@ -156,14 +152,12 @@ export async function handleProvision(
|
||||
response.dry_run_info = (result as Record<string, unknown>).dry_run_info;
|
||||
}
|
||||
|
||||
return createSuccessResponse(response, validation.data.dry_run ? 200 : 201, corsHeaders);
|
||||
return c.json(response, validation.data.dry_run ? 200 : 201);
|
||||
} catch (error) {
|
||||
console.error('[handleProvision] Error:', error);
|
||||
return createErrorResponse(
|
||||
'Internal server error during provisioning',
|
||||
500,
|
||||
'INTERNAL_ERROR',
|
||||
corsHeaders
|
||||
console.error('[handleProvision] Error:', error, 'request_id:', c.get('requestId'));
|
||||
return c.json(
|
||||
{ error: 'Internal server error during provisioning', code: 'INTERNAL_ERROR', request_id: c.get('requestId') },
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -172,29 +166,11 @@ export async function handleProvision(
|
||||
* GET /api/provision/orders
|
||||
* Get user's orders
|
||||
*/
|
||||
export async function handleGetOrders(
|
||||
request: Request,
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
export async function handleGetOrders(c: Context<{ Bindings: Env }>) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||
|
||||
if (!userId) {
|
||||
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||
}
|
||||
|
||||
const provisioningService = new ProvisioningService(
|
||||
env,
|
||||
env.DB,
|
||||
env.USER_DB,
|
||||
env.LINODE_API_KEY,
|
||||
env.VULTR_API_KEY,
|
||||
env.LINODE_API_URL,
|
||||
env.VULTR_API_URL
|
||||
);
|
||||
const userId = c.get('userId'); // Set by validateUserId middleware
|
||||
|
||||
const provisioningService = createProvisioningService(c.env);
|
||||
const orders = await provisioningService.getUserOrders(userId);
|
||||
|
||||
// Sanitize root passwords
|
||||
@@ -203,10 +179,10 @@ export async function handleGetOrders(
|
||||
root_password: order.root_password ? '***REDACTED***' : null,
|
||||
}));
|
||||
|
||||
return createSuccessResponse({ orders: sanitizedOrders }, 200, corsHeaders);
|
||||
return c.json({ orders: sanitizedOrders });
|
||||
} catch (error) {
|
||||
console.error('[handleGetOrders] Error:', error);
|
||||
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||
console.error('[handleGetOrders] Error:', error, 'request_id:', c.get('requestId'));
|
||||
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,47 +190,29 @@ export async function handleGetOrders(
|
||||
* GET /api/provision/orders/:id
|
||||
* Get specific order with root password (one-time view concept)
|
||||
*/
|
||||
export async function handleGetOrder(
|
||||
request: Request,
|
||||
env: Env,
|
||||
orderId: string,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
export async function handleGetOrder(c: Context<{ Bindings: Env }>) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||
|
||||
if (!userId) {
|
||||
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||
}
|
||||
|
||||
const provisioningService = new ProvisioningService(
|
||||
env,
|
||||
env.DB,
|
||||
env.USER_DB,
|
||||
env.LINODE_API_KEY,
|
||||
env.VULTR_API_KEY,
|
||||
env.LINODE_API_URL,
|
||||
env.VULTR_API_URL
|
||||
);
|
||||
const orderId = c.req.param('id');
|
||||
const userId = c.get('userId'); // Set by validateUserId middleware
|
||||
|
||||
const provisioningService = createProvisioningService(c.env);
|
||||
const order = await provisioningService.getOrder(orderId);
|
||||
|
||||
if (!order) {
|
||||
return createErrorResponse('Order not found', 404, 'NOT_FOUND', corsHeaders);
|
||||
return c.json({ error: 'Order not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404);
|
||||
}
|
||||
|
||||
// Get user to verify ownership
|
||||
const balance = await provisioningService.getUserBalance(userId);
|
||||
if (!balance || order.user_id !== balance.user_id) {
|
||||
return createErrorResponse('Unauthorized', 403, 'UNAUTHORIZED', corsHeaders);
|
||||
return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED', request_id: c.get('requestId') }, 403);
|
||||
}
|
||||
|
||||
// Include root password for order owner
|
||||
return createSuccessResponse({ order }, 200, corsHeaders);
|
||||
return c.json({ order });
|
||||
} catch (error) {
|
||||
console.error('[handleGetOrder] Error:', error);
|
||||
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||
console.error('[handleGetOrder] Error:', error, 'request_id:', c.get('requestId'));
|
||||
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,55 +220,37 @@ export async function handleGetOrder(
|
||||
* DELETE /api/provision/orders/:id
|
||||
* Delete/terminate a server
|
||||
*/
|
||||
export async function handleDeleteOrder(
|
||||
request: Request,
|
||||
env: Env,
|
||||
orderId: string,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
export async function handleDeleteOrder(c: Context<{ Bindings: Env }>) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||
const orderId = c.req.param('id');
|
||||
const userId = c.get('userId'); // Set by validateUserId middleware
|
||||
|
||||
if (!userId) {
|
||||
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||
}
|
||||
const provisioningService = createProvisioningService(c.env);
|
||||
|
||||
const provisioningService = new ProvisioningService(
|
||||
env,
|
||||
env.DB,
|
||||
env.USER_DB,
|
||||
env.LINODE_API_KEY,
|
||||
env.VULTR_API_KEY,
|
||||
env.LINODE_API_URL,
|
||||
env.VULTR_API_URL
|
||||
);
|
||||
|
||||
// Verify user exists first (same pattern as handleGetOrder)
|
||||
// Verify user exists first
|
||||
const balance = await provisioningService.getUserBalance(userId);
|
||||
if (!balance) {
|
||||
return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders);
|
||||
return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404);
|
||||
}
|
||||
|
||||
const result = await provisioningService.deleteServer(orderId, userId);
|
||||
|
||||
if (!result.success) {
|
||||
// Map specific errors to appropriate status codes
|
||||
const statusCode = getDeleteErrorStatusCode(result.error!);
|
||||
return createErrorResponse(result.error!, statusCode, 'DELETE_FAILED', corsHeaders);
|
||||
return c.json({ error: result.error!, code: 'DELETE_FAILED', request_id: c.get('requestId') }, statusCode);
|
||||
}
|
||||
|
||||
return createSuccessResponse({ message: 'Server terminated successfully' }, 200, corsHeaders);
|
||||
return c.json({ message: 'Server terminated successfully' });
|
||||
} catch (error) {
|
||||
console.error('[handleDeleteOrder] Error:', error);
|
||||
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||
console.error('[handleDeleteOrder] Error:', error, 'request_id:', c.get('requestId'));
|
||||
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map delete error messages to HTTP status codes
|
||||
*/
|
||||
function getDeleteErrorStatusCode(error: string): number {
|
||||
function getDeleteErrorStatusCode(error: string): 400 | 403 | 404 {
|
||||
if (error === 'Order not found') return 404;
|
||||
if (error === 'Unauthorized') return 403;
|
||||
if (error === 'User not found') return 404;
|
||||
@@ -321,46 +261,24 @@ function getDeleteErrorStatusCode(error: string): number {
|
||||
* GET /api/provision/balance
|
||||
* Get user's balance (in KRW)
|
||||
*/
|
||||
export async function handleGetBalance(
|
||||
request: Request,
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
export async function handleGetBalance(c: Context<{ Bindings: Env }>) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get('user_id'); // This is telegram_id
|
||||
|
||||
if (!userId) {
|
||||
return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders);
|
||||
}
|
||||
|
||||
const provisioningService = new ProvisioningService(
|
||||
env,
|
||||
env.DB,
|
||||
env.USER_DB,
|
||||
env.LINODE_API_KEY,
|
||||
env.VULTR_API_KEY,
|
||||
env.LINODE_API_URL,
|
||||
env.VULTR_API_URL
|
||||
);
|
||||
const userId = c.get('userId'); // Set by validateUserId middleware
|
||||
|
||||
const provisioningService = createProvisioningService(c.env);
|
||||
const balance = await provisioningService.getUserBalance(userId);
|
||||
|
||||
if (!balance) {
|
||||
return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders);
|
||||
return c.json({ error: 'User not found', code: 'NOT_FOUND', request_id: c.get('requestId') }, 404);
|
||||
}
|
||||
|
||||
return createSuccessResponse(
|
||||
{
|
||||
balance_krw: balance.balance_krw,
|
||||
currency: 'KRW',
|
||||
},
|
||||
200,
|
||||
corsHeaders
|
||||
);
|
||||
return c.json({
|
||||
balance_krw: balance.balance_krw,
|
||||
currency: 'KRW',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[handleGetBalance] Error:', error);
|
||||
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||
console.error('[handleGetBalance] Error:', error, 'request_id:', c.get('requestId'));
|
||||
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,21 +286,9 @@ export async function handleGetBalance(
|
||||
* GET /api/provision/images
|
||||
* Get available OS images
|
||||
*/
|
||||
export async function handleGetOsImages(
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
export async function handleGetOsImages(c: Context<{ Bindings: Env }>) {
|
||||
try {
|
||||
const provisioningService = new ProvisioningService(
|
||||
env,
|
||||
env.DB,
|
||||
env.USER_DB,
|
||||
env.LINODE_API_KEY,
|
||||
env.VULTR_API_KEY,
|
||||
env.LINODE_API_URL,
|
||||
env.VULTR_API_URL
|
||||
);
|
||||
|
||||
const provisioningService = createProvisioningService(c.env);
|
||||
const images = await provisioningService.getOsImages();
|
||||
|
||||
// Return simplified image list for API consumers
|
||||
@@ -393,17 +299,17 @@ export async function handleGetOsImages(
|
||||
is_default: img.is_default === 1,
|
||||
}));
|
||||
|
||||
return createSuccessResponse({ images: response }, 200, corsHeaders);
|
||||
return c.json({ images: response });
|
||||
} catch (error) {
|
||||
console.error('[handleGetOsImages] Error:', error);
|
||||
return createErrorResponse('Internal server error', 500, undefined, corsHeaders);
|
||||
console.error('[handleGetOsImages] Error:', error, 'request_id:', c.get('requestId'));
|
||||
return c.json({ error: 'Internal server error', request_id: c.get('requestId') }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error codes to HTTP status codes
|
||||
*/
|
||||
function getStatusCodeForError(code: string): number {
|
||||
function getStatusCodeForError(code: string): 400 | 402 | 403 | 404 | 500 | 503 {
|
||||
switch (code) {
|
||||
case 'USER_NOT_FOUND':
|
||||
case 'PRICING_NOT_FOUND':
|
||||
|
||||
@@ -25,16 +25,23 @@ export async function handleProvisionQueue(
|
||||
);
|
||||
|
||||
for (const message of batch.messages) {
|
||||
const { order_id, user_id, pricing_id } = message.body;
|
||||
const logContext = { order_id, user_id, pricing_id, message_id: message.id };
|
||||
|
||||
try {
|
||||
console.log(`[Queue] Processing message for order ${message.body.order_id}`);
|
||||
console.log('[Queue] Processing message:', JSON.stringify(logContext));
|
||||
|
||||
await provisioningService.processQueueMessage(message.body);
|
||||
|
||||
// Acknowledge successful processing
|
||||
message.ack();
|
||||
console.log(`[Queue] Order ${message.body.order_id} processed successfully`);
|
||||
console.log('[Queue] Message processed successfully:', JSON.stringify(logContext));
|
||||
} catch (error) {
|
||||
console.error(`[Queue] Error processing order ${message.body.order_id}:`, error);
|
||||
console.error('[Queue] Error processing message:', JSON.stringify({
|
||||
...logContext,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
}));
|
||||
|
||||
// Retry the message (will go to DLQ after max_retries)
|
||||
message.retry();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* POST /api/recommend - AI-powered server recommendation handler
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import type {
|
||||
Env,
|
||||
RecommendRequest,
|
||||
@@ -14,9 +15,8 @@ import type {
|
||||
BenchmarkReference,
|
||||
AvailableRegion
|
||||
} from '../types';
|
||||
import { LIMITS } from '../config';
|
||||
import { LIMITS, TECH_CATEGORY_WEIGHTS } from '../config';
|
||||
import {
|
||||
jsonResponse,
|
||||
validateRecommendRequest,
|
||||
generateCacheKey,
|
||||
estimateBandwidth,
|
||||
@@ -32,33 +32,27 @@ import {
|
||||
generateRuleBasedRecommendations,
|
||||
} from '../services/ai-service';
|
||||
|
||||
export async function handleRecommend(
|
||||
request: Request,
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const requestId = crypto.randomUUID();
|
||||
export async function handleRecommend(c: Context<{ Bindings: Env }>) {
|
||||
const requestId = c.get('requestId');
|
||||
|
||||
try {
|
||||
// Check request body size to prevent large payload attacks
|
||||
const contentLength = request.headers.get('Content-Length');
|
||||
const contentLength = c.req.header('Content-Length');
|
||||
if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) {
|
||||
return jsonResponse(
|
||||
{ error: 'Request body too large', max_size: '10KB' },
|
||||
413,
|
||||
corsHeaders
|
||||
return c.json(
|
||||
{ error: 'Request body too large', max_size: '10KB', request_id: requestId },
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request with actual body size check
|
||||
const bodyText = await request.text();
|
||||
const bodyText = await c.req.text();
|
||||
const actualBodySize = new TextEncoder().encode(bodyText).length;
|
||||
|
||||
if (actualBodySize > LIMITS.MAX_REQUEST_BODY_BYTES) {
|
||||
return jsonResponse(
|
||||
{ error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize },
|
||||
413,
|
||||
corsHeaders
|
||||
return c.json(
|
||||
{ error: 'Request body too large', max_size: '10KB', actual_size: actualBodySize, request_id: requestId },
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,24 +62,24 @@ export async function handleRecommend(
|
||||
body = JSON.parse(bodyText) as RecommendRequest;
|
||||
} catch (parseError) {
|
||||
console.error('[Recommend] JSON parse error:', parseError instanceof Error ? parseError.message : 'Unknown');
|
||||
return jsonResponse({
|
||||
return c.json({
|
||||
error: 'Invalid JSON format',
|
||||
request_id: requestId,
|
||||
}, 400, corsHeaders);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Validate body is an object before proceeding
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return jsonResponse({
|
||||
return c.json({
|
||||
error: body && 'lang' in body && body.lang === 'ko' ? '요청 본문은 객체여야 합니다' : 'Request body must be an object',
|
||||
request_id: requestId,
|
||||
}, 400, corsHeaders);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const lang = body.lang || 'en';
|
||||
const validationError = validateRecommendRequest(body, lang);
|
||||
if (validationError) {
|
||||
return jsonResponse(validationError, 400, corsHeaders);
|
||||
return c.json(validationError, 400);
|
||||
}
|
||||
|
||||
console.log('[Recommend] Request summary:', {
|
||||
@@ -102,23 +96,23 @@ export async function handleRecommend(
|
||||
console.log('[Recommend] Cache key:', cacheKey);
|
||||
|
||||
// Check cache (optional - may not be configured)
|
||||
if (env.CACHE) {
|
||||
const cached = await env.CACHE.get(cacheKey);
|
||||
if (c.env.CACHE) {
|
||||
const cached = await c.env.CACHE.get(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
// Validate required fields exist
|
||||
if (parsed && Array.isArray(parsed.recommendations)) {
|
||||
console.log('[Recommend] Cache hit');
|
||||
return jsonResponse(
|
||||
{ ...parsed, cached: true },
|
||||
200,
|
||||
corsHeaders
|
||||
);
|
||||
return c.json({ ...parsed, cached: true });
|
||||
}
|
||||
console.warn('[Recommend] Invalid cached data structure, ignoring');
|
||||
} catch (parseError) {
|
||||
console.warn('[Recommend] Cache parse error, ignoring cached data');
|
||||
// Invalid cache structure, delete and continue
|
||||
console.warn('[Recommend] Invalid cached data structure, deleting:', cacheKey);
|
||||
await c.env.CACHE.delete(cacheKey);
|
||||
} catch {
|
||||
// Corrupted cache, delete and continue
|
||||
console.warn('[Recommend] Corrupted cache, deleting:', cacheKey);
|
||||
await c.env.CACHE.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,8 +121,8 @@ export async function handleRecommend(
|
||||
|
||||
// Phase 1: Execute independent queries in parallel
|
||||
const [techSpecs, benchmarkDataAll] = await Promise.all([
|
||||
queryTechSpecs(env.DB, body.tech_stack),
|
||||
queryBenchmarkData(env.DB, body.tech_stack).catch(err => {
|
||||
queryTechSpecs(c.env.DB, body.tech_stack),
|
||||
queryBenchmarkData(c.env.DB, body.tech_stack).catch(err => {
|
||||
console.warn('[Recommend] Benchmark data unavailable:', err.message);
|
||||
return [] as BenchmarkData[];
|
||||
}),
|
||||
@@ -191,24 +185,12 @@ export async function handleRecommend(
|
||||
console.log(`[Recommend] DB workload inferred from use_case: ${dbWorkload.type} (multiplier: ${dbWorkload.multiplier})`);
|
||||
|
||||
if (techSpecs.length > 0) {
|
||||
// Group specs by category
|
||||
const categoryWeights: Record<string, number> = {
|
||||
'web_server': 0.1, // nginx, apache: reverse proxy uses minimal resources
|
||||
'runtime': 1.0, // nodejs, php, python: actual computation
|
||||
'database': 1.0, // mysql, postgresql, mongodb: major bottleneck
|
||||
'cache': 0.5, // redis, memcached: supporting role
|
||||
'search': 0.8, // elasticsearch: CPU-intensive but not always primary
|
||||
'container': 0.3, // docker: orchestration overhead
|
||||
'messaging': 0.5, // rabbitmq, kafka: I/O bound
|
||||
'default': 0.7 // unknown categories
|
||||
};
|
||||
|
||||
// Calculate weighted vCPU requirements per category
|
||||
const categoryRequirements = new Map<string, number>();
|
||||
|
||||
for (const spec of techSpecs) {
|
||||
const category = spec.category || 'default';
|
||||
const weight = categoryWeights[category] || categoryWeights['default'];
|
||||
const weight = TECH_CATEGORY_WEIGHTS[category] || TECH_CATEGORY_WEIGHTS['default'];
|
||||
|
||||
// Apply DB workload multiplier for database category
|
||||
// Lower multiplier = heavier workload = higher resource needs (lower vcpu_per_users)
|
||||
@@ -260,10 +242,10 @@ export async function handleRecommend(
|
||||
const defaultProviders = bandwidthEstimate?.category === 'very_heavy' ? ['Linode'] : ['Linode', 'Vultr'];
|
||||
|
||||
// Phase 2: Parallel queries including exchange rate for Korean users
|
||||
const exchangeRatePromise = lang === 'ko' ? getExchangeRate(env) : Promise.resolve(1);
|
||||
const exchangeRatePromise = lang === 'ko' ? getExchangeRate(c.env) : Promise.resolve(1);
|
||||
|
||||
// Use repository to fetch candidate servers
|
||||
const repository = new AnvilServerRepository(env.DB);
|
||||
const repository = new AnvilServerRepository(c.env.DB);
|
||||
|
||||
const [candidates, vpsBenchmarks, exchangeRate] = await Promise.all([
|
||||
repository.findServers({
|
||||
@@ -273,7 +255,7 @@ export async function handleRecommend(
|
||||
budgetLimit: body.budget_limit,
|
||||
limit: LIMITS.MAX_AI_CANDIDATES * 3, // Fetch more to allow for filtering
|
||||
}),
|
||||
queryVPSBenchmarksBatch(env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
|
||||
queryVPSBenchmarksBatch(c.env.DB, estimatedCores, estimatedMemory, defaultProviders).catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn('[Recommend] VPS benchmarks unavailable:', message);
|
||||
return [] as VPSBenchmark[];
|
||||
@@ -284,9 +266,9 @@ export async function handleRecommend(
|
||||
// Apply exchange rate to candidates if needed (Korean users)
|
||||
// 서버 가격: 500원 단위 반올림
|
||||
if (lang === 'ko' && exchangeRate !== 1) {
|
||||
candidates.forEach(c => {
|
||||
c.monthly_price = Math.round((c.monthly_price * exchangeRate) / 500) * 500;
|
||||
c.currency = 'KRW';
|
||||
candidates.forEach(candidate => {
|
||||
candidate.monthly_price = Math.round((candidate.monthly_price * exchangeRate) / 500) * 500;
|
||||
candidate.currency = 'KRW';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,15 +276,11 @@ export async function handleRecommend(
|
||||
console.log('[Recommend] VPS benchmark data points:', vpsBenchmarks.length);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'No servers found matching your requirements',
|
||||
recommendations: [],
|
||||
request_id: requestId,
|
||||
},
|
||||
200,
|
||||
corsHeaders
|
||||
);
|
||||
return c.json({
|
||||
error: 'No servers found matching your requirements',
|
||||
recommendations: [],
|
||||
request_id: requestId,
|
||||
}, 404);
|
||||
}
|
||||
|
||||
// Bandwidth-based filtering: prioritize servers with adequate transfer allowance
|
||||
@@ -353,8 +331,8 @@ export async function handleRecommend(
|
||||
let aiResult: { recommendations: RecommendationResult[]; infrastructure_tips?: string[] };
|
||||
try {
|
||||
aiResult = await getAIRecommendations(
|
||||
env,
|
||||
env.OPENAI_API_KEY,
|
||||
c.env,
|
||||
c.env.OPENAI_API_KEY,
|
||||
body,
|
||||
filteredCandidates, // Use bandwidth-filtered candidates
|
||||
benchmarkData,
|
||||
@@ -421,24 +399,25 @@ export async function handleRecommend(
|
||||
};
|
||||
|
||||
// Cache result only if we have recommendations (don't cache empty/failed results)
|
||||
if (env.CACHE && response.recommendations && response.recommendations.length > 0) {
|
||||
await env.CACHE.put(cacheKey, JSON.stringify(response), {
|
||||
if (c.env.CACHE && response.recommendations && response.recommendations.length > 0) {
|
||||
await c.env.CACHE.put(cacheKey, JSON.stringify(response), {
|
||||
expirationTtl: 300, // 5 minutes (reduced from 1 hour for faster iteration)
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse(response, 200, corsHeaders);
|
||||
return c.json(response);
|
||||
} catch (error) {
|
||||
console.error('[Recommend] Error:', error);
|
||||
console.error('[Recommend] Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||||
console.error('[Recommend] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||
return jsonResponse(
|
||||
console.error('[Recommend] Error:', JSON.stringify({
|
||||
request_id: requestId,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
}));
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to generate recommendations',
|
||||
request_id: requestId,
|
||||
},
|
||||
500,
|
||||
corsHeaders
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -646,7 +625,9 @@ async function queryTechSpecs(
|
||||
|
||||
for (const tech of normalizedStack) {
|
||||
conditions.push(`(LOWER(name) = ? OR LOWER(aliases) LIKE ?)`);
|
||||
params.push(tech, `%"${tech}"%`);
|
||||
// Escape special LIKE characters (%, _, \) in tech name
|
||||
const escapedTech = escapeLikePattern(tech);
|
||||
params.push(tech, `%"${escapedTech}"%`);
|
||||
}
|
||||
|
||||
const query = `
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* - lang: Language (en, ko, ja, zh) - default: en
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import type { Env, RecommendationResult, BandwidthEstimate } from '../types';
|
||||
import { jsonResponse, escapeHtml } from '../utils';
|
||||
import { escapeHtml } from '../utils';
|
||||
|
||||
interface ReportData {
|
||||
recommendations: RecommendationResult[];
|
||||
@@ -128,21 +129,25 @@ function getTierColor(index: number): string {
|
||||
return colors[index] || '#6b7280';
|
||||
}
|
||||
|
||||
export async function handleReport(
|
||||
request: Request,
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
export async function handleReport(c: Context<{ Bindings: Env }>) {
|
||||
const requestId = c.get('requestId');
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const dataParam = url.searchParams.get('data');
|
||||
const lang = url.searchParams.get('lang') || 'en';
|
||||
const dataParam = c.req.query('data');
|
||||
const lang = c.req.query('lang') || 'en';
|
||||
|
||||
if (!dataParam) {
|
||||
return jsonResponse(
|
||||
{ error: 'Missing data parameter. Provide Base64-encoded recommendation data.' },
|
||||
400,
|
||||
corsHeaders
|
||||
return c.json(
|
||||
{ error: 'Missing data parameter. Provide Base64-encoded recommendation data.', request_id: requestId },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Validate Base64 data size (max ~75KB decoded)
|
||||
if (dataParam.length > 100000) {
|
||||
return c.json(
|
||||
{ error: 'Data parameter too large', request_id: requestId },
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,37 +157,35 @@ export async function handleReport(
|
||||
const decoded = atob(dataParam);
|
||||
reportData = JSON.parse(decoded) as ReportData;
|
||||
} catch {
|
||||
return jsonResponse(
|
||||
{ error: 'Invalid data parameter. Must be valid Base64-encoded JSON.' },
|
||||
400,
|
||||
corsHeaders
|
||||
return c.json(
|
||||
{ error: 'Invalid data parameter. Must be valid Base64-encoded JSON.', request_id: requestId },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
if (!reportData.recommendations || reportData.recommendations.length === 0) {
|
||||
return jsonResponse(
|
||||
{ error: 'No recommendations in data.' },
|
||||
400,
|
||||
corsHeaders
|
||||
return c.json(
|
||||
{ error: 'No recommendations in data.', request_id: requestId },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const labels = getLabels(lang);
|
||||
const html = generateReportHTML(reportData, labels, lang);
|
||||
|
||||
// Return HTML with CSP that allows inline styles (required for printable report)
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
...corsHeaders,
|
||||
'Content-Security-Policy': "default-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none'",
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Report] Error:', error);
|
||||
return jsonResponse(
|
||||
{ error: 'Failed to generate report' },
|
||||
500,
|
||||
corsHeaders
|
||||
console.error('[Report] Error:', error, 'request_id:', requestId);
|
||||
return c.json(
|
||||
{ error: 'Failed to generate report', request_id: requestId },
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,40 +2,46 @@
|
||||
* GET /api/servers - Server list with filtering handler
|
||||
*/
|
||||
|
||||
import type { Context } from 'hono';
|
||||
import type { Env } from '../types';
|
||||
import { jsonResponse } from '../utils';
|
||||
import { AnvilServerRepository } from '../repositories/AnvilServerRepository';
|
||||
|
||||
/**
|
||||
* GET /api/servers - Server list with filtering
|
||||
* Uses anvil_* tables for pricing data
|
||||
*/
|
||||
export async function handleGetServers(
|
||||
request: Request,
|
||||
env: Env,
|
||||
corsHeaders: Record<string, string>
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const minCpu = url.searchParams.get('minCpu');
|
||||
const minMemory = url.searchParams.get('minMemory');
|
||||
const region = url.searchParams.get('region');
|
||||
export async function handleGetServers(c: Context<{ Bindings: Env }>) {
|
||||
const requestId = c.get('requestId');
|
||||
|
||||
console.log('[GetServers] Query params:', {
|
||||
minCpu,
|
||||
minMemory,
|
||||
region,
|
||||
});
|
||||
try {
|
||||
const minCpu = c.req.query('minCpu');
|
||||
const minMemory = c.req.query('minMemory');
|
||||
const region = c.req.query('region');
|
||||
|
||||
console.log('[GetServers] Query params:', { minCpu, minMemory, region, request_id: requestId });
|
||||
|
||||
// Generate cache key from query parameters
|
||||
const cacheKey = `servers:${url.search || 'all'}`;
|
||||
const cacheKey = `servers:${new URL(c.req.url).search || 'all'}`;
|
||||
|
||||
// Check cache first
|
||||
if (env.CACHE) {
|
||||
const cached = await env.CACHE.get(cacheKey);
|
||||
if (c.env.CACHE) {
|
||||
const cached = await c.env.CACHE.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log('[GetServers] Cache hit for:', cacheKey);
|
||||
return jsonResponse({ ...JSON.parse(cached), cached: true }, 200, corsHeaders);
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
// Validate cache structure
|
||||
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.servers)) {
|
||||
console.log('[GetServers] Cache hit for:', cacheKey);
|
||||
return c.json({ ...parsed, cached: true });
|
||||
}
|
||||
// Invalid cache structure, delete and continue
|
||||
console.warn('[GetServers] Invalid cache structure, deleting:', cacheKey, 'request_id:', requestId);
|
||||
await c.env.CACHE.delete(cacheKey);
|
||||
} catch {
|
||||
// Corrupted cache, delete and continue
|
||||
console.warn('[GetServers] Corrupted cache, deleting:', cacheKey, 'request_id:', requestId);
|
||||
await c.env.CACHE.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,19 +52,19 @@ export async function handleGetServers(
|
||||
if (minCpu) {
|
||||
parsedCpu = parseInt(minCpu, 10);
|
||||
if (isNaN(parsedCpu)) {
|
||||
return jsonResponse({ error: 'Invalid minCpu parameter' }, 400, corsHeaders);
|
||||
return c.json({ error: 'Invalid minCpu parameter' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (minMemory) {
|
||||
parsedMemory = parseInt(minMemory, 10);
|
||||
if (isNaN(parsedMemory)) {
|
||||
return jsonResponse({ error: 'Invalid minMemory parameter' }, 400, corsHeaders);
|
||||
return c.json({ error: 'Invalid minMemory parameter' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Use repository to fetch servers
|
||||
const repository = new AnvilServerRepository(env.DB);
|
||||
const repository = new AnvilServerRepository(c.env.DB);
|
||||
const servers = await repository.findServers({
|
||||
minCpu: parsedCpu,
|
||||
minMemoryGb: parsedMemory,
|
||||
@@ -66,7 +72,7 @@ export async function handleGetServers(
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
console.log('[GetServers] Found servers:', servers.length);
|
||||
console.log('[GetServers] Found servers:', servers.length, 'request_id:', requestId);
|
||||
|
||||
const responseData = {
|
||||
servers,
|
||||
@@ -75,23 +81,18 @@ export async function handleGetServers(
|
||||
};
|
||||
|
||||
// Cache successful results (only if we have servers)
|
||||
if (env.CACHE && servers.length > 0) {
|
||||
await env.CACHE.put(cacheKey, JSON.stringify(responseData), {
|
||||
if (c.env.CACHE && servers.length > 0) {
|
||||
await c.env.CACHE.put(cacheKey, JSON.stringify(responseData), {
|
||||
expirationTtl: 300, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse(responseData, 200, corsHeaders);
|
||||
return c.json(responseData);
|
||||
} catch (error) {
|
||||
console.error('[GetServers] Error:', error);
|
||||
const requestId = crypto.randomUUID();
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'Failed to retrieve servers',
|
||||
request_id: requestId,
|
||||
},
|
||||
500,
|
||||
corsHeaders
|
||||
console.error('[GetServers] Error:', error, 'request_id:', c.get('requestId'));
|
||||
return c.json(
|
||||
{ error: 'Failed to retrieve servers', request_id: c.get('requestId') },
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
199
src/index.ts
199
src/index.ts
@@ -2,10 +2,13 @@
|
||||
* Cloudflare Worker - Server Recommendation System Entry Point
|
||||
*
|
||||
* AI-powered server recommendation service using Workers AI, D1, and KV.
|
||||
* Built with Hono framework.
|
||||
*/
|
||||
|
||||
import type { Env } from './types';
|
||||
import { getAllowedOrigin, checkRateLimit, jsonResponse, isAllowedProvisionOrigin } from './utils';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Env, ProvisionQueueMessage } from './types';
|
||||
import { rateLimiter, provisionAuth, validateOrigin, validateUserId, securityHeaders, requestId } from './middleware';
|
||||
import { handleHealth } from './handlers/health';
|
||||
import { handleGetServers } from './handlers/servers';
|
||||
import { handleRecommend } from './handlers/recommend';
|
||||
@@ -19,139 +22,69 @@ import {
|
||||
handleGetOsImages,
|
||||
} from './handlers/provision';
|
||||
import { handleProvisionQueue } from './handlers/queue';
|
||||
import type { ProvisionQueueMessage } from './types';
|
||||
|
||||
/**
|
||||
* Main request handler
|
||||
*/
|
||||
// Create Hono app with typed bindings
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
// Request ID middleware (first - for tracking)
|
||||
app.use('*', requestId);
|
||||
|
||||
// CORS middleware - uses shared origin validation
|
||||
app.use('*', cors({
|
||||
origin: (origin) => validateOrigin(origin),
|
||||
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'X-API-Key', 'X-Request-ID'],
|
||||
}));
|
||||
|
||||
// Security headers
|
||||
app.use('*', securityHeaders);
|
||||
|
||||
// Rate limiting middleware
|
||||
app.use('*', rateLimiter);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', handleHealth);
|
||||
|
||||
// Server listing
|
||||
app.get('/api/servers', handleGetServers);
|
||||
|
||||
// Recommendations
|
||||
app.post('/api/recommend', handleRecommend);
|
||||
app.get('/api/recommend/report', handleReport);
|
||||
|
||||
// Provisioning endpoints (with auth + user_id validation middleware)
|
||||
const provision = new Hono<{ Bindings: Env }>();
|
||||
provision.use('*', provisionAuth);
|
||||
provision.use('*', validateUserId);
|
||||
provision.post('/', handleProvision);
|
||||
provision.get('/orders', handleGetOrders);
|
||||
provision.get('/orders/:id', handleGetOrder);
|
||||
provision.delete('/orders/:id', handleDeleteOrder);
|
||||
provision.get('/balance', handleGetBalance);
|
||||
provision.get('/images', handleGetOsImages);
|
||||
|
||||
app.route('/api/provision', provision);
|
||||
|
||||
// 404 handler
|
||||
app.notFound((c) => {
|
||||
return c.json(
|
||||
{ error: 'Not found', request_id: c.get('requestId') },
|
||||
404
|
||||
);
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.onError((err, c) => {
|
||||
console.error('[Worker] Unhandled error:', err, 'request_id:', c.get('requestId'));
|
||||
return c.json(
|
||||
{ error: 'Internal server error', request_id: c.get('requestId') },
|
||||
500
|
||||
);
|
||||
});
|
||||
|
||||
// Export for Cloudflare Workers
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Rate limiting (except for health checks)
|
||||
if (path !== '/api/health') {
|
||||
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
|
||||
const rateCheck = await checkRateLimit(clientIP, env);
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
const origin = getAllowedOrigin(request);
|
||||
return jsonResponse(
|
||||
{ error: 'Too many requests', request_id: rateCheck.requestId },
|
||||
429,
|
||||
{
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Vary': 'Origin',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// CORS headers for all responses
|
||||
const origin = getAllowedOrigin(request);
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Vary': 'Origin',
|
||||
};
|
||||
|
||||
// Handle preflight requests
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
// Route handling
|
||||
if (path === '/api/health') {
|
||||
return handleHealth(corsHeaders);
|
||||
}
|
||||
|
||||
if (path === '/api/servers' && request.method === 'GET') {
|
||||
return handleGetServers(request, env, corsHeaders);
|
||||
}
|
||||
|
||||
if (path === '/api/recommend' && request.method === 'POST') {
|
||||
return handleRecommend(request, env, corsHeaders);
|
||||
}
|
||||
|
||||
if (path === '/api/recommend/report' && request.method === 'GET') {
|
||||
return handleReport(request, env, corsHeaders);
|
||||
}
|
||||
|
||||
// Provisioning endpoints (restricted to *.kappa-d8e.workers.dev + API key)
|
||||
if (path.startsWith('/api/provision')) {
|
||||
// Check API key (required for all provision requests)
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
if (!env.PROVISION_API_KEY || apiKey !== env.PROVISION_API_KEY) {
|
||||
return jsonResponse(
|
||||
{ error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED' },
|
||||
401,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
// Check origin for browser requests
|
||||
if (!isAllowedProvisionOrigin(request)) {
|
||||
return jsonResponse(
|
||||
{ error: 'Forbidden: Invalid origin', code: 'FORBIDDEN' },
|
||||
403,
|
||||
corsHeaders
|
||||
);
|
||||
}
|
||||
|
||||
if (path === '/api/provision' && request.method === 'POST') {
|
||||
return handleProvision(request, env, corsHeaders);
|
||||
}
|
||||
|
||||
if (path === '/api/provision/orders' && request.method === 'GET') {
|
||||
return handleGetOrders(request, env, corsHeaders);
|
||||
}
|
||||
|
||||
if (path === '/api/provision/balance' && request.method === 'GET') {
|
||||
return handleGetBalance(request, env, corsHeaders);
|
||||
}
|
||||
|
||||
if (path === '/api/provision/images' && request.method === 'GET') {
|
||||
return handleGetOsImages(env, corsHeaders);
|
||||
}
|
||||
|
||||
// Dynamic route: /api/provision/orders/:id
|
||||
const orderMatch = path.match(/^\/api\/provision\/orders\/([a-zA-Z0-9-]+)$/);
|
||||
if (orderMatch) {
|
||||
const orderId = orderMatch[1];
|
||||
if (request.method === 'GET') {
|
||||
return handleGetOrder(request, env, orderId, corsHeaders);
|
||||
}
|
||||
if (request.method === 'DELETE') {
|
||||
return handleDeleteOrder(request, env, orderId, corsHeaders);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(
|
||||
{ error: 'Not found', request_id: requestId },
|
||||
404,
|
||||
corsHeaders
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Worker] Unhandled error:', error);
|
||||
const origin = getAllowedOrigin(request);
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'Internal server error',
|
||||
request_id: requestId,
|
||||
},
|
||||
500,
|
||||
{
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Vary': 'Origin',
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
fetch: app.fetch,
|
||||
|
||||
/**
|
||||
* Queue handler for async server provisioning
|
||||
|
||||
32
src/middleware/auth.ts
Normal file
32
src/middleware/auth.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Authentication middleware for provisioning endpoints
|
||||
*/
|
||||
|
||||
import { Context, Next } from 'hono';
|
||||
import type { Env } from '../types';
|
||||
import { isAllowedOrigin } from './origin';
|
||||
|
||||
/**
|
||||
* Middleware to check API key for provisioning endpoints
|
||||
*/
|
||||
export async function provisionAuth(c: Context<{ Bindings: Env }>, next: Next) {
|
||||
const apiKey = c.req.header('X-API-Key');
|
||||
|
||||
if (!c.env.PROVISION_API_KEY || apiKey !== c.env.PROVISION_API_KEY) {
|
||||
return c.json(
|
||||
{ error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED', request_id: c.get('requestId') },
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Check origin for browser requests
|
||||
const origin = c.req.header('Origin');
|
||||
if (!isAllowedOrigin(origin)) {
|
||||
return c.json(
|
||||
{ error: 'Forbidden: Invalid origin', code: 'FORBIDDEN', request_id: c.get('requestId') },
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
10
src/middleware/index.ts
Normal file
10
src/middleware/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Middleware exports
|
||||
*/
|
||||
|
||||
export { provisionAuth } from './auth';
|
||||
export { rateLimiter } from './rate-limit';
|
||||
export { validateOrigin, isAllowedOrigin } from './origin';
|
||||
export { validateUserId } from './user-id';
|
||||
export { securityHeaders } from './security';
|
||||
export { requestId } from './request-id';
|
||||
33
src/middleware/origin.ts
Normal file
33
src/middleware/origin.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Shared origin validation utilities
|
||||
*/
|
||||
|
||||
const DEFAULT_ORIGIN = 'https://cloud-orchestrator.kappa-d8e.workers.dev';
|
||||
|
||||
/**
|
||||
* Check if origin is allowed
|
||||
* Returns the allowed origin string or null if rejected
|
||||
*/
|
||||
export function validateOrigin(origin: string | null | undefined): string | null {
|
||||
// No origin = non-browser request (curl, server) - allowed with default
|
||||
if (!origin) return DEFAULT_ORIGIN;
|
||||
|
||||
// Allow only our Cloudflare Workers account subdomain
|
||||
if (origin.endsWith('.kappa-d8e.workers.dev')) return origin;
|
||||
|
||||
// Allow localhost for development
|
||||
if (origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:')) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
// Reject other origins
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request origin is allowed (boolean version)
|
||||
*/
|
||||
export function isAllowedOrigin(origin: string | null | undefined): boolean {
|
||||
return validateOrigin(origin) !== null;
|
||||
}
|
||||
28
src/middleware/rate-limit.ts
Normal file
28
src/middleware/rate-limit.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Rate limiting middleware
|
||||
*/
|
||||
|
||||
import { Context, Next } from 'hono';
|
||||
import type { Env } from '../types';
|
||||
import { checkRateLimit } from '../utils/cache';
|
||||
|
||||
/**
|
||||
* Rate limiting middleware
|
||||
* Skips health check endpoint
|
||||
*/
|
||||
export async function rateLimiter(c: Context<{ Bindings: Env }>, next: Next) {
|
||||
// Skip rate limiting for health checks
|
||||
if (c.req.path === '/api/health') return next();
|
||||
|
||||
const clientIP = c.req.header('CF-Connecting-IP') || 'unknown';
|
||||
const rateCheck = await checkRateLimit(clientIP, c.env);
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return c.json(
|
||||
{ error: 'Too many requests', request_id: c.get('requestId') },
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
29
src/middleware/request-id.ts
Normal file
29
src/middleware/request-id.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Request ID middleware for request tracking
|
||||
*/
|
||||
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
// Extend Hono context to include requestId
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
requestId: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to generate and track request IDs
|
||||
* Adds X-Request-ID header to all responses
|
||||
*/
|
||||
export async function requestId(c: Context, next: Next) {
|
||||
// Use existing request ID from header or generate new one
|
||||
const id = c.req.header('X-Request-ID') || crypto.randomUUID();
|
||||
|
||||
// Store in context for handlers to use
|
||||
c.set('requestId', id);
|
||||
|
||||
// Add to response header
|
||||
c.header('X-Request-ID', id);
|
||||
|
||||
return next();
|
||||
}
|
||||
30
src/middleware/security.ts
Normal file
30
src/middleware/security.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Security headers middleware
|
||||
*/
|
||||
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
/**
|
||||
* Add security headers to all responses
|
||||
*/
|
||||
export async function securityHeaders(c: Context, next: Next) {
|
||||
await next();
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Prevent clickjacking
|
||||
c.header('X-Frame-Options', 'DENY');
|
||||
|
||||
// XSS protection (legacy browsers)
|
||||
c.header('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Referrer policy
|
||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Content Security Policy for API responses
|
||||
c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
|
||||
|
||||
// HSTS - force HTTPS (1 year)
|
||||
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
55
src/middleware/user-id.ts
Normal file
55
src/middleware/user-id.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* User ID validation middleware for provision endpoints
|
||||
*/
|
||||
|
||||
import { Context, Next } from 'hono';
|
||||
import type { Env } from '../types';
|
||||
|
||||
// Extend Hono context to include userId
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
userId: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that don't require user_id validation
|
||||
* Full paths for exact matching (safer than endsWith)
|
||||
*/
|
||||
const SKIP_USER_ID_PATHS = new Set([
|
||||
'/api/provision', // POST /api/provision (body has user_id)
|
||||
'/api/provision/images', // GET /api/provision/images (public endpoint)
|
||||
]);
|
||||
|
||||
/**
|
||||
* Middleware to validate and extract user_id from query parameter
|
||||
* Skips validation for endpoints that don't require user_id
|
||||
*/
|
||||
export async function validateUserId(c: Context<{ Bindings: Env }>, next: Next) {
|
||||
// Skip for endpoints that don't require user_id (exact match)
|
||||
if (SKIP_USER_ID_PATHS.has(c.req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const userId = c.req.query('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return c.json(
|
||||
{ error: 'user_id query parameter is required', code: 'VALIDATION_ERROR', request_id: c.get('requestId') },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Validate user_id format (basic sanitization)
|
||||
if (userId.length > 50 || !/^[\w-]+$/.test(userId)) {
|
||||
return c.json(
|
||||
{ error: 'Invalid user_id format', code: 'VALIDATION_ERROR', request_id: c.get('requestId') },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Store in context for handlers to use
|
||||
c.set('userId', userId);
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -87,20 +87,22 @@ export class ProvisioningRepository {
|
||||
|
||||
async createServerOrder(
|
||||
userId: number,
|
||||
telegramUserId: string,
|
||||
specId: number,
|
||||
region: string,
|
||||
pricePaid: number,
|
||||
label: string | null,
|
||||
image: string | null
|
||||
image: string | null,
|
||||
idempotencyKey: string | null
|
||||
): Promise<ServerOrder> {
|
||||
const result = await this.userDb
|
||||
.prepare(
|
||||
`INSERT INTO server_orders
|
||||
(user_id, spec_id, status, region, price_paid, label, image, billing_type)
|
||||
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly')
|
||||
(user_id, telegram_user_id, spec_id, status, region, price_paid, label, image, billing_type, idempotency_key, created_at, expires_at)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, 'monthly', ?, CURRENT_TIMESTAMP, datetime(CURRENT_TIMESTAMP, '+720 hours'))
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(userId, specId, region, pricePaid, label, image)
|
||||
.bind(userId, telegramUserId, specId, region, pricePaid, label, image, idempotencyKey)
|
||||
.first();
|
||||
|
||||
return result as unknown as ServerOrder;
|
||||
@@ -132,7 +134,16 @@ export class ProvisioningRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order with root password
|
||||
*
|
||||
* SECURITY WARNING: Password is currently stored in plaintext.
|
||||
* TODO: Implement encryption using WebCrypto API (AES-GCM) before production use.
|
||||
* The encryption key should be stored in env.ENCRYPTION_KEY secret.
|
||||
*/
|
||||
async updateOrderRootPassword(orderId: number, rootPassword: string): Promise<void> {
|
||||
// TODO: Encrypt password before storage
|
||||
// const encryptedPassword = await this.encryptPassword(rootPassword, env.ENCRYPTION_KEY);
|
||||
await this.userDb
|
||||
.prepare(
|
||||
`UPDATE server_orders
|
||||
@@ -176,7 +187,9 @@ export class ProvisioningRepository {
|
||||
async getOrdersByUserId(userId: number, limit: number = 20): Promise<ServerOrder[]> {
|
||||
const result = await this.userDb
|
||||
.prepare(
|
||||
'SELECT * FROM server_orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'
|
||||
`SELECT * FROM server_orders
|
||||
WHERE user_id = ? AND status NOT IN ('terminated', 'cancelled')
|
||||
ORDER BY created_at DESC LIMIT ?`
|
||||
)
|
||||
.bind(userId, limit)
|
||||
.all();
|
||||
@@ -184,6 +197,18 @@ export class ProvisioningRepository {
|
||||
return result.results as unknown as ServerOrder[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find order by idempotency key (for duplicate prevention)
|
||||
*/
|
||||
async findOrderByIdempotencyKey(idempotencyKey: string): Promise<ServerOrder | null> {
|
||||
const result = await this.userDb
|
||||
.prepare('SELECT * FROM server_orders WHERE idempotency_key = ?')
|
||||
.bind(idempotencyKey)
|
||||
.first();
|
||||
|
||||
return result as unknown as ServerOrder | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Pricing Lookup (cloud-instances-db)
|
||||
// Uses anvil_pricing, anvil_instances, anvil_regions
|
||||
@@ -233,11 +258,13 @@ export class ProvisioningRepository {
|
||||
*/
|
||||
async createOrderWithPayment(
|
||||
userId: number,
|
||||
telegramUserId: string,
|
||||
specId: number,
|
||||
region: string,
|
||||
priceKrw: number,
|
||||
label: string | null,
|
||||
image: string | null
|
||||
image: string | null,
|
||||
idempotencyKey: string | null
|
||||
): Promise<{ orderId: number | null; error?: string }> {
|
||||
try {
|
||||
// Step 1: Check and deduct balance
|
||||
@@ -249,11 +276,13 @@ export class ProvisioningRepository {
|
||||
// Step 2: Create order
|
||||
const order = await this.createServerOrder(
|
||||
userId,
|
||||
telegramUserId,
|
||||
specId,
|
||||
region,
|
||||
priceKrw,
|
||||
label,
|
||||
image
|
||||
image,
|
||||
idempotencyKey
|
||||
);
|
||||
|
||||
return { orderId: order.id };
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
AIRecommendationResponse,
|
||||
BenchmarkReference,
|
||||
} from '../types';
|
||||
import { i18n, LIMITS } from '../config';
|
||||
import { i18n, LIMITS, TIMEOUTS } from '../config';
|
||||
import {
|
||||
sanitizeForAIPrompt,
|
||||
isValidAIRecommendation,
|
||||
@@ -56,16 +56,15 @@ export async function getAIRecommendations(
|
||||
const languageInstruction = i18n[validLang].aiLanguageInstruction;
|
||||
|
||||
// Build system prompt with benchmark awareness
|
||||
const systemPrompt = `You are a cloud infrastructure expert focused on COST-EFFECTIVE solutions. Your goal is to recommend the SMALLEST and CHEAPEST server that can handle the user's requirements.
|
||||
const systemPrompt = `You are a cloud infrastructure expert who recommends the RIGHT server by understanding user needs - whether they prioritize cost savings or performance.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. NEVER over-provision. Recommend the minimum specs needed.
|
||||
2. Cost efficiency is the PRIMARY factor - cheaper is better if it meets requirements.
|
||||
3. A 1-2 vCPU server can handle 100-500 concurrent users for most web workloads.
|
||||
4. Nginx/reverse proxy needs very little resources - 1 vCPU can handle 1000+ req/sec.
|
||||
5. Provide 3 options: Budget (cheapest viable), Balanced (some headroom), Premium (growth ready).
|
||||
6. NEVER recommend the same server twice. Each recommendation MUST have a DIFFERENT server_id.
|
||||
7. If only 2 suitable servers exist, recommend only 2. Do NOT duplicate.
|
||||
1. UNDERSTAND THE CONTEXT: Budget-conscious users and minimal workloads need different recommendations than high-performance scenarios.
|
||||
2. BUDGET SCENARIOS (<50 concurrent users, personal blogs, portfolios): Basic 1GB/2GB are valid top recommendations. Cost is the PRIMARY factor.
|
||||
3. HIGH-DEMAND SCENARIOS (500+ concurrent users, gaming, databases): Capacity is PRIMARY. Never undersized servers.
|
||||
4. Provide 3 options with meaningful spec progression: Budget (cheapest adequate) → Best Fit (balanced) → Premium (growth ready).
|
||||
5. NEVER recommend the same server twice. Each recommendation MUST have a DIFFERENT server_id.
|
||||
6. If only 2 suitable servers exist, recommend only 2. Do NOT duplicate.
|
||||
|
||||
BANDWIDTH CONSIDERATIONS:
|
||||
- Estimated monthly bandwidth is provided based on concurrent users and use case.
|
||||
@@ -204,16 +203,34 @@ Return ONLY a valid JSON object (no markdown, no code blocks) with this exact st
|
||||
}
|
||||
|
||||
Provide exactly 3 recommendations:
|
||||
1. BUDGET option: Cheapest TOTAL cost (base + bandwidth) that can handle the load
|
||||
2. BALANCED option: Some headroom for traffic spikes
|
||||
3. PREMIUM option: Ready for 2-3x growth
|
||||
1. BEST FIT option: Best match for requirements (balance capacity and cost)
|
||||
2. BUDGET option: Cheapest adequate option (MUST meet minimum requirements)
|
||||
3. PREMIUM option: Higher-spec with room for 2-3x growth
|
||||
|
||||
SCORING (100 points total):
|
||||
- Total Cost Efficiency (40%): Base price + estimated bandwidth overage. Lower total = higher score.
|
||||
- Capacity Fit (30%): Can it handle the concurrent users and bandwidth?
|
||||
- Scalability (30%): Room for growth in CPU, memory, AND bandwidth allowance.
|
||||
SCORING RULES (100 points total):
|
||||
Base scoring:
|
||||
- Capacity Fit (50%): Can it handle concurrent users and bandwidth?
|
||||
- Total Cost Efficiency (30%): Lower cost = higher score among adequate servers
|
||||
- Scalability (20%): Room for growth
|
||||
|
||||
The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have the HIGHEST score.`;
|
||||
BUDGET-CONSCIOUS SCENARIOS:
|
||||
When user explicitly requests "cheapest", "저렴한", "최소", "가장 싼" OR has minimal requirements (<50 concurrent users, personal blog, portfolio):
|
||||
- Basic 1GB or Basic 2GB servers MUST be considered as top recommendations
|
||||
- The cheapest server that meets minimum requirements should score 85-95 points
|
||||
- Cost efficiency becomes PRIMARY factor (50%) while capacity becomes SECONDARY (30%)
|
||||
- For personal blogs (<100 daily visitors): Basic 1GB is often sufficient and should be #1 recommendation
|
||||
- For small sites (100-500 daily visitors): Basic 2GB should be considered for #1 or #2
|
||||
|
||||
HIGH-DEMAND SCENARIOS:
|
||||
For high-concurrency workloads (500+ concurrent users, high-traffic apps, gaming servers, databases):
|
||||
- Capacity MUST be sufficient first - undersized servers should never score above 60
|
||||
- A 1GB server CANNOT handle 1000+ concurrent users - score it accordingly
|
||||
- Standard 4GB or higher should be prioritized for demanding workloads
|
||||
|
||||
CRITICAL VALIDATION:
|
||||
- Never recommend the same server twice (different server_id required)
|
||||
- Budget option should be 40-60% cheaper than Premium option when possible
|
||||
- Ensure meaningful spec progression: Budget < Best Fit < Premium`;
|
||||
|
||||
// Use AI Gateway if configured (bypasses regional restrictions like HKG)
|
||||
// AI Gateway URL format: https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_name}/openai
|
||||
@@ -227,9 +244,9 @@ The option with the LOWEST TOTAL MONTHLY COST (including bandwidth) should have
|
||||
console.log('[AI] Using Cloudflare AI Gateway to bypass regional restrictions');
|
||||
}
|
||||
|
||||
// Create AbortController with 30 second timeout
|
||||
// Create AbortController with configurable timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUTS.AI_REQUEST_MS);
|
||||
|
||||
try {
|
||||
const openaiResponse = await fetch(apiEndpoint, {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
|
||||
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
|
||||
import { TIMEOUTS } from '../config';
|
||||
|
||||
interface LinodeInstance {
|
||||
id: number;
|
||||
@@ -28,7 +29,7 @@ interface LinodeError {
|
||||
export class LinodeProvider extends VPSProviderBase {
|
||||
static readonly DEFAULT_BASE_URL = 'https://api.linode.com/v4';
|
||||
|
||||
constructor(apiKey: string, baseUrl?: string, timeout: number = 30000) {
|
||||
constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) {
|
||||
super({
|
||||
apiKey,
|
||||
baseUrl: baseUrl || LinodeProvider.DEFAULT_BASE_URL,
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ProvisioningRepository } from '../repositories/ProvisioningRepository';
|
||||
import { LinodeProvider } from './linode-provider';
|
||||
import { VultrProvider } from './vultr-provider';
|
||||
import { getExchangeRate } from '../utils/exchange-rate';
|
||||
import { TIMEOUTS } from '../config';
|
||||
|
||||
const TELEGRAM_TIMEOUT_MS = 10000; // 10 seconds for Telegram API calls
|
||||
|
||||
export class ProvisioningService {
|
||||
private repo: ProvisioningRepository;
|
||||
@@ -40,7 +43,28 @@ export class ProvisioningService {
|
||||
* 6. Return immediately with order info
|
||||
*/
|
||||
async provisionServer(request: ProvisionRequest): Promise<ProvisionResponse> {
|
||||
const { telegram_id, pricing_id, label, image, dry_run } = request;
|
||||
const { telegram_id, pricing_id, label, image, dry_run, idempotency_key } = request;
|
||||
|
||||
// Step 0: Check idempotency - if key exists, handle based on order status
|
||||
let existingPendingOrder: ServerOrder | null = null;
|
||||
|
||||
if (idempotency_key) {
|
||||
const existingOrder = await this.repo.findOrderByIdempotencyKey(idempotency_key);
|
||||
if (existingOrder) {
|
||||
// If order is already active, return existing result (true idempotency)
|
||||
if (existingOrder.status === 'active') {
|
||||
console.log(`[ProvisioningService] Idempotent request: order ${existingOrder.id} already active, returning existing`);
|
||||
return {
|
||||
success: true,
|
||||
order: existingOrder,
|
||||
};
|
||||
}
|
||||
// If order is pending/provisioning, store it to use instead of creating new
|
||||
// This handles retry scenarios where previous attempt failed
|
||||
existingPendingOrder = existingOrder;
|
||||
console.log(`[ProvisioningService] Found pending order ${existingOrder.id} with idempotency_key, will use existing instead of creating new`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Validate OS image from DB (or get default)
|
||||
let osImage = image ? await this.repo.getOsImageByKey(image) : await this.repo.getDefaultOsImage();
|
||||
@@ -102,6 +126,7 @@ export class ProvisioningService {
|
||||
order: {
|
||||
id: 0,
|
||||
user_id: user.id,
|
||||
telegram_user_id: telegram_id,
|
||||
spec_id: pricing_id,
|
||||
status: 'pending',
|
||||
region: pricing.region_code,
|
||||
@@ -117,6 +142,7 @@ export class ProvisioningService {
|
||||
label: label || null,
|
||||
image: osImageKey,
|
||||
billing_type: 'monthly',
|
||||
idempotency_key: idempotency_key || null,
|
||||
},
|
||||
dry_run_info: {
|
||||
message: 'Dry run successful. No server created, no balance deducted.',
|
||||
@@ -132,36 +158,54 @@ export class ProvisioningService {
|
||||
} as ProvisionResponse;
|
||||
}
|
||||
|
||||
// Step 5: Generate root password before creating order
|
||||
const rootPassword = this.generateSecurePassword();
|
||||
// Step 5 & 6: Use existing pending order OR create new order with payment
|
||||
let orderId: number;
|
||||
|
||||
// Step 6: Create order with payment (atomic balance deduction + order creation)
|
||||
const orderResult = await this.repo.createOrderWithPayment(
|
||||
user.id,
|
||||
pricing_id,
|
||||
pricing.region_code,
|
||||
priceKrw,
|
||||
label || null,
|
||||
osImageKey
|
||||
);
|
||||
if (existingPendingOrder) {
|
||||
// Use existing pending order - no need to create new or deduct balance again
|
||||
orderId = existingPendingOrder.id;
|
||||
console.log(`[ProvisioningService] Using existing order ${orderId} (idempotency)`);
|
||||
|
||||
if (!orderResult.orderId) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: orderResult.error || 'ORDER_CREATION_FAILED',
|
||||
message: orderResult.error === 'INSUFFICIENT_BALANCE'
|
||||
? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}`
|
||||
: 'Failed to create order',
|
||||
},
|
||||
};
|
||||
// Ensure root_password is set (telegram-bot-workers doesn't set it)
|
||||
if (!existingPendingOrder.root_password) {
|
||||
const rootPassword = this.generateSecurePassword();
|
||||
await this.repo.updateOrderRootPassword(orderId, rootPassword);
|
||||
console.log(`[ProvisioningService] Generated root_password for existing order ${orderId}`);
|
||||
}
|
||||
} else {
|
||||
// Step 5: Generate root password before creating order
|
||||
const rootPassword = this.generateSecurePassword();
|
||||
|
||||
// Step 6: Create order with payment (atomic balance deduction + order creation)
|
||||
const orderResult = await this.repo.createOrderWithPayment(
|
||||
user.id,
|
||||
telegram_id,
|
||||
pricing_id,
|
||||
pricing.region_code,
|
||||
priceKrw,
|
||||
label || null,
|
||||
osImageKey,
|
||||
idempotency_key || null
|
||||
);
|
||||
|
||||
if (!orderResult.orderId) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: orderResult.error || 'ORDER_CREATION_FAILED',
|
||||
message: orderResult.error === 'INSUFFICIENT_BALANCE'
|
||||
? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}`
|
||||
: 'Failed to create order',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
orderId = orderResult.orderId;
|
||||
|
||||
// Step 7: Store root password in order (encrypted in production)
|
||||
await this.repo.updateOrderRootPassword(orderId, rootPassword);
|
||||
}
|
||||
|
||||
const orderId = orderResult.orderId;
|
||||
|
||||
// Step 7: Store root password in order (encrypted in production)
|
||||
await this.repo.updateOrderRootPassword(orderId, rootPassword);
|
||||
|
||||
// Step 8: Update order status to 'queued' and send to Queue
|
||||
await this.repo.updateOrderStatus(orderId, 'provisioning');
|
||||
|
||||
@@ -211,7 +255,9 @@ export class ProvisioningService {
|
||||
const pricing = await this.repo.getPricingWithProvider(pricing_id);
|
||||
if (!pricing) {
|
||||
console.error(`[ProvisioningService] Pricing not found for order ${order_id}`);
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Pricing not found');
|
||||
const errorMsg = 'Pricing not found';
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
|
||||
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -219,7 +265,9 @@ export class ProvisioningService {
|
||||
const provider = this.getProvider(pricing.source_provider);
|
||||
if (!provider) {
|
||||
console.error(`[ProvisioningService] Provider ${pricing.source_provider} not configured for order ${order_id}`);
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `Provider ${pricing.source_provider} not configured`);
|
||||
const errorMsg = `Provider ${pricing.source_provider} not configured`;
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
|
||||
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,7 +275,9 @@ export class ProvisioningService {
|
||||
const osImage = await this.repo.getOsImageByKey(image);
|
||||
if (!osImage) {
|
||||
console.error(`[ProvisioningService] OS image '${image}' not found for order ${order_id}`);
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image '${image}' not found`);
|
||||
const errorMsg = `OS image '${image}' not found`;
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
|
||||
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,10 +288,24 @@ export class ProvisioningService {
|
||||
|
||||
if (!osImageId) {
|
||||
console.error(`[ProvisioningService] OS image '${image}' not available for ${pricing.source_provider}`);
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, `OS image not available for ${pricing.source_provider}`);
|
||||
const errorMsg = `OS image not available for ${pricing.source_provider}`;
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
|
||||
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare SSH keys (admin key for recovery access)
|
||||
// Linode: uses public key string directly
|
||||
// Vultr: uses pre-registered SSH key ID
|
||||
let sshKeys: string[] | undefined;
|
||||
if (pricing.provider_name.toLowerCase() === 'linode' && this.env.ADMIN_SSH_PUBLIC_KEY) {
|
||||
sshKeys = [this.env.ADMIN_SSH_PUBLIC_KEY];
|
||||
console.log(`[ProvisioningService] Admin SSH public key will be added to order ${order_id} (Linode)`);
|
||||
} else if (pricing.provider_name.toLowerCase() === 'vultr' && this.env.ADMIN_SSH_KEY_ID_VULTR) {
|
||||
sshKeys = [this.env.ADMIN_SSH_KEY_ID_VULTR];
|
||||
console.log(`[ProvisioningService] Admin SSH key ID will be added to order ${order_id} (Vultr)`);
|
||||
}
|
||||
|
||||
// Call provider API (use source_region_code for actual provider region)
|
||||
const createResult = await provider.createServer({
|
||||
plan: pricing.instance_id,
|
||||
@@ -249,19 +313,17 @@ export class ProvisioningService {
|
||||
osImage: osImageId,
|
||||
label: label || `order-${order_id}`,
|
||||
rootPassword: root_password,
|
||||
sshKeys,
|
||||
tags: [`user:${user_id}`, `order:${order_id}`],
|
||||
});
|
||||
|
||||
if (!createResult.success) {
|
||||
console.error(`[ProvisioningService] Provider API failed for order ${order_id}:`, createResult.error);
|
||||
await this.repo.rollbackOrder(
|
||||
order_id,
|
||||
user_id,
|
||||
order.price_paid,
|
||||
createResult.error?.message || 'Provider API error'
|
||||
);
|
||||
const errorMsg = createResult.error?.message || 'Provider API error';
|
||||
await this.repo.rollbackOrder(order_id, user_id, order.price_paid, errorMsg);
|
||||
await this.sendProvisioningFailureNotification(order_id, order.telegram_user_id, errorMsg, order.price_paid);
|
||||
// Throw error to trigger retry
|
||||
throw new Error(createResult.error?.message || 'Provider API error');
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Wait for IP assignment if not immediately available
|
||||
@@ -284,6 +346,164 @@ export class ProvisioningService {
|
||||
);
|
||||
|
||||
console.log(`[ProvisioningService] Order ${order_id} provisioned successfully: ${createResult.instanceId}, IP: ${ipv4 || 'pending'}`);
|
||||
|
||||
// Send Telegram notification to user (now that we have real IP and password)
|
||||
await this.sendProvisioningSuccessNotification(
|
||||
order_id,
|
||||
order.telegram_user_id,
|
||||
ipv4 || null,
|
||||
order.label,
|
||||
pricing.region_name,
|
||||
root_password
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Telegram notification for provisioning failure
|
||||
*/
|
||||
private async sendProvisioningFailureNotification(
|
||||
orderId: number,
|
||||
telegramUserId: string,
|
||||
errorMessage: string,
|
||||
refundAmount: number
|
||||
): Promise<void> {
|
||||
// Check if BOT_TOKEN is available
|
||||
if (!this.env.BOT_TOKEN) {
|
||||
console.warn(`[ProvisioningService] BOT_TOKEN not configured - skipping failure notification for order ${orderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const botToken = this.env.BOT_TOKEN;
|
||||
const chatId = parseInt(telegramUserId, 10);
|
||||
|
||||
// Validate chatId
|
||||
if (isNaN(chatId) || chatId <= 0) {
|
||||
console.error(`[ProvisioningService] Invalid Telegram user ID: ${telegramUserId} for order ${orderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const failureMessage = `❌ 서버 프로비저닝 실패
|
||||
|
||||
주문 #${orderId}
|
||||
사유: ${errorMessage}
|
||||
|
||||
잔액 환불: ${refundAmount.toLocaleString()}원
|
||||
|
||||
다시 시도하시거나 관리자에게 문의해주세요.`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TELEGRAM_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: failureMessage,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
console.log(`[ProvisioningService] Sent failure notification for order ${orderId}`);
|
||||
} catch (error) {
|
||||
console.error(`[ProvisioningService] Failed to send failure notification for order ${orderId}:`, error);
|
||||
// Don't throw - notification failure shouldn't affect the rollback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Telegram notification after successful provisioning
|
||||
*/
|
||||
private async sendProvisioningSuccessNotification(
|
||||
orderId: number,
|
||||
telegramUserId: string,
|
||||
ipAddress: string | null,
|
||||
label: string | null,
|
||||
region: string,
|
||||
rootPassword: string
|
||||
): Promise<void> {
|
||||
// Check if BOT_TOKEN is available
|
||||
if (!this.env.BOT_TOKEN) {
|
||||
console.warn(`[ProvisioningService] BOT_TOKEN not configured - skipping Telegram notification for order ${orderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const botToken = this.env.BOT_TOKEN;
|
||||
const chatId = parseInt(telegramUserId, 10);
|
||||
|
||||
// Validate chatId
|
||||
if (isNaN(chatId) || chatId <= 0) {
|
||||
console.error(`[ProvisioningService] Invalid Telegram user ID: ${telegramUserId} for order ${orderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send main success message
|
||||
const successMessage = `✅ 서버 프로비저닝 완료!
|
||||
|
||||
주문 #${orderId}
|
||||
IP 주소: ${ipAddress || 'IP 할당 대기 중'}
|
||||
라벨: ${label || `order-${orderId}`}
|
||||
리전: ${region}
|
||||
|
||||
루트 비밀번호는 보안상 별도로 전송됩니다.`;
|
||||
|
||||
// Send success message with timeout
|
||||
const controller1 = new AbortController();
|
||||
const timeoutId1 = setTimeout(() => controller1.abort(), TELEGRAM_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: successMessage,
|
||||
}),
|
||||
signal: controller1.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId1);
|
||||
}
|
||||
|
||||
console.log(`[ProvisioningService] Sent success notification for order ${orderId}`);
|
||||
|
||||
// Send password in separate message (security best practice)
|
||||
const passwordMessage = `🔐 서버 #${orderId} 루트 비밀번호:
|
||||
|
||||
<code>${rootPassword}</code>
|
||||
|
||||
⚠️ 이 메시지는 즉시 삭제하고 비밀번호를 안전한 곳에 보관하세요.`;
|
||||
|
||||
// Send password message with timeout
|
||||
const controller2 = new AbortController();
|
||||
const timeoutId2 = setTimeout(() => controller2.abort(), TELEGRAM_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: passwordMessage,
|
||||
parse_mode: 'HTML',
|
||||
}),
|
||||
signal: controller2.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId2);
|
||||
}
|
||||
|
||||
console.log(`[ProvisioningService] Sent password notification for order ${orderId}`);
|
||||
} catch (error) {
|
||||
console.error(`[ProvisioningService] Failed to send Telegram notification for order ${orderId}:`, error);
|
||||
// Don't throw - notification failure shouldn't fail the provisioning
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,16 +84,37 @@ export abstract class VPSProviderBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure password
|
||||
* Generate cryptographically secure password using rejection sampling
|
||||
* to avoid modulo bias in character selection
|
||||
*/
|
||||
protected generateSecurePassword(length: number = 32): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
||||
const array = new Uint8Array(length);
|
||||
// Calculate max valid value to avoid modulo bias (256 % 70 = 46, creates bias)
|
||||
const maxValidValue = Math.floor(256 / chars.length) * chars.length;
|
||||
// Request more bytes than needed for rejection sampling
|
||||
const array = new Uint8Array(length * 2);
|
||||
crypto.getRandomValues(array);
|
||||
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += chars[array[i] % chars.length];
|
||||
let i = 0;
|
||||
while (password.length < length && i < array.length) {
|
||||
if (array[i] < maxValidValue) {
|
||||
password += chars[array[i] % chars.length];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Fallback: if we run out of valid bytes, get more
|
||||
while (password.length < length) {
|
||||
const extraBytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(extraBytes);
|
||||
for (let j = 0; j < extraBytes.length && password.length < length; j++) {
|
||||
if (extraBytes[j] < maxValidValue) {
|
||||
password += chars[extraBytes[j] % chars.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types';
|
||||
import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider';
|
||||
import { TIMEOUTS } from '../config';
|
||||
|
||||
interface VultrInstance {
|
||||
id: string;
|
||||
@@ -38,7 +39,7 @@ interface VultrError {
|
||||
export class VultrProvider extends VPSProviderBase {
|
||||
static readonly DEFAULT_BASE_URL = 'https://api.vultr.com/v2';
|
||||
|
||||
constructor(apiKey: string, baseUrl?: string, timeout: number = 30000) {
|
||||
constructor(apiKey: string, baseUrl?: string, timeout: number = TIMEOUTS.VPS_PROVIDER_API_MS) {
|
||||
super({
|
||||
apiKey,
|
||||
baseUrl: baseUrl || VultrProvider.DEFAULT_BASE_URL,
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface Env {
|
||||
PROVISION_API_KEY?: string; // Required for /api/provision/* endpoints
|
||||
// Queue for async provisioning
|
||||
PROVISION_QUEUE: Queue<ProvisionQueueMessage>;
|
||||
// Telegram Bot Token for user notifications
|
||||
BOT_TOKEN?: string; // Optional: for sending Telegram notifications after provisioning
|
||||
// Admin SSH keys for server recovery
|
||||
ADMIN_SSH_PUBLIC_KEY?: string; // Linode: public key string (ssh-rsa AAAA...)
|
||||
ADMIN_SSH_KEY_ID_VULTR?: string; // Vultr: pre-registered SSH key ID
|
||||
}
|
||||
|
||||
// Queue message for async server provisioning
|
||||
@@ -253,6 +258,7 @@ export interface UserDeposit {
|
||||
export interface ServerOrder {
|
||||
id: number; // AUTO INCREMENT
|
||||
user_id: number; // References users.id
|
||||
telegram_user_id: string; // Telegram user ID (for direct reference)
|
||||
spec_id: number; // Server spec ID
|
||||
status: 'pending' | 'provisioning' | 'active' | 'failed' | 'cancelled' | 'terminated';
|
||||
region: string;
|
||||
@@ -268,6 +274,7 @@ export interface ServerOrder {
|
||||
label: string | null;
|
||||
image: string | null;
|
||||
billing_type: string; // 'monthly' default
|
||||
idempotency_key: string | null; // Idempotency key for duplicate prevention
|
||||
}
|
||||
|
||||
export type VPSProvider = 'linode' | 'vultr';
|
||||
@@ -278,6 +285,7 @@ export interface ProvisionRequest {
|
||||
label?: string;
|
||||
image?: string; // OS image (e.g., 'ubuntu_22_04')
|
||||
dry_run?: boolean; // Test mode: validate only, don't create server
|
||||
idempotency_key?: string; // Idempotency key for duplicate prevention (1-128 chars)
|
||||
}
|
||||
|
||||
export interface ProvisionResponse {
|
||||
|
||||
@@ -82,6 +82,18 @@ export function generateCacheKey(req: RecommendRequest): string {
|
||||
|
||||
// In-memory fallback for rate limiting when CACHE KV is unavailable
|
||||
const inMemoryRateLimit = new Map<string, { count: number; resetTime: number }>();
|
||||
const MAX_IN_MEMORY_ENTRIES = 10000;
|
||||
|
||||
/**
|
||||
* Clean up expired entries from in-memory rate limit map
|
||||
*/
|
||||
function cleanupExpiredEntries(now: number): void {
|
||||
for (const [key, record] of inMemoryRateLimit) {
|
||||
if (record.resetTime < now) {
|
||||
inMemoryRateLimit.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting check using KV storage with in-memory fallback
|
||||
@@ -94,6 +106,18 @@ export async function checkRateLimit(clientIP: string, env: Env): Promise<{ allo
|
||||
|
||||
// Use in-memory fallback if CACHE unavailable
|
||||
if (!env.CACHE) {
|
||||
// Cleanup expired entries if map is getting too large
|
||||
if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) {
|
||||
cleanupExpiredEntries(now);
|
||||
// If still too large after cleanup, remove oldest 10% by resetTime
|
||||
if (inMemoryRateLimit.size >= MAX_IN_MEMORY_ENTRIES) {
|
||||
const entries = Array.from(inMemoryRateLimit.entries())
|
||||
.sort((a, b) => a[1].resetTime - b[1].resetTime)
|
||||
.slice(0, Math.floor(MAX_IN_MEMORY_ENTRIES * 0.1));
|
||||
entries.forEach(([key]) => inMemoryRateLimit.delete(key));
|
||||
}
|
||||
}
|
||||
|
||||
const record = inMemoryRateLimit.get(clientIP);
|
||||
|
||||
if (!record || record.resetTime < now) {
|
||||
|
||||
@@ -164,6 +164,20 @@ export function validateRecommendRequest(body: unknown, lang: string = 'en'): Va
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cdn_enabled if provided
|
||||
if (req.cdn_enabled !== undefined && typeof req.cdn_enabled !== 'boolean') {
|
||||
invalidFields.push({ field: 'cdn_enabled', reason: 'must be a boolean' });
|
||||
}
|
||||
|
||||
// Validate cdn_cache_hit_rate if provided
|
||||
if (req.cdn_cache_hit_rate !== undefined) {
|
||||
if (typeof req.cdn_cache_hit_rate !== 'number') {
|
||||
invalidFields.push({ field: 'cdn_cache_hit_rate', reason: 'must be a number' });
|
||||
} else if (req.cdn_cache_hit_rate < 0 || req.cdn_cache_hit_rate > 1) {
|
||||
invalidFields.push({ field: 'cdn_cache_hit_rate', reason: 'must be between 0.0 and 1.0' });
|
||||
}
|
||||
}
|
||||
|
||||
// Return error if any issues found
|
||||
if (missingFields.length > 0 || invalidFields.length > 0) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user