kinaiok commited on
Commit
5ef6e9d
·
0 Parent(s):

Initial deployment setup for Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +65 -0
  2. .env.example +25 -0
  3. .gitattributes +6 -0
  4. .npmrc +2 -0
  5. DEPLOYMENT.md +218 -0
  6. DEPLOYMENT_CHECKLIST.md +217 -0
  7. Dockerfile +57 -0
  8. README.md +70 -0
  9. artifacts/api-server/.replit-artifact/artifact.toml +32 -0
  10. artifacts/api-server/build.mjs +126 -0
  11. artifacts/api-server/dist/index.mjs +0 -0
  12. artifacts/api-server/dist/index.mjs.map +0 -0
  13. artifacts/api-server/dist/pino-file.mjs +0 -0
  14. artifacts/api-server/dist/pino-file.mjs.map +0 -0
  15. artifacts/api-server/dist/pino-pretty.mjs +0 -0
  16. artifacts/api-server/dist/pino-pretty.mjs.map +0 -0
  17. artifacts/api-server/dist/pino-worker.mjs +0 -0
  18. artifacts/api-server/dist/pino-worker.mjs.map +0 -0
  19. artifacts/api-server/dist/thread-stream-worker.mjs +228 -0
  20. artifacts/api-server/dist/thread-stream-worker.mjs.map +7 -0
  21. artifacts/api-server/package.json +45 -0
  22. artifacts/api-server/src/app.ts +44 -0
  23. artifacts/api-server/src/captcha.ts +298 -0
  24. artifacts/api-server/src/guardId.ts +91 -0
  25. artifacts/api-server/src/index.ts +20 -0
  26. artifacts/api-server/src/lib/.gitkeep +0 -0
  27. artifacts/api-server/src/lib/localTempStorage.ts +167 -0
  28. artifacts/api-server/src/lib/logger.ts +20 -0
  29. artifacts/api-server/src/lib/objectAcl.ts +137 -0
  30. artifacts/api-server/src/lib/objectStorage.ts +267 -0
  31. artifacts/api-server/src/lib/videoStorage.ts +264 -0
  32. artifacts/api-server/src/middlewares/.gitkeep +0 -0
  33. artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts +61 -0
  34. artifacts/api-server/src/routes/accounts.ts +85 -0
  35. artifacts/api-server/src/routes/admin.ts +316 -0
  36. artifacts/api-server/src/routes/apiKeys.ts +56 -0
  37. artifacts/api-server/src/routes/auth.ts +154 -0
  38. artifacts/api-server/src/routes/config.ts +285 -0
  39. artifacts/api-server/src/routes/health.ts +11 -0
  40. artifacts/api-server/src/routes/images.ts +457 -0
  41. artifacts/api-server/src/routes/index.ts +20 -0
  42. artifacts/api-server/src/routes/openai.ts +220 -0
  43. artifacts/api-server/src/routes/public.ts +83 -0
  44. artifacts/api-server/src/routes/videos.ts +1297 -0
  45. artifacts/api-server/tsconfig.json +17 -0
  46. artifacts/image-gen/.replit-artifact/artifact.toml +31 -0
  47. artifacts/image-gen/components.json +20 -0
  48. artifacts/image-gen/index.html +16 -0
  49. artifacts/image-gen/package.json +78 -0
  50. artifacts/image-gen/public/favicon.svg +3 -0
.dockerignore ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+
5
+ # Dependencies
6
+ node_modules
7
+ **/node_modules
8
+
9
+ # Build outputs
10
+ dist
11
+ build
12
+ .next
13
+ out
14
+
15
+ # Development
16
+ .env
17
+ .env.local
18
+ .env.*.local
19
+ *.log
20
+
21
+ # IDE
22
+ .vscode
23
+ .idea
24
+ *.swp
25
+ *.swo
26
+ *~
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Test
33
+ coverage
34
+ .nyc_output
35
+
36
+ # Replit specific
37
+ .replit
38
+ .replitignore
39
+ replit.md
40
+ .replit-artifact
41
+
42
+ # Vercel
43
+ .vercel
44
+ vercel.json
45
+
46
+ # Attached assets
47
+ attached_assets
48
+
49
+ # Config
50
+ .config
51
+
52
+ # Agents
53
+ .agents
54
+
55
+ # Scripts
56
+ scripts
57
+
58
+ # Mockup sandbox (不需要部署)
59
+ artifacts/mockup-sandbox
60
+
61
+ # Turnstile solver (單獨部署)
62
+ artifacts/turnstile-solver
63
+
64
+ # API route (Vercel 專用)
65
+ api
.env.example ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space 環境變數範例
2
+ # 複製此檔案為 .env 並填入實際值
3
+
4
+ # 伺服器設定
5
+ PORT=7860
6
+ NODE_ENV=production
7
+
8
+ # 資料庫 (SQLite)
9
+ DATABASE_URL=file:/data/sqlite.db
10
+
11
+ # JWT 認證密鑰 (請更換為隨機字串,至少 32 字元)
12
+ JWT_SECRET=your-random-jwt-secret-key-change-this-in-production
13
+
14
+ # 加密密鑰 (必須是 32 字元,用於加密敏感資料)
15
+ ENCRYPTION_KEY=your-32-character-encryption-key
16
+
17
+ # 暫存儲存路徑
18
+ TEMP_STORAGE_PATH=/app/temp-storage
19
+
20
+ # Turnstile 驗證碼服務 (可選,如果有部署 turnstile-solver)
21
+ # TURNSTILE_SOLVER_URL=https://your-turnstile-solver.vercel.app
22
+
23
+ # 站點配置 (可選)
24
+ # SITE_NAME=AI Generator
25
+ # SITE_DESCRIPTION=AI Image & Video Generator
.gitattributes ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.gif filter=lfs diff=lfs merge=lfs -text
5
+ *.svg filter=lfs diff=lfs merge=lfs -text
6
+ *.webp filter=lfs diff=lfs merge=lfs -text
.npmrc ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ auto-install-peers=false
2
+ strict-peer-dependencies=false
DEPLOYMENT.md ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces 部署指南
2
+
3
+ 本指南說明如何將此專案部署到 Hugging Face Spaces 免費版。
4
+
5
+ ## 前置準備
6
+
7
+ 1. 註冊 [Hugging Face](https://huggingface.co/) 帳號
8
+ 2. 準備 geminigen.ai 帳號憑證
9
+
10
+ ## 部署步驟
11
+
12
+ ### 1. 建立 Space
13
+
14
+ 1. 前往 https://huggingface.co/new-space
15
+ 2. 填寫資訊:
16
+ - **Space name**: 自訂名稱(例如:ai-image-generator)
17
+ - **License**: MIT
18
+ - **Select the Space SDK**: Docker
19
+ - **Space hardware**: CPU basic (免費)
20
+ 3. 點擊 "Create Space"
21
+
22
+ ### 2. 上傳專案檔案
23
+
24
+ #### 方式 A: 使用 Git (推薦)
25
+
26
+ ```bash
27
+ # 克隆 Space 倉庫
28
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
29
+ cd YOUR_SPACE_NAME
30
+
31
+ # 複製專案檔案(排除不需要的檔案)
32
+ cp -r /path/to/project/* .
33
+
34
+ # 確保包含以下關鍵檔案:
35
+ # - Dockerfile
36
+ # - README.md (Space 配置)
37
+ # - artifacts/
38
+ # - lib/
39
+ # - package.json
40
+ # - pnpm-lock.yaml
41
+ # - pnpm-workspace.yaml
42
+ # - tsconfig.base.json
43
+ # - tsconfig.json
44
+
45
+ # 提交並推送
46
+ git add .
47
+ git commit -m "Initial deployment"
48
+ git push
49
+ ```
50
+
51
+ #### 方式 B: 使用 Web 介面
52
+
53
+ 1. 在 Space 頁面點擊 "Files" 標籤
54
+ 2. 點擊 "Add file" → "Upload files"
55
+ 3. 上傳所有必要檔案
56
+
57
+ ### 3. 設定環境變數
58
+
59
+ 在 Space 的 "Settings" 標籤中設定以下環境變數:
60
+
61
+ #### 必要變數
62
+
63
+ ```bash
64
+ # JWT 密鑰(請生成隨機字串)
65
+ JWT_SECRET=your-random-jwt-secret-key-at-least-32-characters
66
+
67
+ # 加密密鑰(必須是 32 字元)
68
+ ENCRYPTION_KEY=your-32-character-encryption-key
69
+ ```
70
+
71
+ #### 可選變數
72
+
73
+ ```bash
74
+ # 資料庫路徑(預設值已足夠)
75
+ DATABASE_URL=file:/data/sqlite.db
76
+
77
+ # 暫存路徑(預設值已足夠)
78
+ TEMP_STORAGE_PATH=/app/temp-storage
79
+
80
+ # Turnstile 驗證碼服務(如果有部署)
81
+ TURNSTILE_SOLVER_URL=https://your-turnstile-solver.vercel.app
82
+ ```
83
+
84
+ ### 4. 啟用 Persistent Storage (重要)
85
+
86
+ 為了保存資料庫和用戶資料:
87
+
88
+ 1. 在 Space Settings 中找到 "Persistent Storage"
89
+ 2. 點擊 "Enable Persistent Storage"
90
+ 3. 設定掛載路徑為 `/data`
91
+
92
+ **注意**: 免費版 Persistent Storage 有容量限制,請定期清理舊檔案。
93
+
94
+ ### 5. 等待建置完成
95
+
96
+ Space 會自動開始建置 Docker 映像,這可能需要 10-15 分鐘。
97
+
98
+ 建置完成後,Space 會自動啟動,您可以在 "App" 標籤中看到應用程式。
99
+
100
+ ## 首次使用
101
+
102
+ 1. 訪問您的 Space URL(例如:https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME)
103
+ 2. 點擊右上角「註冊」按鈕
104
+ 3. 第一個註冊的用戶會自動成為管理員
105
+ 4. 登入後,進入「管理後台」
106
+ 5. 設定 geminigen.ai 憑證:
107
+ - Email
108
+ - Password
109
+ 6. 點擊「儲存憑證」
110
+ 7. 開始使用!
111
+
112
+ ## 常見問題
113
+
114
+ ### Q: 建置失敗怎麼辦?
115
+
116
+ A: 檢查以下項目:
117
+ - 確認 Dockerfile 格式正確
118
+ - 確認所有依賴檔案都已上傳
119
+ - 查看 Space 的 "Logs" 標籤了解錯誤訊息
120
+
121
+ ### Q: 應用程式無法啟動?
122
+
123
+ A: 檢查:
124
+ - 環境變數是否正確設定
125
+ - Persistent Storage 是否已啟用
126
+ - 查看 "Logs" 標籤的錯誤訊息
127
+
128
+ ### Q: 資料庫資料遺失?
129
+
130
+ A: 確保:
131
+ - Persistent Storage 已啟用並掛載到 `/data`
132
+ - DATABASE_URL 指向 `/data/sqlite.db`
133
+
134
+ ### Q: 圖片/影片無法顯示?
135
+
136
+ A: 本地暫存模式下:
137
+ - 檔案會在 24 小時後自動清理
138
+ - 重啟 Space 會清空暫存
139
+ - 這是免費版的限制,如需永久儲存請考慮外部儲存服務
140
+
141
+ ### Q: 如何更新應用程式?
142
+
143
+ A: 使用 Git 推送更新:
144
+
145
+ ```bash
146
+ cd YOUR_SPACE_NAME
147
+ git pull origin main # 拉取最新變更
148
+ # 修改檔案...
149
+ git add .
150
+ git commit -m "Update application"
151
+ git push
152
+ ```
153
+
154
+ ### Q: 效能不足怎麼辦?
155
+
156
+ A: 免費版 CPU basic 有限制:
157
+ - 考慮升級到付費硬體
158
+ - 優化資料庫查詢
159
+ - 減少同時處理的請求數
160
+
161
+ ## 進階配置
162
+
163
+ ### 使用外部資料庫
164
+
165
+ 如果需要更穩定的資料庫,可以使用外部服務:
166
+
167
+ 1. 註冊 [Supabase](https://supabase.com/) 或 [Neon](https://neon.tech/)(都有免費額度)
168
+ 2. 建立 PostgreSQL 資料庫
169
+ 3. 修改環境變數:
170
+ ```bash
171
+ DATABASE_URL=postgresql://user:pass@host:5432/dbname
172
+ ```
173
+ 4. 修改 `lib/db/drizzle.config.ts` 和 `lib/db/src/index.ts` 改回 PostgreSQL 配置
174
+
175
+ ### 使用外部儲存
176
+
177
+ 整合 Cloudflare R2 或其他 S3 相容服務:
178
+
179
+ 1. 註冊服務並取得憑證
180
+ 2. 修改 `artifacts/api-server/src/lib/objectStorage.ts`
181
+ 3. 設定相關環境變數
182
+
183
+ ## 監控與維護
184
+
185
+ ### 查看日誌
186
+
187
+ 在 Space 的 "Logs" 標籤可以查看即時日誌。
188
+
189
+ ### 重啟 Space
190
+
191
+ 在 Settings 中點擊 "Factory reboot" 可以重啟 Space。
192
+
193
+ ### 備份資料
194
+
195
+ 定期下載 `/data/sqlite.db` 進行備份:
196
+
197
+ ```bash
198
+ # 使用 Hugging Face CLI
199
+ huggingface-cli download spaces/YOUR_USERNAME/YOUR_SPACE_NAME data/sqlite.db --repo-type=space
200
+ ```
201
+
202
+ ## 成本估算
203
+
204
+ - **免費版**: CPU basic,適合個人使用或測試
205
+ - **付費版**:
206
+ - CPU upgrade: $0.03/hour
207
+ - GPU T4: $0.60/hour
208
+ - Persistent Storage: 免費 50GB
209
+
210
+ ## 支援
211
+
212
+ 如有問題,請查看:
213
+ - [Hugging Face Spaces 文件](https://huggingface.co/docs/hub/spaces)
214
+ - [專案 GitHub Issues](https://github.com/your-repo/issues)
215
+
216
+ ## 授權
217
+
218
+ MIT License
DEPLOYMENT_CHECKLIST.md ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces 部署檢查清單
2
+
3
+ ## 部署前檢查
4
+
5
+ ### 必要檔案
6
+ - [ ] `Dockerfile` - Docker 建置配置
7
+ - [ ] `README.md` - Space 配置檔(包含 YAML front matter)
8
+ - [ ] `.dockerignore` - 排除不需要的檔案
9
+ - [ ] `.env.example` - 環境變數範例
10
+ - [ ] `DEPLOYMENT.md` - 部署指南
11
+
12
+ ### 專案檔案
13
+ - [ ] `package.json` - 根目錄 workspace 配置
14
+ - [ ] `pnpm-lock.yaml` - 依賴鎖定檔
15
+ - [ ] `pnpm-workspace.yaml` - Workspace 配置
16
+ - [ ] `tsconfig.base.json` - TypeScript 基礎配置
17
+ - [ ] `tsconfig.json` - TypeScript 配置
18
+
19
+ ### 後端檔案
20
+ - [ ] `artifacts/api-server/` - 完整後端程式碼
21
+ - [ ] `artifacts/api-server/src/index.ts` - 已移除背景 token refresh
22
+ - [ ] `artifacts/api-server/src/lib/localTempStorage.ts` - 本地暫存邏輯
23
+
24
+ ### 前端檔案
25
+ - [ ] `artifacts/image-gen/` - 完整前端程式碼
26
+ - [ ] `artifacts/image-gen/dist/` - 建置後會自動生成
27
+
28
+ ### 資料庫檔案
29
+ - [ ] `lib/db/` - 資料庫層
30
+ - [ ] `lib/db/package.json` - 已更新為 better-sqlite3
31
+ - [ ] `lib/db/drizzle.config.ts` - 已改為 SQLite 配置
32
+ - [ ] `lib/db/src/index.ts` - 已改為 SQLite 連接
33
+
34
+ ### 共享庫
35
+ - [ ] `lib/api-client-react/` - React API 客戶端
36
+ - [ ] `lib/api-spec/` - OpenAPI 規格
37
+ - [ ] `lib/api-zod/` - Zod 驗證
38
+
39
+ ## 環境變數設定
40
+
41
+ ### 必要變數(在 Space Settings 中設定)
42
+ - [ ] `JWT_SECRET` - JWT 密鑰(至少 32 字元隨機字串)
43
+ - [ ] `ENCRYPTION_KEY` - 加密密鑰(必須是 32 字元)
44
+
45
+ ### 可選變數
46
+ - [ ] `DATABASE_URL` - 資料庫路徑(預設:file:/data/sqlite.db)
47
+ - [ ] `TEMP_STORAGE_PATH` - 暫存路徑(預設:/app/temp-storage)
48
+ - [ ] `TURNSTILE_SOLVER_URL` - Turnstile 服務 URL(如有)
49
+
50
+ ## Space 設定
51
+
52
+ ### 基本設定
53
+ - [ ] Space SDK 選擇 "Docker"
54
+ - [ ] Space hardware 選擇 "CPU basic"(免費)
55
+ - [ ] License 設定為 "MIT"
56
+
57
+ ### Persistent Storage
58
+ - [ ] 啟用 Persistent Storage
59
+ - [ ] 掛載路徑設定為 `/data`
60
+ - [ ] 確認容量足夠(免費版有限制)
61
+
62
+ ## 建置檢查
63
+
64
+ ### Docker 建置
65
+ - [ ] Dockerfile 語法正確
66
+ - [ ] 所有依賴都能正確安裝
67
+ - [ ] 前端建置成功
68
+ - [ ] 後端建置成功
69
+ - [ ] 暴露端口 7860
70
+
71
+ ### 執行時檢查
72
+ - [ ] 伺服器能正常啟動
73
+ - [ ] 資料庫檔案能正確建立
74
+ - [ ] 暫存目錄能正確建立
75
+ - [ ] 日誌輸出正常
76
+
77
+ ## 功能測試
78
+
79
+ ### 基本功能
80
+ - [ ] 首頁能正常載入
81
+ - [ ] 用戶註冊功能正常
82
+ - [ ] 用戶登入功能正常
83
+ - [ ] 第一個用戶成為管理員
84
+
85
+ ### 管理功能
86
+ - [ ] 能進入管理後台
87
+ - [ ] 能設定 geminigen.ai 憑證
88
+ - [ ] 能查看系統狀態
89
+ - [ ] 能管理用戶
90
+
91
+ ### 圖像生成
92
+ - [ ] 能輸入提示詞
93
+ - [ ] 能選擇模型
94
+ - [ ] 能選擇風格和比例
95
+ - [ ] 能上傳參考圖像
96
+ - [ ] 能成功生成圖像
97
+ - [ ] 圖像能正常顯示
98
+
99
+ ### 影片生成
100
+ - [ ] 能輸入提示詞
101
+ - [ ] 能選擇模型(Grok-3 / Veo 3.1)
102
+ - [ ] 能設定進階選項
103
+ - [ ] 能上傳參考圖像
104
+ - [ ] 能成功生成影片
105
+ - [ ] 進度追蹤正常
106
+ - [ ] 影片能正常播放
107
+
108
+ ### 歷史記錄
109
+ - [ ] 圖像歷史能正常顯示
110
+ - [ ] 影片歷史能正常顯示
111
+ - [ ] 能刪除記錄
112
+ - [ ] 私密/公開過濾正常
113
+
114
+ ## 效能檢查
115
+
116
+ ### 資源使用
117
+ - [ ] CPU 使用率合理
118
+ - [ ] 記憶體使用率合理
119
+ - [ ] 磁碟空間足夠
120
+
121
+ ### 回應時間
122
+ - [ ] 頁面載入速度可接受
123
+ - [ ] API 回應時間合理
124
+ - [ ] 圖像生成時間正常
125
+ - [ ] 影片生成時間正常
126
+
127
+ ## 維護檢查
128
+
129
+ ### 自動清理
130
+ - [ ] 舊檔案能自動清理(24 小時)
131
+ - [ ] 清理日誌正常輸出
132
+ - [ ] 磁碟空間不會持續增長
133
+
134
+ ### 日誌監控
135
+ - [ ] 能在 Space Logs 查看日誌
136
+ - [ ] 錯誤日誌清晰可讀
137
+ - [ ] 無異常錯誤持續出現
138
+
139
+ ### 備份
140
+ - [ ] 知道如何備份資料庫
141
+ - [ ] 知道如何還原資料庫
142
+ - [ ] 定期備份計畫已建立
143
+
144
+ ## 安全檢查
145
+
146
+ ### 憑證安全
147
+ - [ ] JWT_SECRET 是隨機生成的
148
+ - [ ] ENCRYPTION_KEY 是隨機生成的
149
+ - [ ] 不在程式碼中硬編碼敏感資訊
150
+ - [ ] .env 檔案已加入 .gitignore
151
+
152
+ ### 存取控制
153
+ - [ ] 管理功能需要管理員權限
154
+ - [ ] 私密內容只有擁有者能查看
155
+ - [ ] API 端點有適當的認證
156
+
157
+ ## 文件檢查
158
+
159
+ ### 使用者文件
160
+ - [ ] README.md 說明清楚
161
+ - [ ] DEPLOYMENT.md 步驟完整
162
+ - [ ] .env.example 註解清楚
163
+
164
+ ### 開發文件
165
+ - [ ] 程式碼註解充足
166
+ - [ ] 關鍵邏輯有說明
167
+ - [ ] 已知限制有記錄
168
+
169
+ ## 部署後驗證
170
+
171
+ ### 立即檢查
172
+ - [ ] Space 狀態為 "Running"
173
+ - [ ] 能訪問 Space URL
174
+ - [ ] 首頁正常顯示
175
+ - [ ] 無 JavaScript 錯誤
176
+
177
+ ### 24 小時後檢查
178
+ - [ ] Space 仍在運行
179
+ - [ ] 資料庫資料保存正常
180
+ - [ ] 舊檔案已自動清理
181
+ - [ ] 無記憶體洩漏
182
+
183
+ ### 一週後檢查
184
+ - [ ] 長期穩定性良好
185
+ - [ ] 磁碟空間使用穩定
186
+ - [ ] 無異常錯誤累積
187
+ - [ ] 用戶回饋正面
188
+
189
+ ## 問題排查
190
+
191
+ ### 常見問題
192
+ - [ ] 知道如何查看日誌
193
+ - [ ] 知道如何重啟 Space
194
+ - [ ] 知道如何還原資料
195
+ - [ ] 知道如何聯繫支援
196
+
197
+ ### 緊急處理
198
+ - [ ] 有備份還原計畫
199
+ - [ ] 有降級方案
200
+ - [ ] 有聯繫管道
201
+
202
+ ## 完成確認
203
+
204
+ - [ ] 所有檢查項目都已完成
205
+ - [ ] 所有功能都正常運作
206
+ - [ ] 文件都已更新
207
+ - [ ] 團隊成員都已知悉
208
+
209
+ ---
210
+
211
+ **部署日期**: _______________
212
+
213
+ **部署人員**: _______________
214
+
215
+ **Space URL**: _______________
216
+
217
+ **備註**:
Dockerfile ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim
2
+
3
+ # 安裝 pnpm 和必要工具
4
+ RUN npm install -g pnpm && \
5
+ apt-get update && \
6
+ apt-get install -y python3 make g++ && \
7
+ rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ # 複製 pnpm 配置
12
+ COPY .npmrc ./
13
+
14
+ # 複製 workspace 配置(使用 HF 優化版本)
15
+ COPY package.json pnpm-lock.yaml ./
16
+ COPY pnpm-workspace-hf.yaml ./pnpm-workspace.yaml
17
+ COPY tsconfig.base.json tsconfig.json ./
18
+
19
+ # 複製所有 packages
20
+ COPY artifacts ./artifacts
21
+ COPY lib ./lib
22
+
23
+ # 安裝依賴
24
+ RUN pnpm install --no-frozen-lockfile
25
+
26
+ # 驗證關鍵依賴
27
+ RUN echo "=== Checking esbuild ===" && pnpm list esbuild || echo "esbuild not found"
28
+ RUN echo "=== Checking vite ===" && pnpm list vite || echo "vite not found"
29
+ RUN echo "=== Checking typescript ===" && pnpm list typescript || echo "typescript not found"
30
+
31
+ # 建置前端
32
+ RUN echo "=== Starting frontend build ===" && \
33
+ cd artifacts/image-gen && \
34
+ pnpm run build && \
35
+ echo "=== Frontend build completed ==="
36
+
37
+ # 建置後端
38
+ RUN echo "=== Starting backend build ===" && \
39
+ cd artifacts/api-server && \
40
+ pnpm run build && \
41
+ echo "=== Backend build completed ==="
42
+
43
+ # 建立資料和暫存目錄
44
+ RUN mkdir -p /data /app/temp-storage && \
45
+ chmod 777 /data /app/temp-storage
46
+
47
+ # 設定環境變數
48
+ ENV NODE_ENV=production
49
+ ENV PORT=7860
50
+ ENV DATABASE_URL=file:/data/sqlite.db
51
+ ENV TEMP_STORAGE_PATH=/app/temp-storage
52
+
53
+ # 暴露 Hugging Face Spaces 預設端口
54
+ EXPOSE 7860
55
+
56
+ # 啟動命令
57
+ CMD ["node", "artifacts/api-server/dist/index.js"]
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Image & Video Generator
3
+ emoji: 🎨
4
+ colorFrom: purple
5
+ colorTo: pink
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ # AI Image & Video Generator
12
+
13
+ 基於 geminigen.ai API 的圖像與影片生成平台,支援多種 AI 模型。
14
+
15
+ ## 功能特色
16
+
17
+ - 🖼️ **圖像生成**: 支援 Grok, Meta, Imagen Pro/4/Flash, Nano Banana 等模型
18
+ - 🎬 **影片生成**: 支援 Grok-3 和 Veo 3.1 Fast 模型
19
+ - 🎨 **多種風格**: Photorealistic, Anime, Digital Art 等
20
+ - 📐 **自訂比例**: 1:1, 16:9, 9:16, 4:3, 3:4
21
+ - 🔐 **用戶系統**: JWT 認證、點數管理
22
+ - 🖼️ **圖生圖**: 支援參考圖像生成
23
+ - 🎥 **圖生影片**: 支援圖像轉影片
24
+
25
+ ## 環境變數設定
26
+
27
+ 在 Space Settings 中設定以下環境變數:
28
+
29
+ ```bash
30
+ # 資料庫 (自動使用 SQLite)
31
+ DATABASE_URL=file:/data/sqlite.db
32
+
33
+ # JWT 密鑰 (請更換為隨機字串)
34
+ JWT_SECRET=your-random-secret-key-here
35
+
36
+ # 加密密鑰 (32 字元,請更換)
37
+ ENCRYPTION_KEY=your-32-character-encryption-key
38
+
39
+ # Turnstile 驗證碼服務 (可選)
40
+ TURNSTILE_SOLVER_URL=https://your-turnstile-solver.vercel.app
41
+
42
+ # 伺服器設定
43
+ PORT=7860
44
+ NODE_ENV=production
45
+ ```
46
+
47
+ ## 首次使用
48
+
49
+ 1. 訪問應用後,點擊右上角「註冊」
50
+ 2. 第一個註冊的用戶會自動成為管理員
51
+ 3. 進入「管理後台」設定 geminigen.ai 憑證
52
+ 4. 開始生成圖像和影片!
53
+
54
+ ## 技術架構
55
+
56
+ - **前端**: React + TypeScript + Vite + shadcn/ui
57
+ - **後端**: Express.js + TypeScript
58
+ - **資料庫**: SQLite (Drizzle ORM)
59
+ - **儲存**: 本地暫存 (定期清理)
60
+ - **認證**: JWT + bcrypt
61
+
62
+ ## 限制說明
63
+
64
+ - 免費版使用本地暫存,舊檔案會定期清理
65
+ - 建議使用 Persistent Storage 保存資料庫
66
+ - 影片僅提供臨時連結,不做永久儲存
67
+
68
+ ## 授權
69
+
70
+ MIT License
artifacts/api-server/.replit-artifact/artifact.toml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ kind = "api"
2
+ previewPath = "/api" # TODO - should be excluded from preview in the first place
3
+ title = "API Server"
4
+ version = "1.0.0"
5
+ id = "3B4_FFSkEVBkAeYMFRJ2e"
6
+
7
+ [[services]]
8
+ localPort = 8080
9
+ name = "API Server"
10
+ paths = ["/api"]
11
+
12
+ [services.development]
13
+ run = "pnpm --filter @workspace/api-server run dev"
14
+
15
+ [services.production]
16
+
17
+ [services.production.build]
18
+ args = ["pnpm", "--filter", "@workspace/api-server", "run", "build"]
19
+
20
+ [services.production.build.env]
21
+ NODE_ENV = "production"
22
+
23
+ [services.production.run]
24
+ # we don't run through pnpm to make startup faster in production
25
+ args = ["node", "--enable-source-maps", "artifacts/api-server/dist/index.mjs"]
26
+
27
+ [services.production.run.env]
28
+ PORT = "8080"
29
+ NODE_ENV = "production"
30
+
31
+ [services.production.health.startup]
32
+ path = "/api/healthz"
artifacts/api-server/build.mjs ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { build as esbuild } from "esbuild";
5
+ import esbuildPluginPino from "esbuild-plugin-pino";
6
+ import { rm } from "node:fs/promises";
7
+
8
+ // Plugins (e.g. 'esbuild-plugin-pino') may use `require` to resolve dependencies
9
+ globalThis.require = createRequire(import.meta.url);
10
+
11
+ const artifactDir = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ async function buildAll() {
14
+ const distDir = path.resolve(artifactDir, "dist");
15
+ await rm(distDir, { recursive: true, force: true });
16
+
17
+ await esbuild({
18
+ entryPoints: [path.resolve(artifactDir, "src/index.ts")],
19
+ platform: "node",
20
+ bundle: true,
21
+ format: "esm",
22
+ outdir: distDir,
23
+ outExtension: { ".js": ".mjs" },
24
+ logLevel: "info",
25
+ // Some packages may not be bundleable, so we externalize them, we can add more here as needed.
26
+ // Some of the packages below may not be imported or installed, but we're adding them in case they are in the future.
27
+ // Examples of unbundleable packages:
28
+ // - uses native modules and loads them dynamically (e.g. sharp)
29
+ // - use path traversal to read files (e.g. @google-cloud/secret-manager loads sibling .proto files)
30
+ external: [
31
+ "*.node",
32
+ "sharp",
33
+ "better-sqlite3",
34
+ "sqlite3",
35
+ "canvas",
36
+ "bcrypt",
37
+ "argon2",
38
+ "fsevents",
39
+ "re2",
40
+ "farmhash",
41
+ "xxhash-addon",
42
+ "bufferutil",
43
+ "utf-8-validate",
44
+ "ssh2",
45
+ "cpu-features",
46
+ "dtrace-provider",
47
+ "isolated-vm",
48
+ "lightningcss",
49
+ "pg-native",
50
+ "oracledb",
51
+ "mongodb-client-encryption",
52
+ "nodemailer",
53
+ "handlebars",
54
+ "knex",
55
+ "typeorm",
56
+ "protobufjs",
57
+ "onnxruntime-node",
58
+ "@tensorflow/*",
59
+ "@prisma/client",
60
+ "@mikro-orm/*",
61
+ "@grpc/*",
62
+ "@swc/*",
63
+ "@aws-sdk/*",
64
+ "@azure/*",
65
+ "@opentelemetry/*",
66
+ "@google-cloud/*",
67
+ "@google/*",
68
+ "googleapis",
69
+ "firebase-admin",
70
+ "@parcel/watcher",
71
+ "@sentry/profiling-node",
72
+ "@tree-sitter/*",
73
+ "aws-sdk",
74
+ "classic-level",
75
+ "dd-trace",
76
+ "ffi-napi",
77
+ "grpc",
78
+ "hiredis",
79
+ "kerberos",
80
+ "leveldown",
81
+ "miniflare",
82
+ "mysql2",
83
+ "newrelic",
84
+ "odbc",
85
+ "piscina",
86
+ "realm",
87
+ "ref-napi",
88
+ "rocksdb",
89
+ "sass-embedded",
90
+ "sequelize",
91
+ "serialport",
92
+ "snappy",
93
+ "tinypool",
94
+ "usb",
95
+ "workerd",
96
+ "wrangler",
97
+ "zeromq",
98
+ "zeromq-prebuilt",
99
+ "playwright",
100
+ "puppeteer",
101
+ "puppeteer-core",
102
+ "electron",
103
+ ],
104
+ sourcemap: "linked",
105
+ plugins: [
106
+ // pino relies on workers to handle logging, instead of externalizing it we use a plugin to handle it
107
+ esbuildPluginPino({ transports: ["pino-pretty"] })
108
+ ],
109
+ // Make sure packages that are cjs only (e.g. express) but are bundled continue to work in our esm output file
110
+ banner: {
111
+ js: `import { createRequire as __bannerCrReq } from 'node:module';
112
+ import __bannerPath from 'node:path';
113
+ import __bannerUrl from 'node:url';
114
+
115
+ globalThis.require = __bannerCrReq(import.meta.url);
116
+ globalThis.__filename = __bannerUrl.fileURLToPath(import.meta.url);
117
+ globalThis.__dirname = __bannerPath.dirname(globalThis.__filename);
118
+ `,
119
+ },
120
+ });
121
+ }
122
+
123
+ buildAll().catch((err) => {
124
+ console.error(err);
125
+ process.exit(1);
126
+ });
artifacts/api-server/dist/index.mjs ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/index.mjs.map ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/pino-file.mjs ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/pino-file.mjs.map ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/pino-pretty.mjs ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/pino-pretty.mjs.map ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/pino-worker.mjs ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/pino-worker.mjs.map ADDED
The diff for this file is too large to render. See raw diff
 
artifacts/api-server/dist/thread-stream-worker.mjs ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRequire as __bannerCrReq } from 'node:module';
2
+ import __bannerPath from 'node:path';
3
+ import __bannerUrl from 'node:url';
4
+
5
+ globalThis.require = __bannerCrReq(import.meta.url);
6
+ globalThis.__filename = __bannerUrl.fileURLToPath(import.meta.url);
7
+ globalThis.__dirname = __bannerPath.dirname(globalThis.__filename);
8
+
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
11
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
12
+ }) : x)(function(x) {
13
+ if (typeof require !== "undefined") return require.apply(this, arguments);
14
+ throw Error('Dynamic require of "' + x + '" is not supported');
15
+ });
16
+ var __commonJS = (cb, mod) => function __require2() {
17
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
18
+ };
19
+
20
+ // ../../node_modules/.pnpm/real-require@0.2.0/node_modules/real-require/src/index.js
21
+ var require_src = __commonJS({
22
+ "../../node_modules/.pnpm/real-require@0.2.0/node_modules/real-require/src/index.js"(exports, module) {
23
+ var realImport2 = new Function("modulePath", "return import(modulePath)");
24
+ function realRequire2(modulePath) {
25
+ if (typeof __non_webpack__require__ === "function") {
26
+ return __non_webpack__require__(modulePath);
27
+ }
28
+ return __require(modulePath);
29
+ }
30
+ module.exports = { realImport: realImport2, realRequire: realRequire2 };
31
+ }
32
+ });
33
+
34
+ // ../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/indexes.js
35
+ var require_indexes = __commonJS({
36
+ "../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/indexes.js"(exports, module) {
37
+ "use strict";
38
+ var WRITE_INDEX2 = 4;
39
+ var READ_INDEX2 = 8;
40
+ module.exports = {
41
+ WRITE_INDEX: WRITE_INDEX2,
42
+ READ_INDEX: READ_INDEX2
43
+ };
44
+ }
45
+ });
46
+
47
+ // ../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/wait.js
48
+ var require_wait = __commonJS({
49
+ "../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/wait.js"(exports, module) {
50
+ "use strict";
51
+ var MAX_TIMEOUT = 1e3;
52
+ function wait(state2, index, expected, timeout, done) {
53
+ const max = Date.now() + timeout;
54
+ let current = Atomics.load(state2, index);
55
+ if (current === expected) {
56
+ done(null, "ok");
57
+ return;
58
+ }
59
+ let prior = current;
60
+ const check = (backoff) => {
61
+ if (Date.now() > max) {
62
+ done(null, "timed-out");
63
+ } else {
64
+ setTimeout(() => {
65
+ prior = current;
66
+ current = Atomics.load(state2, index);
67
+ if (current === prior) {
68
+ check(backoff >= MAX_TIMEOUT ? MAX_TIMEOUT : backoff * 2);
69
+ } else {
70
+ if (current === expected) done(null, "ok");
71
+ else done(null, "not-equal");
72
+ }
73
+ }, backoff);
74
+ }
75
+ };
76
+ check(1);
77
+ }
78
+ function waitDiff2(state2, index, expected, timeout, done) {
79
+ const max = Date.now() + timeout;
80
+ let current = Atomics.load(state2, index);
81
+ if (current !== expected) {
82
+ done(null, "ok");
83
+ return;
84
+ }
85
+ const check = (backoff) => {
86
+ if (Date.now() > max) {
87
+ done(null, "timed-out");
88
+ } else {
89
+ setTimeout(() => {
90
+ current = Atomics.load(state2, index);
91
+ if (current !== expected) {
92
+ done(null, "ok");
93
+ } else {
94
+ check(backoff >= MAX_TIMEOUT ? MAX_TIMEOUT : backoff * 2);
95
+ }
96
+ }, backoff);
97
+ }
98
+ };
99
+ check(1);
100
+ }
101
+ module.exports = { wait, waitDiff: waitDiff2 };
102
+ }
103
+ });
104
+
105
+ // ../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/worker.js
106
+ var { realImport, realRequire } = require_src();
107
+ var { workerData, parentPort } = __require("worker_threads");
108
+ var { WRITE_INDEX, READ_INDEX } = require_indexes();
109
+ var { waitDiff } = require_wait();
110
+ var {
111
+ dataBuf,
112
+ filename,
113
+ stateBuf
114
+ } = workerData;
115
+ var destination;
116
+ var state = new Int32Array(stateBuf);
117
+ var data = Buffer.from(dataBuf);
118
+ async function start() {
119
+ let worker;
120
+ try {
121
+ if (filename.endsWith(".ts") || filename.endsWith(".cts")) {
122
+ if (!process[/* @__PURE__ */ Symbol.for("ts-node.register.instance")]) {
123
+ realRequire("ts-node/register");
124
+ } else if (process.env.TS_NODE_DEV) {
125
+ realRequire("ts-node-dev");
126
+ }
127
+ worker = realRequire(decodeURIComponent(filename.replace(process.platform === "win32" ? "file:///" : "file://", "")));
128
+ } else {
129
+ worker = await realImport(filename);
130
+ }
131
+ } catch (error) {
132
+ if ((error.code === "ENOTDIR" || error.code === "ERR_MODULE_NOT_FOUND") && filename.startsWith("file://")) {
133
+ worker = realRequire(decodeURIComponent(filename.replace("file://", "")));
134
+ } else if (error.code === void 0 || error.code === "ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING") {
135
+ try {
136
+ worker = realRequire(decodeURIComponent(filename.replace(process.platform === "win32" ? "file:///" : "file://", "")));
137
+ } catch {
138
+ throw error;
139
+ }
140
+ } else {
141
+ throw error;
142
+ }
143
+ }
144
+ if (typeof worker === "object") worker = worker.default;
145
+ if (typeof worker === "object") worker = worker.default;
146
+ destination = await worker(workerData.workerData);
147
+ destination.on("error", function(err) {
148
+ Atomics.store(state, WRITE_INDEX, -2);
149
+ Atomics.notify(state, WRITE_INDEX);
150
+ Atomics.store(state, READ_INDEX, -2);
151
+ Atomics.notify(state, READ_INDEX);
152
+ parentPort.postMessage({
153
+ code: "ERROR",
154
+ err
155
+ });
156
+ });
157
+ destination.on("close", function() {
158
+ const end = Atomics.load(state, WRITE_INDEX);
159
+ Atomics.store(state, READ_INDEX, end);
160
+ Atomics.notify(state, READ_INDEX);
161
+ setImmediate(() => {
162
+ process.exit(0);
163
+ });
164
+ });
165
+ }
166
+ start().then(function() {
167
+ parentPort.postMessage({
168
+ code: "READY"
169
+ });
170
+ process.nextTick(run);
171
+ });
172
+ function run() {
173
+ const current = Atomics.load(state, READ_INDEX);
174
+ const end = Atomics.load(state, WRITE_INDEX);
175
+ if (end === current) {
176
+ if (end === data.length) {
177
+ waitDiff(state, READ_INDEX, end, Infinity, run);
178
+ } else {
179
+ waitDiff(state, WRITE_INDEX, end, Infinity, run);
180
+ }
181
+ return;
182
+ }
183
+ if (end === -1) {
184
+ destination.end();
185
+ return;
186
+ }
187
+ const toWrite = data.toString("utf8", current, end);
188
+ const res = destination.write(toWrite);
189
+ if (res) {
190
+ Atomics.store(state, READ_INDEX, end);
191
+ Atomics.notify(state, READ_INDEX);
192
+ setImmediate(run);
193
+ } else {
194
+ destination.once("drain", function() {
195
+ Atomics.store(state, READ_INDEX, end);
196
+ Atomics.notify(state, READ_INDEX);
197
+ run();
198
+ });
199
+ }
200
+ }
201
+ process.on("unhandledRejection", function(err) {
202
+ parentPort.postMessage({
203
+ code: "ERROR",
204
+ err
205
+ });
206
+ process.exit(1);
207
+ });
208
+ process.on("uncaughtException", function(err) {
209
+ parentPort.postMessage({
210
+ code: "ERROR",
211
+ err
212
+ });
213
+ process.exit(1);
214
+ });
215
+ process.once("exit", (exitCode) => {
216
+ if (exitCode !== 0) {
217
+ process.exit(exitCode);
218
+ return;
219
+ }
220
+ if (destination?.writableNeedDrain && !destination?.writableEnded) {
221
+ parentPort.postMessage({
222
+ code: "WARNING",
223
+ err: new Error("ThreadStream: process exited before destination stream was drained. this may indicate that the destination stream try to write to a another missing stream")
224
+ });
225
+ }
226
+ process.exit(0);
227
+ });
228
+ //# sourceMappingURL=thread-stream-worker.mjs.map
artifacts/api-server/dist/thread-stream-worker.mjs.map ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../node_modules/.pnpm/real-require@0.2.0/node_modules/real-require/src/index.js", "../../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/indexes.js", "../../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/wait.js", "../../../node_modules/.pnpm/thread-stream@3.1.0/node_modules/thread-stream/lib/worker.js"],
4
+ "sourcesContent": ["/* eslint-disable no-new-func, camelcase */\n/* globals __non_webpack__require__ */\n\nconst realImport = new Function('modulePath', 'return import(modulePath)')\n\nfunction realRequire(modulePath) {\n if (typeof __non_webpack__require__ === 'function') {\n return __non_webpack__require__(modulePath)\n }\n\n return require(modulePath)\n}\n\nmodule.exports = { realImport, realRequire }\n", "'use strict'\n\nconst WRITE_INDEX = 4\nconst READ_INDEX = 8\n\nmodule.exports = {\n WRITE_INDEX,\n READ_INDEX\n}\n", "'use strict'\n\nconst MAX_TIMEOUT = 1000\n\nfunction wait (state, index, expected, timeout, done) {\n const max = Date.now() + timeout\n let current = Atomics.load(state, index)\n if (current === expected) {\n done(null, 'ok')\n return\n }\n let prior = current\n const check = (backoff) => {\n if (Date.now() > max) {\n done(null, 'timed-out')\n } else {\n setTimeout(() => {\n prior = current\n current = Atomics.load(state, index)\n if (current === prior) {\n check(backoff >= MAX_TIMEOUT ? MAX_TIMEOUT : backoff * 2)\n } else {\n if (current === expected) done(null, 'ok')\n else done(null, 'not-equal')\n }\n }, backoff)\n }\n }\n check(1)\n}\n\n// let waitDiffCount = 0\nfunction waitDiff (state, index, expected, timeout, done) {\n // const id = waitDiffCount++\n // process._rawDebug(`>>> waitDiff ${id}`)\n const max = Date.now() + timeout\n let current = Atomics.load(state, index)\n if (current !== expected) {\n done(null, 'ok')\n return\n }\n const check = (backoff) => {\n // process._rawDebug(`${id} ${index} current ${current} expected ${expected}`)\n // process._rawDebug('' + backoff)\n if (Date.now() > max) {\n done(null, 'timed-out')\n } else {\n setTimeout(() => {\n current = Atomics.load(state, index)\n if (current !== expected) {\n done(null, 'ok')\n } else {\n check(backoff >= MAX_TIMEOUT ? MAX_TIMEOUT : backoff * 2)\n }\n }, backoff)\n }\n }\n check(1)\n}\n\nmodule.exports = { wait, waitDiff }\n", "'use strict'\n\nconst { realImport, realRequire } = require('real-require')\nconst { workerData, parentPort } = require('worker_threads')\nconst { WRITE_INDEX, READ_INDEX } = require('./indexes')\nconst { waitDiff } = require('./wait')\n\nconst {\n dataBuf,\n filename,\n stateBuf\n} = workerData\n\nlet destination\n\nconst state = new Int32Array(stateBuf)\nconst data = Buffer.from(dataBuf)\n\nasync function start () {\n let worker\n try {\n if (filename.endsWith('.ts') || filename.endsWith('.cts')) {\n // TODO: add support for the TSM modules loader ( https://github.com/lukeed/tsm ).\n if (!process[Symbol.for('ts-node.register.instance')]) {\n realRequire('ts-node/register')\n } else if (process.env.TS_NODE_DEV) {\n realRequire('ts-node-dev')\n }\n // TODO: Support ES imports once tsc, tap & ts-node provide better compatibility guarantees.\n // Remove extra forwardslash on Windows\n worker = realRequire(decodeURIComponent(filename.replace(process.platform === 'win32' ? 'file:///' : 'file://', '')))\n } else {\n worker = (await realImport(filename))\n }\n } catch (error) {\n // A yarn user that tries to start a ThreadStream for an external module\n // provides a filename pointing to a zip file.\n // eg. require.resolve('pino-elasticsearch') // returns /foo/pino-elasticsearch-npm-6.1.0-0c03079478-6915435172.zip/bar.js\n // The `import` will fail to try to load it.\n // This catch block executes the `require` fallback to load the module correctly.\n // In fact, yarn modifies the `require` function to manage the zipped path.\n // More details at https://github.com/pinojs/pino/pull/1113\n // The error codes may change based on the node.js version (ENOTDIR > 12, ERR_MODULE_NOT_FOUND <= 12 )\n if ((error.code === 'ENOTDIR' || error.code === 'ERR_MODULE_NOT_FOUND') &&\n filename.startsWith('file://')) {\n worker = realRequire(decodeURIComponent(filename.replace('file://', '')))\n } else if (error.code === undefined || error.code === 'ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING') {\n // When bundled with pkg, an undefined error is thrown when called with realImport\n // When bundled with pkg and using node v20, an ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING error is thrown when called with realImport\n // More info at: https://github.com/pinojs/thread-stream/issues/143\n try {\n worker = realRequire(decodeURIComponent(filename.replace(process.platform === 'win32' ? 'file:///' : 'file://', '')))\n } catch {\n throw error\n }\n } else {\n throw error\n }\n }\n\n // Depending on how the default export is performed, and on how the code is\n // transpiled, we may find cases of two nested \"default\" objects.\n // See https://github.com/pinojs/pino/issues/1243#issuecomment-982774762\n if (typeof worker === 'object') worker = worker.default\n if (typeof worker === 'object') worker = worker.default\n\n destination = await worker(workerData.workerData)\n\n destination.on('error', function (err) {\n Atomics.store(state, WRITE_INDEX, -2)\n Atomics.notify(state, WRITE_INDEX)\n\n Atomics.store(state, READ_INDEX, -2)\n Atomics.notify(state, READ_INDEX)\n\n parentPort.postMessage({\n code: 'ERROR',\n err\n })\n })\n\n destination.on('close', function () {\n // process._rawDebug('worker close emitted')\n const end = Atomics.load(state, WRITE_INDEX)\n Atomics.store(state, READ_INDEX, end)\n Atomics.notify(state, READ_INDEX)\n setImmediate(() => {\n process.exit(0)\n })\n })\n}\n\n// No .catch() handler,\n// in case there is an error it goes\n// to unhandledRejection\nstart().then(function () {\n parentPort.postMessage({\n code: 'READY'\n })\n\n process.nextTick(run)\n})\n\nfunction run () {\n const current = Atomics.load(state, READ_INDEX)\n const end = Atomics.load(state, WRITE_INDEX)\n\n // process._rawDebug(`pre state ${current} ${end}`)\n\n if (end === current) {\n if (end === data.length) {\n waitDiff(state, READ_INDEX, end, Infinity, run)\n } else {\n waitDiff(state, WRITE_INDEX, end, Infinity, run)\n }\n return\n }\n\n // process._rawDebug(`post state ${current} ${end}`)\n\n if (end === -1) {\n // process._rawDebug('end')\n destination.end()\n return\n }\n\n const toWrite = data.toString('utf8', current, end)\n // process._rawDebug('worker writing: ' + toWrite)\n\n const res = destination.write(toWrite)\n\n if (res) {\n Atomics.store(state, READ_INDEX, end)\n Atomics.notify(state, READ_INDEX)\n setImmediate(run)\n } else {\n destination.once('drain', function () {\n Atomics.store(state, READ_INDEX, end)\n Atomics.notify(state, READ_INDEX)\n run()\n })\n }\n}\n\nprocess.on('unhandledRejection', function (err) {\n parentPort.postMessage({\n code: 'ERROR',\n err\n })\n process.exit(1)\n})\n\nprocess.on('uncaughtException', function (err) {\n parentPort.postMessage({\n code: 'ERROR',\n err\n })\n process.exit(1)\n})\n\nprocess.once('exit', exitCode => {\n if (exitCode !== 0) {\n process.exit(exitCode)\n return\n }\n if (destination?.writableNeedDrain && !destination?.writableEnded) {\n parentPort.postMessage({\n code: 'WARNING',\n err: new Error('ThreadStream: process exited before destination stream was drained. this may indicate that the destination stream try to write to a another missing stream')\n })\n }\n\n process.exit(0)\n})\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAGA,QAAMA,cAAa,IAAI,SAAS,cAAc,2BAA2B;AAEzE,aAASC,aAAY,YAAY;AAC/B,UAAI,OAAO,6BAA6B,YAAY;AAClD,eAAO,yBAAyB,UAAU;AAAA,MAC5C;AAEA,aAAO,UAAQ,UAAU;AAAA,IAC3B;AAEA,WAAO,UAAU,EAAE,YAAAD,aAAY,aAAAC,aAAY;AAAA;AAAA;;;ACb3C;AAAA;AAAA;AAEA,QAAMC,eAAc;AACpB,QAAMC,cAAa;AAEnB,WAAO,UAAU;AAAA,MACf,aAAAD;AAAA,MACA,YAAAC;AAAA,IACF;AAAA;AAAA;;;ACRA;AAAA;AAAA;AAEA,QAAM,cAAc;AAEpB,aAAS,KAAMC,QAAO,OAAO,UAAU,SAAS,MAAM;AACpD,YAAM,MAAM,KAAK,IAAI,IAAI;AACzB,UAAI,UAAU,QAAQ,KAAKA,QAAO,KAAK;AACvC,UAAI,YAAY,UAAU;AACxB,aAAK,MAAM,IAAI;AACf;AAAA,MACF;AACA,UAAI,QAAQ;AACZ,YAAM,QAAQ,CAAC,YAAY;AACzB,YAAI,KAAK,IAAI,IAAI,KAAK;AACpB,eAAK,MAAM,WAAW;AAAA,QACxB,OAAO;AACL,qBAAW,MAAM;AACf,oBAAQ;AACR,sBAAU,QAAQ,KAAKA,QAAO,KAAK;AACnC,gBAAI,YAAY,OAAO;AACrB,oBAAM,WAAW,cAAc,cAAc,UAAU,CAAC;AAAA,YAC1D,OAAO;AACL,kBAAI,YAAY,SAAU,MAAK,MAAM,IAAI;AAAA,kBACpC,MAAK,MAAM,WAAW;AAAA,YAC7B;AAAA,UACF,GAAG,OAAO;AAAA,QACZ;AAAA,MACF;AACA,YAAM,CAAC;AAAA,IACT;AAGA,aAASC,UAAUD,QAAO,OAAO,UAAU,SAAS,MAAM;AAGxD,YAAM,MAAM,KAAK,IAAI,IAAI;AACzB,UAAI,UAAU,QAAQ,KAAKA,QAAO,KAAK;AACvC,UAAI,YAAY,UAAU;AACxB,aAAK,MAAM,IAAI;AACf;AAAA,MACF;AACA,YAAM,QAAQ,CAAC,YAAY;AAGzB,YAAI,KAAK,IAAI,IAAI,KAAK;AACpB,eAAK,MAAM,WAAW;AAAA,QACxB,OAAO;AACL,qBAAW,MAAM;AACf,sBAAU,QAAQ,KAAKA,QAAO,KAAK;AACnC,gBAAI,YAAY,UAAU;AACxB,mBAAK,MAAM,IAAI;AAAA,YACjB,OAAO;AACL,oBAAM,WAAW,cAAc,cAAc,UAAU,CAAC;AAAA,YAC1D;AAAA,UACF,GAAG,OAAO;AAAA,QACZ;AAAA,MACF;AACA,YAAM,CAAC;AAAA,IACT;AAEA,WAAO,UAAU,EAAE,MAAM,UAAAC,UAAS;AAAA;AAAA;;;AC1DlC,IAAM,EAAE,YAAY,YAAY,IAAI;AACpC,IAAM,EAAE,YAAY,WAAW,IAAI,UAAQ,gBAAgB;AAC3D,IAAM,EAAE,aAAa,WAAW,IAAI;AACpC,IAAM,EAAE,SAAS,IAAI;AAErB,IAAM;AAAA,EACJ;AAAA,EACA;AAAA,EACA;AACF,IAAI;AAEJ,IAAI;AAEJ,IAAM,QAAQ,IAAI,WAAW,QAAQ;AACrC,IAAM,OAAO,OAAO,KAAK,OAAO;AAEhC,eAAe,QAAS;AACtB,MAAI;AACJ,MAAI;AACF,QAAI,SAAS,SAAS,KAAK,KAAK,SAAS,SAAS,MAAM,GAAG;AAEzD,UAAI,CAAC,QAAQ,uBAAO,IAAI,2BAA2B,CAAC,GAAG;AACrD,oBAAY,kBAAkB;AAAA,MAChC,WAAW,QAAQ,IAAI,aAAa;AAClC,oBAAY,aAAa;AAAA,MAC3B;AAGA,eAAS,YAAY,mBAAmB,SAAS,QAAQ,QAAQ,aAAa,UAAU,aAAa,WAAW,EAAE,CAAC,CAAC;AAAA,IACtH,OAAO;AACL,eAAU,MAAM,WAAW,QAAQ;AAAA,IACrC;AAAA,EACF,SAAS,OAAO;AASd,SAAK,MAAM,SAAS,aAAa,MAAM,SAAS,2BAC/C,SAAS,WAAW,SAAS,GAAG;AAC/B,eAAS,YAAY,mBAAmB,SAAS,QAAQ,WAAW,EAAE,CAAC,CAAC;AAAA,IAC1E,WAAW,MAAM,SAAS,UAAa,MAAM,SAAS,0CAA0C;AAI9F,UAAI;AACF,iBAAS,YAAY,mBAAmB,SAAS,QAAQ,QAAQ,aAAa,UAAU,aAAa,WAAW,EAAE,CAAC,CAAC;AAAA,MACtH,QAAQ;AACN,cAAM;AAAA,MACR;AAAA,IACF,OAAO;AACL,YAAM;AAAA,IACR;AAAA,EACF;AAKA,MAAI,OAAO,WAAW,SAAU,UAAS,OAAO;AAChD,MAAI,OAAO,WAAW,SAAU,UAAS,OAAO;AAEhD,gBAAc,MAAM,OAAO,WAAW,UAAU;AAEhD,cAAY,GAAG,SAAS,SAAU,KAAK;AACrC,YAAQ,MAAM,OAAO,aAAa,EAAE;AACpC,YAAQ,OAAO,OAAO,WAAW;AAEjC,YAAQ,MAAM,OAAO,YAAY,EAAE;AACnC,YAAQ,OAAO,OAAO,UAAU;AAEhC,eAAW,YAAY;AAAA,MACrB,MAAM;AAAA,MACN;AAAA,IACF,CAAC;AAAA,EACH,CAAC;AAED,cAAY,GAAG,SAAS,WAAY;AAElC,UAAM,MAAM,QAAQ,KAAK,OAAO,WAAW;AAC3C,YAAQ,MAAM,OAAO,YAAY,GAAG;AACpC,YAAQ,OAAO,OAAO,UAAU;AAChC,iBAAa,MAAM;AACjB,cAAQ,KAAK,CAAC;AAAA,IAChB,CAAC;AAAA,EACH,CAAC;AACH;AAKA,MAAM,EAAE,KAAK,WAAY;AACvB,aAAW,YAAY;AAAA,IACrB,MAAM;AAAA,EACR,CAAC;AAED,UAAQ,SAAS,GAAG;AACtB,CAAC;AAED,SAAS,MAAO;AACd,QAAM,UAAU,QAAQ,KAAK,OAAO,UAAU;AAC9C,QAAM,MAAM,QAAQ,KAAK,OAAO,WAAW;AAI3C,MAAI,QAAQ,SAAS;AACnB,QAAI,QAAQ,KAAK,QAAQ;AACvB,eAAS,OAAO,YAAY,KAAK,UAAU,GAAG;AAAA,IAChD,OAAO;AACL,eAAS,OAAO,aAAa,KAAK,UAAU,GAAG;AAAA,IACjD;AACA;AAAA,EACF;AAIA,MAAI,QAAQ,IAAI;AAEd,gBAAY,IAAI;AAChB;AAAA,EACF;AAEA,QAAM,UAAU,KAAK,SAAS,QAAQ,SAAS,GAAG;AAGlD,QAAM,MAAM,YAAY,MAAM,OAAO;AAErC,MAAI,KAAK;AACP,YAAQ,MAAM,OAAO,YAAY,GAAG;AACpC,YAAQ,OAAO,OAAO,UAAU;AAChC,iBAAa,GAAG;AAAA,EAClB,OAAO;AACL,gBAAY,KAAK,SAAS,WAAY;AACpC,cAAQ,MAAM,OAAO,YAAY,GAAG;AACpC,cAAQ,OAAO,OAAO,UAAU;AAChC,UAAI;AAAA,IACN,CAAC;AAAA,EACH;AACF;AAEA,QAAQ,GAAG,sBAAsB,SAAU,KAAK;AAC9C,aAAW,YAAY;AAAA,IACrB,MAAM;AAAA,IACN;AAAA,EACF,CAAC;AACD,UAAQ,KAAK,CAAC;AAChB,CAAC;AAED,QAAQ,GAAG,qBAAqB,SAAU,KAAK;AAC7C,aAAW,YAAY;AAAA,IACrB,MAAM;AAAA,IACN;AAAA,EACF,CAAC;AACD,UAAQ,KAAK,CAAC;AAChB,CAAC;AAED,QAAQ,KAAK,QAAQ,cAAY;AAC/B,MAAI,aAAa,GAAG;AAClB,YAAQ,KAAK,QAAQ;AACrB;AAAA,EACF;AACA,MAAI,aAAa,qBAAqB,CAAC,aAAa,eAAe;AACjE,eAAW,YAAY;AAAA,MACrB,MAAM;AAAA,MACN,KAAK,IAAI,MAAM,4JAA4J;AAAA,IAC7K,CAAC;AAAA,EACH;AAEA,UAAQ,KAAK,CAAC;AAChB,CAAC;",
6
+ "names": ["realImport", "realRequire", "WRITE_INDEX", "READ_INDEX", "state", "waitDiff"]
7
+ }
artifacts/api-server/package.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@workspace/api-server",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "export NODE_ENV=development && pnpm run build && pnpm run start",
8
+ "build": "node ./build.mjs",
9
+ "start": "node --enable-source-maps ./dist/index.mjs",
10
+ "typecheck": "tsc -p tsconfig.json --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@aws-sdk/client-s3": "^3.1024.0",
14
+ "@aws-sdk/lib-storage": "^3.1024.0",
15
+ "@clerk/express": "^2.0.8",
16
+ "@google-cloud/storage": "^7.19.0",
17
+ "@workspace/api-zod": "workspace:*",
18
+ "@workspace/db": "workspace:*",
19
+ "bcryptjs": "^3.0.3",
20
+ "cookie-parser": "^1.4.7",
21
+ "cors": "^2",
22
+ "crypto": "^1.0.1",
23
+ "drizzle-orm": "catalog:",
24
+ "express": "^5",
25
+ "google-auth-library": "^10.6.2",
26
+ "http-proxy-middleware": "^3.0.5",
27
+ "jsonwebtoken": "^9.0.3",
28
+ "multer": "^2.1.1",
29
+ "pino": "^9",
30
+ "pino-http": "^10"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bcryptjs": "^3.0.0",
34
+ "@types/cookie-parser": "^1.4.10",
35
+ "@types/cors": "^2.8.19",
36
+ "@types/express": "^5.0.6",
37
+ "@types/jsonwebtoken": "^9.0.10",
38
+ "@types/multer": "^2.1.0",
39
+ "@types/node": "catalog:",
40
+ "esbuild": "^0.27.3",
41
+ "esbuild-plugin-pino": "^2.3.3",
42
+ "pino-pretty": "^13",
43
+ "thread-stream": "3.1.0"
44
+ }
45
+ }
artifacts/api-server/src/app.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express, { type Express } from "express";
2
+ import cors from "cors";
3
+ import cookieParser from "cookie-parser";
4
+ import pinoHttp from "pino-http";
5
+ import router from "./routes";
6
+ import openaiRouter from "./routes/openai";
7
+ import publicRouter from "./routes/public";
8
+ import accountsRouter from "./routes/accounts";
9
+ import { logger } from "./lib/logger";
10
+
11
+ const app: Express = express();
12
+
13
+ app.use(
14
+ pinoHttp({
15
+ logger,
16
+ serializers: {
17
+ req(req) {
18
+ return {
19
+ id: req.id,
20
+ method: req.method,
21
+ url: req.url?.split("?")[0],
22
+ };
23
+ },
24
+ res(res) {
25
+ return {
26
+ statusCode: res.statusCode,
27
+ };
28
+ },
29
+ },
30
+ }),
31
+ );
32
+
33
+ app.use(cors({ credentials: true, origin: true }));
34
+ app.use(cookieParser());
35
+ app.use(express.json({ limit: "20mb" }));
36
+ app.use(express.urlencoded({ extended: true, limit: "20mb" }));
37
+
38
+ app.use("/api/public", publicRouter);
39
+ app.use("/api/admin/accounts", accountsRouter);
40
+ app.use("/api", router);
41
+ app.use("/v1", openaiRouter);
42
+ app.use("/api/v1", openaiRouter);
43
+
44
+ export default app;
artifacts/api-server/src/captcha.ts ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Turnstile token resolver — 三層優先策略:
3
+ *
4
+ * 1. 自建 Playwright 求解器(playwright_solver_url 已設定)
5
+ * 2. CapSolver API(capsolver_api_key 已設定)
6
+ * 3. 回退 bypass token(必然失敗,但有清楚錯誤訊息)
7
+ *
8
+ * Token 快取 4.5 分鐘(geminigen.ai Turnstile token 約 5 分鐘有效)。
9
+ */
10
+
11
+ import { db, configTable } from "@workspace/db";
12
+ import { eq } from "drizzle-orm";
13
+
14
+ const CAPSOLVER_URL = "https://api.capsolver.com";
15
+ const YESCAPTCHA_URL = "https://api.yescaptcha.com";
16
+ const TURNSTILE_SITE_KEY = "0x4AAAAAACDBydnKT0zYzh2H";
17
+ const TURNSTILE_PAGE_URL = "https://geminigen.ai";
18
+ const TOKEN_TTL_MS = 270_000; // 4.5 minutes
19
+
20
+ interface CachedToken {
21
+ token: string;
22
+ expiresAt: number;
23
+ }
24
+
25
+ let cachedToken: CachedToken | null = null;
26
+
27
+ // ─── DB helper ────────────────────────────────────────────────────────────────
28
+
29
+ async function getConfig(key: string): Promise<string | null> {
30
+ try {
31
+ const rows = await db
32
+ .select()
33
+ .from(configTable)
34
+ .where(eq(configTable.key, key))
35
+ .limit(1);
36
+ return rows[0]?.value?.trim() || null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // ─── Strategy 1: Self-hosted Playwright solver ────────────────────────────────
43
+
44
+ async function solveWithPlaywright(solverUrl: string): Promise<string | null> {
45
+ try {
46
+ const solverSecret = await getConfig("playwright_solver_secret");
47
+ const headers: Record<string, string> = {
48
+ "Content-Type": "application/json",
49
+ };
50
+ if (solverSecret) headers["X-Solver-Secret"] = solverSecret;
51
+
52
+ console.log(`[captcha] Calling Playwright solver: ${solverUrl}`);
53
+ const resp = await fetch(solverUrl, {
54
+ method: "POST",
55
+ headers,
56
+ body: JSON.stringify({}),
57
+ signal: AbortSignal.timeout(55_000),
58
+ });
59
+
60
+ const data = (await resp.json()) as {
61
+ token?: string;
62
+ cached?: boolean;
63
+ solvedInMs?: number;
64
+ error?: string;
65
+ };
66
+
67
+ if (!resp.ok || !data.token) {
68
+ console.error(
69
+ `[captcha] Playwright solver error (${resp.status}): ${data.error || "no token"}`
70
+ );
71
+ return null;
72
+ }
73
+
74
+ console.log(
75
+ `[captcha] Playwright solved — cached=${data.cached}, ms=${data.solvedInMs ?? "N/A"}`
76
+ );
77
+ return data.token;
78
+ } catch (err: any) {
79
+ console.error(`[captcha] Playwright solver fetch error: ${err?.message}`);
80
+ return null;
81
+ }
82
+ }
83
+
84
+ // ─── Strategy 2: CapSolver API ────────────────────────────────────────────────
85
+
86
+ async function solveWithCapsolver(apiKey: string): Promise<string | null> {
87
+ try {
88
+ const createResp = await fetch(`${CAPSOLVER_URL}/createTask`, {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/json" },
91
+ body: JSON.stringify({
92
+ clientKey: apiKey,
93
+ task: {
94
+ type: "AntiTurnstileTaskProxyless",
95
+ websiteURL: TURNSTILE_PAGE_URL,
96
+ websiteKey: TURNSTILE_SITE_KEY,
97
+ },
98
+ }),
99
+ });
100
+
101
+ const createData = (await createResp.json()) as {
102
+ errorId?: number;
103
+ errorCode?: string;
104
+ errorDescription?: string;
105
+ taskId?: string;
106
+ };
107
+
108
+ if (createData.errorId || !createData.taskId) {
109
+ console.error(
110
+ `[captcha] CapSolver create error: ${createData.errorCode} — ${createData.errorDescription}`
111
+ );
112
+ return null;
113
+ }
114
+
115
+ const taskId = createData.taskId;
116
+ console.log(`[captcha] CapSolver task created: ${taskId}`);
117
+
118
+ const maxWait = 120_000;
119
+ const start = Date.now();
120
+ while (Date.now() - start < maxWait) {
121
+ await new Promise((r) => setTimeout(r, 3000));
122
+
123
+ const resultResp = await fetch(`${CAPSOLVER_URL}/getTaskResult`, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({ clientKey: apiKey, taskId }),
127
+ });
128
+
129
+ const resultData = (await resultResp.json()) as {
130
+ errorId?: number;
131
+ errorCode?: string;
132
+ status?: string;
133
+ solution?: { token?: string };
134
+ };
135
+
136
+ if (resultData.errorId) {
137
+ console.error(`[captcha] CapSolver result error: ${resultData.errorCode}`);
138
+ return null;
139
+ }
140
+
141
+ if (resultData.status === "ready") {
142
+ const token = resultData.solution?.token;
143
+ if (token) {
144
+ console.log(`[captcha] CapSolver solved! Token: ${token.substring(0, 20)}...`);
145
+ return token;
146
+ }
147
+ return null;
148
+ }
149
+ }
150
+
151
+ console.error("[captcha] CapSolver timed out after 120s");
152
+ return null;
153
+ } catch (err: any) {
154
+ console.error(`[captcha] CapSolver error: ${err?.message}`);
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // ─── Strategy 3: YesCaptcha API ──��───────────────────────────────────────────
160
+
161
+ async function solveWithYesCaptcha(apiKey: string): Promise<string | null> {
162
+ try {
163
+ const createResp = await fetch(`${YESCAPTCHA_URL}/createTask`, {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/json" },
166
+ body: JSON.stringify({
167
+ clientKey: apiKey,
168
+ task: {
169
+ type: "TurnstileTaskProxylessM1",
170
+ websiteURL: TURNSTILE_PAGE_URL,
171
+ websiteKey: TURNSTILE_SITE_KEY,
172
+ },
173
+ }),
174
+ });
175
+
176
+ const createData = (await createResp.json()) as {
177
+ errorId?: number;
178
+ errorCode?: string;
179
+ errorDescription?: string;
180
+ taskId?: string;
181
+ };
182
+
183
+ if (createData.errorId || !createData.taskId) {
184
+ console.error(
185
+ `[captcha] YesCaptcha create error: ${createData.errorCode} — ${createData.errorDescription}`
186
+ );
187
+ return null;
188
+ }
189
+
190
+ const taskId = createData.taskId;
191
+ console.log(`[captcha] YesCaptcha task created: ${taskId}`);
192
+
193
+ const maxWait = 120_000;
194
+ const start = Date.now();
195
+ while (Date.now() - start < maxWait) {
196
+ await new Promise((r) => setTimeout(r, 3000));
197
+
198
+ const resultResp = await fetch(`${YESCAPTCHA_URL}/getTaskResult`, {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({ clientKey: apiKey, taskId }),
202
+ });
203
+
204
+ const resultData = (await resultResp.json()) as {
205
+ errorId?: number;
206
+ errorCode?: string;
207
+ status?: string;
208
+ solution?: { token?: string };
209
+ };
210
+
211
+ if (resultData.errorId) {
212
+ console.error(`[captcha] YesCaptcha result error: ${resultData.errorCode}`);
213
+ return null;
214
+ }
215
+
216
+ if (resultData.status === "ready") {
217
+ const token = resultData.solution?.token;
218
+ if (token) {
219
+ console.log(`[captcha] YesCaptcha solved! Token: ${token.substring(0, 20)}...`);
220
+ return token;
221
+ }
222
+ return null;
223
+ }
224
+ }
225
+
226
+ console.error("[captcha] YesCaptcha timed out after 120s");
227
+ return null;
228
+ } catch (err: any) {
229
+ console.error(`[captcha] YesCaptcha error: ${err?.message}`);
230
+ return null;
231
+ }
232
+ }
233
+
234
+ // ─── Public API ───────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Get a valid Cloudflare Turnstile token for geminigen.ai.
238
+ *
239
+ * Priority:
240
+ * 1. Self-hosted Playwright solver (playwright_solver_url in config) — 免費
241
+ * 2. YesCaptcha API (yescaptcha_api_key in config) — 有免費額度
242
+ * 3. CapSolver API (capsolver_api_key in config) — 付費備援
243
+ * 4. Bypass placeholder (will fail — no solver configured)
244
+ */
245
+ export async function getTurnstileToken(): Promise<string> {
246
+ if (cachedToken && cachedToken.expiresAt > Date.now()) {
247
+ console.log("[captcha] Using cached Turnstile token");
248
+ return cachedToken.token;
249
+ }
250
+
251
+ let token: string | null = null;
252
+
253
+ // Strategy 1: Self-hosted Playwright solver
254
+ const solverUrl = await getConfig("playwright_solver_url");
255
+ if (solverUrl) {
256
+ token = await solveWithPlaywright(solverUrl);
257
+ if (token) {
258
+ cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS };
259
+ return token;
260
+ }
261
+ console.warn("[captcha] Playwright solver failed — trying YesCaptcha");
262
+ }
263
+
264
+ // Strategy 2: YesCaptcha
265
+ const yescaptchaKey = await getConfig("yescaptcha_api_key");
266
+ if (yescaptchaKey) {
267
+ token = await solveWithYesCaptcha(yescaptchaKey);
268
+ if (token) {
269
+ cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS };
270
+ return token;
271
+ }
272
+ console.warn("[captcha] YesCaptcha failed — trying CapSolver");
273
+ }
274
+
275
+ // Strategy 3: CapSolver
276
+ const capsolverKey = await getConfig("capsolver_api_key");
277
+ if (capsolverKey) {
278
+ token = await solveWithCapsolver(capsolverKey);
279
+ if (token) {
280
+ cachedToken = { token, expiresAt: Date.now() + TOKEN_TTL_MS };
281
+ return token;
282
+ }
283
+ console.warn("[captcha] CapSolver failed — no further fallback");
284
+ }
285
+
286
+ if (!solverUrl && !yescaptchaKey && !capsolverKey) {
287
+ console.warn(
288
+ "[captcha] No Turnstile solver configured — set playwright_solver_url, yescaptcha_api_key, or capsolver_api_key in Admin → Config"
289
+ );
290
+ }
291
+
292
+ return "TURNSTILE_BYPASS";
293
+ }
294
+
295
+ /** Force-clear the cached token (e.g., if geminigen.ai rejects it) */
296
+ export function invalidateTurnstileToken(): void {
297
+ cachedToken = null;
298
+ }
artifacts/api-server/src/guardId.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * geminigen.ai x-guard-id header generator
3
+ *
4
+ * Reverse-engineered from geminigen.ai/_nuxt/nBh81x6X.js
5
+ * Constants extracted from: window.__NUXT__.config.public.antibot
6
+ *
7
+ * Algorithm:
8
+ * stableId = 22-char alphanumeric string (persistent per server instance)
9
+ * timeBucket = Math.floor(Date.now() / 60000) — changes every 60 seconds
10
+ * domFp = "0".repeat(32) — server has no DOM
11
+ *
12
+ * hmacKey = SHA256(`${SECRET_KEY}:${stableId}`).slice(0, 32)
13
+ * signHash = SHA256(`${path}:${METHOD}:${hmacKey}:${timeBucket}:${SECRET_KEY}`)
14
+ *
15
+ * payload = [0x01, ...hexBytes(hmacKey), // 16 bytes
16
+ * ...uint32BE(timeBucket), // 4 bytes
17
+ * ...hexBytes(signHash), // 32 bytes
18
+ * ...hexBytes(domFp)] // 16 bytes → 69 bytes total
19
+ *
20
+ * x-guard-id = base64url(payload) (no padding)
21
+ */
22
+
23
+ import { createHash, randomBytes } from "crypto";
24
+
25
+ const SECRET_KEY = "45NPBH$&";
26
+ const TIME_BUCKET_MS = 60_000;
27
+ const STABLE_ID_LEN = 22;
28
+ const DOM_FP = "0".repeat(32); // 32 hex zeros → 16 zero bytes
29
+
30
+ // ── One stable-id per server instance ────────────────────────────────────────
31
+ function makeStableId(): string {
32
+ const rand = randomBytes(16).toString("hex");
33
+ const hash = createHash("sha256").update(`server:${rand}`).digest("hex");
34
+ // Take alphanumeric chars from the hex (all are [0-9a-f] so fine for [A-Za-z0-9])
35
+ return hash.slice(0, STABLE_ID_LEN);
36
+ }
37
+
38
+ let _stableId: string = makeStableId();
39
+
40
+ /** Replace the stable-id (e.g., if you want to rotate it). */
41
+ export function rotateStableId(): void { _stableId = makeStableId(); }
42
+
43
+ // ── Helpers ───────────────────────────────────────────────────────────────────
44
+
45
+ /** SHA-256 of a UTF-8 string → lowercase hex (64 chars). */
46
+ function sha256(str: string): string {
47
+ return createHash("sha256").update(str, "utf8").digest("hex");
48
+ }
49
+
50
+ /** Convert a hex string to a byte array (every 2 hex chars → 1 byte). */
51
+ function hexToBytes(hex: string): number[] {
52
+ const bytes: number[] = [];
53
+ for (let i = 0; i < hex.length; i += 2) {
54
+ bytes.push(parseInt(hex.substring(i, 2 + i), 16));
55
+ }
56
+ return bytes;
57
+ }
58
+
59
+ /** Encode uint32 as 4 big-endian bytes. */
60
+ function uint32BE(n: number): number[] {
61
+ return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
62
+ }
63
+
64
+ // ── Public API ─────────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Generate the `x-guard-id` header value for a given API path + HTTP method.
68
+ *
69
+ * @param path e.g. "/api/video-gen/grok-stream"
70
+ * @param method e.g. "post"
71
+ */
72
+ export function generateGuardId(path: string, method: string): string {
73
+ const stableId = _stableId;
74
+ const timeBucket = Math.floor(Date.now() / TIME_BUCKET_MS);
75
+
76
+ // hmacKey: first 32 hex chars of SHA256("${SECRET_KEY}:${stableId}") → 16 bytes
77
+ const hmacKey = sha256(`${SECRET_KEY}:${stableId}`).slice(0, 32);
78
+
79
+ // signHash: full 64-hex SHA256 → 32 bytes
80
+ const signHash = sha256(`${path}:${method.toUpperCase()}:${hmacKey}:${timeBucket}:${SECRET_KEY}`);
81
+
82
+ const payload: number[] = [
83
+ 1, // version byte (LK = 1)
84
+ ...hexToBytes(hmacKey), // 16 bytes
85
+ ...uint32BE(timeBucket), // 4 bytes
86
+ ...hexToBytes(signHash), // 32 bytes
87
+ ...hexToBytes(DOM_FP), // 16 bytes
88
+ ];
89
+
90
+ return Buffer.from(payload).toString("base64url"); // Node ≥ 16, no trailing =
91
+ }
artifacts/api-server/src/index.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import app from "./app";
2
+ import { logger } from "./lib/logger";
3
+
4
+ const rawPort = process.env["PORT"] || "7860";
5
+
6
+ const port = Number(rawPort);
7
+
8
+ if (Number.isNaN(port) || port <= 0) {
9
+ throw new Error(`Invalid PORT value: "${rawPort}"`);
10
+ }
11
+
12
+ app.listen(port, async (err) => {
13
+ if (err) {
14
+ logger.error({ err }, "Error listening on port");
15
+ process.exit(1);
16
+ }
17
+
18
+ logger.info({ port }, "Server listening on Hugging Face Space");
19
+ logger.info("Token refresh is handled on-demand (no background loop)");
20
+ });
artifacts/api-server/src/lib/.gitkeep ADDED
File without changes
artifacts/api-server/src/lib/localTempStorage.ts ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { Readable } from "stream";
5
+
6
+ const TEMP_STORAGE_PATH = process.env.TEMP_STORAGE_PATH || "/app/temp-storage";
7
+ const MAX_FILE_AGE_MS = 24 * 60 * 60 * 1000; // 24 小時
8
+
9
+ export class ObjectNotFoundError extends Error {
10
+ constructor() {
11
+ super("Object not found");
12
+ this.name = "ObjectNotFoundError";
13
+ Object.setPrototypeOf(this, ObjectNotFoundError.prototype);
14
+ }
15
+ }
16
+
17
+ export interface LocalFile {
18
+ path: string;
19
+ url: string;
20
+ exists(): Promise<boolean>;
21
+ delete(): Promise<void>;
22
+ getMetadata(): Promise<{ contentType: string; size: number }>;
23
+ createReadStream(): fs.ReadStream;
24
+ }
25
+
26
+ export class LocalTempStorageService {
27
+ private storagePath: string;
28
+
29
+ constructor() {
30
+ this.storagePath = TEMP_STORAGE_PATH;
31
+ this.ensureStorageDir();
32
+ this.startCleanupInterval();
33
+ }
34
+
35
+ private ensureStorageDir(): void {
36
+ if (!fs.existsSync(this.storagePath)) {
37
+ fs.mkdirSync(this.storagePath, { recursive: true });
38
+ }
39
+ }
40
+
41
+ // 定期清理舊檔案
42
+ private startCleanupInterval(): void {
43
+ setInterval(() => {
44
+ this.cleanupOldFiles().catch((err) => {
45
+ console.error("[LocalTempStorage] Cleanup error:", err);
46
+ });
47
+ }, 60 * 60 * 1000); // 每小時執行一次
48
+ }
49
+
50
+ private async cleanupOldFiles(): Promise<void> {
51
+ try {
52
+ const now = Date.now();
53
+ const files = fs.readdirSync(this.storagePath);
54
+
55
+ let deletedCount = 0;
56
+ for (const file of files) {
57
+ const filePath = path.join(this.storagePath, file);
58
+ const stats = fs.statSync(filePath);
59
+
60
+ if (now - stats.mtimeMs > MAX_FILE_AGE_MS) {
61
+ fs.unlinkSync(filePath);
62
+ deletedCount++;
63
+ }
64
+ }
65
+
66
+ if (deletedCount > 0) {
67
+ console.log(`[LocalTempStorage] Cleaned up ${deletedCount} old files`);
68
+ }
69
+ } catch (error) {
70
+ console.error("[LocalTempStorage] Error during cleanup:", error);
71
+ }
72
+ }
73
+
74
+ async saveFile(buffer: Buffer, filename: string, contentType: string = "application/octet-stream"): Promise<LocalFile> {
75
+ const fileId = randomUUID();
76
+ const ext = path.extname(filename) || "";
77
+ const savedFilename = `${fileId}${ext}`;
78
+ const filePath = path.join(this.storagePath, savedFilename);
79
+
80
+ fs.writeFileSync(filePath, buffer);
81
+
82
+ // 儲存 metadata
83
+ const metadataPath = `${filePath}.meta`;
84
+ fs.writeFileSync(metadataPath, JSON.stringify({ contentType, originalName: filename }));
85
+
86
+ return this.getFile(savedFilename);
87
+ }
88
+
89
+ getFile(filename: string): LocalFile {
90
+ const filePath = path.join(this.storagePath, filename);
91
+ const url = `/api/temp-storage/${filename}`;
92
+
93
+ return {
94
+ path: filePath,
95
+ url,
96
+ async exists() {
97
+ return fs.existsSync(filePath);
98
+ },
99
+ async delete() {
100
+ if (fs.existsSync(filePath)) {
101
+ fs.unlinkSync(filePath);
102
+ }
103
+ const metadataPath = `${filePath}.meta`;
104
+ if (fs.existsSync(metadataPath)) {
105
+ fs.unlinkSync(metadataPath);
106
+ }
107
+ },
108
+ async getMetadata() {
109
+ const metadataPath = `${filePath}.meta`;
110
+ if (fs.existsSync(metadataPath)) {
111
+ const meta = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
112
+ const stats = fs.statSync(filePath);
113
+ return {
114
+ contentType: meta.contentType || "application/octet-stream",
115
+ size: stats.size,
116
+ };
117
+ }
118
+ const stats = fs.statSync(filePath);
119
+ return {
120
+ contentType: "application/octet-stream",
121
+ size: stats.size,
122
+ };
123
+ },
124
+ createReadStream() {
125
+ return fs.createReadStream(filePath);
126
+ },
127
+ };
128
+ }
129
+
130
+ async downloadFile(file: LocalFile): Promise<Response> {
131
+ const exists = await file.exists();
132
+ if (!exists) {
133
+ throw new ObjectNotFoundError();
134
+ }
135
+
136
+ const metadata = await file.getMetadata();
137
+ const nodeStream = file.createReadStream();
138
+ const webStream = Readable.toWeb(nodeStream) as ReadableStream;
139
+
140
+ const headers: Record<string, string> = {
141
+ "Content-Type": metadata.contentType,
142
+ "Content-Length": String(metadata.size),
143
+ "Cache-Control": "public, max-age=3600",
144
+ };
145
+
146
+ return new Response(webStream, { headers });
147
+ }
148
+
149
+ // 從 URL 提取檔案名稱
150
+ extractFilenameFromUrl(url: string): string | null {
151
+ const match = url.match(/\/api\/temp-storage\/([^?]+)/);
152
+ return match ? match[1] : null;
153
+ }
154
+
155
+ // 清理所有檔案 (用於測試或維護)
156
+ async cleanupAll(): Promise<void> {
157
+ const files = fs.readdirSync(this.storagePath);
158
+ for (const file of files) {
159
+ const filePath = path.join(this.storagePath, file);
160
+ fs.unlinkSync(filePath);
161
+ }
162
+ console.log(`[LocalTempStorage] Cleaned up all ${files.length} files`);
163
+ }
164
+ }
165
+
166
+ // 單例實例
167
+ export const localTempStorage = new LocalTempStorageService();
artifacts/api-server/src/lib/logger.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pino from "pino";
2
+
3
+ const isProduction = process.env.NODE_ENV === "production";
4
+
5
+ export const logger = pino({
6
+ level: process.env.LOG_LEVEL ?? "info",
7
+ redact: [
8
+ "req.headers.authorization",
9
+ "req.headers.cookie",
10
+ "res.headers['set-cookie']",
11
+ ],
12
+ ...(isProduction
13
+ ? {}
14
+ : {
15
+ transport: {
16
+ target: "pino-pretty",
17
+ options: { colorize: true },
18
+ },
19
+ }),
20
+ });
artifacts/api-server/src/lib/objectAcl.ts ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { File } from "@google-cloud/storage";
2
+
3
+ const ACL_POLICY_METADATA_KEY = "custom:aclPolicy";
4
+
5
+ // Can be flexibly defined according to the use case.
6
+ //
7
+ // Examples:
8
+ // - USER_LIST: the users from a list stored in the database;
9
+ // - EMAIL_DOMAIN: the users whose email is in a specific domain;
10
+ // - GROUP_MEMBER: the users who are members of a specific group;
11
+ // - SUBSCRIBER: the users who are subscribers of a specific service / content
12
+ // creator.
13
+ export enum ObjectAccessGroupType {}
14
+
15
+ export interface ObjectAccessGroup {
16
+ type: ObjectAccessGroupType;
17
+ // The logic id that identifies qualified group members. Format depends on the
18
+ // ObjectAccessGroupType — e.g. a user-list DB id, an email domain, a group id.
19
+ id: string;
20
+ }
21
+
22
+ export enum ObjectPermission {
23
+ READ = "read",
24
+ WRITE = "write",
25
+ }
26
+
27
+ export interface ObjectAclRule {
28
+ group: ObjectAccessGroup;
29
+ permission: ObjectPermission;
30
+ }
31
+
32
+ // Stored as object custom metadata under "custom:aclPolicy" (JSON string).
33
+ export interface ObjectAclPolicy {
34
+ owner: string;
35
+ visibility: "public" | "private";
36
+ aclRules?: Array<ObjectAclRule>;
37
+ }
38
+
39
+ function isPermissionAllowed(
40
+ requested: ObjectPermission,
41
+ granted: ObjectPermission,
42
+ ): boolean {
43
+ if (requested === ObjectPermission.READ) {
44
+ return [ObjectPermission.READ, ObjectPermission.WRITE].includes(granted);
45
+ }
46
+ return granted === ObjectPermission.WRITE;
47
+ }
48
+
49
+ abstract class BaseObjectAccessGroup implements ObjectAccessGroup {
50
+ constructor(
51
+ public readonly type: ObjectAccessGroupType,
52
+ public readonly id: string,
53
+ ) {}
54
+
55
+ public abstract hasMember(userId: string): Promise<boolean>;
56
+ }
57
+
58
+ function createObjectAccessGroup(
59
+ group: ObjectAccessGroup,
60
+ ): BaseObjectAccessGroup {
61
+ switch (group.type) {
62
+ // Implement per access group type, e.g.:
63
+ // case "USER_LIST":
64
+ // return new UserListAccessGroup(group.id);
65
+ default:
66
+ throw new Error(`Unknown access group type: ${group.type}`);
67
+ }
68
+ }
69
+
70
+ export async function setObjectAclPolicy(
71
+ objectFile: File,
72
+ aclPolicy: ObjectAclPolicy,
73
+ ): Promise<void> {
74
+ const [exists] = await objectFile.exists();
75
+ if (!exists) {
76
+ throw new Error(`Object not found: ${objectFile.name}`);
77
+ }
78
+
79
+ await objectFile.setMetadata({
80
+ metadata: {
81
+ [ACL_POLICY_METADATA_KEY]: JSON.stringify(aclPolicy),
82
+ },
83
+ });
84
+ }
85
+
86
+ export async function getObjectAclPolicy(
87
+ objectFile: File,
88
+ ): Promise<ObjectAclPolicy | null> {
89
+ const [metadata] = await objectFile.getMetadata();
90
+ const aclPolicy = metadata?.metadata?.[ACL_POLICY_METADATA_KEY];
91
+ if (!aclPolicy) {
92
+ return null;
93
+ }
94
+ return JSON.parse(aclPolicy as string);
95
+ }
96
+
97
+ export async function canAccessObject({
98
+ userId,
99
+ objectFile,
100
+ requestedPermission,
101
+ }: {
102
+ userId?: string;
103
+ objectFile: File;
104
+ requestedPermission: ObjectPermission;
105
+ }): Promise<boolean> {
106
+ const aclPolicy = await getObjectAclPolicy(objectFile);
107
+ if (!aclPolicy) {
108
+ return false;
109
+ }
110
+
111
+ if (
112
+ aclPolicy.visibility === "public" &&
113
+ requestedPermission === ObjectPermission.READ
114
+ ) {
115
+ return true;
116
+ }
117
+
118
+ if (!userId) {
119
+ return false;
120
+ }
121
+
122
+ if (aclPolicy.owner === userId) {
123
+ return true;
124
+ }
125
+
126
+ for (const rule of aclPolicy.aclRules || []) {
127
+ const accessGroup = createObjectAccessGroup(rule.group);
128
+ if (
129
+ (await accessGroup.hasMember(userId)) &&
130
+ isPermissionAllowed(requestedPermission, rule.permission)
131
+ ) {
132
+ return true;
133
+ }
134
+ }
135
+
136
+ return false;
137
+ }
artifacts/api-server/src/lib/objectStorage.ts ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Storage, File } from "@google-cloud/storage";
2
+ import { Readable } from "stream";
3
+ import { randomUUID } from "crypto";
4
+ import {
5
+ ObjectAclPolicy,
6
+ ObjectPermission,
7
+ canAccessObject,
8
+ getObjectAclPolicy,
9
+ setObjectAclPolicy,
10
+ } from "./objectAcl";
11
+
12
+ const REPLIT_SIDECAR_ENDPOINT = "http://127.0.0.1:1106";
13
+
14
+ export const objectStorageClient = new Storage({
15
+ credentials: {
16
+ audience: "replit",
17
+ subject_token_type: "access_token",
18
+ token_url: `${REPLIT_SIDECAR_ENDPOINT}/token`,
19
+ type: "external_account",
20
+ credential_source: {
21
+ url: `${REPLIT_SIDECAR_ENDPOINT}/credential`,
22
+ format: {
23
+ type: "json",
24
+ subject_token_field_name: "access_token",
25
+ },
26
+ },
27
+ universe_domain: "googleapis.com",
28
+ },
29
+ projectId: "",
30
+ });
31
+
32
+ export class ObjectNotFoundError extends Error {
33
+ constructor() {
34
+ super("Object not found");
35
+ this.name = "ObjectNotFoundError";
36
+ Object.setPrototypeOf(this, ObjectNotFoundError.prototype);
37
+ }
38
+ }
39
+
40
+ export class ObjectStorageService {
41
+ constructor() {}
42
+
43
+ getPublicObjectSearchPaths(): Array<string> {
44
+ const pathsStr = process.env.PUBLIC_OBJECT_SEARCH_PATHS || "";
45
+ const paths = Array.from(
46
+ new Set(
47
+ pathsStr
48
+ .split(",")
49
+ .map((path) => path.trim())
50
+ .filter((path) => path.length > 0)
51
+ )
52
+ );
53
+ if (paths.length === 0) {
54
+ throw new Error(
55
+ "PUBLIC_OBJECT_SEARCH_PATHS not set. Create a bucket in 'Object Storage' " +
56
+ "tool and set PUBLIC_OBJECT_SEARCH_PATHS env var (comma-separated paths)."
57
+ );
58
+ }
59
+ return paths;
60
+ }
61
+
62
+ getPrivateObjectDir(): string {
63
+ const dir = process.env.PRIVATE_OBJECT_DIR || "";
64
+ if (!dir) {
65
+ throw new Error(
66
+ "PRIVATE_OBJECT_DIR not set. Create a bucket in 'Object Storage' " +
67
+ "tool and set PRIVATE_OBJECT_DIR env var."
68
+ );
69
+ }
70
+ return dir;
71
+ }
72
+
73
+ async searchPublicObject(filePath: string): Promise<File | null> {
74
+ for (const searchPath of this.getPublicObjectSearchPaths()) {
75
+ const fullPath = `${searchPath}/${filePath}`;
76
+
77
+ const { bucketName, objectName } = parseObjectPath(fullPath);
78
+ const bucket = objectStorageClient.bucket(bucketName);
79
+ const file = bucket.file(objectName);
80
+
81
+ const [exists] = await file.exists();
82
+ if (exists) {
83
+ return file;
84
+ }
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ async downloadObject(file: File, cacheTtlSec: number = 3600): Promise<Response> {
91
+ const [metadata] = await file.getMetadata();
92
+ const aclPolicy = await getObjectAclPolicy(file);
93
+ const isPublic = aclPolicy?.visibility === "public";
94
+
95
+ const nodeStream = file.createReadStream();
96
+ const webStream = Readable.toWeb(nodeStream) as ReadableStream;
97
+
98
+ const headers: Record<string, string> = {
99
+ "Content-Type": (metadata.contentType as string) || "application/octet-stream",
100
+ "Cache-Control": `${isPublic ? "public" : "private"}, max-age=${cacheTtlSec}`,
101
+ };
102
+ if (metadata.size) {
103
+ headers["Content-Length"] = String(metadata.size);
104
+ }
105
+
106
+ return new Response(webStream, { headers });
107
+ }
108
+
109
+ async getObjectEntityUploadURL(): Promise<string> {
110
+ const privateObjectDir = this.getPrivateObjectDir();
111
+ if (!privateObjectDir) {
112
+ throw new Error(
113
+ "PRIVATE_OBJECT_DIR not set. Create a bucket in 'Object Storage' " +
114
+ "tool and set PRIVATE_OBJECT_DIR env var."
115
+ );
116
+ }
117
+
118
+ const objectId = randomUUID();
119
+ const fullPath = `${privateObjectDir}/uploads/${objectId}`;
120
+
121
+ const { bucketName, objectName } = parseObjectPath(fullPath);
122
+
123
+ return signObjectURL({
124
+ bucketName,
125
+ objectName,
126
+ method: "PUT",
127
+ ttlSec: 900,
128
+ });
129
+ }
130
+
131
+ async getObjectEntityFile(objectPath: string): Promise<File> {
132
+ if (!objectPath.startsWith("/objects/")) {
133
+ throw new ObjectNotFoundError();
134
+ }
135
+
136
+ const parts = objectPath.slice(1).split("/");
137
+ if (parts.length < 2) {
138
+ throw new ObjectNotFoundError();
139
+ }
140
+
141
+ const entityId = parts.slice(1).join("/");
142
+ let entityDir = this.getPrivateObjectDir();
143
+ if (!entityDir.endsWith("/")) {
144
+ entityDir = `${entityDir}/`;
145
+ }
146
+ const objectEntityPath = `${entityDir}${entityId}`;
147
+ const { bucketName, objectName } = parseObjectPath(objectEntityPath);
148
+ const bucket = objectStorageClient.bucket(bucketName);
149
+ const objectFile = bucket.file(objectName);
150
+ const [exists] = await objectFile.exists();
151
+ if (!exists) {
152
+ throw new ObjectNotFoundError();
153
+ }
154
+ return objectFile;
155
+ }
156
+
157
+ normalizeObjectEntityPath(rawPath: string): string {
158
+ if (!rawPath.startsWith("https://storage.googleapis.com/")) {
159
+ return rawPath;
160
+ }
161
+
162
+ const url = new URL(rawPath);
163
+ const rawObjectPath = url.pathname;
164
+
165
+ let objectEntityDir = this.getPrivateObjectDir();
166
+ if (!objectEntityDir.endsWith("/")) {
167
+ objectEntityDir = `${objectEntityDir}/`;
168
+ }
169
+
170
+ if (!rawObjectPath.startsWith(objectEntityDir)) {
171
+ return rawObjectPath;
172
+ }
173
+
174
+ const entityId = rawObjectPath.slice(objectEntityDir.length);
175
+ return `/objects/${entityId}`;
176
+ }
177
+
178
+ async trySetObjectEntityAclPolicy(
179
+ rawPath: string,
180
+ aclPolicy: ObjectAclPolicy
181
+ ): Promise<string> {
182
+ const normalizedPath = this.normalizeObjectEntityPath(rawPath);
183
+ if (!normalizedPath.startsWith("/")) {
184
+ return normalizedPath;
185
+ }
186
+
187
+ const objectFile = await this.getObjectEntityFile(normalizedPath);
188
+ await setObjectAclPolicy(objectFile, aclPolicy);
189
+ return normalizedPath;
190
+ }
191
+
192
+ async canAccessObjectEntity({
193
+ userId,
194
+ objectFile,
195
+ requestedPermission,
196
+ }: {
197
+ userId?: string;
198
+ objectFile: File;
199
+ requestedPermission?: ObjectPermission;
200
+ }): Promise<boolean> {
201
+ return canAccessObject({
202
+ userId,
203
+ objectFile,
204
+ requestedPermission: requestedPermission ?? ObjectPermission.READ,
205
+ });
206
+ }
207
+ }
208
+
209
+ function parseObjectPath(path: string): {
210
+ bucketName: string;
211
+ objectName: string;
212
+ } {
213
+ if (!path.startsWith("/")) {
214
+ path = `/${path}`;
215
+ }
216
+ const pathParts = path.split("/");
217
+ if (pathParts.length < 3) {
218
+ throw new Error("Invalid path: must contain at least a bucket name");
219
+ }
220
+
221
+ const bucketName = pathParts[1];
222
+ const objectName = pathParts.slice(2).join("/");
223
+
224
+ return {
225
+ bucketName,
226
+ objectName,
227
+ };
228
+ }
229
+
230
+ async function signObjectURL({
231
+ bucketName,
232
+ objectName,
233
+ method,
234
+ ttlSec,
235
+ }: {
236
+ bucketName: string;
237
+ objectName: string;
238
+ method: "GET" | "PUT" | "DELETE" | "HEAD";
239
+ ttlSec: number;
240
+ }): Promise<string> {
241
+ const request = {
242
+ bucket_name: bucketName,
243
+ object_name: objectName,
244
+ method,
245
+ expires_at: new Date(Date.now() + ttlSec * 1000).toISOString(),
246
+ };
247
+ const response = await fetch(
248
+ `${REPLIT_SIDECAR_ENDPOINT}/object-storage/signed-object-url`,
249
+ {
250
+ method: "POST",
251
+ headers: {
252
+ "Content-Type": "application/json",
253
+ },
254
+ body: JSON.stringify(request),
255
+ signal: AbortSignal.timeout(30_000),
256
+ }
257
+ );
258
+ if (!response.ok) {
259
+ throw new Error(
260
+ `Failed to sign object URL, errorcode: ${response.status}, ` +
261
+ `make sure you're running on Replit`
262
+ );
263
+ }
264
+
265
+ const { signed_url: signedURL } = await response.json();
266
+ return signedURL;
267
+ }
artifacts/api-server/src/lib/videoStorage.ts ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * videoStorage.ts
3
+ *
4
+ * Downloads generated videos from Grok CDN and stores them in an
5
+ * S3-compatible object storage (hi168.com OSS).
6
+ *
7
+ * Upload: @aws-sdk/lib-storage (multipart, handles large files)
8
+ * Download / stream: @aws-sdk/client-s3 (GetObject)
9
+ */
10
+ import {
11
+ S3Client,
12
+ CreateBucketCommand,
13
+ HeadBucketCommand,
14
+ GetObjectCommand,
15
+ HeadObjectCommand,
16
+ } from "@aws-sdk/client-s3";
17
+ import { Upload } from "@aws-sdk/lib-storage";
18
+ import { Readable } from "stream";
19
+ import { randomUUID } from "crypto";
20
+
21
+ // ─── S3 configuration ────────────────────────────────────────────────────────
22
+
23
+ const S3_ENDPOINT = process.env.S3_ENDPOINT ?? "";
24
+ const S3_BUCKET = process.env.S3_BUCKET ?? "starforge-videos";
25
+ const S3_REGION = process.env.S3_REGION ?? "us-east-1";
26
+ const ACCESS_KEY = process.env.S3_ACCESS_KEY ?? "";
27
+ const SECRET_KEY = process.env.S3_SECRET_KEY ?? "";
28
+
29
+ let s3: S3Client | null = null;
30
+
31
+ function getS3(): S3Client {
32
+ if (!s3) {
33
+ s3 = new S3Client({
34
+ endpoint: S3_ENDPOINT,
35
+ region: S3_REGION,
36
+ credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
37
+ forcePathStyle: true, // required for most non-AWS S3 endpoints
38
+ });
39
+ }
40
+ return s3;
41
+ }
42
+
43
+ /** Returns true if S3 credentials are configured. */
44
+ export function isStorageReady(): boolean {
45
+ return !!(S3_ENDPOINT && ACCESS_KEY && SECRET_KEY);
46
+ }
47
+
48
+ // ─── Bucket init (create if missing) ─────────────────────────────────────────
49
+
50
+ let bucketReady = false;
51
+
52
+ async function ensureBucket(): Promise<void> {
53
+ if (bucketReady) return;
54
+ const client = getS3();
55
+ try {
56
+ await client.send(new HeadBucketCommand({ Bucket: S3_BUCKET }));
57
+ bucketReady = true;
58
+ console.log(`[video-storage] bucket "${S3_BUCKET}" exists`);
59
+ } catch (err: any) {
60
+ if (err.$metadata?.httpStatusCode === 404 || err.name === "NoSuchBucket" || err.name === "NotFound") {
61
+ try {
62
+ await client.send(new CreateBucketCommand({ Bucket: S3_BUCKET }));
63
+ bucketReady = true;
64
+ console.log(`[video-storage] bucket "${S3_BUCKET}" created`);
65
+ } catch (createErr: any) {
66
+ console.error("[video-storage] bucket create error:", createErr?.message ?? createErr);
67
+ }
68
+ } else {
69
+ // treat unknown errors as "bucket exists, proceed"
70
+ console.warn("[video-storage] HeadBucket warn (proceeding):", err?.message ?? err);
71
+ bucketReady = true;
72
+ }
73
+ }
74
+ }
75
+
76
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
77
+
78
+ const USER_AGENT =
79
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
80
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
81
+
82
+ // ─── Upload ───────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Download a video from remoteUrl and upload it to S3.
86
+ *
87
+ * Returns the serving path `/api/videos/stored/<id>.mp4`, or null on failure.
88
+ */
89
+ export async function downloadAndStoreVideo(
90
+ remoteUrl: string,
91
+ bearerToken: string | null,
92
+ ext = "mp4",
93
+ ): Promise<string | null> {
94
+ if (!isStorageReady()) {
95
+ console.warn("[video-storage] S3 not configured — skipping download");
96
+ return null;
97
+ }
98
+
99
+ await ensureBucket();
100
+
101
+ const id = randomUUID();
102
+ const key = `videos/${id}.${ext}`;
103
+
104
+ let hostname = "";
105
+ try { hostname = new URL(remoteUrl).hostname; } catch { /* ok */ }
106
+
107
+ // Cloudflare R2 pre-signed URLs are publicly accessible — no auth needed.
108
+ const isR2 = hostname.endsWith(".r2.cloudflarestorage.com");
109
+ // assets.grok.com is behind Cloudflare; use grok.com as Referer+Origin.
110
+ const isGrokCdn = hostname === "assets.grok.com";
111
+ const referer = isGrokCdn ? "https://grok.com/" : "https://geminigen.ai/";
112
+ const origin = isGrokCdn ? "https://grok.com" : "https://geminigen.ai";
113
+
114
+ const baseHeaders: Record<string, string> = {
115
+ "User-Agent": USER_AGENT,
116
+ Accept: "video/mp4,video/*,*/*",
117
+ ...(isR2 ? {} : { Referer: referer, Origin: origin }),
118
+ };
119
+
120
+ // R2 pre-signed URLs work without any auth; try without token first.
121
+ // For other CDNs, try with Bearer token first.
122
+ const strategies: Array<Record<string, string>> = isR2
123
+ ? [{ ...baseHeaders }]
124
+ : [
125
+ ...(bearerToken ? [{ ...baseHeaders, Authorization: `Bearer ${bearerToken}` }] : []),
126
+ { ...baseHeaders },
127
+ ];
128
+
129
+ let videoBuffer: Buffer | null = null;
130
+ let contentType = "video/mp4";
131
+
132
+ for (const headers of strategies) {
133
+ try {
134
+ const resp = await fetch(remoteUrl, { headers });
135
+ console.log(
136
+ `[video-storage] fetch �� ${resp.status} from ${new URL(remoteUrl).hostname}`,
137
+ );
138
+ if (resp.ok) {
139
+ const ab = await resp.arrayBuffer();
140
+ videoBuffer = Buffer.from(ab);
141
+ const ct = resp.headers.get("content-type");
142
+ if (ct && ct.startsWith("video/")) contentType = ct;
143
+ break;
144
+ }
145
+ } catch (err) {
146
+ console.warn(
147
+ "[video-storage] fetch error:",
148
+ err instanceof Error ? err.message : err,
149
+ );
150
+ }
151
+ }
152
+
153
+ if (!videoBuffer || videoBuffer.length < 1024) {
154
+ console.warn("[video-storage] all download strategies failed");
155
+ return null;
156
+ }
157
+
158
+ try {
159
+ const upload = new Upload({
160
+ client: getS3(),
161
+ params: {
162
+ Bucket: S3_BUCKET,
163
+ Key: key,
164
+ Body: videoBuffer,
165
+ ContentType: contentType,
166
+ },
167
+ });
168
+ await upload.done();
169
+ console.log(`[video-storage] uploaded ${videoBuffer.length} bytes → s3://${S3_BUCKET}/${key}`);
170
+ return `/api/videos/stored/${id}.${ext}`;
171
+ } catch (err) {
172
+ console.error(
173
+ "[video-storage] S3 upload error:",
174
+ err instanceof Error ? err.message : err,
175
+ );
176
+ return null;
177
+ }
178
+ }
179
+
180
+ // ─── Stream / serve ───────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Serve a stored video from S3 to an Express response.
184
+ * objectPath: the `<id>.mp4` portion after /api/videos/stored/
185
+ */
186
+ export async function streamStoredVideo(
187
+ objectPath: string,
188
+ res: import("express").Response,
189
+ rangeHeader?: string,
190
+ ): Promise<void> {
191
+ if (!isStorageReady()) {
192
+ res.status(503).json({ error: "Storage not configured" });
193
+ return;
194
+ }
195
+
196
+ const key = `videos/${objectPath}`;
197
+
198
+ try {
199
+ const client = getS3();
200
+
201
+ // HEAD to get size & content-type
202
+ let size = 0;
203
+ let contentType = "video/mp4";
204
+ try {
205
+ const head = await client.send(
206
+ new HeadObjectCommand({ Bucket: S3_BUCKET, Key: key }),
207
+ );
208
+ size = Number(head.ContentLength ?? 0);
209
+ contentType = head.ContentType ?? contentType;
210
+ } catch {
211
+ // fallback: stream without size
212
+ }
213
+
214
+ res.setHeader("Content-Type", contentType);
215
+ res.setHeader("Accept-Ranges", "bytes");
216
+ res.setHeader("Cache-Control", "public, max-age=86400");
217
+
218
+ let byteRange: { start: number; end: number } | undefined;
219
+
220
+ if (rangeHeader && size) {
221
+ const m = /bytes=(\d*)-(\d*)/.exec(rangeHeader);
222
+ if (m) {
223
+ const start = m[1] ? parseInt(m[1]) : 0;
224
+ const end = m[2] ? parseInt(m[2]) : size - 1;
225
+ byteRange = { start, end };
226
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
227
+ res.setHeader("Content-Length", end - start + 1);
228
+ res.status(206);
229
+ }
230
+ } else if (size) {
231
+ res.setHeader("Content-Length", size);
232
+ res.status(200);
233
+ } else {
234
+ res.status(200);
235
+ }
236
+
237
+ const getCmd = new GetObjectCommand({
238
+ Bucket: S3_BUCKET,
239
+ Key: key,
240
+ ...(byteRange
241
+ ? { Range: `bytes=${byteRange.start}-${byteRange.end}` }
242
+ : {}),
243
+ });
244
+
245
+ const obj = await client.send(getCmd);
246
+ if (!obj.Body) {
247
+ throw new Error("Empty body from S3");
248
+ }
249
+
250
+ // obj.Body is a SdkStreamMixin — convert to Node.js Readable
251
+ const stream = obj.Body as Readable;
252
+ stream.pipe(res);
253
+ stream.on("error", (err) => {
254
+ console.error("[video-storage] stream pipe error:", err.message);
255
+ if (!res.headersSent) res.status(500).end();
256
+ });
257
+ } catch (err) {
258
+ console.error(
259
+ "[video-storage] stream error:",
260
+ err instanceof Error ? err.message : err,
261
+ );
262
+ if (!res.headersSent) res.status(404).json({ error: "Video not found in storage" });
263
+ }
264
+ }
artifacts/api-server/src/middlewares/.gitkeep ADDED
File without changes
artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Clerk Frontend API Proxy Middleware
3
+ *
4
+ * Proxies Clerk Frontend API requests through your domain, enabling Clerk
5
+ * authentication on custom domains and .replit.app deployments without
6
+ * requiring CNAME DNS configuration.
7
+ *
8
+ * See: https://clerk.com/docs/guides/dashboard/dns-domains/proxy-fapi
9
+ *
10
+ * IMPORTANT:
11
+ * - Only active in production (Clerk proxying doesn't work for dev instances)
12
+ * - Must be mounted BEFORE express.json() middleware
13
+ *
14
+ * Usage in app.ts:
15
+ * import { CLERK_PROXY_PATH, clerkProxyMiddleware } from "./middlewares/clerkProxyMiddleware";
16
+ * app.use(CLERK_PROXY_PATH, clerkProxyMiddleware());
17
+ */
18
+
19
+ import { createProxyMiddleware } from "http-proxy-middleware";
20
+ import type { RequestHandler } from "express";
21
+
22
+ const CLERK_FAPI = "https://frontend-api.clerk.dev";
23
+ export const CLERK_PROXY_PATH = "/api/__clerk";
24
+
25
+ export function clerkProxyMiddleware(): RequestHandler {
26
+ // Only run proxy in production — Clerk proxying doesn't work for dev instances
27
+ if (process.env.NODE_ENV !== "production") {
28
+ return (_req, _res, next) => next();
29
+ }
30
+
31
+ const secretKey = process.env.CLERK_SECRET_KEY;
32
+ if (!secretKey) {
33
+ return (_req, _res, next) => next();
34
+ }
35
+
36
+ return createProxyMiddleware({
37
+ target: CLERK_FAPI,
38
+ changeOrigin: true,
39
+ pathRewrite: (path: string) =>
40
+ path.replace(new RegExp(`^${CLERK_PROXY_PATH}`), ""),
41
+ on: {
42
+ proxyReq: (proxyReq, req) => {
43
+ const protocol = req.headers["x-forwarded-proto"] || "https";
44
+ const host = req.headers.host || "";
45
+ const proxyUrl = `${protocol}://${host}${CLERK_PROXY_PATH}`;
46
+
47
+ proxyReq.setHeader("Clerk-Proxy-Url", proxyUrl);
48
+ proxyReq.setHeader("Clerk-Secret-Key", secretKey);
49
+
50
+ const xff = req.headers["x-forwarded-for"];
51
+ const clientIp =
52
+ (Array.isArray(xff) ? xff[0] : xff)?.split(",")[0]?.trim() ||
53
+ req.socket?.remoteAddress ||
54
+ "";
55
+ if (clientIp) {
56
+ proxyReq.setHeader("X-Forwarded-For", clientIp);
57
+ }
58
+ },
59
+ },
60
+ }) as RequestHandler;
61
+ }
artifacts/api-server/src/routes/accounts.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import { db, geminiAccountsTable } from "@workspace/db";
3
+ import { eq, desc } from "drizzle-orm";
4
+ import { requireAdmin } from "./admin";
5
+ import { requireJwtAuth } from "./auth";
6
+
7
+ const router = Router();
8
+
9
+ router.use(requireJwtAuth);
10
+
11
+ router.get("/", requireAdmin, async (_req, res) => {
12
+ const rows = await db
13
+ .select()
14
+ .from(geminiAccountsTable)
15
+ .orderBy(desc(geminiAccountsTable.createdAt));
16
+
17
+ res.json(
18
+ rows.map((r) => ({
19
+ id: r.id,
20
+ label: r.label,
21
+ tokenPreview: r.bearerToken ? r.bearerToken.substring(0, 12) + "..." : null,
22
+ hasRefreshToken: !!r.refreshToken,
23
+ isActive: r.isActive,
24
+ lastUsedAt: r.lastUsedAt?.toISOString() ?? null,
25
+ createdAt: r.createdAt.toISOString(),
26
+ }))
27
+ );
28
+ });
29
+
30
+ router.post("/", requireAdmin, async (req, res) => {
31
+ const { label, bearerToken, refreshToken } = req.body as {
32
+ label?: string;
33
+ bearerToken?: string;
34
+ refreshToken?: string;
35
+ };
36
+
37
+ if (!bearerToken?.trim()) {
38
+ return res.status(400).json({ error: "bearerToken is required" });
39
+ }
40
+
41
+ const [inserted] = await db
42
+ .insert(geminiAccountsTable)
43
+ .values({
44
+ label: (label || "帳戶").trim(),
45
+ bearerToken: bearerToken.trim(),
46
+ refreshToken: refreshToken?.trim() || null,
47
+ isActive: true,
48
+ })
49
+ .returning();
50
+
51
+ res.json({
52
+ id: inserted.id,
53
+ label: inserted.label,
54
+ tokenPreview: inserted.bearerToken.substring(0, 12) + "...",
55
+ isActive: inserted.isActive,
56
+ createdAt: inserted.createdAt.toISOString(),
57
+ });
58
+ });
59
+
60
+ router.patch("/:id/label", requireAdmin, async (req, res) => {
61
+ const id = Number(req.params.id);
62
+ const { label } = req.body as { label?: string };
63
+ if (!label?.trim()) return res.status(400).json({ error: "label is required" });
64
+
65
+ await db.update(geminiAccountsTable).set({ label: label.trim() }).where(eq(geminiAccountsTable.id, id));
66
+ res.json({ success: true });
67
+ });
68
+
69
+ router.patch("/:id/toggle", requireAdmin, async (req, res) => {
70
+ const id = Number(req.params.id);
71
+ const rows = await db.select().from(geminiAccountsTable).where(eq(geminiAccountsTable.id, id)).limit(1);
72
+ if (!rows.length) return res.status(404).json({ error: "Account not found" });
73
+
74
+ const newActive = !rows[0].isActive;
75
+ await db.update(geminiAccountsTable).set({ isActive: newActive }).where(eq(geminiAccountsTable.id, id));
76
+ res.json({ success: true, isActive: newActive });
77
+ });
78
+
79
+ router.delete("/:id", requireAdmin, async (req, res) => {
80
+ const id = Number(req.params.id);
81
+ await db.delete(geminiAccountsTable).where(eq(geminiAccountsTable.id, id));
82
+ res.json({ success: true });
83
+ });
84
+
85
+ export default router;
artifacts/api-server/src/routes/admin.ts ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import bcrypt from "bcryptjs";
3
+ import { randomUUID } from "crypto";
4
+ import { db, usersTable, imagesTable, apiKeysTable, configTable, creditTransactionsTable } from "@workspace/db";
5
+ import { eq, count, desc, sql, inArray } from "drizzle-orm";
6
+ import { requireJwtAuth } from "./auth";
7
+ import { refreshAccessToken, encrypt, getStoredCredentials } from "./config";
8
+ import multer from "multer";
9
+ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
10
+
11
+ const s3 = new S3Client({
12
+ endpoint: process.env.S3_ENDPOINT || "https://s3.hi168.com",
13
+ region: "us-east-1",
14
+ credentials: {
15
+ accessKeyId: process.env.S3_ACCESS_KEY || "",
16
+ secretAccessKey: process.env.S3_SECRET_KEY || "",
17
+ },
18
+ forcePathStyle: true,
19
+ });
20
+ const S3_BUCKET = process.env.DEFAULT_OBJECT_STORAGE_BUCKET_ID || "hi168-25517-1756t1kf";
21
+ const S3_PUBLIC_BASE = `${process.env.S3_ENDPOINT || "https://s3.hi168.com"}/${S3_BUCKET}`;
22
+
23
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
24
+
25
+ const OTP_KEY = "bookmarklet_otp";
26
+
27
+ const router = Router();
28
+
29
+ async function requireAdmin(req: any, res: any, next: any) {
30
+ const user = await db
31
+ .select({ isAdmin: usersTable.isAdmin })
32
+ .from(usersTable)
33
+ .where(eq(usersTable.id, req.jwtUserId))
34
+ .limit(1);
35
+ if (!user[0]?.isAdmin) {
36
+ return res.status(403).json({ error: "Forbidden" });
37
+ }
38
+ next();
39
+ }
40
+
41
+ router.use(requireJwtAuth);
42
+ router.use(requireAdmin);
43
+
44
+ router.get("/stats", async (_req, res) => {
45
+ const [userCount] = await db.select({ count: count() }).from(usersTable);
46
+ const [imageCount] = await db.select({ count: count() }).from(imagesTable);
47
+ const [apiKeyCount] = await db.select({ count: count() }).from(apiKeysTable);
48
+ res.json({
49
+ users: Number(userCount.count),
50
+ images: Number(imageCount.count),
51
+ apiKeys: Number(apiKeyCount.count),
52
+ });
53
+ });
54
+
55
+ router.get("/users", async (_req, res) => {
56
+ const users = await db
57
+ .select({
58
+ id: usersTable.id,
59
+ email: usersTable.email,
60
+ displayName: usersTable.displayName,
61
+ isAdmin: usersTable.isAdmin,
62
+ createdAt: usersTable.createdAt,
63
+ })
64
+ .from(usersTable)
65
+ .orderBy(desc(usersTable.createdAt));
66
+ res.json({ users });
67
+ });
68
+
69
+ router.put("/users/:id", async (req, res) => {
70
+ const id = Number(req.params.id);
71
+ const { displayName, isAdmin, password } = req.body as {
72
+ displayName?: string;
73
+ isAdmin?: boolean;
74
+ password?: string;
75
+ };
76
+
77
+ const updates: Partial<typeof usersTable.$inferInsert> = {};
78
+ if (typeof displayName !== "undefined") updates.displayName = displayName || null;
79
+ if (typeof isAdmin === "boolean") updates.isAdmin = isAdmin;
80
+ if (password) {
81
+ if (password.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
82
+ updates.passwordHash = await bcrypt.hash(password, 12);
83
+ }
84
+
85
+ if (Object.keys(updates).length === 0) {
86
+ return res.status(400).json({ error: "Nothing to update" });
87
+ }
88
+
89
+ const [updated] = await db
90
+ .update(usersTable)
91
+ .set(updates)
92
+ .where(eq(usersTable.id, id))
93
+ .returning({
94
+ id: usersTable.id,
95
+ email: usersTable.email,
96
+ displayName: usersTable.displayName,
97
+ isAdmin: usersTable.isAdmin,
98
+ });
99
+
100
+ if (!updated) return res.status(404).json({ error: "User not found" });
101
+ res.json(updated);
102
+ });
103
+
104
+ router.delete("/users/:id", async (req, res) => {
105
+ const id = Number(req.params.id);
106
+ if (id === req.jwtUserId) {
107
+ return res.status(400).json({ error: "Cannot delete yourself" });
108
+ }
109
+ await db.delete(apiKeysTable).where(eq(apiKeysTable.userId, String(id)));
110
+ const deleted = await db.delete(usersTable).where(eq(usersTable.id, id)).returning({ id: usersTable.id });
111
+ if (!deleted.length) return res.status(404).json({ error: "User not found" });
112
+ res.json({ success: true });
113
+ });
114
+
115
+ router.get("/config", async (_req, res) => {
116
+ const rows = await db.select().from(configTable).orderBy(configTable.key);
117
+ res.json({ config: rows });
118
+ });
119
+
120
+ const CONFIG_KEY_MAP: Record<string, string> = {
121
+ refresh_token: "geminigen_refresh_token",
122
+ access_token: "geminigen_bearer_token",
123
+ capsolver_api_key: "capsolver_api_key",
124
+ playwright_solver_url: "playwright_solver_url",
125
+ playwright_solver_secret: "playwright_solver_secret",
126
+ yescaptcha_api_key: "yescaptcha_api_key",
127
+ };
128
+
129
+ router.put("/config", async (req, res) => {
130
+ const { key, value } = req.body as { key?: string; value?: string };
131
+ if (!key || typeof value === "undefined") {
132
+ return res.status(400).json({ error: "key and value are required" });
133
+ }
134
+ const dbKey = CONFIG_KEY_MAP[key];
135
+ if (!dbKey) {
136
+ return res.status(400).json({ error: "Invalid config key" });
137
+ }
138
+ await db
139
+ .insert(configTable)
140
+ .values({ key: dbKey, value, updatedAt: new Date() })
141
+ .onConflictDoUpdate({ target: configTable.key, set: { value, updatedAt: new Date() } });
142
+ res.json({ success: true });
143
+ });
144
+
145
+ router.get("/setup-status", async (_req, res) => {
146
+ const row = await db
147
+ .select({ key: configTable.key })
148
+ .from(configTable)
149
+ .where(eq(configTable.key, "geminigen_refresh_token"))
150
+ .limit(1);
151
+ res.json({ refreshTokenConfigured: row.length > 0 });
152
+ });
153
+
154
+ router.post("/setup", async (req, res) => {
155
+ const { refreshToken } = req.body as { refreshToken?: string };
156
+ if (!refreshToken?.trim()) {
157
+ return res.status(400).json({ error: "refreshToken is required" });
158
+ }
159
+ const value = refreshToken.trim();
160
+ if (value.length < 20) {
161
+ return res.status(400).json({ error: "Token seems too short — please copy the full refresh_token value." });
162
+ }
163
+ await db
164
+ .insert(configTable)
165
+ .values({ key: "geminigen_refresh_token", value, updatedAt: new Date() })
166
+ .onConflictDoUpdate({ target: configTable.key, set: { value, updatedAt: new Date() } });
167
+ refreshAccessToken().catch(() => {});
168
+ res.json({ success: true });
169
+ });
170
+
171
+ // Save geminigen.ai credentials for auto-renewal
172
+ router.post("/credentials", requireJwtAuth, requireAdmin, async (req, res) => {
173
+ const { username, password } = req.body as { username?: string; password?: string };
174
+ if (!username?.trim() || !password?.trim()) {
175
+ return res.status(400).json({ error: "username and password are required" });
176
+ }
177
+ await db
178
+ .insert(configTable)
179
+ .values({ key: "geminigen_username", value: username.trim(), updatedAt: new Date() })
180
+ .onConflictDoUpdate({ target: configTable.key, set: { value: username.trim(), updatedAt: new Date() } });
181
+ const encPass = encrypt(password.trim());
182
+ await db
183
+ .insert(configTable)
184
+ .values({ key: "geminigen_password_enc", value: encPass, updatedAt: new Date() })
185
+ .onConflictDoUpdate({ target: configTable.key, set: { value: encPass, updatedAt: new Date() } });
186
+
187
+ // Try to login with new credentials in background (may fail if Turnstile solver not configured)
188
+ // Do NOT block saving credentials on login success — Turnstile may prevent immediate login
189
+ refreshAccessToken().then((tok) => {
190
+ if (tok) console.log("[admin] Credential login succeeded after save");
191
+ else console.warn("[admin] Credential login failed after save (Turnstile may be required — token will refresh later)");
192
+ }).catch(() => {});
193
+ res.json({ success: true, note: "Credentials saved. Token will refresh automatically." });
194
+ });
195
+
196
+ // Check credential configuration status
197
+ router.get("/credentials", requireJwtAuth, requireAdmin, async (_req, res) => {
198
+ const creds = await getStoredCredentials();
199
+ res.json({
200
+ configured: !!creds,
201
+ username: creds?.username ?? null,
202
+ });
203
+ });
204
+
205
+ // Delete stored credentials
206
+ router.delete("/credentials", requireJwtAuth, requireAdmin, async (_req, res) => {
207
+ await db.delete(configTable).where(eq(configTable.key, "geminigen_username"));
208
+ await db.delete(configTable).where(eq(configTable.key, "geminigen_password_enc"));
209
+ res.json({ success: true });
210
+ });
211
+
212
+ // Generate a short-lived OTP for the bookmarklet token sync
213
+ router.post("/bookmarklet-otp", async (_req, res) => {
214
+ const otp = randomUUID();
215
+ const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes
216
+ await db
217
+ .insert(configTable)
218
+ .values({ key: OTP_KEY, value: `${otp}:${expiresAt}`, updatedAt: new Date() })
219
+ .onConflictDoUpdate({ target: configTable.key, set: { value: `${otp}:${expiresAt}`, updatedAt: new Date() } });
220
+ res.json({ otp, expiresInSeconds: 600 });
221
+ });
222
+
223
+ // ── Site Config (logo, google ads, credits settings) ─────────────────────────
224
+ const SITE_CONFIG_KEYS = [
225
+ "logo_url", "site_name",
226
+ "enable_credits", "image_gen_cost", "video_gen_cost", "signup_credits",
227
+ "google_ads_enabled", "google_ads_client", "google_ads_slot",
228
+ ];
229
+
230
+ router.get("/site-config", async (_req, res) => {
231
+ const rows = await db
232
+ .select()
233
+ .from(configTable)
234
+ .where(inArray(configTable.key, SITE_CONFIG_KEYS));
235
+ const config: Record<string, string> = {};
236
+ for (const row of rows) config[row.key] = row.value;
237
+ res.json(config);
238
+ });
239
+
240
+ router.put("/site-config", async (req, res) => {
241
+ const updates = req.body as Record<string, string>;
242
+ for (const [key, value] of Object.entries(updates)) {
243
+ if (!SITE_CONFIG_KEYS.includes(key)) continue;
244
+ await db
245
+ .insert(configTable)
246
+ .values({ key, value: String(value), updatedAt: new Date() })
247
+ .onConflictDoUpdate({ target: configTable.key, set: { value: String(value), updatedAt: new Date() } });
248
+ }
249
+ res.json({ success: true });
250
+ });
251
+
252
+ // ── Logo upload ───────────────────────────────────────────────────────────────
253
+ router.post("/logo", upload.single("logo"), async (req, res) => {
254
+ const file = (req as any).file as Express.Multer.File | undefined;
255
+ if (!file) return res.status(400).json({ error: "No file uploaded" });
256
+
257
+ const ext = file.originalname.split(".").pop()?.toLowerCase() || "png";
258
+ const key = `logos/site-logo.${ext}`;
259
+
260
+ await s3.send(new PutObjectCommand({
261
+ Bucket: S3_BUCKET,
262
+ Key: key,
263
+ Body: file.buffer,
264
+ ContentType: file.mimetype,
265
+ ACL: "public-read",
266
+ }));
267
+
268
+ const url = `${S3_PUBLIC_BASE}/${key}?t=${Date.now()}`;
269
+ await db
270
+ .insert(configTable)
271
+ .values({ key: "logo_url", value: url, updatedAt: new Date() })
272
+ .onConflictDoUpdate({ target: configTable.key, set: { value: url, updatedAt: new Date() } });
273
+
274
+ res.json({ success: true, url });
275
+ });
276
+
277
+ // ── Credits management ────────────────────────────────────────────────────────
278
+ router.get("/credits", async (_req, res) => {
279
+ const users = await db
280
+ .select({
281
+ id: usersTable.id,
282
+ email: usersTable.email,
283
+ displayName: usersTable.displayName,
284
+ credits: usersTable.credits,
285
+ isAdmin: usersTable.isAdmin,
286
+ })
287
+ .from(usersTable)
288
+ .orderBy(desc(usersTable.createdAt));
289
+ res.json({ users });
290
+ });
291
+
292
+ router.post("/credits/:userId/adjust", async (req, res) => {
293
+ const userId = Number(req.params.userId);
294
+ const { amount, description } = req.body as { amount?: number; description?: string };
295
+ if (!amount || isNaN(amount)) return res.status(400).json({ error: "amount is required" });
296
+
297
+ const [user] = await db
298
+ .update(usersTable)
299
+ .set({ credits: sql`GREATEST(0, ${usersTable.credits} + ${amount})` })
300
+ .where(eq(usersTable.id, userId))
301
+ .returning({ credits: usersTable.credits });
302
+
303
+ if (!user) return res.status(404).json({ error: "User not found" });
304
+
305
+ await db.insert(creditTransactionsTable).values({
306
+ userId,
307
+ amount,
308
+ type: amount > 0 ? "grant" : "deduct",
309
+ description: description || (amount > 0 ? "管理員手動增加" : "管理員手動扣除"),
310
+ });
311
+
312
+ res.json({ success: true, newBalance: user.credits });
313
+ });
314
+
315
+ export { OTP_KEY, requireAdmin, SITE_CONFIG_KEYS };
316
+ export default router;
artifacts/api-server/src/routes/apiKeys.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import { createHash, randomBytes } from "crypto";
3
+ import { requireJwtAuth } from "./auth";
4
+ import { db, apiKeysTable } from "@workspace/db";
5
+ import { eq, and } from "drizzle-orm";
6
+
7
+ const router = Router();
8
+
9
+ router.get("/", requireJwtAuth, async (req: any, res) => {
10
+ const keys = await db
11
+ .select({
12
+ id: apiKeysTable.id,
13
+ name: apiKeysTable.name,
14
+ keyPrefix: apiKeysTable.keyPrefix,
15
+ createdAt: apiKeysTable.createdAt,
16
+ lastUsedAt: apiKeysTable.lastUsedAt,
17
+ })
18
+ .from(apiKeysTable)
19
+ .where(eq(apiKeysTable.userId, String(req.jwtUserId)));
20
+
21
+ res.json({ keys });
22
+ });
23
+
24
+ router.post("/", requireJwtAuth, async (req: any, res) => {
25
+ const name = (req.body?.name as string)?.trim() || "Default Key";
26
+ const rawKey = `sk-sf-${randomBytes(24).toString("hex")}`;
27
+ const keyHash = createHash("sha256").update(rawKey).digest("hex");
28
+ const keyPrefix = rawKey.slice(0, 12) + "...";
29
+
30
+ const [inserted] = await db
31
+ .insert(apiKeysTable)
32
+ .values({ userId: String(req.jwtUserId), keyHash, keyPrefix, name })
33
+ .returning({
34
+ id: apiKeysTable.id,
35
+ name: apiKeysTable.name,
36
+ keyPrefix: apiKeysTable.keyPrefix,
37
+ createdAt: apiKeysTable.createdAt,
38
+ });
39
+
40
+ res.json({ key: rawKey, ...inserted });
41
+ });
42
+
43
+ router.delete("/:id", requireJwtAuth, async (req: any, res) => {
44
+ const id = Number(req.params.id);
45
+ if (isNaN(id)) return res.status(400).json({ error: "Invalid ID" });
46
+
47
+ const deleted = await db
48
+ .delete(apiKeysTable)
49
+ .where(and(eq(apiKeysTable.id, id), eq(apiKeysTable.userId, String(req.jwtUserId))))
50
+ .returning();
51
+
52
+ if (!deleted.length) return res.status(404).json({ error: "Not found" });
53
+ res.json({ success: true });
54
+ });
55
+
56
+ export default router;
artifacts/api-server/src/routes/auth.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import bcrypt from "bcryptjs";
3
+ import jwt from "jsonwebtoken";
4
+ import { db, usersTable, configTable, creditTransactionsTable } from "@workspace/db";
5
+ import { eq, count } from "drizzle-orm";
6
+
7
+ const router = Router();
8
+ const JWT_SECRET = process.env.SESSION_SECRET || "dev-secret-change-me";
9
+ const COOKIE_NAME = "sf_auth";
10
+ const COOKIE_OPTS = {
11
+ httpOnly: true,
12
+ sameSite: "lax" as const,
13
+ secure: process.env.NODE_ENV === "production",
14
+ maxAge: 30 * 24 * 60 * 60 * 1000,
15
+ path: "/",
16
+ };
17
+
18
+ function signToken(userId: number, email: string) {
19
+ return jwt.sign({ userId, email }, JWT_SECRET, { expiresIn: "30d" });
20
+ }
21
+
22
+ async function isFirstUser(): Promise<boolean> {
23
+ const [row] = await db.select({ count: count() }).from(usersTable);
24
+ return Number(row.count) === 0;
25
+ }
26
+
27
+ router.post("/signup", async (req, res) => {
28
+ const { email, password, displayName } = req.body as {
29
+ email?: string;
30
+ password?: string;
31
+ displayName?: string;
32
+ };
33
+
34
+ if (!email || !password) {
35
+ return res.status(400).json({ error: "Email and password are required" });
36
+ }
37
+ if (password.length < 6) {
38
+ return res.status(400).json({ error: "Password must be at least 6 characters" });
39
+ }
40
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
41
+ return res.status(400).json({ error: "Invalid email format" });
42
+ }
43
+
44
+ const existing = await db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1);
45
+ if (existing.length > 0) {
46
+ return res.status(409).json({ error: "Email already registered" });
47
+ }
48
+
49
+ const firstUser = await isFirstUser();
50
+ const passwordHash = await bcrypt.hash(password, 12);
51
+ const [user] = await db.insert(usersTable).values({
52
+ email: email.toLowerCase(),
53
+ passwordHash,
54
+ displayName: displayName?.trim() || null,
55
+ isAdmin: firstUser,
56
+ }).returning({
57
+ id: usersTable.id,
58
+ email: usersTable.email,
59
+ displayName: usersTable.displayName,
60
+ isAdmin: usersTable.isAdmin,
61
+ });
62
+
63
+ // Grant signup credits from config
64
+ const signupCreditRow = await db.select({ value: configTable.value }).from(configTable).where(eq(configTable.key, "signup_credits")).limit(1);
65
+ const signupCredits = Number(signupCreditRow[0]?.value) || 0;
66
+ if (signupCredits > 0) {
67
+ await db.update(usersTable).set({ credits: signupCredits }).where(eq(usersTable.id, user.id));
68
+ await db.insert(creditTransactionsTable).values({ userId: user.id, amount: signupCredits, type: "signup", description: "新用戶註冊贈點" });
69
+ }
70
+
71
+ const token = signToken(user.id, user.email);
72
+ res.cookie(COOKIE_NAME, token, COOKIE_OPTS);
73
+ res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin, credits: signupCredits });
74
+ });
75
+
76
+ router.post("/login", async (req, res) => {
77
+ const { email, password } = req.body as { email?: string; password?: string };
78
+
79
+ if (!email || !password) {
80
+ return res.status(400).json({ error: "Email and password are required" });
81
+ }
82
+
83
+ const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1);
84
+ if (!user) {
85
+ return res.status(401).json({ error: "Invalid email or password" });
86
+ }
87
+
88
+ const valid = await bcrypt.compare(password, user.passwordHash);
89
+ if (!valid) {
90
+ return res.status(401).json({ error: "Invalid email or password" });
91
+ }
92
+
93
+ const token = signToken(user.id, user.email);
94
+ res.cookie(COOKIE_NAME, token, COOKIE_OPTS);
95
+ res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin });
96
+ });
97
+
98
+ router.post("/logout", (_req, res) => {
99
+ res.clearCookie(COOKIE_NAME, { path: "/" });
100
+ res.json({ success: true });
101
+ });
102
+
103
+ router.get("/me", async (req, res) => {
104
+ const token = req.cookies?.[COOKIE_NAME];
105
+ if (!token) return res.status(401).json({ error: "Not authenticated" });
106
+
107
+ try {
108
+ const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
109
+ const [user] = await db
110
+ .select({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, isAdmin: usersTable.isAdmin })
111
+ .from(usersTable)
112
+ .where(eq(usersTable.id, payload.userId))
113
+ .limit(1);
114
+ if (!user) {
115
+ res.clearCookie(COOKIE_NAME, { path: "/" });
116
+ return res.status(401).json({ error: "User not found" });
117
+ }
118
+ res.json(user);
119
+ } catch {
120
+ res.clearCookie(COOKIE_NAME, { path: "/" });
121
+ res.status(401).json({ error: "Session expired" });
122
+ }
123
+ });
124
+
125
+ export function requireJwtAuth(req: any, res: any, next: any) {
126
+ const token = req.cookies?.[COOKIE_NAME];
127
+ if (!token) return res.status(401).json({ error: "Unauthorized" });
128
+
129
+ try {
130
+ const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
131
+ req.jwtUserId = payload.userId;
132
+ req.jwtEmail = payload.email;
133
+ next();
134
+ } catch {
135
+ res.clearCookie(COOKIE_NAME, { path: "/" });
136
+ res.status(401).json({ error: "Session expired" });
137
+ }
138
+ }
139
+
140
+ export function optionalJwtAuth(req: any, _res: any, next: any) {
141
+ const token = req.cookies?.[COOKIE_NAME];
142
+ if (token) {
143
+ try {
144
+ const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
145
+ req.jwtUserId = payload.userId;
146
+ req.jwtEmail = payload.email;
147
+ } catch {
148
+ // invalid token, continue as guest
149
+ }
150
+ }
151
+ next();
152
+ }
153
+
154
+ export default router;
artifacts/api-server/src/routes/config.ts ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import { db, configTable, geminiAccountsTable } from "@workspace/db";
3
+ import { eq, asc, isNull, and, notInArray } from "drizzle-orm";
4
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
5
+ import { getTurnstileToken, invalidateTurnstileToken } from "../captcha.js";
6
+ import { generateGuardId } from "../guardId.js";
7
+
8
+ const router = Router();
9
+
10
+ const TOKEN_KEY = "geminigen_bearer_token";
11
+ const REFRESH_TOKEN_KEY = "geminigen_refresh_token";
12
+ const USERNAME_KEY = "geminigen_username";
13
+ const PASSWORD_KEY = "geminigen_password_enc";
14
+ const GEMINIGEN_BASE = "https://api.geminigen.ai/api";
15
+ const GEMINIGEN_REFRESH_URL = `${GEMINIGEN_BASE}/refresh-token`;
16
+ const GEMINIGEN_SIGNIN_URL = `${GEMINIGEN_BASE}/login-v2`;
17
+ const UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1";
18
+
19
+ // ── Encryption helpers (AES-256-GCM) ─────────────────────────────────────────
20
+ function getEncKey(): Buffer {
21
+ const secret = process.env.SESSION_SECRET || "starforge-default-secret-change-me";
22
+ return scryptSync(secret, "starforge-salt", 32);
23
+ }
24
+
25
+ export function encrypt(text: string): string {
26
+ const iv = randomBytes(12);
27
+ const cipher = createCipheriv("aes-256-gcm", getEncKey(), iv);
28
+ const enc = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
29
+ const tag = cipher.getAuthTag();
30
+ return iv.toString("hex") + ":" + tag.toString("hex") + ":" + enc.toString("hex");
31
+ }
32
+
33
+ export function decrypt(encoded: string): string {
34
+ const [ivHex, tagHex, encHex] = encoded.split(":");
35
+ const decipher = createDecipheriv("aes-256-gcm", getEncKey(), Buffer.from(ivHex, "hex"));
36
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
37
+ return decipher.update(Buffer.from(encHex, "hex")).toString("utf8") + decipher.final("utf8");
38
+ }
39
+
40
+ // ── DB helpers ────────────────────────────────────────────────────────────────
41
+ async function getConfig(key: string): Promise<string | null> {
42
+ const row = await db.select().from(configTable).where(eq(configTable.key, key)).limit(1);
43
+ return row.length > 0 ? row[0].value : null;
44
+ }
45
+
46
+ async function setConfig(key: string, value: string): Promise<void> {
47
+ await db
48
+ .insert(configTable)
49
+ .values({ key, value, updatedAt: new Date() })
50
+ .onConflictDoUpdate({ target: configTable.key, set: { value, updatedAt: new Date() } });
51
+ }
52
+
53
+ // ── Public helpers ────────────────────────────────────────────────────────────
54
+ export async function getStoredToken(): Promise<string | null> {
55
+ return getConfig(TOKEN_KEY);
56
+ }
57
+
58
+ export async function getStoredRefreshToken(): Promise<string | null> {
59
+ return getConfig(REFRESH_TOKEN_KEY);
60
+ }
61
+
62
+ /**
63
+ * On-demand token getter that proactively refreshes if the stored token is
64
+ * older than 50 minutes. Safe to call in serverless environments where there
65
+ * is no background refresh loop running.
66
+ */
67
+ export async function getValidBearerToken(): Promise<string | null> {
68
+ const rows = await db
69
+ .select()
70
+ .from(configTable)
71
+ .where(eq(configTable.key, TOKEN_KEY))
72
+ .limit(1);
73
+
74
+ if (!rows.length) return null;
75
+
76
+ const row = rows[0];
77
+ const ageMs = Date.now() - new Date(row.updatedAt).getTime();
78
+ const fiftyMinutes = 50 * 60 * 1000;
79
+
80
+ if (ageMs > fiftyMinutes) {
81
+ console.log("[token] Token age > 50 min, proactively refreshing...");
82
+ const fresh = await refreshAccessToken();
83
+ if (fresh) return fresh;
84
+ }
85
+
86
+ return row.value;
87
+ }
88
+
89
+ export async function getStoredCredentials(): Promise<{ username: string; password: string } | null> {
90
+ const [username, passwordEnc] = await Promise.all([getConfig(USERNAME_KEY), getConfig(PASSWORD_KEY)]);
91
+ if (!username || !passwordEnc) return null;
92
+ try {
93
+ return { username, password: decrypt(passwordEnc) };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ // ── Login with email/password → get fresh access+refresh tokens ───────────────
100
+ async function loginWithCredentials(): Promise<{ accessToken: string; refreshToken: string } | null> {
101
+ const creds = await getStoredCredentials();
102
+ if (!creds) return null;
103
+
104
+ try {
105
+ console.log("[token-refresh] Trying credential re-login via /login-v2...");
106
+ // /api/login-v2 requires: username, password, turnstile_token (JSON body)
107
+ const turnstileToken = await getTurnstileToken();
108
+ const resp = await fetch(GEMINIGEN_SIGNIN_URL, {
109
+ method: "POST",
110
+ headers: {
111
+ "Content-Type": "application/json",
112
+ "User-Agent": UA,
113
+ "x-guard-id": generateGuardId("/api/login-v2", "post"),
114
+ },
115
+ body: JSON.stringify({ username: creds.username, password: creds.password, turnstile_token: turnstileToken }),
116
+ });
117
+
118
+ const text = await resp.text();
119
+ console.log(`[token-refresh] signin → HTTP ${resp.status}: ${text.slice(0, 200)}`);
120
+
121
+ if (!resp.ok) {
122
+ invalidateTurnstileToken(); // force fresh token next attempt
123
+ return null;
124
+ }
125
+ const data = JSON.parse(text) as { access_token?: string; refresh_token?: string };
126
+ if (!data.access_token) return null;
127
+
128
+ return { accessToken: data.access_token, refreshToken: data.refresh_token || "" };
129
+ } catch (e: any) {
130
+ console.log(`[token-refresh] signin threw: ${e?.message}`);
131
+ return null;
132
+ }
133
+ }
134
+
135
+ // ── Core: refresh access token (with auto-login fallback) ─────────────────────
136
+ export async function refreshAccessToken(): Promise<string | null> {
137
+ const refreshToken = await getStoredRefreshToken();
138
+
139
+ // ① Try native refresh endpoint if we have a refresh_token
140
+ if (refreshToken) {
141
+ try {
142
+ console.log("[token-refresh] Trying refresh_token endpoint...");
143
+ const resp = await fetch(GEMINIGEN_REFRESH_URL, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ "User-Agent": UA,
148
+ "x-guard-id": generateGuardId("/api/refresh-token", "post"),
149
+ },
150
+ body: JSON.stringify({ refresh_token: refreshToken }),
151
+ });
152
+
153
+ const text = await resp.text();
154
+ console.log(`[token-refresh] refresh → HTTP ${resp.status}: ${text.slice(0, 200)}`);
155
+
156
+ if (resp.ok) {
157
+ const data = JSON.parse(text) as { access_token?: string; refresh_token?: string };
158
+ if (data.access_token) {
159
+ await setConfig(TOKEN_KEY, data.access_token);
160
+ if (data.refresh_token) await setConfig(REFRESH_TOKEN_KEY, data.refresh_token);
161
+ console.log("[token-refresh] Success via refresh_token");
162
+ return data.access_token;
163
+ }
164
+ }
165
+
166
+ const isExpired = text.includes("REFRESH_TOKEN_EXPIRED") || text.includes("expired");
167
+ if (!isExpired) {
168
+ console.warn("[token-refresh] Refresh failed for unknown reason — won't try credentials");
169
+ return null;
170
+ }
171
+ console.log("[token-refresh] Refresh token expired — falling back to credential login");
172
+ } catch (e: any) {
173
+ console.log(`[token-refresh] refresh threw: ${e?.message}`);
174
+ }
175
+ } else {
176
+ console.log("[token-refresh] No refresh token stored — trying credential login directly");
177
+ }
178
+
179
+ // ② Fallback: login with stored email/password
180
+ const result = await loginWithCredentials();
181
+ if (!result) {
182
+ console.warn("[token-refresh] All strategies failed — no credentials stored or login failed");
183
+ return null;
184
+ }
185
+
186
+ await setConfig(TOKEN_KEY, result.accessToken);
187
+ if (result.refreshToken) await setConfig(REFRESH_TOKEN_KEY, result.refreshToken);
188
+ console.log("[token-refresh] Success via credential re-login");
189
+ return result.accessToken;
190
+ }
191
+
192
+ // ── Token Pool helpers ────────────────────────────────────────────────────────
193
+
194
+ const GEMINIGEN_REFRESH = "https://api.geminigen.ai/api/refresh-token";
195
+
196
+ /** Get the least-recently-used active account from the pool. Returns null if pool is empty. */
197
+ export async function getPoolToken(excludeIds: number[] = []): Promise<{ token: string; accountId: number } | null> {
198
+ const query = db
199
+ .select()
200
+ .from(geminiAccountsTable)
201
+ .where(
202
+ excludeIds.length > 0
203
+ ? and(eq(geminiAccountsTable.isActive, true), notInArray(geminiAccountsTable.id, excludeIds))
204
+ : eq(geminiAccountsTable.isActive, true)
205
+ )
206
+ .orderBy(asc(geminiAccountsTable.lastUsedAt))
207
+ .limit(1);
208
+
209
+ const rows = await query;
210
+ if (!rows.length) return null;
211
+ const account = rows[0];
212
+ return { token: account.bearerToken, accountId: account.id };
213
+ }
214
+
215
+ /** Mark an account as just used (update lastUsedAt). */
216
+ export async function markAccountUsed(accountId: number): Promise<void> {
217
+ await db
218
+ .update(geminiAccountsTable)
219
+ .set({ lastUsedAt: new Date() })
220
+ .where(eq(geminiAccountsTable.id, accountId));
221
+ }
222
+
223
+ /** Try to refresh a specific pool account's token via its refresh_token. Returns new token or null. */
224
+ export async function tryRefreshPoolAccount(accountId: number): Promise<string | null> {
225
+ const rows = await db.select().from(geminiAccountsTable).where(eq(geminiAccountsTable.id, accountId)).limit(1);
226
+ if (!rows.length) return null;
227
+ const account = rows[0];
228
+ if (!account.refreshToken) return null;
229
+
230
+ try {
231
+ const resp = await fetch(GEMINIGEN_REFRESH, {
232
+ method: "POST",
233
+ headers: {
234
+ "Content-Type": "application/json",
235
+ "User-Agent": UA,
236
+ "x-guard-id": generateGuardId("/api/refresh-token", "post"),
237
+ },
238
+ body: JSON.stringify({ refresh_token: account.refreshToken }),
239
+ });
240
+ if (!resp.ok) return null;
241
+ const data = await resp.json() as { access_token?: string; refresh_token?: string };
242
+ if (!data.access_token) return null;
243
+
244
+ await db.update(geminiAccountsTable).set({
245
+ bearerToken: data.access_token,
246
+ ...(data.refresh_token ? { refreshToken: data.refresh_token } : {}),
247
+ }).where(eq(geminiAccountsTable.id, accountId));
248
+
249
+ console.log(`[pool] Account ${accountId} token refreshed`);
250
+ return data.access_token;
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /** Disable an account in the pool (e.g., when all refresh strategies fail). */
257
+ export async function disablePoolAccount(accountId: number): Promise<void> {
258
+ await db.update(geminiAccountsTable).set({ isActive: false }).where(eq(geminiAccountsTable.id, accountId));
259
+ console.warn(`[pool] Account ${accountId} disabled — token unrecoverable`);
260
+ }
261
+
262
+ // ── Routes ────────────────────────────────────────────────────────────────────
263
+ router.get("/token", async (_req, res) => {
264
+ const token = await getConfig(TOKEN_KEY);
265
+ if (!token) return res.json({ configured: false, token: null });
266
+ return res.json({ configured: true, token: token.substring(0, 10) + "..." });
267
+ });
268
+
269
+ router.post("/token", async (req, res) => {
270
+ const { token, refreshToken } = req.body as { token?: string; refreshToken?: string };
271
+ if (!token?.trim()) return res.status(400).json({ error: "INVALID_TOKEN", message: "Token is required" });
272
+
273
+ await setConfig(TOKEN_KEY, token.trim());
274
+ if (refreshToken?.trim()) await setConfig(REFRESH_TOKEN_KEY, refreshToken.trim());
275
+
276
+ res.json({ success: true, message: "Token saved successfully" });
277
+ });
278
+
279
+ router.delete("/token", async (_req, res) => {
280
+ await db.delete(configTable).where(eq(configTable.key, TOKEN_KEY));
281
+ await db.delete(configTable).where(eq(configTable.key, REFRESH_TOKEN_KEY));
282
+ res.json({ success: true, message: "Token removed" });
283
+ });
284
+
285
+ export default router;
artifacts/api-server/src/routes/health.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, type IRouter } from "express";
2
+ import { HealthCheckResponse } from "@workspace/api-zod";
3
+
4
+ const router: IRouter = Router();
5
+
6
+ router.get("/healthz", (_req, res) => {
7
+ const data = HealthCheckResponse.parse({ status: "ok" });
8
+ res.json(data);
9
+ });
10
+
11
+ export default router;
artifacts/api-server/src/routes/images.ts ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import { db, imagesTable, usersTable, configTable, creditTransactionsTable } from "@workspace/db";
3
+ import {
4
+ GenerateImageBody,
5
+ GetImageHistoryQueryParams,
6
+ DeleteImageParams,
7
+ } from "@workspace/api-zod";
8
+ import { desc, eq, count, and, or, isNull, sql } from "drizzle-orm";
9
+ import {
10
+ getValidBearerToken, refreshAccessToken,
11
+ getPoolToken, markAccountUsed, tryRefreshPoolAccount, disablePoolAccount,
12
+ } from "./config";
13
+ import { optionalJwtAuth } from "./auth";
14
+ import { generateGuardId } from "../guardId";
15
+
16
+ const router = Router();
17
+
18
+ const GEMINIGEN_BASE = "https://api.geminigen.ai/api";
19
+
20
+ const STYLE_PROMPTS: Record<string, string> = {
21
+ realistic: "photorealistic, high quality, detailed, 8k resolution",
22
+ anime: "anime style, manga art style, japanese animation",
23
+ artistic: "artistic, fine art, expressive brushwork",
24
+ cartoon: "cartoon style, colorful, fun illustration",
25
+ sketch: "pencil sketch, hand drawn, black and white drawing",
26
+ oil_painting: "oil painting, classical art style, textured canvas",
27
+ watercolor: "watercolor painting, soft colors, fluid brushstrokes",
28
+ digital_art: "digital art, concept art, highly detailed digital illustration",
29
+ };
30
+
31
+ const ORIENTATION_MAP: Record<string, string> = {
32
+ "1:1": "square",
33
+ "16:9": "landscape",
34
+ "9:16": "portrait",
35
+ "4:3": "landscape",
36
+ "3:4": "portrait",
37
+ "2:3": "portrait",
38
+ "3:2": "landscape",
39
+ };
40
+
41
+ const USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1";
42
+
43
+ function base64ToBlob(base64: string, mime: string): Blob {
44
+ const binary = Buffer.from(base64, "base64");
45
+ return new Blob([binary], { type: mime });
46
+ }
47
+
48
+ async function pollForImage(uuid: string, token: string, maxWaitMs = 120000): Promise<string | null> {
49
+ const interval = 3000;
50
+ const start = Date.now();
51
+
52
+ while (Date.now() - start < maxWaitMs) {
53
+ await new Promise((r) => setTimeout(r, interval));
54
+
55
+ const resp = await fetch(`${GEMINIGEN_BASE}/history/${uuid}`, {
56
+ headers: {
57
+ Authorization: `Bearer ${token}`,
58
+ "x-guard-id": generateGuardId("/api/history/" + uuid, "get"),
59
+ "User-Agent": USER_AGENT,
60
+ Accept: "application/json",
61
+ },
62
+ });
63
+
64
+ if (!resp.ok) break;
65
+
66
+ const data = await resp.json() as {
67
+ status?: number;
68
+ generated_image?: Array<{ image_url?: string }>;
69
+ };
70
+
71
+ if (data.status === 2 || data.status === 3) {
72
+ return data.generated_image?.[0]?.image_url || null;
73
+ }
74
+
75
+ if (typeof data.status === "number" && data.status > 3) break;
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ async function callGrokEndpoint(prompt: string, orientation: string, token: string, refImageBase64?: string, refImageMime?: string) {
82
+ const form = new FormData();
83
+ form.append("prompt", prompt);
84
+ form.append("orientation", orientation);
85
+ form.append("num_result", "1");
86
+
87
+ if (refImageBase64 && refImageMime) {
88
+ const blob = base64ToBlob(refImageBase64, refImageMime);
89
+ form.append("files", blob, "reference.jpg");
90
+ }
91
+
92
+ const resp = await fetch(`${GEMINIGEN_BASE}/imagen/grok`, {
93
+ method: "POST",
94
+ headers: { Authorization: `Bearer ${token}`, "x-guard-id": generateGuardId("/api/imagen/grok", "post"), "User-Agent": USER_AGENT, Accept: "application/json" },
95
+ body: form,
96
+ });
97
+
98
+ const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") }));
99
+ return { status: resp.status, body };
100
+ }
101
+
102
+ async function callMetaEndpoint(prompt: string, orientation: string, token: string, refImageBase64?: string, refImageMime?: string) {
103
+ const form = new FormData();
104
+ form.append("prompt", prompt);
105
+ form.append("orientation", orientation);
106
+ form.append("num_result", "1");
107
+
108
+ if (refImageBase64 && refImageMime) {
109
+ const blob = base64ToBlob(refImageBase64, refImageMime);
110
+ form.append("files", blob, "reference.jpg");
111
+ }
112
+
113
+ const resp = await fetch(`${GEMINIGEN_BASE}/meta_ai/generate`, {
114
+ method: "POST",
115
+ headers: { Authorization: `Bearer ${token}`, "x-guard-id": generateGuardId("/api/meta_ai/generate", "post"), "User-Agent": USER_AGENT, Accept: "application/json" },
116
+ body: form,
117
+ });
118
+
119
+ const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") }));
120
+ return { status: resp.status, body };
121
+ }
122
+
123
+ async function callImagenEndpoint(model: string, prompt: string, aspectRatio: string, style: string, token: string, refImageBase64?: string, refImageMime?: string, resolution?: string) {
124
+ const form = new FormData();
125
+ form.append("prompt", prompt);
126
+ form.append("model", model);
127
+ form.append("aspect_ratio", aspectRatio);
128
+ form.append("output_format", "jpg");
129
+ if (resolution) form.append("resolution", resolution);
130
+
131
+ if (refImageBase64 && refImageMime) {
132
+ const blob = base64ToBlob(refImageBase64, refImageMime);
133
+ form.append("files", blob, "reference.jpg");
134
+ }
135
+
136
+ const resp = await fetch(`${GEMINIGEN_BASE}/generate_image`, {
137
+ method: "POST",
138
+ headers: { Authorization: `Bearer ${token}`, "x-guard-id": generateGuardId("/api/generate_image", "post"), "User-Agent": USER_AGENT, Accept: "application/json" },
139
+ body: form,
140
+ });
141
+
142
+ const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") }));
143
+ return { status: resp.status, body };
144
+ }
145
+
146
+ // ── Credit helpers ─────────────────────────────────────────────────────────────
147
+ async function getConfigVal(key: string): Promise<string | null> {
148
+ const rows = await db.select({ value: configTable.value }).from(configTable).where(eq(configTable.key, key)).limit(1);
149
+ return rows[0]?.value ?? null;
150
+ }
151
+
152
+ async function checkAndDeductCredits(userId: number, cost: number, description: string): Promise<{ ok: boolean; balance?: number }> {
153
+ const enabled = await getConfigVal("enable_credits");
154
+ if (enabled !== "true") return { ok: true };
155
+
156
+ const [user] = await db.select({ credits: usersTable.credits }).from(usersTable).where(eq(usersTable.id, userId)).limit(1);
157
+ if (!user) return { ok: false };
158
+ if (user.credits < cost) return { ok: false, balance: user.credits };
159
+
160
+ const [updated] = await db
161
+ .update(usersTable)
162
+ .set({ credits: sql`${usersTable.credits} - ${cost}` })
163
+ .where(eq(usersTable.id, userId))
164
+ .returning({ credits: usersTable.credits });
165
+
166
+ await db.insert(creditTransactionsTable).values({
167
+ userId,
168
+ amount: -cost,
169
+ type: "spend",
170
+ description,
171
+ });
172
+
173
+ return { ok: true, balance: updated.credits };
174
+ }
175
+
176
+ router.post("/generate", optionalJwtAuth, async (req, res) => {
177
+ const bodyResult = GenerateImageBody.safeParse(req.body);
178
+ if (!bodyResult.success) {
179
+ return res.status(400).json({ error: "VALIDATION_ERROR", message: "Invalid request body" });
180
+ }
181
+
182
+ const {
183
+ prompt,
184
+ style = "realistic",
185
+ aspectRatio = "1:1",
186
+ model = "grok",
187
+ resolution,
188
+ referenceImageBase64,
189
+ referenceImageMime,
190
+ isPrivate = false,
191
+ } = bodyResult.data as any;
192
+
193
+ const userId: number | null = (req as any).jwtUserId ?? null;
194
+
195
+ // ── Credits check ────────────────────────────────────────────────────────────
196
+ if (userId !== null) {
197
+ const costStr = await getConfigVal("image_gen_cost");
198
+ const cost = Number(costStr) || 0;
199
+ if (cost > 0) {
200
+ const creditResult = await checkAndDeductCredits(userId, cost, `圖片生成(${model})`);
201
+ if (!creditResult.ok) {
202
+ return res.status(402).json({
203
+ error: "INSUFFICIENT_CREDITS",
204
+ message: `點數不足,此操作需要 ${cost} 點`,
205
+ balance: creditResult.balance ?? 0,
206
+ });
207
+ }
208
+ }
209
+ }
210
+
211
+ const stylePrompt = style === "none" ? "" : (STYLE_PROMPTS[style] || "");
212
+ const fullPrompt = stylePrompt ? `${prompt}, ${stylePrompt}` : prompt;
213
+ const orientation = ORIENTATION_MAP[aspectRatio] || "square";
214
+
215
+ const isImagenModel = model === "imagen-pro" || model === "imagen-4" || model === "imagen-flash" || model === "nano-banana-pro" || model === "nano-banana-2";
216
+ const apiModelId = isImagenModel ? model : model;
217
+
218
+ let imageUrl = "";
219
+ let usedFallback = false;
220
+ let fallbackReason = "";
221
+ let responseStatus = 0;
222
+ let responseBody: unknown = {};
223
+ let pollResult: Record<string, unknown> = {};
224
+ const startTime = Date.now();
225
+
226
+ // ── Pool-aware token selection ──────────────────────────────────────────────
227
+ const failedPoolIds: number[] = [];
228
+ let currentAccountId: number | null = null;
229
+
230
+ async function pickToken(): Promise<string | null> {
231
+ const poolEntry = await getPoolToken(failedPoolIds);
232
+ if (poolEntry) {
233
+ currentAccountId = poolEntry.accountId;
234
+ return poolEntry.token;
235
+ }
236
+ currentAccountId = null;
237
+ return getValidBearerToken();
238
+ }
239
+
240
+ async function handleTokenExpiry(): Promise<string | null> {
241
+ if (currentAccountId !== null) {
242
+ const refreshed = await tryRefreshPoolAccount(currentAccountId);
243
+ if (refreshed) return refreshed;
244
+ failedPoolIds.push(currentAccountId);
245
+ const next = await getPoolToken(failedPoolIds);
246
+ if (next) {
247
+ currentAccountId = next.accountId;
248
+ return next.token;
249
+ }
250
+ }
251
+ return refreshAccessToken();
252
+ }
253
+
254
+ let token = await pickToken();
255
+
256
+ const requestInfo = {
257
+ url: isImagenModel
258
+ ? `${GEMINIGEN_BASE}/generate_image`
259
+ : model === "meta"
260
+ ? `${GEMINIGEN_BASE}/meta_ai/generate`
261
+ : `${GEMINIGEN_BASE}/imagen/grok`,
262
+ model: isImagenModel ? apiModelId : model,
263
+ fields: isImagenModel
264
+ ? { prompt: fullPrompt, model: apiModelId, aspect_ratio: aspectRatio, output_format: "jpg", ...(resolution ? { resolution } : {}), hasReferenceImage: !!referenceImageBase64 }
265
+ : { prompt: fullPrompt, orientation, num_result: "1", hasReferenceImage: !!referenceImageBase64 },
266
+ };
267
+
268
+ try {
269
+ if (!token) throw new Error("未設定 API Token,請到設定頁面輸入 token");
270
+
271
+ let result: { status: number; body: unknown };
272
+
273
+ if (model === "grok") {
274
+ result = await callGrokEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime);
275
+ } else if (model === "meta") {
276
+ result = await callMetaEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime);
277
+ } else {
278
+ result = await callImagenEndpoint(apiModelId, fullPrompt, aspectRatio, style, token, referenceImageBase64, referenceImageMime, resolution);
279
+ }
280
+
281
+ if (result.status === 401) {
282
+ const newToken = await handleTokenExpiry();
283
+ if (!newToken) throw new Error("Token 已過期且無法自動刷新");
284
+ token = newToken;
285
+
286
+ if (model === "grok") result = await callGrokEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime);
287
+ else if (model === "meta") result = await callMetaEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime);
288
+ else result = await callImagenEndpoint(apiModelId, fullPrompt, aspectRatio, style, token, referenceImageBase64, referenceImageMime, resolution);
289
+ }
290
+
291
+ responseStatus = result.status;
292
+ responseBody = result.body;
293
+
294
+ const data = result.body as { uuid?: string; base64_images?: string; generated_image?: Array<{ image_url?: string }>; detail?: { error_code?: string; error_message?: string } };
295
+
296
+ const errMsg = (data?.detail?.error_message || "").toLowerCase();
297
+ const isTokenExpired = result.status === 401 || result.status === 403
298
+ || data?.detail?.error_code === "TOKEN_EXPIRED"
299
+ || errMsg.includes("expired") || errMsg.includes("token");
300
+
301
+ if (isTokenExpired) {
302
+ const newToken = await handleTokenExpiry();
303
+ if (!newToken) throw new Error("Token 已過期且無法自動刷新,請重新取得");
304
+ token = newToken;
305
+
306
+ let retryResult: { status: number; body: unknown };
307
+ if (model === "grok") retryResult = await callGrokEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime);
308
+ else if (model === "meta") retryResult = await callMetaEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime);
309
+ else retryResult = await callImagenEndpoint(apiModelId, fullPrompt, aspectRatio, style, token, referenceImageBase64, referenceImageMime, resolution);
310
+
311
+ responseStatus = retryResult.status;
312
+ responseBody = retryResult.body;
313
+ Object.assign(data, retryResult.body);
314
+ }
315
+
316
+ if (!result.status.toString().startsWith("2") && !isTokenExpired) {
317
+ const msg = (data as any)?.detail?.error_message || (data as any)?.detail?.error_code || `HTTP ${result.status}`;
318
+ throw new Error(`API 錯誤:${msg}`);
319
+ }
320
+
321
+ const finalData = responseBody as typeof data;
322
+
323
+ if ((finalData as any)?.detail?.error_code && (finalData as any)?.detail?.error_code !== "TOKEN_EXPIRED") {
324
+ const msg = (finalData as any)?.detail?.error_message || (finalData as any)?.detail?.error_code;
325
+ throw new Error(`API 錯誤:${msg}`);
326
+ }
327
+
328
+ if (data.base64_images) {
329
+ imageUrl = `data:image/png;base64,${data.base64_images}`;
330
+ pollResult = { type: "immediate_base64" };
331
+ } else if (data.generated_image?.[0]?.image_url) {
332
+ imageUrl = data.generated_image[0].image_url;
333
+ pollResult = { type: "immediate_url" };
334
+ } else if (data.uuid) {
335
+ pollResult.uuid = data.uuid;
336
+ const polledUrl = await pollForImage(data.uuid, token);
337
+ if (!polledUrl) throw new Error("圖片生成逾時或未返回結果");
338
+ imageUrl = polledUrl;
339
+ pollResult.imageUrl = imageUrl;
340
+ pollResult.status = "completed";
341
+ } else {
342
+ throw new Error("API 未返回圖片或任務 UUID");
343
+ }
344
+ } catch (err: unknown) {
345
+ const errMsg = err instanceof Error ? err.message : String(err);
346
+ req.log.warn({ err }, "Image generation failed, using fallback");
347
+ usedFallback = true;
348
+ fallbackReason = errMsg;
349
+
350
+ const fallbackSizes: Record<string, string> = {
351
+ "1:1": "1024/1024", "16:9": "1344/768", "9:16": "768/1344",
352
+ "4:3": "1152/896", "3:4": "896/1152", "2:3": "768/1152", "3:2": "1152/768",
353
+ };
354
+ const seed = Math.floor(Math.random() * 1000000);
355
+ imageUrl = `https://picsum.photos/seed/${seed}/${fallbackSizes[aspectRatio] || "1024/1024"}`;
356
+ }
357
+
358
+ const durationMs = Date.now() - startTime;
359
+ const isTokenError = usedFallback && (
360
+ fallbackReason?.includes("Token 已過期") ||
361
+ fallbackReason?.includes("無法自動刷新") ||
362
+ fallbackReason?.includes("未設定 API Token") ||
363
+ fallbackReason?.includes("REFRESH_TOKEN_EXPIRED")
364
+ );
365
+
366
+ // Mark the pool account as used (after successful generation)
367
+ if (currentAccountId !== null) {
368
+ markAccountUsed(currentAccountId).catch(() => {});
369
+ }
370
+
371
+ const [inserted] = await db
372
+ .insert(imagesTable)
373
+ .values({ imageUrl, prompt, style, aspectRatio, model, isPrivate: !!isPrivate, userId })
374
+ .returning();
375
+
376
+ res.json({
377
+ id: inserted.id,
378
+ imageUrl: inserted.imageUrl,
379
+ prompt: inserted.prompt,
380
+ style: inserted.style,
381
+ aspectRatio: inserted.aspectRatio,
382
+ model: inserted.model,
383
+ createdAt: inserted.createdAt.toISOString(),
384
+ ...(usedFallback ? { error: fallbackReason, tokenExpired: isTokenError } : {}),
385
+ apiDebug: {
386
+ requestUrl: requestInfo.url,
387
+ requestMethod: "POST",
388
+ requestContentType: "multipart/form-data",
389
+ requestHeaders: {
390
+ Authorization: token ? "Bearer ****" : "(無 Token)",
391
+ "User-Agent": USER_AGENT,
392
+ Accept: "application/json",
393
+ },
394
+ requestBody: requestInfo.fields,
395
+ responseStatus,
396
+ responseBody,
397
+ pollResult,
398
+ durationMs,
399
+ usedFallback,
400
+ ...(fallbackReason ? { fallbackReason } : {}),
401
+ },
402
+ });
403
+ });
404
+
405
+ router.get("/history", optionalJwtAuth, async (req, res) => {
406
+ const paramsResult = GetImageHistoryQueryParams.safeParse({
407
+ limit: req.query.limit ? Number(req.query.limit) : 20,
408
+ offset: req.query.offset ? Number(req.query.offset) : 0,
409
+ });
410
+
411
+ const { limit = 20, offset = 0 } = paramsResult.success ? paramsResult.data : {};
412
+ const currentUserId: number | null = (req as any).jwtUserId ?? null;
413
+
414
+ // Visibility filter:
415
+ // - Public images are always visible
416
+ // - Private images are only visible to their owner
417
+ const visibilityFilter = currentUserId
418
+ ? or(eq(imagesTable.isPrivate, false), and(eq(imagesTable.isPrivate, true), eq(imagesTable.userId, currentUserId)))
419
+ : eq(imagesTable.isPrivate, false);
420
+
421
+ const [images, [{ value: total }]] = await Promise.all([
422
+ db.select().from(imagesTable).where(visibilityFilter).orderBy(desc(imagesTable.createdAt)).limit(limit).offset(offset),
423
+ db.select({ value: count() }).from(imagesTable).where(visibilityFilter),
424
+ ]);
425
+
426
+ res.json({
427
+ images: images.map((img) => ({
428
+ id: img.id,
429
+ imageUrl: img.imageUrl,
430
+ prompt: img.prompt,
431
+ style: img.style,
432
+ aspectRatio: img.aspectRatio,
433
+ model: img.model,
434
+ isPrivate: img.isPrivate,
435
+ userId: img.userId,
436
+ createdAt: img.createdAt.toISOString(),
437
+ })),
438
+ total: Number(total),
439
+ });
440
+ });
441
+
442
+ router.delete("/:id", async (req, res) => {
443
+ const paramsResult = DeleteImageParams.safeParse({ id: Number(req.params.id) });
444
+ if (!paramsResult.success) {
445
+ return res.status(400).json({ error: "INVALID_ID", message: "Invalid image ID" });
446
+ }
447
+
448
+ const deleted = await db.delete(imagesTable).where(eq(imagesTable.id, paramsResult.data.id)).returning();
449
+
450
+ if (deleted.length === 0) {
451
+ return res.status(404).json({ error: "NOT_FOUND", message: "Image not found" });
452
+ }
453
+
454
+ res.json({ success: true, message: "Image deleted successfully" });
455
+ });
456
+
457
+ export default router;
artifacts/api-server/src/routes/index.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, type IRouter } from "express";
2
+ import healthRouter from "./health";
3
+ import imagesRouter from "./images";
4
+ import videosRouter from "./videos";
5
+ import configRouter from "./config";
6
+ import apiKeysRouter from "./apiKeys";
7
+ import authRouter from "./auth";
8
+ import adminRouter from "./admin";
9
+
10
+ const router: IRouter = Router();
11
+
12
+ router.use(healthRouter);
13
+ router.use("/images", imagesRouter);
14
+ router.use("/videos", videosRouter);
15
+ router.use("/config", configRouter);
16
+ router.use("/apikeys", apiKeysRouter);
17
+ router.use("/auth", authRouter);
18
+ router.use("/admin", adminRouter);
19
+
20
+ export default router;
artifacts/api-server/src/routes/openai.ts ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import { createHash } from "crypto";
3
+ import { db, apiKeysTable } from "@workspace/db";
4
+ import { eq } from "drizzle-orm";
5
+ import { getValidBearerToken, refreshAccessToken, getPoolToken, markAccountUsed, tryRefreshPoolAccount } from "./config";
6
+
7
+ const router = Router();
8
+
9
+ const GEMINIGEN_BASE = "https://api.geminigen.ai/api";
10
+ const USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1";
11
+
12
+ const SUPPORTED_MODELS = [
13
+ "grok", "meta", "imagen-pro", "imagen-4", "imagen-flash", "nano-banana-pro", "nano-banana-2",
14
+ ];
15
+
16
+ const ALIAS_MAP: Record<string, string> = {
17
+ "dall-e-3": "grok",
18
+ "dall-e-2": "grok",
19
+ "gpt-image-1": "grok",
20
+ };
21
+
22
+ const SIZE_TO_ASPECT: Record<string, string> = {
23
+ "256x256": "1:1",
24
+ "512x512": "1:1",
25
+ "1024x1024": "1:1",
26
+ "1792x1024": "16:9",
27
+ "1024x1792": "9:16",
28
+ };
29
+
30
+ const ORIENTATION_MAP: Record<string, string> = {
31
+ "1:1": "square", "16:9": "landscape", "9:16": "portrait",
32
+ "4:3": "landscape", "3:4": "portrait", "2:3": "portrait", "3:2": "landscape",
33
+ };
34
+
35
+ async function validateApiKey(req: any, res: any, next: any) {
36
+ const auth = req.headers.authorization as string | undefined;
37
+ if (!auth?.startsWith("Bearer ")) {
38
+ return res.status(401).json({ error: { message: "No API key provided.", type: "invalid_request_error", code: "invalid_api_key" } });
39
+ }
40
+ const raw = auth.slice(7);
41
+ const hash = createHash("sha256").update(raw).digest("hex");
42
+ const [apiKey] = await db.select().from(apiKeysTable).where(eq(apiKeysTable.keyHash, hash)).limit(1);
43
+ if (!apiKey) {
44
+ return res.status(401).json({ error: { message: "Invalid API key.", type: "invalid_request_error", code: "invalid_api_key" } });
45
+ }
46
+ db.update(apiKeysTable).set({ lastUsedAt: new Date() }).where(eq(apiKeysTable.id, apiKey.id)).catch(() => {});
47
+ req.apiKeyRecord = apiKey;
48
+ next();
49
+ }
50
+
51
+ async function callGrokEndpoint(prompt: string, orientation: string, token: string) {
52
+ const form = new FormData();
53
+ form.append("prompt", prompt);
54
+ form.append("orientation", orientation);
55
+ form.append("num_result", "1");
56
+ const resp = await fetch(`${GEMINIGEN_BASE}/imagen/grok`, {
57
+ method: "POST",
58
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": USER_AGENT, Accept: "application/json" },
59
+ body: form,
60
+ });
61
+ const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") }));
62
+ return { status: resp.status, body };
63
+ }
64
+
65
+ async function callMetaEndpoint(prompt: string, orientation: string, token: string) {
66
+ const form = new FormData();
67
+ form.append("prompt", prompt);
68
+ form.append("orientation", orientation);
69
+ form.append("num_result", "1");
70
+ const resp = await fetch(`${GEMINIGEN_BASE}/meta_ai/generate`, {
71
+ method: "POST",
72
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": USER_AGENT, Accept: "application/json" },
73
+ body: form,
74
+ });
75
+ const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") }));
76
+ return { status: resp.status, body };
77
+ }
78
+
79
+ async function callImagenEndpoint(model: string, prompt: string, aspectRatio: string, token: string) {
80
+ const form = new FormData();
81
+ form.append("prompt", prompt);
82
+ form.append("model", model);
83
+ form.append("aspect_ratio", aspectRatio);
84
+ form.append("output_format", "jpg");
85
+ const resp = await fetch(`${GEMINIGEN_BASE}/generate_image`, {
86
+ method: "POST",
87
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": USER_AGENT, Accept: "application/json" },
88
+ body: form,
89
+ });
90
+ const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") }));
91
+ return { status: resp.status, body };
92
+ }
93
+
94
+ async function pollForImage(uuid: string, token: string, maxWaitMs = 120000): Promise<string | null> {
95
+ const interval = 3000;
96
+ const start = Date.now();
97
+ while (Date.now() - start < maxWaitMs) {
98
+ await new Promise((r) => setTimeout(r, interval));
99
+ const resp = await fetch(`${GEMINIGEN_BASE}/history/${uuid}`, {
100
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": USER_AGENT, Accept: "application/json" },
101
+ });
102
+ if (!resp.ok) break;
103
+ const data = await resp.json() as { status?: number; generated_image?: Array<{ image_url?: string }> };
104
+ if (data.status === 2 || data.status === 3) return data.generated_image?.[0]?.image_url || null;
105
+ if (typeof data.status === "number" && data.status > 3) break;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ router.get("/models", validateApiKey, (_req, res) => {
111
+ const created = Math.floor(Date.now() / 1000);
112
+ res.json({
113
+ object: "list",
114
+ data: SUPPORTED_MODELS.map((id) => ({
115
+ id,
116
+ object: "model",
117
+ created,
118
+ owned_by: "starforge",
119
+ })),
120
+ });
121
+ });
122
+
123
+ router.post("/images/generations", validateApiKey, async (req, res) => {
124
+ const {
125
+ model: rawModel = "grok",
126
+ prompt,
127
+ n = 1,
128
+ size = "1024x1024",
129
+ response_format = "url",
130
+ } = req.body as {
131
+ model?: string;
132
+ prompt?: string;
133
+ n?: number;
134
+ size?: string;
135
+ response_format?: string;
136
+ };
137
+
138
+ if (!prompt) {
139
+ return res.status(400).json({ error: { message: "prompt is required", type: "invalid_request_error" } });
140
+ }
141
+
142
+ const model = ALIAS_MAP[rawModel] || (SUPPORTED_MODELS.includes(rawModel) ? rawModel : "grok");
143
+ const aspectRatio = SIZE_TO_ASPECT[size] || "1:1";
144
+ const orientation = ORIENTATION_MAP[aspectRatio] || "square";
145
+ const isImagenModel = ["imagen-pro", "imagen-4", "imagen-flash", "nano-banana-pro", "nano-banana-2"].includes(model);
146
+
147
+ // Pool-aware token selection
148
+ const failedPoolIds: number[] = [];
149
+ let currentAccountId: number | null = null;
150
+
151
+ async function pickToken(): Promise<string | null> {
152
+ const poolEntry = await getPoolToken(failedPoolIds);
153
+ if (poolEntry) { currentAccountId = poolEntry.accountId; return poolEntry.token; }
154
+ currentAccountId = null;
155
+ return getValidBearerToken();
156
+ }
157
+
158
+ async function handleExpiry(): Promise<string | null> {
159
+ if (currentAccountId !== null) {
160
+ const refreshed = await tryRefreshPoolAccount(currentAccountId);
161
+ if (refreshed) return refreshed;
162
+ failedPoolIds.push(currentAccountId);
163
+ const next = await getPoolToken(failedPoolIds);
164
+ if (next) { currentAccountId = next.accountId; return next.token; }
165
+ }
166
+ return refreshAccessToken();
167
+ }
168
+
169
+ let token = await pickToken();
170
+ if (!token) return res.status(503).json({ error: { message: "Service not configured.", type: "server_error" } });
171
+
172
+ const images: { url?: string; b64_json?: string }[] = [];
173
+ const count = Math.min(Math.max(Number(n) || 1, 1), 4);
174
+
175
+ try {
176
+ for (let i = 0; i < count; i++) {
177
+ let result: { status: number; body: unknown };
178
+
179
+ if (model === "grok") result = await callGrokEndpoint(prompt, orientation, token);
180
+ else if (model === "meta") result = await callMetaEndpoint(prompt, orientation, token);
181
+ else result = await callImagenEndpoint(model, prompt, aspectRatio, token);
182
+
183
+ if (result.status === 401) {
184
+ const newToken = await handleExpiry();
185
+ if (!newToken) throw new Error("Token expired");
186
+ token = newToken;
187
+ if (model === "grok") result = await callGrokEndpoint(prompt, orientation, token);
188
+ else if (model === "meta") result = await callMetaEndpoint(prompt, orientation, token);
189
+ else result = await callImagenEndpoint(model, prompt, aspectRatio, token);
190
+ }
191
+
192
+ const data = result.body as { uuid?: string; base64_images?: string; generated_image?: Array<{ image_url?: string }> };
193
+
194
+ let imageUrl = "";
195
+ if (data.base64_images) {
196
+ imageUrl = `data:image/png;base64,${data.base64_images}`;
197
+ } else if (data.generated_image?.[0]?.image_url) {
198
+ imageUrl = data.generated_image[0].image_url;
199
+ } else if (data.uuid) {
200
+ const polled = await pollForImage(data.uuid, token);
201
+ if (!polled) throw new Error("Generation timed out");
202
+ imageUrl = polled;
203
+ }
204
+
205
+ if (response_format === "b64_json" && imageUrl.startsWith("data:")) {
206
+ images.push({ b64_json: imageUrl.split(",")[1] });
207
+ } else {
208
+ images.push({ url: imageUrl });
209
+ }
210
+ }
211
+ } catch (err: unknown) {
212
+ const msg = err instanceof Error ? err.message : String(err);
213
+ return res.status(500).json({ error: { message: msg, type: "server_error" } });
214
+ }
215
+
216
+ if (currentAccountId !== null) markAccountUsed(currentAccountId).catch(() => {});
217
+ res.json({ created: Math.floor(Date.now() / 1000), data: images });
218
+ });
219
+
220
+ export default router;
artifacts/api-server/src/routes/public.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from "express";
2
+ import { db, configTable, geminiAccountsTable } from "@workspace/db";
3
+ import { eq, sql } from "drizzle-orm";
4
+ import { OTP_KEY, SITE_CONFIG_KEYS } from "./admin";
5
+
6
+ const router = Router();
7
+
8
+ const TOKEN_KEY = "geminigen_bearer_token";
9
+ const REFRESH_TOKEN_KEY = "geminigen_refresh_token";
10
+
11
+ router.post("/receive-tokens", async (req, res) => {
12
+ const { otp, access_token, refresh_token, label } = req.body as {
13
+ otp?: string;
14
+ access_token?: string;
15
+ refresh_token?: string;
16
+ label?: string;
17
+ };
18
+
19
+ if (!otp || !access_token) {
20
+ return res.status(400).json({ error: "otp 和 access_token 為必填" });
21
+ }
22
+
23
+ const row = await db
24
+ .select()
25
+ .from(configTable)
26
+ .where(eq(configTable.key, OTP_KEY))
27
+ .limit(1);
28
+
29
+ if (!row.length) {
30
+ return res.status(401).json({ error: "OTP 無效或已過期,請重新產生書籤" });
31
+ }
32
+
33
+ const [storedOtp, expiresAtStr] = row[0].value.split(":");
34
+ const expiresAt = Number(expiresAtStr);
35
+
36
+ if (storedOtp !== otp || Date.now() > expiresAt) {
37
+ return res.status(401).json({ error: "OTP 無效或已過期,請重新產生書籤" });
38
+ }
39
+
40
+ // Delete OTP (one-time use)
41
+ await db.delete(configTable).where(eq(configTable.key, OTP_KEY));
42
+
43
+ if (label) {
44
+ // Save to pool as a new account
45
+ await db.insert(geminiAccountsTable).values({
46
+ label: label.trim(),
47
+ bearerToken: access_token,
48
+ refreshToken: refresh_token || null,
49
+ isActive: true,
50
+ });
51
+ return res.json({ success: true, message: `帳戶「${label}」已加入 Token 池!` });
52
+ }
53
+
54
+ // Legacy: save as single token in config
55
+ await db
56
+ .insert(configTable)
57
+ .values({ key: TOKEN_KEY, value: access_token, updatedAt: new Date() })
58
+ .onConflictDoUpdate({ target: configTable.key, set: { value: access_token, updatedAt: new Date() } });
59
+
60
+ if (refresh_token) {
61
+ await db
62
+ .insert(configTable)
63
+ .values({ key: REFRESH_TOKEN_KEY, value: refresh_token, updatedAt: new Date() })
64
+ .onConflictDoUpdate({ target: configTable.key, set: { value: refresh_token, updatedAt: new Date() } });
65
+ }
66
+
67
+ return res.json({ success: true, message: "Token 已成功同步!" });
68
+ });
69
+
70
+ // ── Public site config (logo, Google Ads) ─────────────────────────────────────
71
+ const PUBLIC_SITE_KEYS = ["logo_url", "site_name", "google_ads_enabled", "google_ads_client", "google_ads_slot"];
72
+
73
+ router.get("/site-config", async (_req, res) => {
74
+ const rows = await db
75
+ .select()
76
+ .from(configTable)
77
+ .where(sql`${configTable.key} = ANY(ARRAY[${sql.raw(PUBLIC_SITE_KEYS.map(k => `'${k}'`).join(","))}]::text[])`);
78
+ const config: Record<string, string> = {};
79
+ for (const row of rows) config[row.key] = row.value;
80
+ res.json(config);
81
+ });
82
+
83
+ export default router;
artifacts/api-server/src/routes/videos.ts ADDED
@@ -0,0 +1,1297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router, type Response as ExpressResponse, type Request } from "express";
2
+ import { db, videosTable, usersTable, configTable, creditTransactionsTable } from "@workspace/db";
3
+ import { desc, eq, and, or, sql } from "drizzle-orm";
4
+ import { randomUUID } from "crypto";
5
+ import {
6
+ getValidBearerToken,
7
+ refreshAccessToken,
8
+ getPoolToken,
9
+ tryRefreshPoolAccount,
10
+ } from "./config";
11
+ import { getTurnstileToken, invalidateTurnstileToken } from "../captcha";
12
+ import { generateGuardId } from "../guardId";
13
+ import { optionalJwtAuth } from "./auth";
14
+ import { downloadAndStoreVideo, streamStoredVideo, isStorageReady } from "../lib/videoStorage";
15
+
16
+ const router = Router();
17
+
18
+ const GEMINIGEN_BASE = "https://api.geminigen.ai";
19
+ const GROK_ENDPOINT = `${GEMINIGEN_BASE}/api/video-gen/grok-stream`;
20
+ const VEO_ENDPOINT = `${GEMINIGEN_BASE}/api/video-gen/veo`;
21
+ const USER_AGENT =
22
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
23
+
24
+ // ── In-memory task store ─────────────────────────────────────────────────────
25
+ // Tasks live here between POST /generate (which creates them) and the
26
+ // SSE /progress/:taskId endpoint which a client subscribes to.
27
+
28
+ type TaskStatus = "pending" | "complete" | "failed";
29
+
30
+ interface ProgressEvent {
31
+ type: "start" | "progress" | "complete" | "error";
32
+ message?: string;
33
+ status?: number;
34
+ uuid?: string;
35
+ video?: unknown;
36
+ errorCode?: string;
37
+ }
38
+
39
+ interface Task {
40
+ status: TaskStatus;
41
+ createdAt: number;
42
+ buffered: ProgressEvent[]; // events buffered before client connects
43
+ clients: Set<(e: ProgressEvent) => void>; // active SSE listeners
44
+ }
45
+
46
+ const tasks = new Map<string, Task>();
47
+
48
+ // Clean up tasks older than 30 min
49
+ setInterval(() => {
50
+ const cutoff = Date.now() - 30 * 60 * 1000;
51
+ for (const [id, t] of tasks) {
52
+ if (t.createdAt < cutoff) tasks.delete(id);
53
+ }
54
+ }, 5 * 60 * 1000);
55
+
56
+ function createTask(): string {
57
+ const id = randomUUID();
58
+ tasks.set(id, { status: "pending", createdAt: Date.now(), buffered: [], clients: new Set() });
59
+ return id;
60
+ }
61
+
62
+ function broadcast(task: Task, event: ProgressEvent): void {
63
+ if (task.status === "pending") task.buffered.push(event);
64
+ for (const client of task.clients) {
65
+ try { client(event); } catch { /* ignore broken pipe */ }
66
+ }
67
+ }
68
+
69
+ function finishTask(task: Task, status: TaskStatus, final: ProgressEvent): void {
70
+ task.status = status;
71
+ task.buffered.push(final);
72
+ for (const client of task.clients) {
73
+ try { client(final); } catch {}
74
+ }
75
+ task.clients.clear();
76
+ }
77
+
78
+ // ── Video generation options ──────────────────────────────────────────────────
79
+ export type VideoModel = "grok-3" | "veo-3-fast";
80
+
81
+ export interface VideoGenOptions {
82
+ model: VideoModel;
83
+ prompt: string;
84
+ negativePrompt?: string;
85
+ aspectRatio: "16:9" | "9:16" | "1:1" | "3:4" | "4:3";
86
+ resolution: "480p" | "720p" | "1080p";
87
+ duration: 5 | 6 | 8 | 10;
88
+ enhancePrompt: boolean;
89
+ refImageBase64?: string;
90
+ refImageMime?: string;
91
+ }
92
+
93
+ // ── Model constraints ─────────────────────────────────────────────────────────
94
+ // Grok-3 limits (API confirmed)
95
+ const GROK3_MAX_DURATION = 6;
96
+ const GROK3_MAX_RESOLUTION = "720p";
97
+
98
+ // Veo 3.1 Fast limits (API confirmed)
99
+ // duration: 8s only, aspect ratios: 16:9 / 9:16 only
100
+ const VEO_VALID_ASPECT_RATIOS = new Set(["16:9", "9:16"]);
101
+ const VEO_DURATION = 8 as const;
102
+
103
+ // Grok-3 aspect ratio mapping: raw user ratio → API keyword
104
+ const GROK_ASPECT_RATIO_MAP: Record<string, string> = {
105
+ "16:9": "landscape",
106
+ "4:3": "landscape",
107
+ "3:2": "3:2",
108
+ "9:16": "portrait",
109
+ "3:4": "portrait",
110
+ "2:3": "2:3",
111
+ "1:1": "square",
112
+ };
113
+
114
+ const VALID_ASPECT_RATIOS = new Set(["16:9", "9:16", "1:1", "3:4", "4:3"]);
115
+ const VALID_RESOLUTIONS = new Set(["480p", "720p", "1080p"]);
116
+ const VALID_DURATIONS = new Set([5, 6, 8, 10]);
117
+ const RESOLUTION_RANK: Record<string, number> = { "480p": 0, "720p": 1, "1080p": 2 };
118
+
119
+ export function parseVideoOptions(body: Record<string, unknown>, prompt: string): VideoGenOptions {
120
+ const model: VideoModel = body.model === "veo-3-fast" ? "veo-3-fast" : "grok-3";
121
+
122
+ let aspectRatio = VALID_ASPECT_RATIOS.has(body.aspectRatio as string)
123
+ ? (body.aspectRatio as VideoGenOptions["aspectRatio"]) : "16:9";
124
+
125
+ let resolution = VALID_RESOLUTIONS.has(body.resolution as string)
126
+ ? (body.resolution as VideoGenOptions["resolution"]) : "480p";
127
+
128
+ let duration = VALID_DURATIONS.has(Number(body.duration))
129
+ ? (Number(body.duration) as VideoGenOptions["duration"]) : 6;
130
+
131
+ if (model === "grok-3") {
132
+ // Clamp resolution to max 720p
133
+ if (RESOLUTION_RANK[resolution] > RESOLUTION_RANK[GROK3_MAX_RESOLUTION]) {
134
+ resolution = GROK3_MAX_RESOLUTION;
135
+ }
136
+ // Clamp duration to max 6s
137
+ if (duration > GROK3_MAX_DURATION) {
138
+ duration = GROK3_MAX_DURATION as VideoGenOptions["duration"];
139
+ }
140
+ } else if (model === "veo-3-fast") {
141
+ // Force duration to 8s
142
+ duration = VEO_DURATION;
143
+ // Restrict to 16:9 / 9:16
144
+ if (!VEO_VALID_ASPECT_RATIOS.has(aspectRatio)) {
145
+ aspectRatio = "16:9";
146
+ }
147
+ }
148
+
149
+ const negativePrompt = typeof body.negativePrompt === "string" && body.negativePrompt.trim()
150
+ ? body.negativePrompt.trim() : undefined;
151
+ const enhancePrompt = body.enhancePrompt !== false;
152
+
153
+ return { model, prompt, negativePrompt, aspectRatio, resolution, duration, enhancePrompt };
154
+ }
155
+
156
+ // ── Helper: build FormData ───────────────────────────────────────────────────
157
+ function base64ToBlob(base64: string, mime: string): Blob {
158
+ const binary = Buffer.from(base64, "base64");
159
+ return new Blob([binary], { type: mime });
160
+ }
161
+
162
+ // ── Grok-3 form builder ───────────────────────────────────────────────────────
163
+ function buildGrokForm(turnstileToken: string, opts: VideoGenOptions): FormData {
164
+ const form = new FormData();
165
+ form.append("prompt", opts.prompt);
166
+ form.append("model", "grok-3");
167
+ form.append("model_name", "grok-3");
168
+ // Aspect ratio: API expects 'landscape'|'portrait'|'square'|'3:2'|'2:3'
169
+ const apiAspectRatio = GROK_ASPECT_RATIO_MAP[opts.aspectRatio] ?? "landscape";
170
+ form.append("aspect_ratio", apiAspectRatio);
171
+ form.append("resolution", opts.resolution);
172
+ form.append("duration", opts.duration.toString());
173
+ form.append("num_result", "1");
174
+ form.append("enhance_prompt", opts.enhancePrompt ? "true" : "false");
175
+ form.append("turnstile_token", turnstileToken);
176
+ if (opts.negativePrompt) form.append("negative_prompt", opts.negativePrompt);
177
+ if (opts.refImageBase64 && opts.refImageMime) {
178
+ // Presence of 'files' tells the API this is image-to-video.
179
+ // Do NOT set mode='image_to_video' — the 'mode' field is a creativity level
180
+ // (normal | custom | extremely-crazy | extremely-spicy-or-crazy).
181
+ form.append("files", base64ToBlob(opts.refImageBase64, opts.refImageMime), "reference.jpg");
182
+ form.append("mode", "custom");
183
+ }
184
+ return form;
185
+ }
186
+
187
+ // ── Veo form builder ──────────────────────────────────────────────────────────
188
+ function buildVeoForm(turnstileToken: string, opts: VideoGenOptions): FormData {
189
+ const form = new FormData();
190
+ form.append("prompt", opts.prompt);
191
+ form.append("model", "veo-3-fast");
192
+ // Veo uses raw ratio strings directly ('16:9' or '9:16')
193
+ form.append("aspect_ratio", opts.aspectRatio);
194
+ form.append("duration", opts.duration.toString());
195
+ form.append("enhance_prompt", opts.enhancePrompt ? "true" : "false");
196
+ form.append("turnstile_token", turnstileToken);
197
+ if (opts.negativePrompt) form.append("negative_prompt", opts.negativePrompt);
198
+ if (opts.refImageBase64 && opts.refImageMime) {
199
+ form.append("ref_images", base64ToBlob(opts.refImageBase64, opts.refImageMime), "reference.jpg");
200
+ form.append("mode_image", "image_to_video");
201
+ }
202
+ return form;
203
+ }
204
+
205
+ // ── Grok-3 SSE endpoint ───────────────────────────────────────────────────────
206
+ async function callGrokEndpoint(
207
+ bearerToken: string,
208
+ turnstileToken: string,
209
+ opts: VideoGenOptions,
210
+ ): Promise<Response> {
211
+ const guardId = generateGuardId("/api/video-gen/grok-stream", "post");
212
+ return fetch(GROK_ENDPOINT, {
213
+ method: "POST",
214
+ headers: {
215
+ Authorization: `Bearer ${bearerToken}`,
216
+ "x-guard-id": guardId,
217
+ "User-Agent": USER_AGENT,
218
+ Accept: "text/event-stream, application/json",
219
+ },
220
+ body: buildGrokForm(turnstileToken, opts),
221
+ });
222
+ }
223
+
224
+ // ── Veo POST endpoint (returns {uuid}, then poll) ─────────────────────────────
225
+ async function callVeoEndpoint(
226
+ bearerToken: string,
227
+ turnstileToken: string,
228
+ opts: VideoGenOptions,
229
+ ): Promise<{ uuid: string }> {
230
+ const guardId = generateGuardId("/api/video-gen/veo", "post");
231
+ const resp = await fetch(VEO_ENDPOINT, {
232
+ method: "POST",
233
+ headers: {
234
+ Authorization: `Bearer ${bearerToken}`,
235
+ "x-guard-id": guardId,
236
+ "User-Agent": USER_AGENT,
237
+ Accept: "application/json",
238
+ },
239
+ body: buildVeoForm(turnstileToken, opts),
240
+ });
241
+ if (!resp.ok) {
242
+ const raw = await resp.text().catch(() => "");
243
+ const { code, msg } = parseErrBody(raw);
244
+ throw Object.assign(new Error(msg || `HTTP ${resp.status}`), { code, status: resp.status, raw });
245
+ }
246
+ const data = await resp.json() as { uuid?: string; history_uuid?: string; task_id?: string; id?: string };
247
+ console.log("[veo-submit] response:", JSON.stringify(data).slice(0, 300));
248
+ const uuid = data.uuid || data.history_uuid || data.task_id || data.id;
249
+ if (!uuid) throw new Error(`Veo API did not return a UUID. Response: ${JSON.stringify(data).slice(0, 200)}`);
250
+ return { uuid };
251
+ }
252
+
253
+ // ── One-shot history fetch: get the R2 pre-signed URL after generation ────────
254
+ // geminigen.ai stores completed videos to Cloudflare R2 and returns a 7-day
255
+ // pre-signed URL in generated_video[0].video_url. This works without any
256
+ // additional auth, unlike the intermediate assets.grok.com CDN URL.
257
+ async function fetchHistoryVideoUrl(
258
+ uuid: string,
259
+ bearerToken: string,
260
+ ): Promise<{ videoUrl: string; thumbnailUrl: string | null } | null> {
261
+ try {
262
+ const guardId = generateGuardId("/api/history/" + uuid, "get");
263
+ const resp = await fetch(`${GEMINIGEN_BASE}/api/history/${uuid}`, {
264
+ headers: {
265
+ Authorization: `Bearer ${bearerToken}`,
266
+ "x-guard-id": guardId,
267
+ "User-Agent": USER_AGENT,
268
+ Accept: "application/json",
269
+ },
270
+ });
271
+ if (!resp.ok) {
272
+ console.warn("[history-fetch] HTTP", resp.status, "for uuid", uuid);
273
+ return null;
274
+ }
275
+ const data = await resp.json() as {
276
+ status?: number;
277
+ generated_video?: Array<{ video_url?: string; thumbnail_url?: string }>;
278
+ };
279
+ const vid = data.generated_video?.[0];
280
+ const videoUrl = vid?.video_url ?? null;
281
+ const thumbnailUrl = vid?.thumbnail_url ?? null;
282
+ if (videoUrl) {
283
+ console.log("[history-fetch] got R2 URL:", videoUrl.slice(0, 120));
284
+ return { videoUrl, thumbnailUrl };
285
+ }
286
+ return null;
287
+ } catch (err) {
288
+ console.warn("[history-fetch] error:", err instanceof Error ? err.message : err);
289
+ return null;
290
+ }
291
+ }
292
+
293
+ // ── Veo polling: GET /api/history/{uuid} until status 2/3 ────────────────────
294
+ // Veo generation typically takes 2–8 minutes on geminigen.ai; we poll up to
295
+ // 12 minutes with a 20s interval (first check after 15s to catch fast results).
296
+ async function pollVeoHistory(
297
+ uuid: string,
298
+ bearerToken: string,
299
+ onProgress: (msg: string) => void,
300
+ maxWaitMs = 720_000, // 12 minutes
301
+ ): Promise<{ videoUrl: string; thumbnailUrl: string | null } | null> {
302
+ const start = Date.now();
303
+ let attempt = 0;
304
+ let waitMs = 15_000; // first check sooner; subsequent checks every 20s
305
+
306
+ while (Date.now() - start < maxWaitMs) {
307
+ await new Promise((r) => setTimeout(r, waitMs));
308
+ waitMs = 20_000; // steady-state interval
309
+ attempt++;
310
+ const elapsed = Math.round((Date.now() - start) / 1000);
311
+ onProgress(`Veo 生成中,第 ${attempt} 次確認... (已等待 ${elapsed}s)`);
312
+
313
+ const guardId = generateGuardId("/api/history/" + uuid, "get");
314
+ let resp: Response;
315
+ try {
316
+ resp = await fetch(`${GEMINIGEN_BASE}/api/history/${uuid}`, {
317
+ headers: {
318
+ Authorization: `Bearer ${bearerToken}`,
319
+ "x-guard-id": guardId,
320
+ "User-Agent": USER_AGENT,
321
+ Accept: "application/json",
322
+ },
323
+ signal: AbortSignal.timeout(15_000),
324
+ });
325
+ } catch (fetchErr) {
326
+ console.warn("[veo-poll] fetch error on attempt", attempt, ":", fetchErr instanceof Error ? fetchErr.message : fetchErr);
327
+ continue;
328
+ }
329
+
330
+ if (!resp.ok) {
331
+ console.warn("[veo-poll] history HTTP", resp.status, "for uuid", uuid, "attempt", attempt);
332
+ continue;
333
+ }
334
+
335
+ let data: {
336
+ status?: number;
337
+ generated_video?: Array<{ video_url?: string; thumbnail_url?: string; media_url?: string }>;
338
+ video_url?: string;
339
+ };
340
+ try {
341
+ data = await resp.json();
342
+ } catch {
343
+ console.warn("[veo-poll] JSON parse failed on attempt", attempt);
344
+ continue;
345
+ }
346
+
347
+ console.log("[veo-poll] attempt", attempt, "status=", data.status,
348
+ "has_video=", !!(data.generated_video?.[0]?.video_url || data.video_url));
349
+
350
+ if (data.status === 2) {
351
+ const vid = data.generated_video?.[0];
352
+ // Try multiple possible URL fields
353
+ const videoUrl = vid?.video_url || vid?.media_url || data.video_url || null;
354
+ const thumbnailUrl = vid?.thumbnail_url ?? null;
355
+ if (videoUrl) {
356
+ console.log("[veo-poll] completed with video URL:", videoUrl.slice(0, 120));
357
+ return { videoUrl, thumbnailUrl };
358
+ }
359
+ // Status 2 but no URL — wait a bit and try once more
360
+ console.warn("[veo-poll] status=2 but no video URL, retrying in 10s...");
361
+ await new Promise((r) => setTimeout(r, 10_000));
362
+ const guardId2 = generateGuardId("/api/history/" + uuid, "get");
363
+ const resp2 = await fetch(`${GEMINIGEN_BASE}/api/history/${uuid}`, {
364
+ headers: { Authorization: `Bearer ${bearerToken}`, "x-guard-id": guardId2,
365
+ "User-Agent": USER_AGENT, Accept: "application/json" },
366
+ }).catch(() => null);
367
+ if (resp2?.ok) {
368
+ const data2 = await resp2.json().catch(() => ({})) as typeof data;
369
+ const vid2 = data2.generated_video?.[0];
370
+ const videoUrl2 = vid2?.video_url || vid2?.media_url || data2.video_url || null;
371
+ if (videoUrl2) return { videoUrl: videoUrl2, thumbnailUrl: vid2?.thumbnail_url ?? null };
372
+ }
373
+ return null; // complete but no URL
374
+ }
375
+
376
+ if (data.status === 3) {
377
+ console.warn("[veo-poll] status=3 (failed) for uuid", uuid);
378
+ return null; // failed
379
+ }
380
+
381
+ // status 1 = still generating; any other status: continue polling
382
+ }
383
+
384
+ console.warn("[veo-poll] timed out after", maxWaitMs / 1000, "s for uuid", uuid);
385
+ return null; // timeout
386
+ }
387
+
388
+ // ── Helper: parse error body ─────────────────────────────────────────────────
389
+ function parseErrBody(text: string): { code: string; msg: string } {
390
+ try {
391
+ const d = JSON.parse(text) as { detail?: { error_code?: string; error_message?: string } };
392
+ return {
393
+ code: d?.detail?.error_code || "",
394
+ msg: (d?.detail?.error_message || "").toLowerCase(),
395
+ };
396
+ } catch {
397
+ return { code: "", msg: text.toLowerCase() };
398
+ }
399
+ }
400
+
401
+ // ── SSE stream reader ─────────────────────────────────────────────────────────
402
+ interface StreamResult {
403
+ videoUrl: string | null;
404
+ thumbnailUrl: string | null;
405
+ uuid: string | null;
406
+ lastEvent: unknown;
407
+ errorCode: string | null;
408
+ errorMsg: string | null;
409
+ }
410
+
411
+ /**
412
+ * Resolve a potentially-relative video URL to an absolute URL.
413
+ *
414
+ * geminigen.ai's SSE stream returns a field like:
415
+ * "videoUrl": "users/{userId}/generated/{videoId}/gen_xxx.mp4?signed=..."
416
+ * which needs to be prefixed with the R2 CDN base, or it may already be a
417
+ * full https:// signed URL (if the JSON was not truncated in our logs).
418
+ *
419
+ * NOTE: confirmed from production logs — video files are served from
420
+ * https://assets.grok.com/ NOT from https://api.geminigen.ai/
421
+ */
422
+ const VIDEO_CDN_BASE = "https://assets.grok.com/";
423
+
424
+ function resolveVideoUrl(rawUrl: string): string {
425
+ if (!rawUrl) return rawUrl;
426
+ if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return rawUrl;
427
+ // Relative path → prepend CDN base
428
+ return VIDEO_CDN_BASE + rawUrl;
429
+ }
430
+
431
+ /**
432
+ * Extract the geminigen.ai streaming video generation response from a parsed
433
+ * SSE event. The structure (confirmed from production logs) is:
434
+ *
435
+ * parsed.data.result.response.streamingVideoGenerationResponse
436
+ * .progress : 0–100
437
+ * .videoUrl : relative or absolute URL (only when progress === 100)
438
+ */
439
+ function extractSVGR(parsed: Record<string, unknown>): Record<string, unknown> | null {
440
+ try {
441
+ const data = parsed.data as Record<string, unknown> | undefined;
442
+ const result = data?.result as Record<string, unknown> | undefined;
443
+ const response = result?.response as Record<string, unknown> | undefined;
444
+ const svgr = response?.streamingVideoGenerationResponse as Record<string, unknown> | undefined;
445
+ return svgr ?? null;
446
+ } catch {
447
+ return null;
448
+ }
449
+ }
450
+
451
+ async function readVideoStream(
452
+ resp: Response,
453
+ onEvent: (parsed: Record<string, unknown>) => void,
454
+ maxWaitMs = 300_000,
455
+ ): Promise<StreamResult> {
456
+ const decoder = new TextDecoder();
457
+ const reader = resp.body?.getReader();
458
+ const empty: StreamResult = {
459
+ videoUrl: null, thumbnailUrl: null, uuid: null, lastEvent: null, errorCode: null, errorMsg: null,
460
+ };
461
+ if (!reader) return empty;
462
+
463
+ let videoUrl: string | null = null;
464
+ let thumbnailUrl: string | null = null;
465
+ let uuid: string | null = null;
466
+ let lastEvent: unknown = null;
467
+ let errorCode: string | null = null;
468
+ let errorMsg: string | null = null;
469
+ let buffer = "";
470
+ let historyUuid: string | null = null;
471
+ const deadline = Date.now() + maxWaitMs;
472
+
473
+ try {
474
+ while (Date.now() < deadline) {
475
+ const { done, value } = await reader.read();
476
+ if (done) break;
477
+
478
+ buffer += decoder.decode(value, { stream: true });
479
+ const lines = buffer.split("\n");
480
+ buffer = lines.pop() ?? "";
481
+
482
+ for (const line of lines) {
483
+ const trimmed = line.trim();
484
+ if (!trimmed || trimmed === "data: [DONE]") continue;
485
+
486
+ const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
487
+
488
+ let parsed: Record<string, unknown>;
489
+ try {
490
+ parsed = JSON.parse(jsonStr);
491
+ } catch {
492
+ continue;
493
+ }
494
+
495
+ lastEvent = parsed;
496
+ // Log full event (no truncation — we need the complete video URL)
497
+ console.log("[video-stream]", JSON.stringify(parsed));
498
+
499
+ // ── Error detection ───────────────────────────────────────────────────
500
+ // Pattern A: {"detail":{"error_code":"..."}}
501
+ const detail = parsed.detail as Record<string, string> | undefined;
502
+ if (detail?.error_code) {
503
+ errorCode = detail.error_code;
504
+ errorMsg = detail.error_message || detail.error_code;
505
+ console.error(`[video-stream] error: ${errorCode} — ${errorMsg}`);
506
+ return { videoUrl: null, thumbnailUrl: null, uuid, lastEvent, errorCode, errorMsg };
507
+ }
508
+ // Pattern B: top-level error_code
509
+ if (typeof parsed.error_code === "string" && parsed.error_code) {
510
+ errorCode = parsed.error_code;
511
+ errorMsg = (parsed.error_message as string) || parsed.error_code;
512
+ return { videoUrl: null, thumbnailUrl: null, uuid, lastEvent, errorCode, errorMsg };
513
+ }
514
+ // Pattern C: top-level error string (no status field)
515
+ if (typeof parsed.error === "string" && parsed.error && typeof parsed.status === "undefined") {
516
+ errorCode = "STREAM_ERROR";
517
+ errorMsg = parsed.error;
518
+ return { videoUrl: null, thumbnailUrl: null, uuid, lastEvent, errorCode, errorMsg };
519
+ }
520
+
521
+ // ── Initial event: {"success":true,"message":"Video generation started",
522
+ // "history_id":...,"history_uuid":"...","status":1} ──
523
+ if (parsed.success === true && typeof parsed.history_uuid === "string") {
524
+ historyUuid = parsed.history_uuid;
525
+ uuid = historyUuid;
526
+ if (parsed.message === "Video generation started") {
527
+ onEvent({ type: "started", historyUuid });
528
+ continue;
529
+ }
530
+ }
531
+
532
+ // ── Completion signal: {"success":true,"message":"Video generation complete..."} ──
533
+ if (parsed.success === true && typeof parsed.message === "string"
534
+ && parsed.message.includes("Video generation complete")) {
535
+ console.log("[video-stream] generation complete signal received");
536
+ return { videoUrl, thumbnailUrl, uuid, lastEvent, errorCode: null, errorMsg: null };
537
+ }
538
+
539
+ // ── HD URL event: {"success":true,"data":{"video_id":"...","hdMediaUrl":"https://assets.grok.com/..."}} ──
540
+ // Prefer this over the relative videoUrl from SVGR since it's already absolute and higher quality.
541
+ {
542
+ const d = parsed.data as Record<string, unknown> | undefined;
543
+ if (d && typeof d.hdMediaUrl === "string" && d.hdMediaUrl.startsWith("https://")) {
544
+ console.log("[video-url] using hdMediaUrl:", d.hdMediaUrl.slice(0, 120));
545
+ videoUrl = d.hdMediaUrl;
546
+ }
547
+ }
548
+
549
+ // ── Progress events: data.result.response.streamingVideoGenerationResponse ──
550
+ const svgr = extractSVGR(parsed);
551
+ if (svgr) {
552
+ const progress = typeof svgr.progress === "number" ? svgr.progress : null;
553
+ const rawUrl = typeof svgr.videoUrl === "string" ? svgr.videoUrl : null;
554
+ const videoId = typeof svgr.videoId === "string" ? svgr.videoId : undefined;
555
+
556
+ if (rawUrl) {
557
+ videoUrl = resolveVideoUrl(rawUrl);
558
+ try {
559
+ const u = new URL(videoUrl);
560
+ console.log("[video-url] origin:", u.origin);
561
+ console.log("[video-url] path:", u.pathname);
562
+ const qs = u.search;
563
+ for (let i = 0; i < qs.length; i += 200) {
564
+ console.log("[video-url] qs[" + Math.floor(i / 200) + "]:", qs.slice(i, i + 200));
565
+ }
566
+ console.log("[video-url] total_length:", videoUrl.length, "| raw_length:", rawUrl.length);
567
+ } catch {
568
+ console.log("[video-url] raw (non-URL):", rawUrl.slice(0, 200));
569
+ }
570
+ }
571
+
572
+ // Extract thumbnail URL (only present at progress=100)
573
+ const rawThumb = typeof svgr.thumbnailImageUrl === "string" ? svgr.thumbnailImageUrl : null;
574
+ if (rawThumb) thumbnailUrl = resolveVideoUrl(rawThumb);
575
+
576
+ onEvent({ type: "progress", progress, videoId, videoUrl });
577
+
578
+ if (progress === 100 && videoUrl) {
579
+ console.log("[video-stream] progress=100, video ready");
580
+ // Don't return yet — wait for the "Video generation complete" signal
581
+ // which ensures the backend has finished processing.
582
+ // But if no more events come, videoUrl is set so we'll return at stream end.
583
+ }
584
+ continue;
585
+ }
586
+
587
+ // ── Legacy format fallback ─────────────────────────────────────────────
588
+ // In case geminigen.ai changes format back, still check old fields
589
+ if (!uuid && typeof parsed.uuid === "string") uuid = parsed.uuid;
590
+ const legacyCandidate =
591
+ (parsed.video_url as string | undefined) ||
592
+ (parsed.generated_video as Array<{ video_url?: string }> | undefined)?.[0]?.video_url ||
593
+ null;
594
+ if (legacyCandidate) videoUrl = resolveVideoUrl(legacyCandidate);
595
+
596
+ if (typeof parsed.status === "number" && (parsed.status === 2 || parsed.status === 3)) {
597
+ return { videoUrl, thumbnailUrl, uuid, lastEvent, errorCode: null, errorMsg: null };
598
+ }
599
+ if (typeof parsed.status === "number" && parsed.status > 3) {
600
+ errorCode = "GEN_FAILED";
601
+ errorMsg = (parsed.message as string) || `status=${parsed.status}`;
602
+ return { videoUrl: null, thumbnailUrl: null, uuid, lastEvent, errorCode, errorMsg };
603
+ }
604
+ }
605
+ }
606
+ } finally {
607
+ reader.cancel().catch(() => {});
608
+ }
609
+
610
+ // Stream ended naturally — if we have a videoUrl it's a success
611
+ return { videoUrl, thumbnailUrl, uuid, lastEvent, errorCode, errorMsg };
612
+ }
613
+
614
+ // ── Shared token helpers ──────────────────────────────────────────────────────
615
+ function makeTokenHelpers() {
616
+ const failedPoolIds: number[] = [];
617
+ let currentAccountId: number | null = null;
618
+
619
+ async function pickToken(): Promise<string | null> {
620
+ const poolEntry = await getPoolToken(failedPoolIds);
621
+ if (poolEntry) { currentAccountId = poolEntry.accountId; return poolEntry.token; }
622
+ currentAccountId = null;
623
+ return getValidBearerToken();
624
+ }
625
+
626
+ async function handleTokenExpiry(): Promise<string | null> {
627
+ if (currentAccountId !== null) {
628
+ const refreshed = await tryRefreshPoolAccount(currentAccountId);
629
+ if (refreshed) return refreshed;
630
+ failedPoolIds.push(currentAccountId);
631
+ const next = await getPoolToken(failedPoolIds);
632
+ if (next) { currentAccountId = next.accountId; return next.token; }
633
+ }
634
+ return refreshAccessToken();
635
+ }
636
+
637
+ return { pickToken, handleTokenExpiry };
638
+ }
639
+
640
+ // ── Grok-3 background task runner (SSE streaming) ────────────────────────────
641
+ async function runGrokTask(
642
+ taskId: string,
643
+ opts: VideoGenOptions,
644
+ isPrivate: boolean,
645
+ userId: number | null,
646
+ ): Promise<void> {
647
+ const task = tasks.get(taskId);
648
+ if (!task) return;
649
+
650
+ const { pickToken, handleTokenExpiry } = makeTokenHelpers();
651
+
652
+ try {
653
+ broadcast(task, { type: "start", message: "正在取得 Turnstile 驗證碼..." });
654
+ let turnstileToken = await getTurnstileToken();
655
+
656
+ broadcast(task, { type: "start", message: "正在取得 Bearer Token..." });
657
+ let token = await pickToken();
658
+
659
+ if (!token) {
660
+ finishTask(task, "failed", { type: "error", errorCode: "NO_TOKEN", message: "未設定 API Token,請到管理後台設定" });
661
+ return;
662
+ }
663
+
664
+ broadcast(task, { type: "start", message: "正在連接 geminigen.ai (Grok-3)..." });
665
+
666
+ let resp = await callGrokEndpoint(token, turnstileToken, opts);
667
+
668
+ // ── Handle HTTP-level errors ─────────────────────────────────────────────
669
+ if (!resp.ok) {
670
+ const rawText = await resp.text().catch(() => "");
671
+ const { code, msg } = parseErrBody(rawText);
672
+
673
+ const isCaptcha = msg.includes("captcha") || msg.includes("turnstile")
674
+ || code === "CAPTCHA_ERROR" || code === "INVALID_CAPTCHA";
675
+
676
+ if (isCaptcha) {
677
+ broadcast(task, { type: "start", message: "Turnstile 拒絕,正在重新取得..." });
678
+ invalidateTurnstileToken();
679
+ turnstileToken = await getTurnstileToken();
680
+ resp = await callGrokEndpoint(token, turnstileToken, opts);
681
+ } else {
682
+ const isExpired = resp.status === 401 || resp.status === 403
683
+ || code === "TOKEN_EXPIRED" || code === "INVALID_CREDENTIALS"
684
+ || msg.includes("token") || msg.includes("expired") || msg.includes("credential");
685
+ if (isExpired) {
686
+ broadcast(task, { type: "start", message: "Token 過期,正在刷新..." });
687
+ const newToken = await handleTokenExpiry();
688
+ if (!newToken) {
689
+ finishTask(task, "failed", {
690
+ type: "error", errorCode: "TOKEN_EXPIRED",
691
+ message: "Token 已過期且無法自動刷新,請到管理後台更新 Token",
692
+ });
693
+ return;
694
+ }
695
+ token = newToken;
696
+ resp = await callGrokEndpoint(token, turnstileToken, opts);
697
+ }
698
+ }
699
+
700
+ if (!resp.ok) {
701
+ const raw2 = await resp.text().catch(() => "");
702
+ const { code: c2, msg: m2 } = parseErrBody(raw2);
703
+ finishTask(task, "failed", {
704
+ type: "error",
705
+ errorCode: c2 || "API_ERROR",
706
+ message: m2 || `HTTP ${resp.status}`,
707
+ });
708
+ return;
709
+ }
710
+ }
711
+
712
+ broadcast(task, { type: "start", message: "AI 正在生成影片,這可能需要 1–5 分鐘..." });
713
+
714
+ // ── Read SSE stream ──────────────────────────────────────────────────────
715
+ const makeProgressHandler = (label: string) => (parsed: Record<string, unknown>) => {
716
+ // New format: { type: "progress", progress: 0-100 }
717
+ if (parsed.type === "progress") {
718
+ const pct = typeof parsed.progress === "number" ? parsed.progress : null;
719
+ const pctStr = pct !== null ? ` (${pct}%)` : "";
720
+ broadcast(task, { type: "progress", progress: pct, message: `${label}${pctStr}` });
721
+ } else if (parsed.type === "started") {
722
+ broadcast(task, { type: "progress", message: "AI 開始生成..." });
723
+ } else {
724
+ // Legacy / fallback
725
+ const u = typeof parsed.uuid === "string" ? parsed.uuid : undefined;
726
+ broadcast(task, { type: "progress", uuid: u, message: label });
727
+ }
728
+ };
729
+
730
+ let streamResult = await readVideoStream(resp, makeProgressHandler("AI 生成中"));
731
+
732
+ // ── Retry logic for stream-level errors ──────────────────────────────────
733
+ if (!streamResult.videoUrl && streamResult.errorCode) {
734
+ const ec = streamResult.errorCode;
735
+ const em = (streamResult.errorMsg || "").toLowerCase();
736
+
737
+ const isStreamCaptcha = ec === "CAPTCHA_ERROR" || ec === "INVALID_CAPTCHA"
738
+ || em.includes("captcha") || em.includes("turnstile");
739
+
740
+ const isStreamToken = ec === "TOKEN_EXPIRED" || ec === "INVALID_CREDENTIALS"
741
+ || ec === "UNAUTHORIZED" || em.includes("token") || em.includes("expired")
742
+ || em.includes("credentials") || em.includes("invalid credential");
743
+
744
+ if (isStreamCaptcha) {
745
+ broadcast(task, { type: "start", message: "Turnstile 在串流中拒絕,重新取得並重試..." });
746
+ invalidateTurnstileToken();
747
+ turnstileToken = await getTurnstileToken();
748
+ const retryResp = await callGrokEndpoint(token, turnstileToken, opts);
749
+ if (retryResp.ok) {
750
+ streamResult = await readVideoStream(retryResp, makeProgressHandler("AI 生成中(重試)"));
751
+ }
752
+ } else if (isStreamToken) {
753
+ broadcast(task, { type: "start", message: `Token 錯誤 (${ec}),重新刷新並重試...` });
754
+ const newToken = await handleTokenExpiry();
755
+ if (!newToken) {
756
+ finishTask(task, "failed", {
757
+ type: "error", errorCode: "TOKEN_EXPIRED",
758
+ message: "Token 已過期且無法自動刷新,請到管理後台更新 Token",
759
+ });
760
+ return;
761
+ }
762
+ token = newToken;
763
+ const retryResp = await callGrokEndpoint(token, turnstileToken, opts);
764
+ if (retryResp.ok) {
765
+ streamResult = await readVideoStream(retryResp, makeProgressHandler("AI 生成中(重試)"));
766
+ }
767
+ }
768
+ }
769
+
770
+ let { videoUrl, thumbnailUrl, uuid, errorCode: finalCode, errorMsg: finalMsg } = streamResult;
771
+
772
+ if (!videoUrl && !uuid) {
773
+ const msg = finalMsg ? `生成失敗:${finalMsg}` : "未取得影片 URL,生成可能已失敗或超時";
774
+ finishTask(task, "failed", { type: "error", errorCode: finalCode || "NO_VIDEO", message: msg });
775
+ return;
776
+ }
777
+
778
+ // ── Step 2: Fetch R2 pre-signed URL from history API ─────────────────────
779
+ // The SSE stream gives us an assets.grok.com URL (requires auth + Cloudflare cookies).
780
+ // The history API returns a Cloudflare R2 pre-signed URL (7-day, publicly accessible).
781
+ // Always try to get the R2 URL regardless of whether SSE already gave us a URL.
782
+ if (uuid) {
783
+ broadcast(task, { type: "progress", progress: 100, message: "正在取得影片下載連結..." });
784
+ const historyResult = await fetchHistoryVideoUrl(uuid, token);
785
+ if (historyResult?.videoUrl) {
786
+ videoUrl = historyResult.videoUrl;
787
+ if (historyResult.thumbnailUrl) thumbnailUrl = historyResult.thumbnailUrl;
788
+ console.log("[grok-task] using R2 URL from history API");
789
+ }
790
+ }
791
+
792
+ if (!videoUrl) {
793
+ const msg = finalMsg ? `生成失敗:${finalMsg}` : "未取得影片 URL,生成可能已失敗或超時";
794
+ finishTask(task, "failed", { type: "error", errorCode: finalCode || "NO_VIDEO", message: msg });
795
+ return;
796
+ }
797
+
798
+ // Store the URL (R2 pre-signed if available, falls back to assets.grok.com).
799
+ const [saved] = await db
800
+ .insert(videosTable)
801
+ .values({
802
+ videoUrl,
803
+ thumbnailUrl: thumbnailUrl ?? null,
804
+ prompt: opts.prompt,
805
+ negativePrompt: opts.negativePrompt ?? null,
806
+ model: opts.model,
807
+ aspectRatio: opts.aspectRatio,
808
+ resolution: opts.resolution,
809
+ duration: opts.duration,
810
+ hasRefImage: !!(opts.refImageBase64 && opts.refImageMime),
811
+ isPrivate,
812
+ userId,
813
+ })
814
+ .returning();
815
+
816
+ finishTask(task, "complete", { type: "complete", video: saved, uuid: uuid ?? undefined });
817
+
818
+ // ── Background: download video to S3 for permanent storage ───────────────
819
+ // R2 pre-signed URLs expire in 7 days; S3 copy is permanent.
820
+ if (isStorageReady()) {
821
+ (async () => {
822
+ try {
823
+ const storedPath = await downloadAndStoreVideo(videoUrl!, token);
824
+ if (storedPath) {
825
+ await db.update(videosTable).set({ videoUrl: storedPath }).where(eq(videosTable.id, saved.id));
826
+ console.log(`[grok-task] video cached in storage: ${storedPath}`);
827
+ }
828
+ } catch (e) {
829
+ console.warn("[grok-task] background storage failed:", e instanceof Error ? e.message : e);
830
+ }
831
+ })();
832
+ }
833
+ } catch (err: unknown) {
834
+ const msg = err instanceof Error ? err.message : String(err);
835
+ console.error("[grok-task] unexpected error:", msg);
836
+ finishTask(task, "failed", { type: "error", errorCode: "INTERNAL_ERROR", message: msg });
837
+ }
838
+ }
839
+
840
+ // ── Veo 3.1 Fast background task runner (POST + polling) ─────────────────────
841
+ async function runVeoTask(
842
+ taskId: string,
843
+ opts: VideoGenOptions,
844
+ isPrivate: boolean,
845
+ userId: number | null,
846
+ ): Promise<void> {
847
+ const task = tasks.get(taskId);
848
+ if (!task) return;
849
+
850
+ const { pickToken, handleTokenExpiry } = makeTokenHelpers();
851
+
852
+ try {
853
+ broadcast(task, { type: "start", message: "正在取得 Turnstile 驗證碼..." });
854
+ let turnstileToken = await getTurnstileToken();
855
+
856
+ broadcast(task, { type: "start", message: "正在取得 Bearer Token..." });
857
+ let token = await pickToken();
858
+
859
+ if (!token) {
860
+ finishTask(task, "failed", { type: "error", errorCode: "NO_TOKEN", message: "未設定 API Token,請到管理後台設定" });
861
+ return;
862
+ }
863
+
864
+ broadcast(task, { type: "start", message: "正在提交 Veo 3.1 Fast 任務..." });
865
+
866
+ // Submit to Veo endpoint (returns UUID immediately)
867
+ let veoResult: { uuid: string };
868
+ try {
869
+ veoResult = await callVeoEndpoint(token, turnstileToken, opts);
870
+ } catch (err: unknown) {
871
+ const e = err as Error & { code?: string; status?: number };
872
+ const code = e.code ?? "";
873
+ const msg = (e.message ?? "").toLowerCase();
874
+
875
+ const isCaptcha = msg.includes("captcha") || msg.includes("turnstile")
876
+ || code === "CAPTCHA_ERROR" || code === "INVALID_CAPTCHA";
877
+ const isExpired = e.status === 401 || e.status === 403
878
+ || code === "TOKEN_EXPIRED" || msg.includes("token") || msg.includes("expired");
879
+
880
+ if (isCaptcha) {
881
+ broadcast(task, { type: "start", message: "Turnstile 拒絕,重新取得並重試..." });
882
+ invalidateTurnstileToken();
883
+ turnstileToken = await getTurnstileToken();
884
+ veoResult = await callVeoEndpoint(token, turnstileToken, opts);
885
+ } else if (isExpired) {
886
+ broadcast(task, { type: "start", message: "Token 過期,正在刷新..." });
887
+ const newToken = await handleTokenExpiry();
888
+ if (!newToken) {
889
+ finishTask(task, "failed", { type: "error", errorCode: "TOKEN_EXPIRED", message: "Token 過期且無法自動刷新,請到管理後台更新 Token" });
890
+ return;
891
+ }
892
+ token = newToken;
893
+ veoResult = await callVeoEndpoint(token, turnstileToken, opts);
894
+ } else {
895
+ throw err;
896
+ }
897
+ }
898
+
899
+ const { uuid } = veoResult;
900
+ broadcast(task, {
901
+ type: "progress",
902
+ progress: 5,
903
+ message: `Veo 任務已提交 (UUID: ${uuid.slice(0, 8)}...),等待生成(每 30 秒確認一次)...`,
904
+ });
905
+
906
+ // Poll until done (max 5 minutes, every 30s)
907
+ const pollResult = await pollVeoHistory(
908
+ uuid,
909
+ token,
910
+ (msg) => broadcast(task, { type: "progress", progress: null, message: msg }),
911
+ );
912
+
913
+ if (!pollResult || !pollResult.videoUrl) {
914
+ finishTask(task, "failed", { type: "error", errorCode: "NO_VIDEO", message: "Veo 未返回影片 URL,可能生成失敗或超時" });
915
+ return;
916
+ }
917
+
918
+ const { videoUrl, thumbnailUrl } = pollResult;
919
+
920
+ const absVideoUrl = resolveVideoUrl(videoUrl);
921
+ const absThumbUrl = thumbnailUrl ? resolveVideoUrl(thumbnailUrl) : null;
922
+
923
+ // Store raw CDN URL immediately; background GCS download follows.
924
+ const [saved] = await db
925
+ .insert(videosTable)
926
+ .values({
927
+ videoUrl: absVideoUrl,
928
+ thumbnailUrl: absThumbUrl ?? null,
929
+ prompt: opts.prompt,
930
+ negativePrompt: opts.negativePrompt ?? null,
931
+ model: opts.model,
932
+ aspectRatio: opts.aspectRatio,
933
+ resolution: opts.resolution,
934
+ duration: opts.duration,
935
+ hasRefImage: !!(opts.refImageBase64 && opts.refImageMime),
936
+ isPrivate,
937
+ userId,
938
+ })
939
+ .returning();
940
+
941
+ finishTask(task, "complete", { type: "complete", video: saved, uuid });
942
+
943
+ // ── Background: download video to GCS ────────────────────────────────────
944
+ if (isStorageReady()) {
945
+ (async () => {
946
+ try {
947
+ const storedPath = await downloadAndStoreVideo(absVideoUrl, token);
948
+ if (storedPath) {
949
+ await db.update(videosTable).set({ videoUrl: storedPath }).where(eq(videosTable.id, saved.id));
950
+ console.log(`[veo-task] video cached in storage: ${storedPath}`);
951
+ }
952
+ } catch (e) {
953
+ console.warn("[veo-task] background storage failed:", e instanceof Error ? e.message : e);
954
+ }
955
+ })();
956
+ }
957
+ } catch (err: unknown) {
958
+ const msg = err instanceof Error ? err.message : String(err);
959
+ console.error("[veo-task] unexpected error:", msg);
960
+ finishTask(task, "failed", { type: "error", errorCode: "INTERNAL_ERROR", message: msg });
961
+ }
962
+ }
963
+
964
+ // ── Credit helpers ─────────────────────────────────────────────────────────────
965
+ async function getVideoConfigVal(key: string): Promise<string | null> {
966
+ const rows = await db.select({ value: configTable.value }).from(configTable).where(eq(configTable.key, key)).limit(1);
967
+ return rows[0]?.value ?? null;
968
+ }
969
+
970
+ async function checkAndDeductVideoCredits(userId: number, cost: number, description: string): Promise<{ ok: boolean; balance?: number }> {
971
+ const enabled = await getVideoConfigVal("enable_credits");
972
+ if (enabled !== "true") return { ok: true };
973
+
974
+ const [user] = await db.select({ credits: usersTable.credits }).from(usersTable).where(eq(usersTable.id, userId)).limit(1);
975
+ if (!user) return { ok: false };
976
+ if (user.credits < cost) return { ok: false, balance: user.credits };
977
+
978
+ const [updated] = await db
979
+ .update(usersTable)
980
+ .set({ credits: sql`${usersTable.credits} - ${cost}` })
981
+ .where(eq(usersTable.id, userId))
982
+ .returning({ credits: usersTable.credits });
983
+
984
+ await db.insert(creditTransactionsTable).values({ userId, amount: -cost, type: "spend", description });
985
+ return { ok: true, balance: updated.credits };
986
+ }
987
+
988
+ // ── POST /api/videos/generate ─────────────────────────────────────────────────
989
+ // Returns taskId immediately; generation runs in background.
990
+ router.post("/generate", optionalJwtAuth, async (req, res) => {
991
+ const body = req.body as Record<string, unknown>;
992
+
993
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
994
+ if (!prompt || prompt.length < 1 || prompt.length > 2000) {
995
+ return res.status(400).json({ error: "INVALID_BODY", message: "prompt is required (1-2000 chars)" });
996
+ }
997
+ const isPrivate = body.isPrivate === true;
998
+
999
+ // Parse video options (aspectRatio, resolution, duration, negativePrompt, enhancePrompt)
1000
+ const opts = parseVideoOptions(body, prompt);
1001
+
1002
+ // Attach reference image if provided
1003
+ const refImageBase64 = typeof body.referenceImageBase64 === "string" ? body.referenceImageBase64 : undefined;
1004
+ const refImageMime = typeof body.referenceImageMime === "string" ? body.referenceImageMime : undefined;
1005
+ if (refImageBase64 && refImageMime) {
1006
+ opts.refImageBase64 = refImageBase64;
1007
+ opts.refImageMime = refImageMime;
1008
+ }
1009
+
1010
+ const userId: number | null = (req as any).jwtUserId ?? null;
1011
+
1012
+ // ── Credits check ────────────────────────────────────────────────────────────
1013
+ if (userId !== null) {
1014
+ const costStr = await getVideoConfigVal("video_gen_cost");
1015
+ const cost = Number(costStr) || 0;
1016
+ if (cost > 0) {
1017
+ const creditResult = await checkAndDeductVideoCredits(userId, cost, `影片生成(${opts.model})`);
1018
+ if (!creditResult.ok) {
1019
+ return res.status(402).json({
1020
+ error: "INSUFFICIENT_CREDITS",
1021
+ message: `點數不足,此操作需要 ${cost} 點`,
1022
+ balance: creditResult.balance ?? 0,
1023
+ });
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ const taskId = createTask();
1029
+ res.json({ taskId });
1030
+
1031
+ // Dispatch to the correct runner based on model
1032
+ const runner = opts.model === "veo-3-fast" ? runVeoTask : runGrokTask;
1033
+ runner(taskId, opts, isPrivate, userId).catch((err) => {
1034
+ console.error("[video-task] uncaught:", err);
1035
+ const t = tasks.get(taskId);
1036
+ if (t && t.status === "pending") {
1037
+ finishTask(t, "failed", { type: "error", errorCode: "INTERNAL_ERROR", message: String(err) });
1038
+ }
1039
+ });
1040
+ });
1041
+
1042
+ // ── GET /api/videos/progress/:taskId (SSE) ──────────────────────────────────
1043
+ router.get("/progress/:taskId", (req, res: ExpressResponse) => {
1044
+ const task = tasks.get(req.params.taskId);
1045
+ if (!task) return res.status(404).json({ error: "TASK_NOT_FOUND" });
1046
+
1047
+ res.writeHead(200, {
1048
+ "Content-Type": "text/event-stream",
1049
+ "Cache-Control": "no-cache",
1050
+ Connection: "keep-alive",
1051
+ "X-Accel-Buffering": "no",
1052
+ });
1053
+ res.flushHeaders();
1054
+
1055
+ const send = (event: ProgressEvent) => {
1056
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
1057
+ };
1058
+
1059
+ // Replay buffered events
1060
+ for (const ev of task.buffered) {
1061
+ send(ev);
1062
+ }
1063
+
1064
+ // If already done, close immediately
1065
+ if (task.status !== "pending") {
1066
+ res.write("data: {\"type\":\"done\"}\n\n");
1067
+ res.end();
1068
+ return;
1069
+ }
1070
+
1071
+ // Subscribe to future events
1072
+ task.clients.add(send);
1073
+
1074
+ // Heartbeat every 20s to prevent proxy / CDN from closing idle SSE connections
1075
+ const heartbeat = setInterval(() => {
1076
+ try { res.write(": heartbeat\n\n"); } catch { clearInterval(heartbeat); }
1077
+ }, 20_000);
1078
+
1079
+ const onClose = () => {
1080
+ clearInterval(heartbeat);
1081
+ task.clients.delete(send);
1082
+ };
1083
+ req.on("close", onClose);
1084
+ req.on("end", onClose);
1085
+ });
1086
+
1087
+ // Helper: rewrite raw geminigen URLs to proxy URL for backward compatibility.
1088
+ // assets.grok.com URLs are returned as-is (browser plays directly).
1089
+ // /api/videos/stored/ URLs are returned as-is (GCS).
1090
+ function toProxyUrl(url: string | null): string | null {
1091
+ if (!url) return null;
1092
+ if (url.startsWith("/api/videos/proxy")) return url;
1093
+ if (url.startsWith("/api/videos/stored")) return url;
1094
+ try {
1095
+ const u = new URL(url);
1096
+ // Cloudflare R2 pre-signed URLs (*.r2.cloudflarestorage.com) are publicly
1097
+ // accessible — no proxy needed. Browser plays them directly.
1098
+ if (u.hostname.endsWith(".r2.cloudflarestorage.com")) return url;
1099
+ // assets.grok.com requires auth/cookies — route through our proxy.
1100
+ if (u.hostname === "api.geminigen.ai" || u.hostname === "assets.grok.com") {
1101
+ return `/api/videos/proxy?url=${encodeURIComponent(url)}`;
1102
+ }
1103
+ } catch { /* relative path or non-URL */ }
1104
+ return url;
1105
+ }
1106
+
1107
+ // ── GET /api/videos/stored/<id>.mp4 ──────────────────────────────────────────
1108
+ // Serve videos that were downloaded from CDN and stored in GCS.
1109
+ router.get(/^\/stored\/(.+)$/, async (req, res) => {
1110
+ const objectPath = (req.params as Record<string, string>)[0];
1111
+ if (!objectPath) return res.status(400).json({ error: "Missing objectPath" });
1112
+ await streamStoredVideo(objectPath, res, req.headers.range);
1113
+ });
1114
+
1115
+ // ── GET /api/videos/history ───────────────────────────────────────────────────
1116
+ router.get("/history", optionalJwtAuth, async (req, res) => {
1117
+ const userId: number | null = (req as any).jwtUserId ?? null;
1118
+ const limit = Math.min(Number(req.query.limit) || 20, 50);
1119
+ const offset = Number(req.query.offset) || 0;
1120
+
1121
+ const visibilityFilter = userId
1122
+ ? or(eq(videosTable.isPrivate, false), and(eq(videosTable.isPrivate, true), eq(videosTable.userId, userId)))
1123
+ : eq(videosTable.isPrivate, false);
1124
+
1125
+ const rows = await db
1126
+ .select()
1127
+ .from(videosTable)
1128
+ .where(visibilityFilter)
1129
+ .orderBy(desc(videosTable.createdAt))
1130
+ .limit(limit)
1131
+ .offset(offset);
1132
+
1133
+ // Rewrite any legacy direct geminigen URLs to proxy URLs
1134
+ const videos = rows.map((v) => ({
1135
+ ...v,
1136
+ videoUrl: toProxyUrl(v.videoUrl) ?? v.videoUrl,
1137
+ thumbnailUrl: toProxyUrl(v.thumbnailUrl),
1138
+ }));
1139
+
1140
+ return res.json({ videos, limit, offset });
1141
+ });
1142
+
1143
+ // ── GET /api/videos/proxy ─────────────────────────────────────────────────────
1144
+ // Proxy geminigen.ai video/thumbnail files with Bearer token auth.
1145
+ // Supports HTTP Range requests so the browser's <video> player can seek.
1146
+ router.get("/proxy", async (req, res) => {
1147
+ const raw = typeof req.query.url === "string" ? req.query.url : "";
1148
+ if (!raw) return res.status(400).json({ error: "Missing url param" });
1149
+
1150
+ // Only proxy geminigen.ai and Grok CDN assets
1151
+ let targetUrl: URL;
1152
+ try {
1153
+ targetUrl = new URL(raw);
1154
+ } catch {
1155
+ return res.status(400).json({ error: "Invalid url" });
1156
+ }
1157
+ const ALLOWED_PROXY_HOSTS = ["api.geminigen.ai", "assets.grok.com"];
1158
+ if (!ALLOWED_PROXY_HOSTS.includes(targetUrl.hostname)) {
1159
+ return res.status(403).json({ error: "URL not allowed" });
1160
+ }
1161
+
1162
+ // Forward Range header if present (needed for video seeking)
1163
+ const rangeHeader = req.headers.range;
1164
+
1165
+ /**
1166
+ * assets.grok.com is the Grok CDN. It appears to be a public CDN that does
1167
+ * NOT accept the geminigen.ai JWT as auth (returns 403 if you send it).
1168
+ * Strategy: try first WITHOUT Authorization, fall back to with-token if needed.
1169
+ *
1170
+ * api.geminigen.ai requires Bearer auth as before.
1171
+ */
1172
+ const isGrokCdn = targetUrl.hostname === "assets.grok.com";
1173
+
1174
+ // For assets.grok.com, use Referer=https://grok.com/ (same origin as the CDN).
1175
+ // For geminigen.ai, use Referer=https://geminigen.ai/.
1176
+ const referer = isGrokCdn ? "https://grok.com/" : "https://geminigen.ai/";
1177
+
1178
+ const buildHeaders = (token?: string | null): Record<string, string> => {
1179
+ const h: Record<string, string> = {
1180
+ "User-Agent": USER_AGENT,
1181
+ Accept: "video/mp4,video/*,*/*",
1182
+ Referer: referer,
1183
+ Origin: isGrokCdn ? "https://grok.com" : "https://geminigen.ai",
1184
+ };
1185
+ if (rangeHeader) h["Range"] = rangeHeader;
1186
+ if (token) h["Authorization"] = `Bearer ${token}`;
1187
+ return h;
1188
+ };
1189
+
1190
+ try {
1191
+ // Always fetch the Bearer token (needed for both geminigen.ai and assets.grok.com)
1192
+ const poolResult = await getPoolToken();
1193
+ const bearerToken = poolResult?.token ?? await getValidBearerToken();
1194
+
1195
+ // 1. First attempt: with Bearer token for both endpoints
1196
+ let upstream = await fetch(targetUrl.toString(), {
1197
+ headers: buildHeaders(bearerToken),
1198
+ });
1199
+
1200
+ // 2. If first attempt fails, try without auth (in case token is wrong for CDN)
1201
+ if (!upstream.ok && upstream.status !== 206) {
1202
+ console.warn(`[video-proxy] first attempt ${upstream.status} for ${targetUrl.hostname}${targetUrl.pathname}`);
1203
+
1204
+ if (isGrokCdn) {
1205
+ // Try without auth as fallback
1206
+ upstream = await fetch(targetUrl.toString(), { headers: buildHeaders(null) });
1207
+
1208
+ if (!upstream.ok && upstream.status !== 206) {
1209
+ // Refresh token and try again
1210
+ const newToken = await refreshAccessToken();
1211
+ if (newToken) {
1212
+ upstream = await fetch(targetUrl.toString(), { headers: buildHeaders(newToken) });
1213
+ }
1214
+ }
1215
+ } else {
1216
+ // geminigen.ai rejected token — refresh and retry
1217
+ if (!bearerToken) {
1218
+ console.error("[video-proxy] no token available");
1219
+ return res.status(502).json({ error: "No bearer token available for proxy" });
1220
+ }
1221
+ if ((upstream.status === 401 || upstream.status === 403)) {
1222
+ console.warn(`[video-proxy] refreshing token after ${upstream.status}...`);
1223
+ const newToken = await refreshAccessToken();
1224
+ if (newToken) {
1225
+ upstream = await fetch(targetUrl.toString(), { headers: buildHeaders(newToken) });
1226
+ }
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ console.log(`[video-proxy] final status ${upstream.status} for ${targetUrl.hostname}${targetUrl.pathname}`);
1232
+
1233
+ if (!upstream.ok && upstream.status !== 206) {
1234
+ console.error(`[video-proxy] upstream ${upstream.status} for ${targetUrl.pathname}`);
1235
+ return res.status(upstream.status).json({ error: "Upstream error", status: upstream.status });
1236
+ }
1237
+
1238
+ // Forward response headers
1239
+ const ct = upstream.headers.get("content-type");
1240
+ const cl = upstream.headers.get("content-length");
1241
+ const cr = upstream.headers.get("content-range");
1242
+ const ac = upstream.headers.get("accept-ranges");
1243
+
1244
+ if (ct) res.setHeader("Content-Type", ct);
1245
+ if (cl) res.setHeader("Content-Length", cl);
1246
+ if (cr) res.setHeader("Content-Range", cr);
1247
+ if (ac) res.setHeader("Accept-Ranges", ac);
1248
+ else res.setHeader("Accept-Ranges", "bytes");
1249
+
1250
+ // Cache for 1 hour (videos don't change)
1251
+ res.setHeader("Cache-Control", "public, max-age=3600");
1252
+
1253
+ res.status(upstream.status);
1254
+
1255
+ if (!upstream.body) {
1256
+ return res.end();
1257
+ }
1258
+
1259
+ // Stream the body
1260
+ const reader = upstream.body.getReader();
1261
+ const pump = async () => {
1262
+ const { done, value } = await reader.read();
1263
+ if (done) { res.end(); return; }
1264
+ if (!res.write(value)) {
1265
+ // Backpressure: wait for drain
1266
+ await new Promise<void>((r) => res.once("drain", r));
1267
+ }
1268
+ await pump();
1269
+ };
1270
+
1271
+ req.on("close", () => reader.cancel().catch(() => {}));
1272
+ await pump();
1273
+ } catch (err) {
1274
+ console.error("[video-proxy] fetch error:", err);
1275
+ if (!res.headersSent) res.status(502).json({ error: "Proxy fetch failed" });
1276
+ }
1277
+ });
1278
+
1279
+ // ── DELETE /api/videos/:id ────────────────────────────────────────────────────
1280
+ router.delete("/:id", optionalJwtAuth, async (req, res) => {
1281
+ const id = Number(req.params.id);
1282
+ if (isNaN(id)) return res.status(400).json({ error: "INVALID_ID" });
1283
+
1284
+ const userId: number | null = (req as any).jwtUserId ?? null;
1285
+ const rows = await db.select().from(videosTable).where(eq(videosTable.id, id)).limit(1);
1286
+ if (!rows.length) return res.status(404).json({ error: "NOT_FOUND" });
1287
+
1288
+ const video = rows[0];
1289
+ if (video.userId !== null && video.userId !== userId) {
1290
+ return res.status(403).json({ error: "FORBIDDEN" });
1291
+ }
1292
+
1293
+ await db.delete(videosTable).where(eq(videosTable.id, id));
1294
+ return res.json({ success: true });
1295
+ });
1296
+
1297
+ export default router;
artifacts/api-server/tsconfig.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src"],
9
+ "references": [
10
+ {
11
+ "path": "../../lib/db"
12
+ },
13
+ {
14
+ "path": "../../lib/api-zod"
15
+ }
16
+ ]
17
+ }
artifacts/image-gen/.replit-artifact/artifact.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ kind = "web"
2
+ previewPath = "/"
3
+ title = "AI 生圖工具"
4
+ version = "1.0.0"
5
+ id = "artifacts/image-gen"
6
+ router = "path"
7
+
8
+ [[integratedSkills]]
9
+ name = "react-vite"
10
+ version = "1.0.0"
11
+
12
+ [[services]]
13
+ name = "web"
14
+ paths = [ "/" ]
15
+ localPort = 22062
16
+
17
+ [services.development]
18
+ run = "pnpm --filter @workspace/image-gen run dev"
19
+
20
+ [services.production]
21
+ build = [ "pnpm", "--filter", "@workspace/image-gen", "run", "build" ]
22
+ publicDir = "artifacts/image-gen/dist/public"
23
+ serve = "static"
24
+
25
+ [[services.production.rewrites]]
26
+ from = "/*"
27
+ to = "/index.html"
28
+
29
+ [services.env]
30
+ PORT = "22062"
31
+ BASE_PATH = "/"
artifacts/image-gen/components.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/index.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ }
20
+ }
artifacts/image-gen/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
6
+ <title>AI 生圖工具</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
artifacts/image-gen/package.json ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@workspace/image-gen",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --config vite.config.ts --host 0.0.0.0",
8
+ "build": "vite build --config vite.config.ts",
9
+ "build:vercel": "vite build --config vite.config.vercel.ts",
10
+ "serve": "vite preview --config vite.config.ts --host 0.0.0.0",
11
+ "typecheck": "tsc -p tsconfig.json --noEmit"
12
+ },
13
+ "devDependencies": {
14
+ "@hookform/resolvers": "^3.10.0",
15
+ "@radix-ui/react-accordion": "^1.2.4",
16
+ "@radix-ui/react-alert-dialog": "^1.1.7",
17
+ "@radix-ui/react-aspect-ratio": "^1.1.3",
18
+ "@radix-ui/react-avatar": "^1.1.4",
19
+ "@radix-ui/react-checkbox": "^1.1.5",
20
+ "@radix-ui/react-collapsible": "^1.1.4",
21
+ "@radix-ui/react-context-menu": "^2.2.7",
22
+ "@radix-ui/react-dialog": "^1.1.7",
23
+ "@radix-ui/react-dropdown-menu": "^2.1.7",
24
+ "@radix-ui/react-hover-card": "^1.1.7",
25
+ "@radix-ui/react-label": "^2.1.3",
26
+ "@radix-ui/react-menubar": "^1.1.7",
27
+ "@radix-ui/react-navigation-menu": "^1.2.6",
28
+ "@radix-ui/react-popover": "^1.1.7",
29
+ "@radix-ui/react-progress": "^1.1.3",
30
+ "@radix-ui/react-radio-group": "^1.2.4",
31
+ "@radix-ui/react-scroll-area": "^1.2.4",
32
+ "@radix-ui/react-select": "^2.1.7",
33
+ "@radix-ui/react-separator": "^1.1.3",
34
+ "@radix-ui/react-slider": "^1.2.4",
35
+ "@radix-ui/react-slot": "^1.2.0",
36
+ "@radix-ui/react-switch": "^1.1.4",
37
+ "@radix-ui/react-tabs": "^1.1.4",
38
+ "@radix-ui/react-toast": "^1.2.7",
39
+ "@radix-ui/react-toggle": "^1.1.3",
40
+ "@radix-ui/react-toggle-group": "^1.1.3",
41
+ "@radix-ui/react-tooltip": "^1.2.0",
42
+ "@replit/vite-plugin-cartographer": "catalog:",
43
+ "@replit/vite-plugin-dev-banner": "catalog:",
44
+ "@replit/vite-plugin-runtime-error-modal": "catalog:",
45
+ "@tailwindcss/typography": "^0.5.15",
46
+ "@tailwindcss/vite": "catalog:",
47
+ "@tanstack/react-query": "catalog:",
48
+ "@types/node": "catalog:",
49
+ "@types/react": "catalog:",
50
+ "@types/react-dom": "catalog:",
51
+ "@vitejs/plugin-react": "catalog:",
52
+ "@workspace/api-client-react": "workspace:*",
53
+ "class-variance-authority": "catalog:",
54
+ "clsx": "catalog:",
55
+ "cmdk": "^1.1.1",
56
+ "date-fns": "^3.6.0",
57
+ "embla-carousel-react": "^8.6.0",
58
+ "framer-motion": "catalog:",
59
+ "input-otp": "^1.4.2",
60
+ "lucide-react": "catalog:",
61
+ "next-themes": "^0.4.6",
62
+ "react": "catalog:",
63
+ "react-day-picker": "^9.11.1",
64
+ "react-dom": "catalog:",
65
+ "react-hook-form": "^7.55.0",
66
+ "react-icons": "^5.4.0",
67
+ "react-resizable-panels": "^2.1.7",
68
+ "recharts": "^2.15.2",
69
+ "sonner": "^2.0.7",
70
+ "tailwind-merge": "catalog:",
71
+ "tailwindcss": "catalog:",
72
+ "tw-animate-css": "^1.4.0",
73
+ "vaul": "^1.1.2",
74
+ "vite": "catalog:",
75
+ "wouter": "^3.3.5",
76
+ "zod": "catalog:"
77
+ }
78
+ }
artifacts/image-gen/public/favicon.svg ADDED

Git LFS Details

  • SHA256: 8ffbde9092b1fa4de97c9481b76f518b131268c82e7c555041925225b1dab6e0
  • Pointer size: 128 Bytes
  • Size of remote file: 163 Bytes