import express from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import multer from 'multer'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import { PrismaClient } from '@prisma/client'; dotenv.config(); const prisma = new PrismaClient(); const app = express(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const uploadDir = path.join(__dirname, '..', 'uploads'); const fsPromises = fs.promises; const jwtSecret = process.env.JWT_SECRET || 'super-secret-key'; const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3100'; const port = process.env.PORT || 4000; const host = process.env.HOST || '0.0.0.0'; if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadDir), filename: (_req, file, cb) => { const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const ext = path.extname(file.originalname) || '.png'; cb(null, `${unique}${ext}`); }, }); const upload = multer({ storage }); app.use( cors({ origin: frontendOrigin, credentials: true, }) ); app.use(express.json()); app.use(cookieParser()); app.use('/uploads', express.static(uploadDir)); function setAuthCookie(res, token) { res.cookie('token', token, { httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 7 * 24 * 60 * 60 * 1000, }); } function clearAuthCookie(res) { res.clearCookie('token'); } async function getUserFromToken(req) { const token = req.cookies.token; if (!token) return null; try { const payload = jwt.verify(token, jwtSecret); const user = await prisma.user.findUnique({ where: { id: payload.sub }, include: { armyCategories: true, bases: { include: { defenses: true, }, }, }, }); return user; } catch (_err) { return null; } } async function requireAuth(req, res, next) { const user = await getUserFromToken(req); if (!user) { return res.status(401).json({ error: 'Authentication required' }); } req.user = user; return next(); } app.post('/auth/signup', async (req, res) => { try { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } if (username.length < 3) { return res.status(400).json({ error: 'Username must be at least 3 characters' }); } if (password.length < 6) { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } const existing = await prisma.user.findUnique({ where: { username: username.toLowerCase() } }); if (existing) { return res.status(400).json({ error: 'Username already in use' }); } const passwordHash = await bcrypt.hash(password, 10); const user = await prisma.user.create({ data: { username: username.toLowerCase(), passwordHash, }, }); const token = jwt.sign({ sub: user.id }, jwtSecret, { expiresIn: '7d' }); setAuthCookie(res, token); return res.status(201).json({ user: sanitizeUser(user) }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.post('/auth/login', async (req, res) => { try { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ error: 'Username and password are required' }); } const user = await prisma.user.findUnique({ where: { username: username.toLowerCase() } }); if (!user) { return res.status(401).json({ error: 'Invalid username or password' }); } const valid = await bcrypt.compare(password, user.passwordHash); if (!valid) { return res.status(401).json({ error: 'Invalid username or password' }); } const token = jwt.sign({ sub: user.id }, jwtSecret, { expiresIn: '7d' }); setAuthCookie(res, token); return res.json({ user: sanitizeUser(user) }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.post('/auth/logout', (_req, res) => { clearAuthCookie(res); res.json({ message: 'Logged out' }); }); app.get('/auth/me', async (req, res) => { const user = await getUserFromToken(req); if (!user) { return res.status(401).json({ error: 'Authentication required' }); } return res.json({ user: sanitizeUser(user) }); }); app.get('/army-categories', requireAuth, async (req, res) => { const categories = await prisma.armyCategory.findMany({ where: { userId: req.user.id }, orderBy: { createdAt: 'asc' }, }); res.json({ categories: categories.map((category) => ({ id: category.id, name: category.name, createdAt: category.createdAt, })) }); }); app.post('/army-categories', requireAuth, async (req, res) => { const { name } = req.body || {}; if (!name || !name.trim()) { return res.status(400).json({ error: 'Category name is required' }); } try { const category = await prisma.armyCategory.create({ data: { name: name.trim(), userId: req.user.id, }, }); return res.status(201).json({ category: { id: category.id, name: category.name, createdAt: category.createdAt, } }); } catch (error) { if (error.code === 'P2002') { return res.status(400).json({ error: 'Category name already exists' }); } console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.delete('/army-categories/:categoryId', requireAuth, async (req, res) => { try { const { categoryId } = req.params; const category = await prisma.armyCategory.findFirst({ where: { id: categoryId, userId: req.user.id }, }); if (!category) { return res.status(404).json({ error: 'Army category not found' }); } await prisma.$transaction([ prisma.defense.deleteMany({ where: { armyCategoryId: category.id, base: { userId: req.user.id }, }, }), prisma.armyCategory.delete({ where: { id: category.id } }), ]); return res.json({ message: 'Army category deleted' }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.get('/bases', requireAuth, async (req, res) => { const bases = await prisma.base.findMany({ where: { userId: req.user.id }, orderBy: { createdAt: 'desc' }, }); res.json({ bases: bases.map((base) => ({ id: base.id, title: base.title, description: base.description || '', url: base.url || '', imageUrl: buildImageUrl(base), isPrivate: base.isPrivate, createdAt: base.createdAt, })), }); }); app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => { try { const { title, description, url, imageMode, imageUrl, isPrivate } = req.body; if (!title || !title.trim()) { return res.status(400).json({ error: 'Title is required' }); } if (url && !isValidUrl(url)) { return res.status(400).json({ error: 'Planning link must be a valid URL' }); } let storedImageUrl = null; let storedImagePath = null; if (imageMode === 'url') { if (!imageUrl || !isValidUrl(imageUrl)) { return res.status(400).json({ error: 'Image URL must be valid' }); } storedImageUrl = imageUrl.trim(); } else if (req.file) { storedImagePath = req.file.filename; } const base = await prisma.base.create({ data: { title: title.trim(), description: description?.trim() || null, url: url?.trim() || null, imageUrl: storedImageUrl, imagePath: storedImagePath, isPrivate: parseBoolean(isPrivate), userId: req.user.id, }, }); return res.status(201).json({ base: { id: base.id, title: base.title, description: base.description || '', url: base.url || '', imageUrl: buildImageUrl(base), isPrivate: base.isPrivate, createdAt: base.createdAt, }, }); } catch (error) { console.error(error); if (req.file) { await deleteImageFile(req.file.filename); } return res.status(500).json({ error: 'Internal server error' }); } }); app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, res) => { let newUploadFilename = null; try { const { baseId } = req.params; const { title, description, url, imageMode, imageUrl, removeImage, isPrivate, } = req.body; if (!title || !title.trim()) { if (req.file) { await deleteImageFile(req.file.filename); } return res.status(400).json({ error: 'Title is required' }); } if (url && !isValidUrl(url)) { if (req.file) { await deleteImageFile(req.file.filename); } return res.status(400).json({ error: 'Planning link must be a valid URL' }); } const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }); if (!base) { if (req.file) { await deleteImageFile(req.file.filename); } return res.status(404).json({ error: 'Base not found' }); } const shouldRemoveImage = parseBoolean(removeImage); const parsedIsPrivate = parseBoolean(isPrivate); let imagePath = base.imagePath; let storedImageUrl = base.imageUrl; let previousImagePathToDelete = null; if (shouldRemoveImage) { previousImagePathToDelete = base.imagePath; imagePath = null; storedImageUrl = null; } else if (imageMode === 'url') { if (imageUrl) { if (!isValidUrl(imageUrl)) { if (req.file) { await deleteImageFile(req.file.filename); } return res.status(400).json({ error: 'Image URL must be valid' }); } previousImagePathToDelete = base.imagePath; imagePath = null; storedImageUrl = imageUrl.trim(); } } else if (req.file) { newUploadFilename = req.file.filename; previousImagePathToDelete = base.imagePath; imagePath = req.file.filename; storedImageUrl = null; } const updatedBase = await prisma.base.update({ where: { id: base.id }, data: { title: title.trim(), description: description?.trim() || null, url: url?.trim() || null, imageUrl: storedImageUrl, imagePath, isPrivate: parsedIsPrivate, }, }); if (previousImagePathToDelete && previousImagePathToDelete !== imagePath) { await deleteImageFile(previousImagePathToDelete); } return res.json({ base: { id: updatedBase.id, title: updatedBase.title, description: updatedBase.description || '', url: updatedBase.url || '', imageUrl: buildImageUrl(updatedBase), isPrivate: updatedBase.isPrivate, createdAt: updatedBase.createdAt, }, }); } catch (error) { console.error(error); if (req.file) { await deleteImageFile(req.file.filename); } else if (newUploadFilename) { await deleteImageFile(newUploadFilename); } return res.status(500).json({ error: 'Internal server error' }); } }); app.delete('/bases/:baseId', requireAuth, async (req, res) => { try { const { baseId } = req.params; const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }); if (!base) { return res.status(404).json({ error: 'Base not found' }); } await prisma.$transaction([ prisma.defense.deleteMany({ where: { baseId: base.id } }), prisma.base.delete({ where: { id: base.id } }), ]); if (base.imagePath) { await deleteImageFile(base.imagePath); } return res.json({ message: 'Base deleted' }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => { try { const { baseId } = req.params; const { stars, percent, trophies, armyCategoryId } = req.body || {}; const parsedStars = Number(stars); const parsedPercent = Number(percent); const parsedTrophies = Number(trophies ?? 0); if (!armyCategoryId) { return res.status(400).json({ error: 'Army category is required' }); } if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) { return res.status(400).json({ error: 'Stars must be between 0 and 3' }); } if (!Number.isFinite(parsedPercent) || parsedPercent < 0 || parsedPercent > 100) { return res.status(400).json({ error: 'Percent must be between 0 and 100' }); } if (!Number.isFinite(parsedTrophies) || parsedTrophies < -200 || parsedTrophies > 200) { return res.status(400).json({ error: 'Trophies must be between -200 and 200' }); } const [base, category] = await Promise.all([ prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }), prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }), ]); if (!base) { return res.status(404).json({ error: 'Base not found' }); } if (!category) { return res.status(404).json({ error: 'Army category not found' }); } await prisma.defense.create({ data: { baseId: base.id, armyCategoryId: category.id, stars: parsedStars, percent: parsedPercent, trophies: parsedTrophies, }, }); return res.status(201).json({ message: 'Defense logged' }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.put('/defenses/:defenseId', requireAuth, async (req, res) => { try { const { defenseId } = req.params; const { baseId, armyCategoryId, stars, percent, trophies } = req.body || {}; if (!baseId) { return res.status(400).json({ error: 'Base is required' }); } if (!armyCategoryId) { return res.status(400).json({ error: 'Army category is required' }); } const parsedStars = Number(stars); const parsedPercent = Number(percent); const parsedTrophies = Number(trophies ?? 0); if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) { return res.status(400).json({ error: 'Stars must be between 0 and 3' }); } if (!Number.isFinite(parsedPercent) || parsedPercent < 0 || parsedPercent > 100) { return res.status(400).json({ error: 'Percent must be between 0 and 100' }); } if (!Number.isFinite(parsedTrophies) || parsedTrophies < -200 || parsedTrophies > 200) { return res.status(400).json({ error: 'Trophies must be between -200 and 200' }); } const [defense, base, category] = await Promise.all([ prisma.defense.findFirst({ where: { id: defenseId, base: { userId: req.user.id } }, }), prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }), prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }), ]); if (!defense) { return res.status(404).json({ error: 'Defense not found' }); } if (!base) { return res.status(404).json({ error: 'Base not found' }); } if (!category) { return res.status(404).json({ error: 'Army category not found' }); } await prisma.defense.update({ where: { id: defense.id }, data: { baseId: base.id, armyCategoryId: category.id, stars: parsedStars, percent: parsedPercent, trophies: parsedTrophies, }, }); return res.json({ message: 'Defense updated' }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.delete('/defenses/:defenseId', requireAuth, async (req, res) => { try { const { defenseId } = req.params; const result = await prisma.defense.deleteMany({ where: { id: defenseId, base: { userId: req.user.id } }, }); if (!result.count) { return res.status(404).json({ error: 'Defense not found' }); } return res.json({ message: 'Defense deleted' }); } catch (error) { console.error(error); return res.status(500).json({ error: 'Internal server error' }); } }); app.get('/defenses', requireAuth, async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.user.id }, include: { armyCategories: true, bases: { include: { defenses: true, }, }, }, }); const categoryLookup = new Map( user.armyCategories.map((category) => [category.id, category.name]) ); const defenses = []; const baseBuckets = new Map(); const categoryBuckets = new Map(); user.bases.forEach((base) => { baseBuckets.set(base.id, { title: base.title, items: [], categories: new Map(), }); }); user.armyCategories.forEach((category) => { categoryBuckets.set(category.id, { name: category.name, items: [], bases: new Map(), }); }); user.bases.forEach((base) => { base.defenses.forEach((defense) => { defenses.push({ id: defense.id, stars: defense.stars, percent: defense.percent, trophies: defense.trophies, armyCategoryId: defense.armyCategoryId, createdAt: defense.createdAt, baseId: base.id, baseTitle: base.title, }); const baseBucket = baseBuckets.get(base.id); baseBucket.items.push(defense); if (!baseBucket.categories.has(defense.armyCategoryId)) { baseBucket.categories.set(defense.armyCategoryId, []); } baseBucket.categories.get(defense.armyCategoryId).push(defense); if (!categoryBuckets.has(defense.armyCategoryId)) { categoryBuckets.set(defense.armyCategoryId, { name: categoryLookup.get(defense.armyCategoryId) || 'Unknown Army', items: [], bases: new Map(), }); } const categoryBucket = categoryBuckets.get(defense.armyCategoryId); categoryBucket.items.push({ ...defense, baseId: base.id, baseTitle: base.title }); if (!categoryBucket.bases.has(base.id)) { categoryBucket.bases.set(base.id, []); } categoryBucket.bases.get(base.id).push(defense); }); }); defenses.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); const bases = Array.from(baseBuckets.entries()).map(([baseId, bucket]) => { const summary = summarizeDefenses(bucket.items); const categories = Array.from(bucket.categories.entries()).map(([categoryId, items]) => ({ categoryId, name: categoryLookup.get(categoryId) || 'Unknown Army', ...summarizeDefenses(items), })); categories.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); return { baseId, title: bucket.title, ...summary, categories, }; }); bases.sort((a, b) => a.title.localeCompare(b.title)); const categories = Array.from(categoryBuckets.entries()).map(([categoryId, bucket]) => { const summary = summarizeDefenses(bucket.items); const baseSummaries = Array.from(bucket.bases.entries()).map(([baseId, items]) => ({ baseId, title: baseBuckets.get(baseId)?.title || 'Unknown Base', ...summarizeDefenses(items), })); baseSummaries.sort((a, b) => b.count - a.count || a.title.localeCompare(b.title)); return { categoryId, name: bucket.name, ...summary, bases: baseSummaries, }; }); categories.sort((a, b) => a.name.localeCompare(b.name)); res.json({ overall: summarizeDefenses(defenses), categories, bases, defenses, }); } catch (error) { console.error(error); res.status(500).json({ error: 'Internal server error' }); } }); app.get('/profiles', requireAuth, async (req, res) => { try { const searchTerm = (req.query.search ?? '').toString().trim(); const users = await prisma.user.findMany({ where: searchTerm ? { username: { contains: searchTerm, mode: 'insensitive', }, } : undefined, orderBy: { username: 'asc' }, take: 25, select: { id: true, username: true, createdAt: true, bases: { where: { isPrivate: false }, select: { id: true, _count: { select: { defenses: true } }, }, }, }, }); const profiles = users.map((user) => { const publicBaseCount = user.bases.length; const publicDefenseCount = user.bases.reduce((sum, base) => sum + base._count.defenses, 0); return { id: user.id, username: user.username, createdAt: user.createdAt, publicBaseCount, publicDefenseCount, }; }); res.json({ profiles }); } catch (error) { console.error(error); res.status(500).json({ error: 'Internal server error' }); } }); app.get('/profiles/:username', requireAuth, async (req, res) => { try { const usernameParam = req.params.username.toLowerCase(); const profileUser = await prisma.user.findUnique({ where: { username: usernameParam }, select: { id: true, username: true, createdAt: true, }, }); if (!profileUser) { return res.status(404).json({ error: 'Profile not found' }); } const isOwner = profileUser.id === req.user.id; const bases = await prisma.base.findMany({ where: { userId: profileUser.id, ...(isOwner ? {} : { isPrivate: false }), }, orderBy: { createdAt: 'desc' }, include: { defenses: { orderBy: { createdAt: 'desc' }, include: { armyCategory: { select: { id: true, name: true }, }, }, }, }, }); const serializedBases = bases.map((base) => { const defenses = base.defenses.map((defense) => ({ id: defense.id, stars: defense.stars, percent: defense.percent, trophies: defense.trophies, createdAt: defense.createdAt, armyCategoryId: defense.armyCategoryId, armyCategoryName: defense.armyCategory?.name || 'Unknown Army', })); return { id: base.id, title: base.title, description: base.description || '', url: base.url || '', imageUrl: buildImageUrl(base), isPrivate: base.isPrivate, createdAt: base.createdAt, summary: summarizeDefenses(defenses), defenses, }; }); const allVisibleDefenses = serializedBases.flatMap((base) => base.defenses); const overallSummary = summarizeDefenses(allVisibleDefenses); res.json({ profile: { id: profileUser.id, username: profileUser.username, createdAt: profileUser.createdAt, isOwner, visibleBaseCount: serializedBases.length, defenseCount: allVisibleDefenses.length, summary: overallSummary, }, bases: serializedBases, }); } catch (error) { console.error(error); res.status(500).json({ error: 'Internal server error' }); } }); function summarizeDefenses(defenses) { if (!defenses.length) { return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 }; } const totalStars = defenses.reduce((sum, defense) => sum + Number(defense.stars || 0), 0); const totalPercent = defenses.reduce((sum, defense) => sum + Number(defense.percent || 0), 0); const totalTrophies = defenses.reduce((sum, defense) => sum + Number(defense.trophies || 0), 0); const count = defenses.length; return { count, averageStars: Number((totalStars / count).toFixed(2)), averagePercent: Number((totalPercent / count).toFixed(2)), averageTrophies: Number((totalTrophies / count).toFixed(2)), }; } async function deleteImageFile(imagePath) { if (!imagePath) return; const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath); try { await fsPromises.unlink(filePath); } catch (error) { if (error.code !== 'ENOENT') { console.error(`Failed to delete image file ${filePath}`, error); } } } function parseBoolean(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); return ['true', '1', 'yes', 'on'].includes(normalized); } return false; } function sanitizeUser(user) { return { id: user.id, username: user.username, createdAt: user.createdAt, }; } function buildImageUrl(base) { if (base.imageUrl) return base.imageUrl; if (base.imagePath) return `/uploads/${base.imagePath}`; return ''; } function isValidUrl(value) { try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; } catch (_err) { return false; } } app.listen(port, host, () => { console.log(`Backend listening on http://${host}:${port}`); });