From afcaa0b2173ffcf7d26e9dd7be786e78eda3d2be Mon Sep 17 00:00:00 2001 From: Hymmel Date: Thu, 9 Oct 2025 13:08:51 +0200 Subject: [PATCH] a --- backend/package.json | 1 + backend/src/server.js | 195 +++++++++++++++++++---- frontend/app/globals.css | 5 + frontend/app/icon-192.tsx | 31 ++++ frontend/app/icon.tsx | 31 ++++ frontend/app/layout.tsx | 20 ++- frontend/app/manifest.ts | 31 ++++ frontend/app/page.tsx | 26 ++- frontend/app/service-worker-provider.tsx | 41 +++++ frontend/public/sw.js | 67 ++++++++ 10 files changed, 415 insertions(+), 33 deletions(-) create mode 100644 frontend/app/icon-192.tsx create mode 100644 frontend/app/icon.tsx create mode 100644 frontend/app/manifest.ts create mode 100644 frontend/app/service-worker-provider.tsx create mode 100644 frontend/public/sw.js diff --git a/backend/package.json b/backend/package.json index 639f67e..8110733 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/server.js b/backend/src/server.js index 1b14195..49c9963 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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,26 +81,34 @@ const corsOptions = { const port = process.env.PORT || 4000; const host = process.env.HOST || '0.0.0.0'; -if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); +let upload; + +if (useS3Storage) { + upload = multer({ storage: multer.memoryStorage() }); +} else { + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + 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)}`; + const ext = path.extname(file.originalname) || '.png'; + cb(null, `${unique}${ext}`); + }, + }); + + upload = multer({ storage: diskStorage }); } -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(corsOptions)); app.options('*', cors(corsOptions)); app.use(express.json()); app.use(cookieParser()); -app.use('/uploads', express.static(uploadDir)); +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 ''; } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 6cee42e..0bad51e 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -132,6 +132,11 @@ select { color: inherit; } +input[type='file'] { + width: fit-content; + justify-self: start; +} + textarea { resize: vertical; } diff --git a/frontend/app/icon-192.tsx b/frontend/app/icon-192.tsx new file mode 100644 index 0000000..98b4da0 --- /dev/null +++ b/frontend/app/icon-192.tsx @@ -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( + ( +
+ BT +
+ ) + ); +} diff --git a/frontend/app/icon.tsx b/frontend/app/icon.tsx new file mode 100644 index 0000000..b5a6c89 --- /dev/null +++ b/frontend/app/icon.tsx @@ -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( + ( +
+ BT +
+ ) + ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2aee197..709b51b 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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 ( - - {children} + + + + {children} + ); } diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts new file mode 100644 index 0000000..bd6e9f8 --- /dev/null +++ b/frontend/app/manifest.ts @@ -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' + } + ] + }; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 621e5dd..80ed021 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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); diff --git a/frontend/app/service-worker-provider.tsx b/frontend/app/service-worker-provider.tsx new file mode 100644 index 0000000..ff571a0 --- /dev/null +++ b/frontend/app/service-worker-provider.tsx @@ -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; +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..22d08a8 --- /dev/null +++ b/frontend/public/sw.js @@ -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(); + } +});