This commit is contained in:
Hymmel 2025-10-09 13:08:51 +02:00
parent 87c1e377a3
commit afcaa0b217
10 changed files with 415 additions and 33 deletions

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@prisma/client": "^5.17.0",
"@aws-sdk/client-s3": "^3.583.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",

View file

@ -7,8 +7,10 @@ import jwt from 'jsonwebtoken';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { randomUUID } from 'crypto';
import dotenv from 'dotenv';
import { PrismaClient } from '@prisma/client';
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
dotenv.config();
@ -18,6 +20,28 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadDir = path.join(__dirname, '..', 'uploads');
const fsPromises = fs.promises;
const assetBaseUrl = (process.env.ASSET_BASE_URL || '').replace(/\/$/, '');
const s3Bucket = process.env.S3_BUCKET || '';
const s3Region = process.env.S3_REGION || 'us-east-1';
const s3Endpoint = process.env.S3_ENDPOINT || undefined;
const s3AccessKey = process.env.S3_ACCESS_KEY || '';
const s3SecretKey = process.env.S3_SECRET_KEY || '';
const s3ForcePathStyle = parseBoolean(process.env.S3_FORCE_PATH_STYLE ?? 'true');
const useS3Storage = Boolean(s3Bucket);
const s3Client = useS3Storage
? new S3Client({
region: s3Region,
endpoint: s3Endpoint,
forcePathStyle: s3ForcePathStyle,
credentials:
s3AccessKey && s3SecretKey
? {
accessKeyId: s3AccessKey,
secretAccessKey: s3SecretKey,
}
: undefined,
})
: null;
const jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
const defaultFrontendOrigins = ['http://localhost:3100', 'http://localhost:3000'];
const configuredOrigins = process.env.FRONTEND_ORIGINS || process.env.FRONTEND_ORIGIN || '';
@ -57,11 +81,16 @@ const corsOptions = {
const port = process.env.PORT || 4000;
const host = process.env.HOST || '0.0.0.0';
let upload;
if (useS3Storage) {
upload = multer({ storage: multer.memoryStorage() });
} else {
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
const diskStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@ -70,13 +99,16 @@ const storage = multer.diskStorage({
},
});
const upload = multer({ storage });
upload = multer({ storage: diskStorage });
}
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
if (!useS3Storage) {
app.use('/uploads', express.static(uploadDir));
}
function setAuthCookie(res, token) {
res.cookie('token', token, {
@ -283,12 +315,19 @@ app.get('/bases', requireAuth, async (req, res) => {
});
app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => {
let uploadedFile = null;
try {
const { title, description, url, imageMode, imageUrl, isPrivate } = req.body;
if (!title || !title.trim()) {
if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(400).json({ error: 'Title is required' });
}
if (url && !isValidUrl(url)) {
if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(400).json({ error: 'Planning link must be a valid URL' });
}
@ -296,11 +335,16 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
let storedImagePath = null;
if (imageMode === 'url') {
if (!imageUrl || !isValidUrl(imageUrl)) {
if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(400).json({ error: 'Image URL must be valid' });
}
storedImageUrl = imageUrl.trim();
} else if (req.file) {
storedImagePath = req.file.filename;
uploadedFile = await persistUploadedFile(req.file);
storedImagePath = uploadedFile?.path || null;
storedImageUrl = uploadedFile?.publicUrl || null;
}
const base = await prisma.base.create({
@ -328,7 +372,9 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
});
} catch (error) {
console.error(error);
if (req.file) {
if (uploadedFile?.path) {
await deleteImageFile(uploadedFile.path);
} else if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(500).json({ error: 'Internal server error' });
@ -336,7 +382,7 @@ app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) =>
});
app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, res) => {
let newUploadFilename = null;
let uploadedFile = null;
try {
const { baseId } = req.params;
const {
@ -350,14 +396,14 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
} = req.body;
if (!title || !title.trim()) {
if (req.file) {
if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(400).json({ error: 'Title is required' });
}
if (url && !isValidUrl(url)) {
if (req.file) {
if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(400).json({ error: 'Planning link must be a valid URL' });
@ -365,7 +411,7 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
if (!base) {
if (req.file) {
if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(404).json({ error: 'Base not found' });
@ -385,7 +431,7 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
} else if (imageMode === 'url') {
if (imageUrl) {
if (!isValidUrl(imageUrl)) {
if (req.file) {
if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
}
return res.status(400).json({ error: 'Image URL must be valid' });
@ -395,10 +441,10 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
storedImageUrl = imageUrl.trim();
}
} else if (req.file) {
newUploadFilename = req.file.filename;
uploadedFile = await persistUploadedFile(req.file);
previousImagePathToDelete = base.imagePath;
imagePath = req.file.filename;
storedImageUrl = null;
imagePath = uploadedFile?.path || null;
storedImageUrl = uploadedFile?.publicUrl || null;
}
const updatedBase = await prisma.base.update({
@ -430,10 +476,10 @@ app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, r
});
} catch (error) {
console.error(error);
if (req.file) {
if (uploadedFile?.path) {
await deleteImageFile(uploadedFile.path);
} else if (!useS3Storage && req.file?.filename) {
await deleteImageFile(req.file.filename);
} else if (newUploadFilename) {
await deleteImageFile(newUploadFilename);
}
return res.status(500).json({ error: 'Internal server error' });
}
@ -863,8 +909,101 @@ function summarizeDefenses(defenses) {
};
}
function buildStoredImageUrl(imagePath) {
if (!imagePath) {
return '';
}
if (useS3Storage) {
const base = assetBaseUrl || deriveS3EndpointBase();
if (!base) {
return '';
}
return `${base.replace(/\/$/, '')}/${imagePath.replace(/^\/+/, '')}`;
}
return `/uploads/${imagePath.replace(/^\/+/, '')}`;
}
function deriveS3EndpointBase() {
if (!s3Endpoint) {
return '';
}
const trimmedEndpoint = s3Endpoint.replace(/\/$/, '');
if (s3ForcePathStyle || !trimmedEndpoint) {
return `${trimmedEndpoint}/${s3Bucket}`;
}
try {
const endpointUrl = new URL(trimmedEndpoint);
const portPart = endpointUrl.port ? `:${endpointUrl.port}` : '';
return `${endpointUrl.protocol}//${s3Bucket}.${endpointUrl.hostname}${portPart}`;
} catch (_error) {
return `${trimmedEndpoint}/${s3Bucket}`;
}
}
function buildStorageKey(originalName = '') {
const ext = path.extname(originalName || '').trim().slice(0, 10) || '.png';
return `bases/${Date.now()}-${randomUUID()}${ext}`;
}
async function persistUploadedFile(file) {
if (!file) {
return null;
}
if (useS3Storage) {
const key = buildStorageKey(file.originalname);
try {
await s3Client.send(
new PutObjectCommand({
Bucket: s3Bucket,
Key: key,
Body: file.buffer,
ContentType: file.mimetype || 'application/octet-stream',
Metadata: {
originalname: file.originalname || 'uploaded-image',
},
})
);
} catch (error) {
console.error('Failed to upload image to S3', error);
throw error;
}
return {
path: key,
publicUrl: buildStoredImageUrl(key),
};
}
return {
path: file.filename,
publicUrl: buildStoredImageUrl(file.filename),
};
}
async function deleteImageFile(imagePath) {
if (!imagePath) return;
if (useS3Storage) {
try {
await s3Client.send(
new DeleteObjectCommand({
Bucket: s3Bucket,
Key: imagePath,
})
);
} catch (error) {
if (error?.$metadata?.httpStatusCode !== 404) {
console.error(`Failed to delete image from S3 with key ${imagePath}`, error);
}
}
return;
}
const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath);
try {
await fsPromises.unlink(filePath);
@ -899,7 +1038,7 @@ function sanitizeUser(user) {
function buildImageUrl(base) {
if (base.imageUrl) return base.imageUrl;
if (base.imagePath) return `/uploads/${base.imagePath}`;
if (base.imagePath) return buildStoredImageUrl(base.imagePath);
return '';
}

View file

@ -132,6 +132,11 @@ select {
color: inherit;
}
input[type='file'] {
width: fit-content;
justify-self: start;
}
textarea {
resize: vertical;
}

31
frontend/app/icon-192.tsx Normal file
View file

@ -0,0 +1,31 @@
import { ImageResponse } from 'next/og';
export const contentType = 'image/png';
export const size = {
width: 192,
height: 192,
};
export default function Icon192() {
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0f172a',
color: '#f8fafc',
fontSize: 88,
fontWeight: 700,
letterSpacing: -4,
fontFamily: '"Inter", "Segoe UI", "Helvetica Neue", sans-serif',
}}
>
BT
</div>
)
);
}

31
frontend/app/icon.tsx Normal file
View file

@ -0,0 +1,31 @@
import { ImageResponse } from 'next/og';
export const contentType = 'image/png';
export const size = {
width: 512,
height: 512,
};
export default function Icon() {
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0f172a',
color: '#f8fafc',
fontSize: 220,
fontWeight: 700,
letterSpacing: -8,
fontFamily: '"Inter", "Segoe UI", "Helvetica Neue", sans-serif',
}}
>
BT
</div>
)
);
}

View file

@ -1,15 +1,29 @@
import type { Metadata } from 'next';
import './globals.css';
import { ServiceWorkerProvider } from './service-worker-provider';
export const metadata: Metadata = {
title: 'Base Noter',
metadataBase: new URL('https://basetracker.lona-development.org'),
title: {
default: 'Base Tracker',
template: '%s | Base Tracker',
},
applicationName: 'Base Tracker',
description: 'Track how each Clash of Clans base defends against every army',
manifest: '/manifest.webmanifest',
themeColor: '#0f172a',
icons: {
icon: '/icon.png',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<html lang="en" dir="auto">
<body>
<ServiceWorkerProvider />
{children}
</body>
</html>
);
}

31
frontend/app/manifest.ts Normal file
View file

@ -0,0 +1,31 @@
import { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Base Tracker',
short_name: 'Base Tracker',
description: 'Track how each Clash of Clans base defends against every army',
lang: 'en',
dir: 'auto',
start_url: 'https://basetracker.lona-development.org/',
scope: '/',
display: 'standalone',
display_override: ['window-controls-overlay', 'fullscreen'],
background_color: '#0f172a',
theme_color: '#0f172a',
orientation: 'any',
id: 'basetracker.lona',
icons: [
{
src: '/icon.png',
sizes: '512x512',
type: 'image/png'
},
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
}
]
};
}

View file

@ -41,6 +41,17 @@ const API = {
profileDetail: (username: string) => `${API_BASE}/profiles/${encodeURIComponent(username)}`,
};
function resolveImageUrl(url: string | null | undefined) {
if (!url) {
return '';
}
try {
return new URL(url, `${API_BASE}/`).toString();
} catch (_error) {
return url;
}
}
type User = {
id: string;
username: string;
@ -242,7 +253,11 @@ export default function Page() {
request('GET', API.categories),
request('GET', API.defenses),
]);
setBases(basesRes.bases || []);
const normalizedBases = ((basesRes.bases || []) as BaseItem[]).map((base) => ({
...base,
imageUrl: resolveImageUrl(base.imageUrl),
}));
setBases(normalizedBases);
setCategories(categoriesRes.categories || []);
setDefenses(defensesRes.defenses || []);
setSummaries({
@ -555,7 +570,14 @@ export default function Page() {
setProfileError('');
setProfileSelectedBase(null);
const data: ProfileDetail = await request('GET', API.profileDetail(username));
setProfileDetail(data);
const normalizedDetail: ProfileDetail = {
...data,
bases: data.bases.map((base) => ({
...base,
imageUrl: resolveImageUrl(base.imageUrl),
})),
};
setProfileDetail(normalizedDetail);
setView('dashboard');
} catch (error: any) {
setProfileDetail(null);

View file

@ -0,0 +1,41 @@
'use client';
import { useEffect } from 'react';
const SW_PATH = '/sw.js';
export function ServiceWorkerProvider() {
useEffect(() => {
if (!('serviceWorker' in navigator)) {
return;
}
const registerServiceWorker = async () => {
try {
const registration = await navigator.serviceWorker.register(SW_PATH, { scope: '/' });
if (registration.waiting) {
registration.waiting.postMessage('skipWaiting');
}
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!document.hidden) {
window.location.reload();
}
});
} catch (error) {
console.error('Service worker registration failed:', error);
}
};
if (process.env.NODE_ENV === 'production') {
registerServiceWorker();
} else {
navigator.serviceWorker.getRegistration(SW_PATH).then((registration) => {
registration?.unregister();
});
}
}, []);
return null;
}

67
frontend/public/sw.js Normal file
View file

@ -0,0 +1,67 @@
const CACHE_NAME = 'base-tracker-cache-v1';
const APP_SHELL = ['/'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(async () => {
const offlineFallback = await caches.match('/');
return offlineFallback ?? Response.error();
})
);
return;
}
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const networkFetch = fetch(event.request)
.then((networkResponse) => {
if (
networkResponse &&
networkResponse.status === 200 &&
networkResponse.type === 'basic' &&
!networkResponse.headers.get('Cache-Control')?.includes('no-store')
) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
}
return networkResponse;
})
.catch(() => cachedResponse);
return cachedResponse || networkFetch;
})
);
});
self.addEventListener('message', (event) => {
if (event.data === 'skipWaiting') {
self.skipWaiting();
}
});