Initial commit: CF Multisite 멀티테넌트 정적 호스팅
Some checks failed
Deploy to CF Multisite / deploy (push) Failing after 1m53s
Some checks failed
Deploy to CF Multisite / deploy (push) Failing after 1m53s
- 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:
62
.gitea/workflows/deploy.yml
Normal file
62
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Gitea Actions 워크플로우 예시
|
||||||
|
# 고객 저장소에서 사용할 템플릿
|
||||||
|
|
||||||
|
name: Deploy to CF Multisite
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
# Hugo 사이트인 경우 (선택사항)
|
||||||
|
# - name: Setup Hugo
|
||||||
|
# run: |
|
||||||
|
# wget -qO- https://github.com/gohugoio/hugo/releases/download/v0.139.0/hugo_extended_0.139.0_linux-amd64.tar.gz | tar xz
|
||||||
|
# sudo mv hugo /usr/local/bin/
|
||||||
|
|
||||||
|
# - name: Build Hugo
|
||||||
|
# run: hugo --minify
|
||||||
|
|
||||||
|
- name: Install AWS CLI
|
||||||
|
run: |
|
||||||
|
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||||
|
unzip -q awscliv2.zip
|
||||||
|
sudo ./aws/install
|
||||||
|
|
||||||
|
- name: Upload to R2
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
R2_BUCKET: multisite-bucket
|
||||||
|
# 저장소 이름을 고객 ID로 사용 (또는 별도 설정)
|
||||||
|
CUSTOMER_ID: ${{ github.repository_owner }}
|
||||||
|
run: |
|
||||||
|
# 정적 파일 디렉토리 (Hugo: public, 일반: . 또는 dist)
|
||||||
|
SOURCE_DIR="."
|
||||||
|
|
||||||
|
# Hugo 빌드 결과가 있으면 그것 사용
|
||||||
|
if [ -d "public" ]; then
|
||||||
|
SOURCE_DIR="public"
|
||||||
|
fi
|
||||||
|
|
||||||
|
aws s3 sync "$SOURCE_DIR" "s3://${R2_BUCKET}/sites/${CUSTOMER_ID}/" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" \
|
||||||
|
--exclude ".git/*" \
|
||||||
|
--exclude ".gitea/*" \
|
||||||
|
--exclude "*.md" \
|
||||||
|
--exclude "README*" \
|
||||||
|
--delete
|
||||||
|
|
||||||
|
echo "Deployed to: https://${CUSTOMER_ID}.yoursite.com"
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
358
README.md
Normal file
358
README.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# CF Multisite
|
||||||
|
|
||||||
|
Cloudflare Workers + R2 기반 멀티테넌트 정적 사이트 호스팅 플랫폼
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gitea (gitea.anvil.it.com) │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ site-a │ │ site-b │ │ site-c │ ... │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ push │ push │ push │
|
||||||
|
└───────┼─────────────┼─────────────┼─────────────────────────────┘
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gitea Actions Runner (jp1) │
|
||||||
|
│ aws s3 sync → R2 │
|
||||||
|
└───────────────────────────┬─────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Cloudflare R2 Bucket │
|
||||||
|
│ /sites/site-a/index.html │
|
||||||
|
│ /sites/site-b/index.html │
|
||||||
|
│ /sites/site-c/index.html │
|
||||||
|
└───────────────────────────┬─────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Cloudflare Workers │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Cache │ │ Rate Limit │ │ Admin API │ │
|
||||||
|
│ │ (Edge) │ │ (KV) │ │ (REST) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
└───────────────────────────┬─────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Custom Domains │
|
||||||
|
│ site-a.actions.it.com site-b.actions.it.com ... │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
### 멀티테넌트 호스팅
|
||||||
|
- 서브도메인 기반 고객 분리: `{customer}.actions.it.com`
|
||||||
|
- R2에 고객별 디렉토리 구조: `/sites/{customer}/`
|
||||||
|
- 자동 index.html 라우팅
|
||||||
|
|
||||||
|
### 캐싱 전략
|
||||||
|
- Edge 캐시로 R2 요청 최소화 → 비용 절감
|
||||||
|
- 파일 타입별 TTL 최적화:
|
||||||
|
|
||||||
|
| 파일 타입 | 캐시 TTL |
|
||||||
|
|-----------|----------|
|
||||||
|
| HTML | 1시간 |
|
||||||
|
| CSS, JS, JSON | 24시간 |
|
||||||
|
| 이미지 (PNG, JPG, GIF, SVG) | 7일 |
|
||||||
|
| 폰트 (WOFF, WOFF2, TTF) | 30일 |
|
||||||
|
|
||||||
|
### Rate Limiting (티어별)
|
||||||
|
|
||||||
|
| 티어 | 분당 요청 | 일일 대역폭 | 월간 대역폭 |
|
||||||
|
|------|-----------|-------------|-------------|
|
||||||
|
| Free | 60 | 5GB | ~150GB |
|
||||||
|
| Basic | 300 | 50GB | ~1.5TB |
|
||||||
|
| Pro | 1,000 | 500GB | ~15TB |
|
||||||
|
|
||||||
|
### 사용량 추적 (KV)
|
||||||
|
- 고객별 일일 요청 수
|
||||||
|
- 고객별 일일 대역폭
|
||||||
|
- 분당 요청 수 (Rate Limit용)
|
||||||
|
- 7일간 데이터 보관
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
모든 API는 Bearer 토큰 인증 필요:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $ADMIN_TOKEN" https://site.actions.it.com/api/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 엔드포인트
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| GET | `/api/usage/:customer?days=7` | 고객 사용량 조회 |
|
||||||
|
| GET | `/api/customers` | 전체 고객 목록 |
|
||||||
|
| PUT | `/api/tier/:customer` | 고객 티어 변경 |
|
||||||
|
| GET | `/api/stats` | 전체 통계 |
|
||||||
|
| DELETE | `/api/customer/:customer` | 고객 데이터 삭제 |
|
||||||
|
|
||||||
|
### 사용 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 고객 사용량 조회
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"https://multisite-demo.actions.it.com/api/usage/multisite-demo?days=7"
|
||||||
|
|
||||||
|
# 티어 변경
|
||||||
|
curl -X PUT \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tier": "basic"}' \
|
||||||
|
"https://multisite-demo.actions.it.com/api/tier/customer-name"
|
||||||
|
|
||||||
|
# 전체 통계
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"https://multisite-demo.actions.it.com/api/stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 설치 및 배포
|
||||||
|
|
||||||
|
### 1. 프로젝트 클론
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/projects
|
||||||
|
git clone <repo> cf-multisite
|
||||||
|
cd cf-multisite
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Cloudflare 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# wrangler 로그인
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# KV 네임스페이스 생성
|
||||||
|
npx wrangler kv:namespace create USAGE
|
||||||
|
|
||||||
|
# R2 버킷 생성 (이미 있으면 스킵)
|
||||||
|
npx wrangler r2 bucket create multisite-bucket
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. wrangler.toml 설정
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "cf-multisite"
|
||||||
|
main = "src/worker.js"
|
||||||
|
compatibility_date = "2024-12-01"
|
||||||
|
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "BUCKET"
|
||||||
|
bucket_name = "multisite-bucket"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "USAGE"
|
||||||
|
id = "<KV_NAMESPACE_ID>"
|
||||||
|
|
||||||
|
routes = [
|
||||||
|
{ pattern = "*.actions.it.com", zone_name = "actions.it.com" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 시크릿 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Admin API 토큰 생성 및 설정
|
||||||
|
openssl rand -hex 32 # 토큰 생성
|
||||||
|
npx wrangler secret put ADMIN_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
# 또는
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 고객 사이트 추가
|
||||||
|
|
||||||
|
### 1. Gitea 저장소 생성
|
||||||
|
|
||||||
|
고객용 저장소를 Gitea에 생성합니다.
|
||||||
|
|
||||||
|
### 2. Workflow 파일 추가
|
||||||
|
|
||||||
|
`.gitea/workflows/deploy.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Deploy to CF Multisite
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install AWS CLI
|
||||||
|
run: |
|
||||||
|
curl -sL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
|
||||||
|
cd /tmp && unzip -q awscliv2.zip && sudo ./aws/install
|
||||||
|
|
||||||
|
- name: Deploy to R2
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_KEY }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
run: |
|
||||||
|
SITE_ID="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
|
aws s3 sync . "s3://multisite-bucket/sites/${SITE_ID}/" \
|
||||||
|
--endpoint-url "${R2_ENDPOINT}" \
|
||||||
|
--region auto \
|
||||||
|
--exclude ".git/*" \
|
||||||
|
--exclude ".gitea/*" \
|
||||||
|
--exclude "README.md" \
|
||||||
|
--delete
|
||||||
|
|
||||||
|
echo "Deployed to: https://${SITE_ID}.actions.it.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Secrets 설정 (Gitea Organization)
|
||||||
|
|
||||||
|
| Secret | 값 |
|
||||||
|
|--------|-----|
|
||||||
|
| `R2_ACCESS_KEY` | R2 API 토큰 Access Key |
|
||||||
|
| `R2_SECRET_KEY` | R2 API 토큰 Secret Key |
|
||||||
|
| `R2_ENDPOINT` | `https://<account-id>.r2.cloudflarestorage.com` |
|
||||||
|
|
||||||
|
### 4. Push → 자동 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial site"
|
||||||
|
git push origin main
|
||||||
|
# → https://{repo-name}.actions.it.com 에서 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
## 로컬 개발
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 개발 서버 실행
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 테스트 URL
|
||||||
|
# http://localhost:8787?site=demo
|
||||||
|
# http://localhost:8787?site=multisite-demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
cf-multisite/
|
||||||
|
├── src/
|
||||||
|
│ └── worker.js # Workers 메인 코드
|
||||||
|
│ # - 라우팅
|
||||||
|
│ # - 캐싱
|
||||||
|
│ # - Rate Limiting
|
||||||
|
│ # - Admin API
|
||||||
|
├── scripts/
|
||||||
|
│ └── upload.js # 수동 R2 업로드 스크립트
|
||||||
|
├── sample-site/ # 샘플 사이트
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── about.html
|
||||||
|
│ ├── contact.html
|
||||||
|
│ └── style.css
|
||||||
|
├── .gitea/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── deploy.yml # Gitea Actions 템플릿
|
||||||
|
├── wrangler.toml # Workers 설정
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 인프라 구성
|
||||||
|
|
||||||
|
| 컴포넌트 | 위치 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| Gitea | gitea.anvil.it.com | Git 호스팅 |
|
||||||
|
| Gitea Runner | jp1 (Incus) | CI/CD 실행 |
|
||||||
|
| R2 Bucket | multisite-bucket | 정적 파일 저장 |
|
||||||
|
| KV Namespace | USAGE | 사용량 추적 |
|
||||||
|
| Workers | cf-multisite | 라우팅/캐싱/API |
|
||||||
|
| Domain | *.actions.it.com | 와일드카드 도메인 |
|
||||||
|
|
||||||
|
## 비용 구조
|
||||||
|
|
||||||
|
### Cloudflare 무료 티어
|
||||||
|
|
||||||
|
| 항목 | 무료 한도 |
|
||||||
|
|------|-----------|
|
||||||
|
| Workers 요청 | 일 10만 건 |
|
||||||
|
| R2 저장 | 10GB |
|
||||||
|
| R2 Class A (쓰기) | 월 100만 건 |
|
||||||
|
| R2 Class B (읽기) | 월 1000만 건 |
|
||||||
|
| KV 읽기 | 일 10만 건 |
|
||||||
|
| KV 쓰기 | 일 1000건 |
|
||||||
|
|
||||||
|
### 예상 비용 (1000 고객 기준)
|
||||||
|
|
||||||
|
```
|
||||||
|
저장: 1000 × 50MB = 50GB → 초과 40GB × $0.015 = $0.60/월
|
||||||
|
읽기: 캐시 히트율 90% 가정 → 대부분 무료
|
||||||
|
Workers: 캐시 히트 시에도 실행됨 → 유료 플랜 권장 ($5/월)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 모니터링
|
||||||
|
|
||||||
|
### 응답 헤더
|
||||||
|
|
||||||
|
| 헤더 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `X-Cache: HIT/MISS` | 캐시 상태 |
|
||||||
|
| `X-Customer` | 고객 ID |
|
||||||
|
| `X-Tier` | 고객 티어 |
|
||||||
|
| `X-RateLimit-Reason` | Rate Limit 사유 (rpm/bandwidth) |
|
||||||
|
|
||||||
|
### 사용량 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 특정 고객
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"https://any.actions.it.com/api/usage/customer-name"
|
||||||
|
|
||||||
|
# 전체 현황
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"https://any.actions.it.com/api/stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 확장 계획
|
||||||
|
|
||||||
|
### 클러스터링 옵션
|
||||||
|
|
||||||
|
| 방식 | 장점 | 적합한 규모 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| Incus 단일 노드 | 간단, 저비용 | 현재 |
|
||||||
|
| Incus 클러스터 (jp1+kr1) | HA, 마이그레이션 | 중규모 |
|
||||||
|
| Kubernetes | 자동 스케일링 | 대규모 |
|
||||||
|
|
||||||
|
### 결제 연동 (검토 중)
|
||||||
|
|
||||||
|
| PG | 대상 | 비고 |
|
||||||
|
|----|------|------|
|
||||||
|
| Toss Payments | 국내 고객 | 보증보험 필요 |
|
||||||
|
| Stripe (일본 법인) | 해외/텔레그램 | 직접 연동 가능 |
|
||||||
|
|
||||||
|
## 크레덴셜 (Vault)
|
||||||
|
|
||||||
|
```
|
||||||
|
Vault: https://vault.anvil.it.com
|
||||||
|
Path: secret/cf-multisite
|
||||||
|
|
||||||
|
- admin_token: Admin API 인증 토큰
|
||||||
|
- r2_access_key: R2 API Access Key
|
||||||
|
- r2_secret_key: R2 API Secret Key
|
||||||
|
```
|
||||||
|
|
||||||
|
## 관련 링크
|
||||||
|
|
||||||
|
- Cloudflare Dashboard: https://dash.cloudflare.com
|
||||||
|
- Gitea: https://gitea.anvil.it.com
|
||||||
|
- 샘플 사이트: https://multisite-demo.actions.it.com
|
||||||
3351
package-lock.json
generated
Normal file
3351
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "cf-multisite",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Multi-tenant static site hosting with Cloudflare Workers + R2",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"r2:create": "wrangler r2 bucket create multisite-bucket",
|
||||||
|
"r2:list": "wrangler r2 bucket list",
|
||||||
|
"upload": "node scripts/upload.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^3.99.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
sample-site/about.html
Normal file
46
sample-site/about.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>소개 - 데모 사이트</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="logo">Demo Site</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">홈</a></li>
|
||||||
|
<li><a href="/about" class="active">소개</a></li>
|
||||||
|
<li><a href="/contact">연락처</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="content">
|
||||||
|
<h1>소개</h1>
|
||||||
|
<p>이 사이트는 CF Multisite 플랫폼의 데모 사이트입니다.</p>
|
||||||
|
|
||||||
|
<h2>기술 스택</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Cloudflare Workers</strong> - 엣지에서 실행되는 서버리스 함수</li>
|
||||||
|
<li><strong>Cloudflare R2</strong> - S3 호환 오브젝트 스토리지</li>
|
||||||
|
<li><strong>Gitea</strong> - 셀프 호스팅 Git 서버</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>작동 방식</h2>
|
||||||
|
<ol>
|
||||||
|
<li>고객이 Gitea에 정적 파일 업로드</li>
|
||||||
|
<li>CI/CD가 R2 버킷에 파일 동기화</li>
|
||||||
|
<li>Workers가 요청을 받아 R2에서 파일 서빙</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2024 CF Multisite. Powered by Cloudflare.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
sample-site/contact.html
Normal file
37
sample-site/contact.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>연락처 - 데모 사이트</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="logo">Demo Site</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">홈</a></li>
|
||||||
|
<li><a href="/about">소개</a></li>
|
||||||
|
<li><a href="/contact" class="active">연락처</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="content">
|
||||||
|
<h1>연락처</h1>
|
||||||
|
<p>문의사항이 있으시면 아래로 연락해 주세요.</p>
|
||||||
|
|
||||||
|
<div class="contact-info">
|
||||||
|
<p><strong>이메일:</strong> contact@example.com</p>
|
||||||
|
<p><strong>GitHub:</strong> github.com/example</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2024 CF Multisite. Powered by Cloudflare.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
sample-site/index.html
Normal file
47
sample-site/index.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>데모 사이트</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/" class="logo">Demo Site</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">홈</a></li>
|
||||||
|
<li><a href="/about">소개</a></li>
|
||||||
|
<li><a href="/contact">연락처</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>CF Multisite에 오신 것을 환영합니다</h1>
|
||||||
|
<p>Cloudflare Workers + R2로 구동되는 무료 웹 호스팅 서비스입니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="features">
|
||||||
|
<div class="feature">
|
||||||
|
<h3>무제한 사이트</h3>
|
||||||
|
<p>원하는 만큼 사이트를 만들 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>글로벌 CDN</h3>
|
||||||
|
<p>전 세계 어디서나 빠른 속도를 제공합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>자동 HTTPS</h3>
|
||||||
|
<p>SSL 인증서가 자동으로 적용됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2024 CF Multisite. Powered by Cloudflare.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
148
sample-site/style.css
Normal file
148
sample-site/style.css
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav ul {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #666;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover,
|
||||||
|
nav a.active {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main */
|
||||||
|
main {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Features */
|
||||||
|
.features {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature h3 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul,
|
||||||
|
.content ol {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
145
scripts/upload.js
Normal file
145
scripts/upload.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* R2 업로드 스크립트
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* node scripts/upload.js <customer-id> <source-dir>
|
||||||
|
*
|
||||||
|
* 예시:
|
||||||
|
* node scripts/upload.js demo-site ./sample-site
|
||||||
|
*
|
||||||
|
* 환경변수:
|
||||||
|
* R2_ENDPOINT - R2 엔드포인트 URL
|
||||||
|
* R2_ACCESS_KEY - R2 접근 키
|
||||||
|
* R2_SECRET_KEY - R2 시크릿 키
|
||||||
|
* R2_BUCKET - 버킷 이름 (기본: multisite-bucket)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { S3Client, PutObjectCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 재귀적으로 모든 파일 찾기
|
||||||
|
function getAllFiles(dir, files = []) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
getAllFiles(fullPath, files);
|
||||||
|
} else {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [,, customerId, sourceDir] = process.argv;
|
||||||
|
|
||||||
|
if (!customerId || !sourceDir) {
|
||||||
|
console.error('Usage: node scripts/upload.js <customer-id> <source-dir>');
|
||||||
|
console.error('Example: node scripts/upload.js demo-site ./sample-site');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 환경변수 체크
|
||||||
|
const endpoint = process.env.R2_ENDPOINT;
|
||||||
|
const accessKeyId = process.env.R2_ACCESS_KEY;
|
||||||
|
const secretAccessKey = process.env.R2_SECRET_KEY;
|
||||||
|
const bucket = process.env.R2_BUCKET || 'multisite-bucket';
|
||||||
|
|
||||||
|
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||||
|
console.error('Missing environment variables:');
|
||||||
|
console.error(' R2_ENDPOINT, R2_ACCESS_KEY, R2_SECRET_KEY');
|
||||||
|
console.error('');
|
||||||
|
console.error('R2 API 토큰 생성: Cloudflare Dashboard > R2 > Manage R2 API Tokens');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 클라이언트 생성 (R2는 S3 호환)
|
||||||
|
const s3 = new S3Client({
|
||||||
|
region: 'auto',
|
||||||
|
endpoint,
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourcePath = path.resolve(sourceDir);
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
console.error(`Source directory not found: ${sourcePath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Uploading to R2...`);
|
||||||
|
console.log(` Customer: ${customerId}`);
|
||||||
|
console.log(` Source: ${sourcePath}`);
|
||||||
|
console.log(` Bucket: ${bucket}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 모든 파일 수집
|
||||||
|
const files = getAllFiles(sourcePath);
|
||||||
|
const prefix = `sites/${customerId}`;
|
||||||
|
|
||||||
|
// 업로드
|
||||||
|
let uploaded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const relativePath = path.relative(sourcePath, file);
|
||||||
|
const key = `${prefix}/${relativePath}`.replace(/\\/g, '/');
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = fs.readFileSync(file);
|
||||||
|
|
||||||
|
await s3.send(new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(` ✓ ${key}`);
|
||||||
|
uploaded++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ✗ ${key}: ${error.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`Done! Uploaded: ${uploaded}, Failed: ${failed}`);
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('Fatal error:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
30
wrangler.toml
Normal file
30
wrangler.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name = "cf-multisite"
|
||||||
|
main = "src/worker.js"
|
||||||
|
compatibility_date = "2024-12-01"
|
||||||
|
|
||||||
|
# R2 버킷 바인딩
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "BUCKET"
|
||||||
|
bucket_name = "multisite-bucket"
|
||||||
|
|
||||||
|
# KV 네임스페이스 (사용량 추적)
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "USAGE"
|
||||||
|
id = "16cb07c303e64ed49ab179b6d5be7c32"
|
||||||
|
|
||||||
|
# 커스텀 도메인 설정
|
||||||
|
routes = [
|
||||||
|
{ pattern = "*.actions.it.com", zone_name = "actions.it.com" }
|
||||||
|
]
|
||||||
|
|
||||||
|
# 개발용 로컬 R2
|
||||||
|
[dev]
|
||||||
|
port = 8787
|
||||||
|
|
||||||
|
# 환경별 설정
|
||||||
|
[env.production]
|
||||||
|
name = "cf-multisite-prod"
|
||||||
|
|
||||||
|
[[env.production.r2_buckets]]
|
||||||
|
binding = "BUCKET"
|
||||||
|
bucket_name = "multisite-bucket"
|
||||||
Reference in New Issue
Block a user