kinaiok commited on
Commit ·
5ef6e9d
0
Parent(s):
Initial deployment setup for Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +65 -0
- .env.example +25 -0
- .gitattributes +6 -0
- .npmrc +2 -0
- DEPLOYMENT.md +218 -0
- DEPLOYMENT_CHECKLIST.md +217 -0
- Dockerfile +57 -0
- README.md +70 -0
- artifacts/api-server/.replit-artifact/artifact.toml +32 -0
- artifacts/api-server/build.mjs +126 -0
- artifacts/api-server/dist/index.mjs +0 -0
- artifacts/api-server/dist/index.mjs.map +0 -0
- artifacts/api-server/dist/pino-file.mjs +0 -0
- artifacts/api-server/dist/pino-file.mjs.map +0 -0
- artifacts/api-server/dist/pino-pretty.mjs +0 -0
- artifacts/api-server/dist/pino-pretty.mjs.map +0 -0
- artifacts/api-server/dist/pino-worker.mjs +0 -0
- artifacts/api-server/dist/pino-worker.mjs.map +0 -0
- artifacts/api-server/dist/thread-stream-worker.mjs +228 -0
- artifacts/api-server/dist/thread-stream-worker.mjs.map +7 -0
- artifacts/api-server/package.json +45 -0
- artifacts/api-server/src/app.ts +44 -0
- artifacts/api-server/src/captcha.ts +298 -0
- artifacts/api-server/src/guardId.ts +91 -0
- artifacts/api-server/src/index.ts +20 -0
- artifacts/api-server/src/lib/.gitkeep +0 -0
- artifacts/api-server/src/lib/localTempStorage.ts +167 -0
- artifacts/api-server/src/lib/logger.ts +20 -0
- artifacts/api-server/src/lib/objectAcl.ts +137 -0
- artifacts/api-server/src/lib/objectStorage.ts +267 -0
- artifacts/api-server/src/lib/videoStorage.ts +264 -0
- artifacts/api-server/src/middlewares/.gitkeep +0 -0
- artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts +61 -0
- artifacts/api-server/src/routes/accounts.ts +85 -0
- artifacts/api-server/src/routes/admin.ts +316 -0
- artifacts/api-server/src/routes/apiKeys.ts +56 -0
- artifacts/api-server/src/routes/auth.ts +154 -0
- artifacts/api-server/src/routes/config.ts +285 -0
- artifacts/api-server/src/routes/health.ts +11 -0
- artifacts/api-server/src/routes/images.ts +457 -0
- artifacts/api-server/src/routes/index.ts +20 -0
- artifacts/api-server/src/routes/openai.ts +220 -0
- artifacts/api-server/src/routes/public.ts +83 -0
- artifacts/api-server/src/routes/videos.ts +1297 -0
- artifacts/api-server/tsconfig.json +17 -0
- artifacts/image-gen/.replit-artifact/artifact.toml +31 -0
- artifacts/image-gen/components.json +20 -0
- artifacts/image-gen/index.html +16 -0
- artifacts/image-gen/package.json +78 -0
- 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
|