import express, { type NextFunction, type Request, type Response } from 'express' import cookieParser from 'cookie-parser' import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import multer from 'multer' import { mkdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import pg from 'pg' type Role = 'user' | 'admin' type CloudStatus = 'pending' | 'approved' | 'rejected' interface AuthUser { id: string email: string username: string avatar_url: string | null role: Role is_disabled: boolean created_at: string } interface JwtPayload { sub: string } declare global { namespace Express { interface Request { user?: AuthUser } } } const { Pool } = pg const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.resolve(__dirname, '..') const uploadRoot = process.env.UPLOAD_ROOT ? path.resolve(process.env.UPLOAD_ROOT) : path.join(rootDir, 'server/uploads') const cloudUploadRoot = path.join(uploadRoot, 'clouds') const cookieName = 'opencloud_session' const jwtSecret = process.env.JWT_SECRET || 'opencloud-dev-secret-change-me' const port = Number(process.env.PORT || 3001) if (!process.env.DATABASE_URL) { console.warn('DATABASE_URL is not set. API routes will fail until a PostgreSQL connection string is provided.') } const pool = new Pool({ connectionString: process.env.DATABASE_URL, }) const app = express() const upload = multer({ storage: multer.memoryStorage(), limits: { files: 2, fileSize: 20 * 1024 * 1024, }, }) app.use(express.json({ limit: '1mb' })) app.use(cookieParser()) app.use('/uploads', express.static(uploadRoot)) function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise) { return (req: Request, res: Response, next: NextFunction) => { void fn(req, res, next).catch(next) } } function assertDatabaseUrl() { if (!process.env.DATABASE_URL) { const error = new Error('缺少 DATABASE_URL,后端无法连接 PostgreSQL。') Object.assign(error, { status: 500 }) throw error } } function signSession(userId: string) { return jwt.sign({ sub: userId } satisfies JwtPayload, jwtSecret, { expiresIn: '7d' }) } function setSessionCookie(res: Response, userId: string) { res.cookie(cookieName, signSession(userId), { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 7 * 24 * 60 * 60 * 1000, path: '/', }) } function clearSessionCookie(res: Response) { res.clearCookie(cookieName, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', }) } function publicUser(row: Record): AuthUser { return { id: String(row.id), email: String(row.email), username: String(row.username), avatar_url: (row.avatar_url as string | null) ?? null, role: row.role as Role, is_disabled: Boolean(row.is_disabled), created_at: String(row.created_at), } } function publicProfile(row: Record) { return { id: String(row.id), username: String(row.username), avatar_url: (row.avatar_url as string | null) ?? null, role: row.role as Role, is_disabled: Boolean(row.is_disabled), created_at: String(row.created_at), } } async function fetchUserById(id: string) { assertDatabaseUrl() const { rows } = await pool.query( 'select id,email,username,avatar_url,role,is_disabled,created_at from users where id = $1', [id], ) return rows[0] ? publicUser(rows[0]) : null } async function optionalAuth(req: Request, _res: Response, next: NextFunction) { try { const token = req.cookies?.[cookieName] if (!token) return next() const payload = jwt.verify(token, jwtSecret) as JwtPayload const user = await fetchUserById(payload.sub) if (user && !user.is_disabled) req.user = user return next() } catch { return next() } } function requireAuth(req: Request, _res: Response, next: NextFunction) { if (!req.user) { const error = new Error('请先登录。') Object.assign(error, { status: 401 }) return next(error) } return next() } function requireAdmin(req: Request, _res: Response, next: NextFunction) { if (req.user?.role !== 'admin') { const error = new Error('需要管理员权限。') Object.assign(error, { status: 403 }) return next(error) } return next() } app.use(optionalAuth) function toCloudRow(row: Record) { const cloudTypeId = row.cloud_type_id as number | null const username = row.username as string | null return { id: String(row.id), user_id: String(row.user_id), cloud_type_id: cloudTypeId, custom_cloud_type: (row.custom_cloud_type as string | null) ?? null, image_url: String(row.image_url), thumbnail_url: (row.thumbnail_url as string | null) ?? null, latitude: row.latitude === null ? null : Number(row.latitude), longitude: row.longitude === null ? null : Number(row.longitude), location_name: (row.location_name as string | null) ?? null, description: (row.description as string | null) ?? null, captured_at: (row.captured_at as string | null) ?? null, status: row.status as CloudStatus, is_hidden: Boolean(row.is_hidden), created_at: String(row.created_at), cloud_types: cloudTypeId ? { id: cloudTypeId, name: String(row.cloud_type_name), name_en: String(row.cloud_type_name_en), genus: String(row.cloud_type_genus), rarity: row.cloud_type_rarity, description: (row.cloud_type_description as string | null) ?? null, icon_url: (row.cloud_type_icon_url as string | null) ?? null, created_at: String(row.cloud_type_created_at), } : null, profiles: username ? { id: String(row.user_id), username, avatar_url: (row.avatar_url as string | null) ?? null, role: row.user_role as Role, is_disabled: Boolean(row.user_is_disabled), created_at: String(row.user_created_at), } : null, } } const cloudSelect = ` c.*, ct.name as cloud_type_name, ct.name_en as cloud_type_name_en, ct.genus as cloud_type_genus, ct.rarity as cloud_type_rarity, ct.description as cloud_type_description, ct.icon_url as cloud_type_icon_url, ct.created_at as cloud_type_created_at, u.username, u.avatar_url, u.role as user_role, u.is_disabled as user_is_disabled, u.created_at as user_created_at ` const cloudJoin = ` from clouds c left join cloud_types ct on ct.id = c.cloud_type_id join users u on u.id = c.user_id ` function parseString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : null } function parseNullableNumber(value: unknown) { if (value === null || value === undefined || value === '') return null const number = Number(value) return Number.isFinite(number) ? number : null } function parseNullableInteger(value: unknown) { const number = parseNullableNumber(value) return number === null ? null : Math.trunc(number) } function parseBoolean(value: unknown) { return value === true || value === 'true' || value === '1' } function normalizeUploadExtension(file: Express.Multer.File) { const extension = path.extname(file.originalname).toLowerCase().replace(/[^a-z0-9.]/g, '') if (extension && ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) return extension if (file.mimetype === 'image/png') return '.png' if (file.mimetype === 'image/webp') return '.webp' if (file.mimetype === 'image/gif') return '.gif' return '.jpg' } async function writeCloudFile(userId: string, file: Express.Multer.File, suffix = '') { const userDir = path.join(cloudUploadRoot, userId) await mkdir(userDir, { recursive: true }) const random = Math.random().toString(36).slice(2, 10) const extension = suffix ? '.jpg' : normalizeUploadExtension(file) const filename = `${Date.now()}-${random}${suffix}${extension}` const absolutePath = path.join(userDir, filename) await writeFile(absolutePath, file.buffer) return { absolutePath, publicPath: `/uploads/clouds/${userId}/${filename}`, } } async function removePublicFile(publicPath: string | null) { if (!publicPath || !publicPath.startsWith('/uploads/clouds/')) return const absolutePath = path.resolve(uploadRoot, publicPath.replace(/^\/uploads\//, '')) if (!absolutePath.startsWith(cloudUploadRoot)) return await rm(absolutePath, { force: true }) } app.get('/api/health', (_req, res) => { res.json({ ok: true }) }) app.post('/api/auth/register', asyncHandler(async (req, res) => { assertDatabaseUrl() const email = parseString(req.body.email)?.toLowerCase() const password = typeof req.body.password === 'string' ? req.body.password : '' const username = parseString(req.body.username) if (!email || !username || password.length < 8) { res.status(400).json({ message: '请填写有效邮箱、用户名和至少 8 位密码。' }) return } const passwordHash = await bcrypt.hash(password, 12) try { const { rows } = await pool.query( `insert into users (email, password_hash, username) values ($1, $2, $3) returning id,email,username,avatar_url,role,is_disabled,created_at`, [email, passwordHash, username], ) const user = publicUser(rows[0]) setSessionCookie(res, user.id) res.status(201).json({ user, profile: publicProfile(rows[0]) }) } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === '23505') { res.status(409).json({ message: '邮箱或昵称已被使用。' }) return } throw error } })) app.post('/api/auth/login', asyncHandler(async (req, res) => { assertDatabaseUrl() const email = parseString(req.body.email)?.toLowerCase() const password = typeof req.body.password === 'string' ? req.body.password : '' if (!email || !password) { res.status(400).json({ message: '请输入邮箱和密码。' }) return } const { rows } = await pool.query( 'select id,email,password_hash,username,avatar_url,role,is_disabled,created_at from users where email = $1', [email], ) const row = rows[0] if (!row || !(await bcrypt.compare(password, row.password_hash))) { res.status(401).json({ message: '邮箱或密码错误。' }) return } if (row.is_disabled) { res.status(403).json({ message: '账号已被禁用。' }) return } const user = publicUser(row) setSessionCookie(res, user.id) res.json({ user, profile: publicProfile(row) }) })) app.post('/api/auth/logout', (_req, res) => { clearSessionCookie(res) res.json({ ok: true }) }) app.get('/api/auth/me', (req, res) => { res.json({ user: req.user ?? null, profile: req.user ? publicProfile(req.user as unknown as Record) : null }) }) app.patch('/api/auth/password', requireAuth, asyncHandler(async (req, res) => { const password = typeof req.body.password === 'string' ? req.body.password : '' if (password.length < 6) { res.status(400).json({ message: '新密码至少需要 6 位。' }) return } const passwordHash = await bcrypt.hash(password, 12) await pool.query('update users set password_hash = $1, updated_at = now() where id = $2', [passwordHash, req.user!.id]) res.json({ ok: true }) })) app.get('/api/profiles/check-username', asyncHandler(async (req, res) => { const username = parseString(req.query.username) const exclude = parseString(req.query.exclude) if (!username) { res.json({ available: false }) return } const params: unknown[] = [username] let sql = 'select id from users where username = $1' if (exclude) { params.push(exclude) sql += ' and id <> $2' } sql += ' limit 1' const { rows } = await pool.query(sql, params) res.json({ available: rows.length === 0 }) })) app.patch('/api/me/profile', requireAuth, asyncHandler(async (req, res) => { const username = parseString(req.body.username) if (!username || username.length < 2 || username.length > 32) { res.status(400).json({ message: '昵称长度需要在 2 到 32 个字符之间。' }) return } try { const { rows } = await pool.query( `update users set username = $1, updated_at = now() where id = $2 returning id,email,username,avatar_url,role,is_disabled,created_at`, [username, req.user!.id], ) res.json({ profile: publicProfile(rows[0]), user: publicUser(rows[0]) }) } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === '23505') { res.status(409).json({ message: '这个昵称已经被使用,请换一个。' }) return } throw error } })) app.get('/api/cloud-types', asyncHandler(async (_req, res) => { const { rows } = await pool.query('select * from cloud_types order by id') res.json({ data: rows }) })) app.get('/api/me/collections', requireAuth, asyncHandler(async (req, res) => { const { rows } = await pool.query( `select uc.*, c.image_url, c.thumbnail_url, c.captured_at, c.created_at as cloud_created_at, c.location_name from user_collections uc left join clouds c on c.id = uc.first_cloud_id where uc.user_id = $1 order by uc.unlocked_at asc`, [req.user!.id], ) res.json({ data: rows.map(row => ({ id: row.id, user_id: row.user_id, cloud_type_id: row.cloud_type_id, first_cloud_id: row.first_cloud_id, unlocked_at: row.unlocked_at, firstCloud: row.first_cloud_id ? { id: row.first_cloud_id, image_url: row.image_url, thumbnail_url: row.thumbnail_url, captured_at: row.captured_at, created_at: row.cloud_created_at, location_name: row.location_name, } : null, })), }) })) app.get('/api/profiles/:id', asyncHandler(async (req, res) => { const profileId = String(req.params.id) const user = await fetchUserById(profileId) if (!user) { res.status(404).json({ message: '用户不存在。' }) return } res.json({ profile: publicProfile(user as unknown as Record) }) })) app.get('/api/profiles/:id/clouds', asyncHandler(async (req, res) => { const profileId = String(req.params.id) const own = req.user?.id === profileId && req.query.own === 'true' const params: unknown[] = [profileId] let sql = `select ${cloudSelect} ${cloudJoin} where c.user_id = $1` if (!own) sql += ` and c.status = 'approved' and c.is_hidden = false` sql += ' order by c.captured_at desc nulls last, c.created_at desc' const { rows } = await pool.query(sql, params) res.json({ data: rows.map(toCloudRow) }) })) app.get('/api/clouds', asyncHandler(async (req, res) => { const page = Math.max(1, Number(req.query.page || 1)) const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize || 50))) const params: unknown[] = [] const where = [`c.status = 'approved'`, 'c.is_hidden = false'] const typeId = req.query.typeId === 'all' ? null : parseNullableInteger(req.query.typeId) if (typeId) { params.push(typeId) where.push(`c.cloud_type_id = $${params.length}`) } const search = parseString(req.query.search) if (search) { if (search.startsWith('@')) { params.push(`%${search.slice(1).trim()}%`) where.push(`u.username ilike $${params.length}`) } else { params.push(`%${search}%`) where.push(`(ct.name ilike $${params.length} or ct.name_en ilike $${params.length} or c.custom_cloud_type ilike $${params.length})`) } } const whereSql = where.length ? `where ${where.join(' and ')}` : '' const countResult = await pool.query(`select count(*)::int as count ${cloudJoin} ${whereSql}`, params) params.push(pageSize, (page - 1) * pageSize) const dataResult = await pool.query( `select ${cloudSelect} ${cloudJoin} ${whereSql} order by c.created_at desc limit $${params.length - 1} offset $${params.length}`, params, ) res.json({ data: dataResult.rows.map(toCloudRow), count: countResult.rows[0].count }) })) app.get('/api/clouds/map', asyncHandler(async (req, res) => { const field = req.query.field === 'created_at' ? 'created_at' : 'captured_at' const start = parseString(req.query.start) const end = parseString(req.query.end) if (!start || !end) { res.status(400).json({ message: '缺少时间范围。' }) return } const { rows } = await pool.query( `select ${cloudSelect} ${cloudJoin} where c.status = 'approved' and c.is_hidden = false and c.latitude is not null and c.longitude is not null and c.${field} >= $1 and c.${field} < $2 order by c.${field} asc limit 1000`, [start, end], ) res.json({ data: rows.map(toCloudRow) }) })) app.get('/api/cloud-types/:id/gallery', asyncHandler(async (req, res) => { const typeId = Number(String(req.params.id)) const countResult = await pool.query( `select count(*)::int as count from clouds where cloud_type_id = $1 and status = 'approved' and is_hidden = false`, [typeId], ) const { rows } = await pool.query( `select ${cloudSelect} ${cloudJoin} where c.cloud_type_id = $1 and c.status = 'approved' and c.is_hidden = false order by c.captured_at desc nulls last, c.created_at desc limit 24`, [typeId], ) res.json({ data: rows.map(toCloudRow), count: countResult.rows[0].count }) })) app.post('/api/clouds', requireAuth, upload.fields([ { name: 'image', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }, ]), asyncHandler(async (req, res) => { const files = req.files as Record | undefined const image = files?.image?.[0] const thumbnail = files?.thumbnail?.[0] if (!image || !thumbnail) { res.status(400).json({ message: '请同时上传原图和缩略图。' }) return } let imageFile: Awaited> | null = null let thumbnailFile: Awaited> | null = null const client = await pool.connect() try { imageFile = await writeCloudFile(req.user!.id, image) thumbnailFile = await writeCloudFile(req.user!.id, thumbnail, '-thumb') await client.query('begin') const insertResult = await client.query( `insert into clouds ( user_id, cloud_type_id, custom_cloud_type, image_url, thumbnail_url, latitude, longitude, location_name, description, captured_at, status, is_hidden ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending',$11) returning id, cloud_type_id, created_at`, [ req.user!.id, parseNullableInteger(req.body.cloud_type_id), parseString(req.body.custom_cloud_type), imageFile.publicPath, thumbnailFile.publicPath, parseNullableNumber(req.body.latitude), parseNullableNumber(req.body.longitude), parseString(req.body.location_name), parseString(req.body.description), parseString(req.body.captured_at), parseBoolean(req.body.is_hidden), ], ) const inserted = insertResult.rows[0] let unlockedBadge = null if (inserted.cloud_type_id) { const collectionResult = await client.query( `insert into user_collections (user_id, cloud_type_id, first_cloud_id) values ($1, $2, $3) on conflict (user_id, cloud_type_id) do nothing returning cloud_type_id, unlocked_at`, [req.user!.id, inserted.cloud_type_id, inserted.id], ) const collection = collectionResult.rows[0] if (collection) { const typeResult = await client.query( 'select id,name,name_en,rarity from cloud_types where id = $1', [collection.cloud_type_id], ) const type = typeResult.rows[0] if (type) { unlockedBadge = { cloudTypeId: type.id, cloudName: type.name, cloudNameEn: type.name_en, rarity: type.rarity, unlockedAt: collection.unlocked_at, } } } } await client.query('commit') res.status(201).json({ cloud: inserted, unlockedBadge }) } catch (error) { await client.query('rollback').catch(() => undefined) await Promise.all([ removePublicFile(imageFile?.publicPath ?? null), removePublicFile(thumbnailFile?.publicPath ?? null), ]) throw error } finally { client.release() } })) app.patch('/api/clouds/:id', requireAuth, asyncHandler(async (req, res) => { const cloudId = String(req.params.id) const isAdmin = req.user!.role === 'admin' const ownerResult = await pool.query('select user_id from clouds where id = $1', [cloudId]) const owner = ownerResult.rows[0]?.user_id if (!owner) { res.status(404).json({ message: '图片不存在。' }) return } if (!isAdmin && owner !== req.user!.id) { res.status(403).json({ message: '只能修改自己的图片。' }) return } const allowed = ['cloud_type_id', 'custom_cloud_type', 'latitude', 'longitude', 'location_name', 'description', 'captured_at', 'is_hidden', 'status'] const set: string[] = [] const params: unknown[] = [] for (const key of allowed) { if (!(key in req.body)) continue if (key === 'status' && !isAdmin) continue params.push(key === 'is_hidden' ? parseBoolean(req.body[key]) : key === 'cloud_type_id' ? parseNullableInteger(req.body[key]) : key === 'latitude' || key === 'longitude' ? parseNullableNumber(req.body[key]) : parseString(req.body[key])) set.push(`${key} = $${params.length}`) } if (!set.length) { res.status(400).json({ message: '没有可更新的字段。' }) return } params.push(cloudId) await pool.query(`update clouds set ${set.join(', ')}, updated_at = now() where id = $${params.length}`, params) const { rows } = await pool.query(`select ${cloudSelect} ${cloudJoin} where c.id = $1`, [cloudId]) res.json({ cloud: toCloudRow(rows[0]) }) })) app.delete('/api/clouds/:id', requireAuth, asyncHandler(async (req, res) => { const cloudId = String(req.params.id) const isAdmin = req.user!.role === 'admin' const { rows } = await pool.query('select id,user_id,image_url,thumbnail_url from clouds where id = $1', [cloudId]) const cloud = rows[0] if (!cloud) { res.status(404).json({ message: '图片不存在。' }) return } if (!isAdmin && cloud.user_id !== req.user!.id) { res.status(403).json({ message: '只能删除自己的图片。' }) return } await pool.query('delete from clouds where id = $1', [cloudId]) const fileErrors = await Promise.allSettled([ removePublicFile(cloud.image_url), removePublicFile(cloud.thumbnail_url), ]) const failed = fileErrors.some(result => result.status === 'rejected') if (failed) console.error('cloud file deletion failed', fileErrors) res.json({ deleted: [cloud.id], fileCleanupFailed: failed }) })) app.get('/api/admin/stats', requireAuth, requireAdmin, asyncHandler(async (_req, res) => { const today = new Date() today.setHours(0, 0, 0, 0) const { rows } = await pool.query( `select (select count(*)::int from users) as users, (select count(*)::int from clouds) as images, (select count(*)::int from clouds where created_at >= $1) as "todayUploads", (select count(*)::int from clouds where status = 'pending') as pending, (select count(*)::int from clouds where status = 'approved') as approved, (select count(*)::int from clouds where status = 'rejected') as rejected, (select count(*)::int from clouds where is_hidden = true) as hidden`, [today.toISOString()], ) res.json({ stats: rows[0] }) })) app.get('/api/admin/users', requireAuth, requireAdmin, asyncHandler(async (_req, res) => { const { rows } = await pool.query( 'select id,username,avatar_url,role,is_disabled,created_at from users order by created_at desc limit 100', ) res.json({ data: rows.map(publicProfile) }) })) app.patch('/api/admin/users/:id', requireAuth, requireAdmin, asyncHandler(async (req, res) => { const userId = String(req.params.id) if (userId === req.user!.id && (req.body.role !== 'admin' || parseBoolean(req.body.is_disabled))) { res.status(400).json({ message: '不能移除自己的管理员权限或禁用当前账号。' }) return } const set: string[] = [] const params: unknown[] = [] if (req.body.role === 'user' || req.body.role === 'admin') { params.push(req.body.role) set.push(`role = $${params.length}`) } if ('is_disabled' in req.body) { params.push(parseBoolean(req.body.is_disabled)) set.push(`is_disabled = $${params.length}`) } if (!set.length) { res.status(400).json({ message: '没有可更新的字段。' }) return } params.push(userId) const { rows } = await pool.query( `update users set ${set.join(', ')}, updated_at = now() where id = $${params.length} returning id,username,avatar_url,role,is_disabled,created_at`, params, ) res.json({ profile: publicProfile(rows[0]) }) })) app.get('/api/admin/clouds', requireAuth, requireAdmin, asyncHandler(async (_req, res) => { const { rows } = await pool.query( `select ${cloudSelect} ${cloudJoin} order by c.created_at desc limit 120`, ) res.json({ data: rows.map(toCloudRow) }) })) app.patch('/api/admin/clouds', requireAuth, requireAdmin, asyncHandler(async (req, res) => { const ids = Array.isArray(req.body.ids) ? req.body.ids.filter((id: unknown) => typeof id === 'string') : [] if (!ids.length) { res.status(400).json({ message: '请选择图片。' }) return } const set: string[] = [] const params: unknown[] = [] if (['pending', 'approved', 'rejected'].includes(req.body.status)) { params.push(req.body.status) set.push(`status = $${params.length}`) } if ('is_hidden' in req.body) { params.push(parseBoolean(req.body.is_hidden)) set.push(`is_hidden = $${params.length}`) } if (!set.length) { res.status(400).json({ message: '没有可更新的字段。' }) return } params.push(ids) const { rows } = await pool.query( `update clouds set ${set.join(', ')}, updated_at = now() where id = any($${params.length}::uuid[]) returning id`, params, ) res.json({ updated: rows.map(row => row.id) }) })) app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { const payload = error as { status?: number; message?: string } const status = payload.status || 500 if (status >= 500) console.error(error) res.status(status).json({ message: payload.message || '服务器内部错误。' }) }) if (!existsSync(uploadRoot)) { await mkdir(uploadRoot, { recursive: true }) } app.listen(port, () => { console.log(`OpenCloud API listening on http://localhost:${port}`) })