BaseTracker/backend/src/server.js
2025-10-09 10:17:44 +02:00

883 lines
25 KiB
JavaScript

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}`);
});