import { Router } from "express"; import bcrypt from "bcryptjs"; import { randomUUID } from "crypto"; import { db, usersTable, imagesTable, apiKeysTable, configTable, creditTransactionsTable } from "@workspace/db"; import { eq, count, desc, sql, inArray } from "drizzle-orm"; import { requireJwtAuth } from "./auth"; import { refreshAccessToken, encrypt, getStoredCredentials } from "./config"; import multer from "multer"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT || "https://s3.hi168.com", region: "us-east-1", credentials: { accessKeyId: process.env.S3_ACCESS_KEY || "", secretAccessKey: process.env.S3_SECRET_KEY || "", }, forcePathStyle: true, }); const S3_BUCKET = process.env.DEFAULT_OBJECT_STORAGE_BUCKET_ID || "hi168-25517-1756t1kf"; const S3_PUBLIC_BASE = `${process.env.S3_ENDPOINT || "https://s3.hi168.com"}/${S3_BUCKET}`; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } }); const OTP_KEY = "bookmarklet_otp"; const router = Router(); async function requireAdmin(req: any, res: any, next: any) { const user = await db .select({ isAdmin: usersTable.isAdmin }) .from(usersTable) .where(eq(usersTable.id, req.jwtUserId)) .limit(1); if (!user[0]?.isAdmin) { return res.status(403).json({ error: "Forbidden" }); } next(); } router.use(requireJwtAuth); router.use(requireAdmin); router.get("/stats", async (_req, res) => { const [userCount] = await db.select({ count: count() }).from(usersTable); const [imageCount] = await db.select({ count: count() }).from(imagesTable); const [apiKeyCount] = await db.select({ count: count() }).from(apiKeysTable); res.json({ users: Number(userCount.count), images: Number(imageCount.count), apiKeys: Number(apiKeyCount.count), }); }); router.get("/users", async (_req, res) => { const users = await db .select({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, isAdmin: usersTable.isAdmin, createdAt: usersTable.createdAt, }) .from(usersTable) .orderBy(desc(usersTable.createdAt)); res.json({ users }); }); router.put("/users/:id", async (req, res) => { const id = Number(req.params.id); const { displayName, isAdmin, password } = req.body as { displayName?: string; isAdmin?: boolean; password?: string; }; const updates: Partial = {}; if (typeof displayName !== "undefined") updates.displayName = displayName || null; if (typeof isAdmin === "boolean") updates.isAdmin = isAdmin; if (password) { if (password.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); updates.passwordHash = await bcrypt.hash(password, 12); } if (Object.keys(updates).length === 0) { return res.status(400).json({ error: "Nothing to update" }); } const [updated] = await db .update(usersTable) .set(updates) .where(eq(usersTable.id, id)) .returning({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, isAdmin: usersTable.isAdmin, }); if (!updated) return res.status(404).json({ error: "User not found" }); res.json(updated); }); router.delete("/users/:id", async (req, res) => { const id = Number(req.params.id); if (id === req.jwtUserId) { return res.status(400).json({ error: "Cannot delete yourself" }); } await db.delete(apiKeysTable).where(eq(apiKeysTable.userId, String(id))); const deleted = await db.delete(usersTable).where(eq(usersTable.id, id)).returning({ id: usersTable.id }); if (!deleted.length) return res.status(404).json({ error: "User not found" }); res.json({ success: true }); }); router.get("/config", async (_req, res) => { const rows = await db.select().from(configTable).orderBy(configTable.key); res.json({ config: rows }); }); const CONFIG_KEY_MAP: Record = { refresh_token: "geminigen_refresh_token", access_token: "geminigen_bearer_token", capsolver_api_key: "capsolver_api_key", playwright_solver_url: "playwright_solver_url", playwright_solver_secret: "playwright_solver_secret", yescaptcha_api_key: "yescaptcha_api_key", }; router.put("/config", async (req, res) => { const { key, value } = req.body as { key?: string; value?: string }; if (!key || typeof value === "undefined") { return res.status(400).json({ error: "key and value are required" }); } const dbKey = CONFIG_KEY_MAP[key]; if (!dbKey) { return res.status(400).json({ error: "Invalid config key" }); } await db .insert(configTable) .values({ key: dbKey, value, updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value, updatedAt: new Date() } }); res.json({ success: true }); }); router.get("/setup-status", async (_req, res) => { const row = await db .select({ key: configTable.key }) .from(configTable) .where(eq(configTable.key, "geminigen_refresh_token")) .limit(1); res.json({ refreshTokenConfigured: row.length > 0 }); }); router.post("/setup", async (req, res) => { const { refreshToken } = req.body as { refreshToken?: string }; if (!refreshToken?.trim()) { return res.status(400).json({ error: "refreshToken is required" }); } const value = refreshToken.trim(); if (value.length < 20) { return res.status(400).json({ error: "Token seems too short — please copy the full refresh_token value." }); } await db .insert(configTable) .values({ key: "geminigen_refresh_token", value, updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value, updatedAt: new Date() } }); refreshAccessToken().catch(() => {}); res.json({ success: true }); }); // Save geminigen.ai credentials for auto-renewal router.post("/credentials", requireJwtAuth, requireAdmin, async (req, res) => { const { username, password } = req.body as { username?: string; password?: string }; if (!username?.trim() || !password?.trim()) { return res.status(400).json({ error: "username and password are required" }); } await db .insert(configTable) .values({ key: "geminigen_username", value: username.trim(), updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value: username.trim(), updatedAt: new Date() } }); const encPass = encrypt(password.trim()); await db .insert(configTable) .values({ key: "geminigen_password_enc", value: encPass, updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value: encPass, updatedAt: new Date() } }); // Try to login with new credentials in background (may fail if Turnstile solver not configured) // Do NOT block saving credentials on login success — Turnstile may prevent immediate login refreshAccessToken().then((tok) => { if (tok) console.log("[admin] Credential login succeeded after save"); else console.warn("[admin] Credential login failed after save (Turnstile may be required — token will refresh later)"); }).catch(() => {}); res.json({ success: true, note: "Credentials saved. Token will refresh automatically." }); }); // Check credential configuration status router.get("/credentials", requireJwtAuth, requireAdmin, async (_req, res) => { const creds = await getStoredCredentials(); res.json({ configured: !!creds, username: creds?.username ?? null, }); }); // Delete stored credentials router.delete("/credentials", requireJwtAuth, requireAdmin, async (_req, res) => { await db.delete(configTable).where(eq(configTable.key, "geminigen_username")); await db.delete(configTable).where(eq(configTable.key, "geminigen_password_enc")); res.json({ success: true }); }); // Generate a short-lived OTP for the bookmarklet token sync router.post("/bookmarklet-otp", async (_req, res) => { const otp = randomUUID(); const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes await db .insert(configTable) .values({ key: OTP_KEY, value: `${otp}:${expiresAt}`, updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value: `${otp}:${expiresAt}`, updatedAt: new Date() } }); res.json({ otp, expiresInSeconds: 600 }); }); // ── Site Config (logo, google ads, credits settings) ───────────────────────── const SITE_CONFIG_KEYS = [ "logo_url", "site_name", "enable_credits", "image_gen_cost", "video_gen_cost", "signup_credits", "google_ads_enabled", "google_ads_client", "google_ads_slot", ]; router.get("/site-config", async (_req, res) => { const rows = await db .select() .from(configTable) .where(inArray(configTable.key, SITE_CONFIG_KEYS)); const config: Record = {}; for (const row of rows) config[row.key] = row.value; res.json(config); }); router.put("/site-config", async (req, res) => { const updates = req.body as Record; for (const [key, value] of Object.entries(updates)) { if (!SITE_CONFIG_KEYS.includes(key)) continue; await db .insert(configTable) .values({ key, value: String(value), updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value: String(value), updatedAt: new Date() } }); } res.json({ success: true }); }); // ── Logo upload ─────────────────────────────────────────────────────────────── router.post("/logo", upload.single("logo"), async (req, res) => { const file = (req as any).file as Express.Multer.File | undefined; if (!file) return res.status(400).json({ error: "No file uploaded" }); const ext = file.originalname.split(".").pop()?.toLowerCase() || "png"; const key = `logos/site-logo.${ext}`; await s3.send(new PutObjectCommand({ Bucket: S3_BUCKET, Key: key, Body: file.buffer, ContentType: file.mimetype, ACL: "public-read", })); const url = `${S3_PUBLIC_BASE}/${key}?t=${Date.now()}`; await db .insert(configTable) .values({ key: "logo_url", value: url, updatedAt: new Date() }) .onConflictDoUpdate({ target: configTable.key, set: { value: url, updatedAt: new Date() } }); res.json({ success: true, url }); }); // ── Credits management ──────────────────────────────────────────────────────── router.get("/credits", async (_req, res) => { const users = await db .select({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, credits: usersTable.credits, isAdmin: usersTable.isAdmin, }) .from(usersTable) .orderBy(desc(usersTable.createdAt)); res.json({ users }); }); router.post("/credits/:userId/adjust", async (req, res) => { const userId = Number(req.params.userId); const { amount, description } = req.body as { amount?: number; description?: string }; if (!amount || isNaN(amount)) return res.status(400).json({ error: "amount is required" }); const [user] = await db .update(usersTable) .set({ credits: sql`GREATEST(0, ${usersTable.credits} + ${amount})` }) .where(eq(usersTable.id, userId)) .returning({ credits: usersTable.credits }); if (!user) return res.status(404).json({ error: "User not found" }); await db.insert(creditTransactionsTable).values({ userId, amount, type: amount > 0 ? "grant" : "deduct", description: description || (amount > 0 ? "管理員手動增加" : "管理員手動扣除"), }); res.json({ success: true, newBalance: user.credits }); }); export { OTP_KEY, requireAdmin, SITE_CONFIG_KEYS }; export default router;