kinaiok
Initial deployment setup for Hugging Face Spaces
5ef6e9d
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 });
});
// 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<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 });
});
// โ”€โ”€ 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;