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( + ( +