Files
anvil-hosting/functions/api/pricing.ts
kappa b14d93be9d security: add SRI, remove Tailwind CDN, restrict CORS
- Add SRI hash to Alpine.js (integrity + crossorigin)
- Remove Tailwind CDN, use prebuilt style.css only
- Add CSS variables for terminal theme colors
- Restrict CORS to https://hosting.anvil.it.com

Performance: ~500ms LCP improvement (no JIT compilation)
Security: CDN tampering protection, API access restriction

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:12:47 +09:00

231 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pricing endpoint - Anvil 가격표 (D1 직접 조회)
* GET /api/pricing → D1 cloud-instances-db (anvil_* 테이블)
*
* Anvil Regions:
* - Tokyo 1-3 (Linode × 2 + Vultr)
* - Osaka 1-2 (Linode + Vultr)
* - Seoul 1 (Vultr)
* - Singapore 1 (Linode)
*/
import { type PagesFunction } from '@cloudflare/workers-types';
interface Env {
DB: D1Database;
}
interface AnvilPricingRow {
instance_id: number;
instance_name: string;
display_name: string;
category: string;
vcpus: number;
memory_gb: number;
disk_gb: number;
transfer_tb: number | null;
network_gbps: number | null;
gpu_model: string | null;
gpu_vram_gb: number | null;
region_id: number;
region_name: string;
region_display_name: string;
country_code: string;
source_provider: string;
hourly_price: number;
monthly_price: number;
}
interface TransferPricingRow {
region_name: string;
region_display_name: string;
price_per_gb: number;
}
// 기본 환율 (API 실패 시 fallback)
const DEFAULT_USD_TO_KRW = 1450;
// 실시간 환율 조회 (exchangerate-api.com)
async function fetchExchangeRate(): Promise<{ rate: number; source: string }> {
try {
const response = await fetch('https://open.er-api.com/v6/latest/USD', {
cf: { cacheTtl: 3600 }, // 1시간 캐시
});
if (!response.ok) {
throw new Error(`Exchange rate API error: ${response.status}`);
}
const data = await response.json() as { rates?: { KRW?: number } };
const rate = data.rates?.KRW;
if (rate && rate > 0) {
return { rate: Math.round(rate), source: 'realtime' };
}
throw new Error('Invalid exchange rate data');
} catch (error) {
console.error('[Pricing] Exchange rate fetch failed:', error);
return { rate: DEFAULT_USD_TO_KRW, source: 'fallback' };
}
}
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const onRequestGet: PagesFunction<Env> = async ({ env }) => {
try {
// 실시간 환율 조회
const exchangeRate = await fetchExchangeRate();
const USD_TO_KRW = exchangeRate.rate;
// Anvil 가격 조회 (인스턴스 + 리전 + 가격 조인)
const pricingSql = `
SELECT
ai.id as instance_id,
ai.name as instance_name,
ai.display_name,
ai.category,
ai.vcpus,
ai.memory_gb,
ai.disk_gb,
ai.transfer_tb,
ai.network_gbps,
ai.gpu_model,
ai.gpu_vram_gb,
ar.id as region_id,
ar.name as region_name,
ar.display_name as region_display_name,
ar.country_code,
ar.source_provider,
ap.hourly_price,
ap.monthly_price
FROM anvil_pricing ap
JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
WHERE ai.active = 1 AND ar.active = 1
ORDER BY ar.id, ap.monthly_price ASC
`;
// 트래픽 가격 조회
const transferSql = `
SELECT
ar.name as region_name,
ar.display_name as region_display_name,
atp.price_per_gb
FROM anvil_transfer_pricing atp
JOIN anvil_regions ar ON atp.anvil_region_id = ar.id
WHERE ar.active = 1
`;
const [pricingResult, transferResult] = await Promise.all([
env.DB.prepare(pricingSql).all<AnvilPricingRow>(),
env.DB.prepare(transferSql).all<TransferPricingRow>(),
]);
// 인스턴스 변환 (프론트엔드 형식)
const instances = pricingResult.results.map((row) => ({
id: row.instance_name,
instance_name: row.instance_name,
display_name: row.display_name,
category: row.category,
vcpu: row.vcpus,
memory_mb: Math.round(row.memory_gb * 1024),
memory_gb: row.memory_gb,
storage_gb: row.disk_gb,
transfer_tb: row.transfer_tb,
network_gbps: row.network_gbps,
gpu_model: row.gpu_model,
gpu_vram_gb: row.gpu_vram_gb,
has_gpu: row.category === 'gpu' || row.gpu_model !== null,
region: {
id: row.region_id,
name: row.region_name,
display_name: row.region_display_name,
country_code: row.country_code,
},
provider: row.source_provider,
pricing: {
hourly_price: row.hourly_price,
monthly_price: row.monthly_price,
hourly_price_krw: Math.round(row.hourly_price * USD_TO_KRW), // 1원 단위 반올림
monthly_price_krw: Math.round(row.monthly_price * USD_TO_KRW / 500) * 500, // 500원 단위 반올림
},
}));
// 트래픽 가격 변환 (리전별) - 1원 단위 반올림
const transferPricing = transferResult.results.reduce((acc, row) => {
acc[row.region_display_name] = {
price_per_gb: row.price_per_gb,
price_per_gb_krw: Math.round(row.price_per_gb * USD_TO_KRW),
};
return acc;
}, {} as Record<string, { price_per_gb: number; price_per_gb_krw: number }>);
// 리전별 카운트
const regionCounts: Record<string, number> = {};
for (const inst of instances) {
const regionKey = inst.region.display_name.toLowerCase().replace(/\s+/g, '-');
regionCounts[regionKey] = (regionCounts[regionKey] || 0) + 1;
}
// 사용 가능한 리전 목록 (순서 유지: Tokyo 1,2,3 → Osaka 1,2 → Seoul 1 → Singapore 1)
const seenRegions = new Set<number>();
const regions = instances
.filter((i) => {
if (seenRegions.has(i.region.id)) return false;
seenRegions.add(i.region.id);
return true;
})
.map((i) => ({
id: i.region.display_name.toLowerCase().replace(/\s+/g, '-'),
name: i.region.display_name,
country_code: i.region.country_code,
count: regionCounts[i.region.display_name.toLowerCase().replace(/\s+/g, '-')] || 0,
}));
return new Response(
JSON.stringify({
success: true,
total: instances.length,
exchange_rate: {
usd_to_krw: USD_TO_KRW,
source: exchangeRate.source,
},
region_counts: regionCounts,
regions,
transfer_pricing: transferPricing,
instances,
}),
{
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300', // 5분 캐시
},
}
);
} catch (error) {
console.error('[Pricing] D1 query error:', error);
return new Response(
JSON.stringify({
success: false,
error: error instanceof Error ? error.message : 'Database query failed',
}),
{
status: 500,
headers: {
...CORS_HEADERS,
'Content-Type': 'application/json',
},
}
);
}
};
export const onRequestOptions: PagesFunction<Env> = async () => {
return new Response(null, { status: 204, headers: CORS_HEADERS });
};