This commit is contained in:
Hymmel 2025-10-09 08:56:46 +02:00
commit 93494d6ec7
4 changed files with 586 additions and 0 deletions

21
Dockerfile Normal file
View file

@ -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"]

25
package.json Normal file
View file

@ -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"
}
}

53
prisma/schema.prisma Normal file
View file

@ -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
}

487
src/server.js Normal file
View file

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