| 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<typeof usersTable.$inferInsert> = {}; |
| 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<string, string> = { |
| 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 }); |
| }); |
|
|
| |
| 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() } }); |
|
|
| |
| |
| 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." }); |
| }); |
|
|
| |
| router.get("/credentials", requireJwtAuth, requireAdmin, async (_req, res) => { |
| const creds = await getStoredCredentials(); |
| res.json({ |
| configured: !!creds, |
| username: creds?.username ?? null, |
| }); |
| }); |
|
|
| |
| 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 }); |
| }); |
|
|
| |
| router.post("/bookmarklet-otp", async (_req, res) => { |
| const otp = randomUUID(); |
| const expiresAt = Date.now() + 10 * 60 * 1000; |
| 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 }); |
| }); |
|
|
| |
| 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<string, string> = {}; |
| 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<string, string>; |
| 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 }); |
| }); |
|
|
| |
| 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 }); |
| }); |
|
|
| |
| 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; |
|
|