Init
This commit is contained in:
commit
93494d6ec7
4 changed files with 586 additions and 0 deletions
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
25
package.json
Normal 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
53
prisma/schema.prisma
Normal 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
487
src/server.js
Normal 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}`);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue