From 93494d6ec74f02c240fa3d761d22321a393cd8d0 Mon Sep 17 00:00:00 2001 From: Hymmel Date: Thu, 9 Oct 2025 08:56:46 +0200 Subject: [PATCH] Init --- Dockerfile | 21 ++ package.json | 25 +++ prisma/schema.prisma | 53 +++++ src/server.js | 487 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 586 insertions(+) create mode 100644 Dockerfile create mode 100644 package.json create mode 100644 prisma/schema.prisma create mode 100644 src/server.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c91e5bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine + +# Ensure Prisma engines can find the expected OpenSSL libraries on Alpine +RUN apk add --no-cache openssl1.1-compat + +WORKDIR /app + +COPY package.json ./ +COPY prisma ./prisma +RUN npm install +RUN npx prisma generate + +COPY src ./src +RUN mkdir -p uploads + +ENV NODE_ENV=production +ENV PORT=4000 + +EXPOSE 4000 + +CMD ["sh", "-c", "npx prisma db push && node src/server.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..639f67e --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "base-noter-backend", + "version": "1.0.0", + "type": "module", + "main": "src/server.js", + "scripts": { + "dev": "node src/server.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy", + "prisma:push": "prisma db push" + }, + "dependencies": { + "@prisma/client": "^5.17.0", + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1" + }, + "devDependencies": { + "prisma": "^5.17.0" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..2c2615c --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,53 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + createdAt DateTime @default(now()) + armyCategories ArmyCategory[] + bases Base[] +} + +model ArmyCategory { + id String @id @default(cuid()) + name String + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + userId String + defenses Defense[] + + @@unique([userId, name]) +} + +model Base { + id String @id @default(cuid()) + title String + description String? + url String? + imageUrl String? + imagePath String? + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + userId String + defenses Defense[] +} + +model Defense { + id String @id @default(cuid()) + stars Int + percent Int + trophies Int + createdAt DateTime @default(now()) + base Base @relation(fields: [baseId], references: [id]) + baseId String + armyCategory ArmyCategory @relation(fields: [armyCategoryId], references: [id]) + armyCategoryId String +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..6a95c96 --- /dev/null +++ b/src/server.js @@ -0,0 +1,487 @@ +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 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.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), + createdAt: base.createdAt, + })), + }); +}); + +app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => { + try { + const { title, description, url, imageMode, imageUrl } = 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; + } 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, + 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), + createdAt: base.createdAt, + }, + }); + } 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.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' }); + } +}); + +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)), + }; +} + +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}`); +});