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:
kappa
2026-01-29 09:20:46 +09:00
commit 8850031c45
12 changed files with 4824 additions and 0 deletions

576
src/worker.js Normal file
View 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' }
});
}
}
};