Initial commit: CF Multisite 멀티테넌트 정적 호스팅
- Cloudflare Workers + R2 기반 - Edge 캐싱으로 비용 절감 - 티어별 Rate Limiting (free/basic/pro) - KV 기반 사용량 추적 - Admin API (usage, customers, tiers, stats) - Gitea Actions 배포 워크플로우 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
576
src/worker.js
Normal file
576
src/worker.js
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* CF Multisite - Multi-tenant static site hosting
|
||||
*
|
||||
* 구조:
|
||||
* R2: /sites/{customer}/index.html
|
||||
* URL: {customer}.yoursite.com → /sites/{customer}/
|
||||
*
|
||||
* 캐시 전략:
|
||||
* - 정적 파일: 24시간 캐시 (CSS, JS, 이미지, 폰트)
|
||||
* - HTML: 1시간 캐시
|
||||
* - 캐시 히트 시 R2 요청 없음 → 비용 절감
|
||||
*
|
||||
* Rate Limiting:
|
||||
* - 분당 요청 수 제한
|
||||
* - 일일 대역폭 제한
|
||||
*
|
||||
* Admin API:
|
||||
* - GET /api/usage/:customer - 고객 사용량 조회
|
||||
* - GET /api/customers - 전체 고객 목록
|
||||
* - PUT /api/tier/:customer - 고객 티어 변경
|
||||
* - GET /api/stats - 전체 통계
|
||||
*/
|
||||
|
||||
// MIME 타입 매핑
|
||||
const MIME_TYPES = {
|
||||
html: 'text/html; charset=utf-8',
|
||||
css: 'text/css',
|
||||
js: 'application/javascript',
|
||||
json: 'application/json',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
pdf: 'application/pdf',
|
||||
xml: 'application/xml',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
};
|
||||
|
||||
// 캐시 TTL 설정 (초 단위)
|
||||
const CACHE_TTL = {
|
||||
html: 3600,
|
||||
css: 86400,
|
||||
js: 86400,
|
||||
json: 3600,
|
||||
png: 604800,
|
||||
jpg: 604800,
|
||||
jpeg: 604800,
|
||||
gif: 604800,
|
||||
svg: 604800,
|
||||
ico: 604800,
|
||||
woff: 2592000,
|
||||
woff2: 2592000,
|
||||
ttf: 2592000,
|
||||
pdf: 86400,
|
||||
default: 3600,
|
||||
};
|
||||
|
||||
// Rate Limit 설정 (티어별)
|
||||
const RATE_LIMITS = {
|
||||
free: {
|
||||
requests_per_minute: 60,
|
||||
bandwidth_per_day: 5 * 1024 * 1024 * 1024, // 5GB/일 → 150GB/월
|
||||
},
|
||||
basic: {
|
||||
requests_per_minute: 300,
|
||||
bandwidth_per_day: 50 * 1024 * 1024 * 1024, // 50GB/일 → 1.5TB/월
|
||||
},
|
||||
pro: {
|
||||
requests_per_minute: 1000,
|
||||
bandwidth_per_day: 500 * 1024 * 1024 * 1024, // 500GB/일 → 15TB/월
|
||||
},
|
||||
};
|
||||
|
||||
// HTML 템플릿들
|
||||
const notFoundHtml = `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>페이지를 찾을 수 없습니다</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; display: flex;
|
||||
justify-content: center; align-items: center; height: 100vh;
|
||||
margin: 0; background: #f5f5f5; }
|
||||
.container { text-align: center; }
|
||||
h1 { font-size: 72px; margin: 0; color: #333; }
|
||||
p { color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>404</h1>
|
||||
<p>요청하신 페이지를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const noSiteHtml = `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>사이트 준비 중</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; display: flex;
|
||||
justify-content: center; align-items: center; height: 100vh;
|
||||
margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.container { text-align: center; color: white; }
|
||||
h1 { font-size: 48px; margin: 0 0 16px 0; }
|
||||
p { opacity: 0.9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Coming Soon</h1>
|
||||
<p>이 사이트는 현재 준비 중입니다.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const rateLimitHtml = `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>요청 제한 초과</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; display: flex;
|
||||
justify-content: center; align-items: center; height: 100vh;
|
||||
margin: 0; background: #fff3cd; }
|
||||
.container { text-align: center; }
|
||||
h1 { font-size: 48px; margin: 0; color: #856404; }
|
||||
p { color: #856404; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>429</h1>
|
||||
<p>요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// 유틸리티 함수
|
||||
function getToday() {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function getMinuteKey() {
|
||||
const now = new Date();
|
||||
return `${now.getUTCHours()}:${now.getUTCMinutes()}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify(data, null, 2), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 고객 티어 조회
|
||||
async function getCustomerTier(env, customer) {
|
||||
const tier = await env.USAGE.get(`tier:${customer}`);
|
||||
return tier || 'free';
|
||||
}
|
||||
|
||||
// Rate Limit 체크
|
||||
async function checkRateLimit(env, customer) {
|
||||
const tier = await getCustomerTier(env, customer);
|
||||
const limits = RATE_LIMITS[tier] || RATE_LIMITS.free;
|
||||
|
||||
const today = getToday();
|
||||
const minute = getMinuteKey();
|
||||
|
||||
const minuteKey = `rpm:${customer}:${minute}`;
|
||||
const currentRpm = parseInt(await env.USAGE.get(minuteKey) || '0');
|
||||
|
||||
if (currentRpm >= limits.requests_per_minute) {
|
||||
return { allowed: false, reason: 'rpm', limit: limits.requests_per_minute };
|
||||
}
|
||||
|
||||
const bandwidthKey = `bw:${customer}:${today}`;
|
||||
const currentBw = parseInt(await env.USAGE.get(bandwidthKey) || '0');
|
||||
|
||||
if (currentBw >= limits.bandwidth_per_day) {
|
||||
return { allowed: false, reason: 'bandwidth', limit: limits.bandwidth_per_day };
|
||||
}
|
||||
|
||||
return { allowed: true, tier, limits };
|
||||
}
|
||||
|
||||
// 사용량 기록
|
||||
async function recordUsage(env, customer, bytes) {
|
||||
const today = getToday();
|
||||
const minute = getMinuteKey();
|
||||
|
||||
const minuteKey = `rpm:${customer}:${minute}`;
|
||||
const currentRpm = parseInt(await env.USAGE.get(minuteKey) || '0');
|
||||
await env.USAGE.put(minuteKey, String(currentRpm + 1), { expirationTtl: 120 });
|
||||
|
||||
const bandwidthKey = `bw:${customer}:${today}`;
|
||||
const currentBw = parseInt(await env.USAGE.get(bandwidthKey) || '0');
|
||||
await env.USAGE.put(bandwidthKey, String(currentBw + bytes), { expirationTtl: 86400 * 7 });
|
||||
|
||||
const dailyReqKey = `req:${customer}:${today}`;
|
||||
const currentReq = parseInt(await env.USAGE.get(dailyReqKey) || '0');
|
||||
await env.USAGE.put(dailyReqKey, String(currentReq + 1), { expirationTtl: 86400 * 7 });
|
||||
|
||||
// 고객 목록에 추가 (처음 요청하는 고객)
|
||||
const customersKey = 'customers:list';
|
||||
const customersList = JSON.parse(await env.USAGE.get(customersKey) || '[]');
|
||||
if (!customersList.includes(customer)) {
|
||||
customersList.push(customer);
|
||||
await env.USAGE.put(customersKey, JSON.stringify(customersList));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin API 핸들러
|
||||
// ============================================
|
||||
|
||||
// 인증 체크
|
||||
function checkAuth(request, env) {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
return token === env.ADMIN_TOKEN;
|
||||
}
|
||||
|
||||
// 고객 사용량 조회
|
||||
async function getCustomerUsage(env, customer, days = 7) {
|
||||
const tier = await getCustomerTier(env, customer);
|
||||
const limits = RATE_LIMITS[tier] || RATE_LIMITS.free;
|
||||
|
||||
const usage = {
|
||||
customer,
|
||||
tier,
|
||||
limits: {
|
||||
requests_per_minute: limits.requests_per_minute,
|
||||
bandwidth_per_day: formatBytes(limits.bandwidth_per_day),
|
||||
bandwidth_per_day_bytes: limits.bandwidth_per_day,
|
||||
},
|
||||
daily: [],
|
||||
total: {
|
||||
requests: 0,
|
||||
bandwidth: 0,
|
||||
bandwidth_formatted: '0 B',
|
||||
}
|
||||
};
|
||||
|
||||
// 최근 N일 데이터 수집
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
const requests = parseInt(await env.USAGE.get(`req:${customer}:${dateStr}`) || '0');
|
||||
const bandwidth = parseInt(await env.USAGE.get(`bw:${customer}:${dateStr}`) || '0');
|
||||
|
||||
usage.daily.push({
|
||||
date: dateStr,
|
||||
requests,
|
||||
bandwidth,
|
||||
bandwidth_formatted: formatBytes(bandwidth),
|
||||
bandwidth_percent: ((bandwidth / limits.bandwidth_per_day) * 100).toFixed(2) + '%',
|
||||
});
|
||||
|
||||
usage.total.requests += requests;
|
||||
usage.total.bandwidth += bandwidth;
|
||||
}
|
||||
|
||||
usage.total.bandwidth_formatted = formatBytes(usage.total.bandwidth);
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
// API 라우터
|
||||
async function handleAPI(request, env, url) {
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
// CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 인증 체크 (ADMIN_TOKEN 환경변수 필요)
|
||||
if (!checkAuth(request, env)) {
|
||||
return jsonResponse({ error: 'Unauthorized', message: 'Invalid or missing token' }, 401);
|
||||
}
|
||||
|
||||
// GET /api/usage/:customer - 고객별 사용량
|
||||
const usageMatch = path.match(/^\/api\/usage\/([^\/]+)$/);
|
||||
if (usageMatch && method === 'GET') {
|
||||
const customer = usageMatch[1];
|
||||
const days = parseInt(url.searchParams.get('days') || '7');
|
||||
const usage = await getCustomerUsage(env, customer, days);
|
||||
return jsonResponse(usage);
|
||||
}
|
||||
|
||||
// GET /api/customers - 전체 고객 목록
|
||||
if (path === '/api/customers' && method === 'GET') {
|
||||
const customersList = JSON.parse(await env.USAGE.get('customers:list') || '[]');
|
||||
const customers = [];
|
||||
|
||||
for (const customer of customersList) {
|
||||
const tier = await getCustomerTier(env, customer);
|
||||
const today = getToday();
|
||||
const requests = parseInt(await env.USAGE.get(`req:${customer}:${today}`) || '0');
|
||||
const bandwidth = parseInt(await env.USAGE.get(`bw:${customer}:${today}`) || '0');
|
||||
|
||||
customers.push({
|
||||
customer,
|
||||
tier,
|
||||
today: {
|
||||
requests,
|
||||
bandwidth: formatBytes(bandwidth),
|
||||
bandwidth_bytes: bandwidth,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 대역폭 기준 내림차순 정렬
|
||||
customers.sort((a, b) => b.today.bandwidth_bytes - a.today.bandwidth_bytes);
|
||||
|
||||
return jsonResponse({
|
||||
count: customers.length,
|
||||
customers,
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/tier/:customer - 티어 변경
|
||||
const tierMatch = path.match(/^\/api\/tier\/([^\/]+)$/);
|
||||
if (tierMatch && method === 'PUT') {
|
||||
const customer = tierMatch[1];
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
|
||||
const newTier = body.tier;
|
||||
if (!['free', 'basic', 'pro'].includes(newTier)) {
|
||||
return jsonResponse({ error: 'Invalid tier. Must be: free, basic, or pro' }, 400);
|
||||
}
|
||||
|
||||
await env.USAGE.put(`tier:${customer}`, newTier);
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
customer,
|
||||
tier: newTier,
|
||||
limits: RATE_LIMITS[newTier],
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/stats - 전체 통계
|
||||
if (path === '/api/stats' && method === 'GET') {
|
||||
const customersList = JSON.parse(await env.USAGE.get('customers:list') || '[]');
|
||||
const today = getToday();
|
||||
|
||||
let totalRequests = 0;
|
||||
let totalBandwidth = 0;
|
||||
const tierCounts = { free: 0, basic: 0, pro: 0 };
|
||||
|
||||
for (const customer of customersList) {
|
||||
const tier = await getCustomerTier(env, customer);
|
||||
tierCounts[tier]++;
|
||||
|
||||
const requests = parseInt(await env.USAGE.get(`req:${customer}:${today}`) || '0');
|
||||
const bandwidth = parseInt(await env.USAGE.get(`bw:${customer}:${today}`) || '0');
|
||||
|
||||
totalRequests += requests;
|
||||
totalBandwidth += bandwidth;
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
date: today,
|
||||
customers: {
|
||||
total: customersList.length,
|
||||
by_tier: tierCounts,
|
||||
},
|
||||
today: {
|
||||
requests: totalRequests,
|
||||
bandwidth: formatBytes(totalBandwidth),
|
||||
bandwidth_bytes: totalBandwidth,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/customer/:customer - 고객 삭제 (데이터만)
|
||||
const deleteMatch = path.match(/^\/api\/customer\/([^\/]+)$/);
|
||||
if (deleteMatch && method === 'DELETE') {
|
||||
const customer = deleteMatch[1];
|
||||
|
||||
// 고객 목록에서 제거
|
||||
const customersList = JSON.parse(await env.USAGE.get('customers:list') || '[]');
|
||||
const newList = customersList.filter(c => c !== customer);
|
||||
await env.USAGE.put('customers:list', JSON.stringify(newList));
|
||||
|
||||
// 티어 정보 삭제
|
||||
await env.USAGE.delete(`tier:${customer}`);
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
message: `Customer ${customer} removed from tracking`,
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({ error: 'Not Found', endpoints: [
|
||||
'GET /api/usage/:customer?days=7',
|
||||
'GET /api/customers',
|
||||
'PUT /api/tier/:customer {"tier": "free|basic|pro"}',
|
||||
'GET /api/stats',
|
||||
'DELETE /api/customer/:customer',
|
||||
]}, 404);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 메인 핸들러
|
||||
// ============================================
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
const host = url.hostname;
|
||||
const path = url.pathname;
|
||||
|
||||
// Admin API 라우팅
|
||||
if (path.startsWith('/api/')) {
|
||||
return handleAPI(request, env, url);
|
||||
}
|
||||
|
||||
// 고객 ID 추출
|
||||
let customer = url.searchParams.get('site');
|
||||
|
||||
if (!customer) {
|
||||
const parts = host.split('.');
|
||||
if (parts.length >= 2 && parts[0] !== 'www') {
|
||||
customer = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// 고객 ID 없으면 메인 페이지
|
||||
if (!customer || customer === 'localhost') {
|
||||
return new Response(noSiteHtml, {
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
// Rate Limit 체크
|
||||
const rateCheck = await checkRateLimit(env, customer);
|
||||
if (!rateCheck.allowed) {
|
||||
return new Response(rateLimitHtml, {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Retry-After': rateCheck.reason === 'rpm' ? '60' : '3600',
|
||||
'X-RateLimit-Reason': rateCheck.reason,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 경로 정규화
|
||||
let filePath = path;
|
||||
|
||||
if (filePath.endsWith('/')) {
|
||||
filePath += 'index.html';
|
||||
}
|
||||
|
||||
if (!filePath.includes('.')) {
|
||||
filePath += '.html';
|
||||
}
|
||||
|
||||
// 캐시 키 생성
|
||||
const cacheKey = new Request(`https://cache.internal/${customer}${filePath}`, request);
|
||||
const cache = caches.default;
|
||||
|
||||
// 1. 캐시에서 먼저 확인
|
||||
let response = await cache.match(cacheKey);
|
||||
|
||||
if (response) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('X-Cache', 'HIT');
|
||||
|
||||
const contentLength = parseInt(response.headers.get('Content-Length') || '0');
|
||||
ctx.waitUntil(recordUsage(env, customer, contentLength));
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 캐시 미스 - R2에서 가져오기
|
||||
const key = `sites/${customer}${filePath}`;
|
||||
|
||||
try {
|
||||
const object = await env.BUCKET.get(key);
|
||||
|
||||
if (!object) {
|
||||
if (filePath === '/index.html') {
|
||||
return new Response(noSiteHtml, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(notFoundHtml, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
const ext = filePath.split('.').pop().toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
const ttl = CACHE_TTL[ext] || CACHE_TTL.default;
|
||||
|
||||
const headers = new Headers({
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': object.size,
|
||||
'Cache-Control': `public, max-age=${ttl}`,
|
||||
'X-Customer': customer,
|
||||
'X-Cache': 'MISS',
|
||||
'X-Tier': rateCheck.tier,
|
||||
});
|
||||
|
||||
if (object.etag) {
|
||||
headers.set('ETag', object.etag);
|
||||
}
|
||||
|
||||
response = new Response(object.body, { headers });
|
||||
|
||||
ctx.waitUntil(Promise.all([
|
||||
cache.put(cacheKey, response.clone()),
|
||||
recordUsage(env, customer, object.size)
|
||||
]));
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching from R2:', error);
|
||||
return new Response('Internal Server Error', {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user