Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: '4'
preCommands: ./prepare.sh
packageManager: pnpm
command: deploy --env production
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
**/.*
.wrangler
dist
node_modules
/data/migrations
Expand Down
207 changes: 207 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Cloudflare Drop 是一個基於 Cloudflare Workers 的輕量級文件分享工具,使用 D1 Database(SQLite)和 KV 存儲實現文件上傳、分享和管理功能。

**技術棧**:

- **後端**: Cloudflare Workers + Hono (Web framework) + Chanfana (OpenAPI)
- **數據庫**: Drizzle ORM + D1 Database (SQLite)
- **存儲**: Cloudflare KV (文件二進制存儲)
- **前端**: Preact + Vite + MobX + Material-UI
- **包管理器**: pnpm (必須使用,不要用 npm 或 yarn)

## Development Commands

### 本地開發

```bash
# 首次啟動前的準備工作(生成 wrangler.toml、應用遷移)
pnpm prestart

# 同時啟動前後端開發伺服器(推薦)
pnpm start

# 僅啟動前端(Vite dev server,端口由 SHARE_PORT 環境變量決定)
pnpm dev:web

# 僅啟動 Worker(Wrangler dev server,監聽 0.0.0.0)
pnpm dev:app
```

### 構建和部署

```bash
# 構建前端靜態資源
pnpm build:web

# 生成數據庫遷移文件(修改 schema 後執行)
pnpm generate

# 部署到生產環境(包含前置任務:構建前端 + 生成遷移 + 應用遷移)
pnpm deploy
```

### 數據庫遷移

```bash
# 應用本地 D1 遷移
wrangler d1 migrations apply airdrop --local

# 應用生產環境遷移
wrangler d1 migrations apply airdrop --remote --env production
```

### 代碼質量

```bash
# 自動格式化和修復 lint 錯誤
pnpm lint
```

## Architecture

### 後端架構(Cloudflare Worker)

**入口**: `src/index.ts`
使用 Hono 框架構建 API,通過 Chanfana 提供 OpenAPI 支持。

**核心組件**:

- **Endpoint 基類** (`src/endpoint.ts`): 所有 API 端點的基類

- `getDB(c)`: 獲取 Drizzle D1 Database 實例
- `getKV(c)`: 獲取 KV Namespace 實例
- `success(data)`: 統一成功響應格式
- `error(message)`: 統一錯誤響應格式

- **中間件系統** (`src/middlewares/`):

- `db.middleware.ts`: 注入 Drizzle DB 實例到 context
- `auth.middleware.ts`: 管理後台認證(驗證 ADMIN_TOKEN)
- `limit.middleware.ts`: 上傳頻率限制(使用 Cloudflare Rate Limit API)
- `terminal.middleware.ts`: 終端處理中間件

- **文件處理** (`src/files/`):

- `fileCreate.ts`: 創建文件分享記錄
- `fileChunkCreate.ts`: 上傳文件分塊(支持大文件分塊上傳)
- `mergeFileChunk.ts`: 合併文件分塊
- `fileFetch.ts`: 下載文件
- `fileShareCodeFetch.ts`: 根據分享碼獲取文件信息

- **管理後台** (`src/admin/`):

- `listShares.ts`: 列出所有分享
- `deleteShare.ts`: 刪除分享
- `getInfo.ts`: 獲取統計信息

- **定時任務** (`src/scheduled.ts`):
每 10 分鐘執行一次,清理過期的 KV 存儲和 D1 記錄(見 `wrangler.example.toml` 的 `triggers.crons`)

### 前端架構(Preact)

**入口**: `web/index.tsx`
使用 Preact + MobX 狀態管理 + Material-UI 組件庫。

**目錄結構**:

- `web/views/`: 頁面組件
- `Home/`: 首頁(文件上傳和分享)
- `Admin/`: 管理後台
- `web/components/`: 可重用組件
- `web/api/`: API 客戶端(Axios)
- `web/theme/`: Material-UI 自定義主題
- `web/helpers/`: 工具函數

### 數據庫架構(D1 + Drizzle)

**Schema 定義**: `data/schemas/files.schema.ts`
**遷移目錄**: `data/migrations/`
**配置文件**: `data/drizzle.config.ts`

主要表結構:

- `files`: 文件分享記錄
- `id`: 主鍵(CUID2)
- `objectId`: KV 中的文件 ID
- `filename`: 文件名
- `hash`: 文件 hash 值
- `code`: 分享碼(唯一)
- `size`: 文件大小
- `is_ephemeral`: 是否閱後即焚
- `expires_at`: 過期時間
- `created_at`: 創建時間

## Environment Configuration

### 本地開發

創建 `.dev.vars` 文件(參考 `.dev.vars.example`):

```bash
ADMIN_TOKEN=your-admin-token
SHARE_DURATION=1hour
SHARE_MAX_SIZE_IN_MB=10
```

### Cloudflare 配置

需要在 Cloudflare Dashboard 創建:

1. **D1 Database** (名稱: `airdrop`)
2. **KV Namespace** (binding: `file_drops`)

然後配置 `wrangler.toml`(可通過 `prepare.sh` 自動生成):

- `D1_ID` 和 `D1_NAME`: D1 Database 配置
- `KV_ID`: KV Namespace ID
- `CUSTOM_DOMAIN`: 自定義域名(可選)
- `RATE_LIMIT`: 上傳頻率限制(每 10 秒的請求數)

## Key Patterns

### API 響應格式

所有 API 端點使用統一的響應格式(來自 `Endpoint` 基類):

```typescript
// 成功
{ message: 'ok', result: true, data: <payload> }

// 錯誤
{ message: <error message>, result: false, data: null }
```

### 文件上傳流程

1. 前端將大文件分塊(通過 `fileChunkCreate` 端點)
2. 所有分塊上傳完成後調用 `mergeFileChunk` 合併
3. 合併後返回分享碼和下載鏈接

### 分享碼生成

使用 CUID2 生成唯一的分享碼(見 `@paralleldrive/cuid2`)

### 認證機制

管理後台通過 URL 路徑中的 token 認證:
`/admin/{ADMIN_TOKEN}` - 訪問管理後台
後端通過 `auth.middleware.ts` 驗證

## Important Notes

- **使用 pnpm**: 項目配置了 `packageManager: "pnpm@9.15.3"`,必須使用 pnpm
- **TypeScript 配置**: 專案有多個 tsconfig 文件:
- `tsconfig.web.json`: 前端配置
- `tsconfig.worker.json`: Worker 配置
- `tsconfig.node.json`: Node.js 工具配置
- **Drizzle Schema 修改**: 修改 `data/schemas/*.schema.ts` 後必須執行 `pnpm generate` 生成遷移
- **Worker 限制**:
- Cloudflare Workers 有 CPU 時間限制(免費版 10ms,付費版 50ms)
- 文件通過 KV 存儲,單個值最大 25MB
- **定時任務**: Cron 觸發器僅在生產環境運行,本地開發不會執行
- **Husky Git Hooks**: 提交前會自動執行 `prettier` 和 `eslint`(見 `lint-staged` 配置)
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import prettierConfig from 'eslint-config-prettier'
export default [
...tseslint.config(eslint.configs.recommended, tseslint.configs.recommended),
{
ignores: ['**/.*', 'dist'],
ignores: ['**/.*', 'dist/**', '.wrangler/**'],
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
Expand Down
8 changes: 4 additions & 4 deletions src/files/fileCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,14 @@ export class FileCreate extends Endpoint {
(!data || data.byteLength === 0) &&
(!objectId || (Array.isArray(objectId) && !objectId.length))
) {
return this.error('分享内容为空')
return this.error('EMPTY_CONTENT')
}

const envMax = Number.parseInt(c.env.SHARE_MAX_SIZE_IN_MB, 10)
const max = Number.isNaN(envMax) || envMax <= 0 ? 10 : envMax

if (size > max * 1000 * 1000) {
return this.error(`文件大于 ${max}M`)
return this.error('FILE_TOO_LARGE')
}

const kv = this.getKV(c)
Expand All @@ -140,7 +140,7 @@ export class FileCreate extends Endpoint {
} else if (typeof objectId === 'string') {
const cacheFile = await kv.get(objectId, 'stream')
if (!cacheFile) {
return this.error('分片上传的文件不存在')
return this.error('CHUNK_NOT_FOUND')
}
// 分片存储
} else if (Array.isArray(objectId) && objectId.length) {
Expand Down Expand Up @@ -172,7 +172,7 @@ export class FileCreate extends Endpoint {
const shareCode = shareCodes.find((d) => !records.includes(d))

if (!shareCode) {
return this.error('分享码生成失败,请重试')
return this.error('CODE_GENERATION_FAILED')
}

const [due, dueType] = resolveDuration(duration || c.env.SHARE_DURATION)
Expand Down
4 changes: 2 additions & 2 deletions src/files/fileFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ export class FileFetch extends Endpoint {
const kv = this.getKV(c)
const tokenValue = await kv.get(token, 'text')
if (!tokenValue || tokenValue !== token) {
return this.error('无效的 token', true)
return this.error('INVALID_TOKEN', true)
}
await kv.delete(token)
const db = this.getDB(c)
const [record] = await db.select().from(files).where(eq(files.id, id))
if (!record) {
return this.error('无效的 object id', true)
return this.error('INVALID_OBJECT_ID', true)
}
const objectId = record.objectId

Expand Down
4 changes: 2 additions & 2 deletions src/files/fileShareCodeFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,12 @@ export class FileShareCodeFetch extends Endpoint {
const file = await getFile(db, code)

if (!file) {
return this.error('分享码无效')
return this.error('INVALID_CODE')
}

const day = dayjs(file.due_date)
if (day.isBefore(dayjs())) {
return this.error('分享已过期')
return this.error('SHARE_EXPIRED')
}

const { objectId, ...rest } = file
Expand Down
6 changes: 5 additions & 1 deletion tsconfig.worker.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
"outDir": "",
"target": "esnext",
"module": "esnext",
"types": ["@types/node", "@types/service-worker-mock", "@cloudflare/workers-types/2023-07-01"],
"types": [
"@types/node",
"@types/service-worker-mock",
"@cloudflare/workers-types/2023-07-01"
],
"paths": {
"@data/*": ["./data/*"]
}
Expand Down
5 changes: 3 additions & 2 deletions web/api/uploader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { AxiosProgressEvent } from 'axios'
import { getUserUUID } from '../helpers'
import { i18nStore } from '../i18n'

interface ChunkInfo {
sha: string
Expand Down Expand Up @@ -89,7 +90,7 @@ export class Uploader {
const { data } = await axios.put('/files', formData)
return data as ApiResponseType<FileUploadedType>
}
throw new Error('建议使用 R2')
throw new Error(i18nStore.t('errors', 'suggestR2'))
}

static async uploadWithChunk(
Expand Down Expand Up @@ -231,7 +232,7 @@ export class Uploader {
const data: ApiResponseType<ChunkInfo> = await response.json()

if (!data.result) {
throw new Error('获取分片信息失败')
throw new Error(i18nStore.t('errors', 'chunkInfoFailed'))
}
return data.data!
}
Expand Down
20 changes: 10 additions & 10 deletions web/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import CircularProgress from '@mui/material/CircularProgress'
import IconButton from '@mui/material/IconButton'

import { Message, useMessage, Github } from './'
import { LanguageSwitch } from '../i18n'

export interface LayoutProps {
children?: ComponentChildren
Expand Down Expand Up @@ -58,16 +59,15 @@ export function Layout({ children }: LayoutProps) {
</span>
</Typography>
</Link>
<IconButton
sx={{
position: 'relative',
top: -10,
}}
href="https://github.com/oustn/cloudflare-drop"
target="_blank"
>
<Github />
</IconButton>
<Box className="flex items-center gap-2 flex-shrink-0">
<LanguageSwitch />
<IconButton
href="https://github.com/oustn/cloudflare-drop"
target="_blank"
>
<Github />
</IconButton>
</Box>
</Box>
{injectedChildren}
</div>
Expand Down
6 changes: 4 additions & 2 deletions web/helpers/encryptor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-expect-error sub module
import { ArgonType, hash } from 'argon2-browser/dist/argon2-bundled.min.js'
import { i18nStore } from '../i18n'

export class Encryptor {
private static HEADER_METADATA_SIZE = 4 // 增加版本信息(2 字节)
Expand Down Expand Up @@ -91,7 +92,8 @@ export class Encryptor {
),
)
const version = new DataView(header.buffer).getUint16(0, true)
if (version !== this.VERSION) throw new Error('版本不匹配')
if (version !== this.VERSION)
throw new Error(i18nStore.t('errors', 'versionMismatch'))

const salt = header.slice(
Encryptor.VERSION_LENGTH,
Expand Down Expand Up @@ -136,7 +138,7 @@ export class Encryptor {
encryptedData,
)
if (!this.compareBuffers(dataHash, recalculatedHash)) {
throw new Error('数据完整性校验失败')
throw new Error(i18nStore.t('errors', 'integrityCheckFailed'))
}

return new Blob([decryptedData])
Expand Down
Loading