diff --git a/Dockerfile b/Dockerfile index 3cd2a1b..abc64e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,8 @@ RUN npm ci --omit=dev && npm cache clean --force # Copy the rest of the application code COPY . . -# Create uploads directory -RUN mkdir -p public/uploads +# Create uploads directory structure +RUN mkdir -p public/uploads/images-driver public/uploads/images-circuit # Expose the port the app runs on EXPOSE 3001 diff --git a/docker-compose.yml b/docker-compose.yml index fa9cc13..208a440 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,7 +45,7 @@ services: - LOG_LEVEL=${LOG_LEVEL:-info} - NODE_ENV=${NODE_ENV:-production} volumes: - - uploads_shared:/usr/src/app/public/uploads + - ./public/uploads:/usr/src/app/public/uploads - ./logs:/usr/src/app/logs depends_on: db: @@ -79,7 +79,7 @@ services: - ./config/Caddyfile.http:/etc/caddy/Caddyfile:ro # For HTTPS production mode (Let's Encrypt): # - ./config/Caddyfile.https-production:/etc/caddy/Caddyfile:ro - - uploads_shared:/var/www/html/uploads:ro + - ./public/uploads:/var/www/html/uploads:ro - caddy_data:/data - caddy_config:/config - caddy_logs:/var/log/caddy @@ -102,7 +102,6 @@ volumes: caddy_data: caddy_config: caddy_logs: - uploads_shared: networks: app-network: diff --git a/package-lock.json b/package-lock.json index c71c68e..bd2131d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "ISC", "dependencies": { + "connect-pg-simple": "^10.0.0", "dotenv": "^17.2.1", "express": "^5.1.0", "express-session": "^1.18.2", @@ -2769,6 +2770,18 @@ "typedarray": "^0.0.6" } }, + "node_modules/connect-pg-simple": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz", + "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==", + "license": "MIT", + "dependencies": { + "pg": "^8.12.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", diff --git a/package.json b/package.json index ca69c9c..482b92f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "connect-pg-simple": "^10.0.0", "dotenv": "^17.2.1", "express": "^5.1.0", "express-session": "^1.18.2", diff --git a/public/index.html b/public/index.html index 0395e5e..fec5b7e 100644 --- a/public/index.html +++ b/public/index.html @@ -108,6 +108,10 @@

ADD NEW DRIVER

+ Max file size: 5MB. Supported formats: JPG, PNG, GIF, WebP
@@ -142,15 +146,19 @@

EDIT DRIVER

No profile picture uploaded

+
-
- + Max file size: 5MB. Supported formats: JPG, PNG, GIF, WebP
@@ -192,6 +200,10 @@

RACE SETTINGS

No circuit image uploaded

+
- + Max file size: 5MB. Supported formats: JPG, PNG, GIF, WebP diff --git a/public/js/forms.js b/public/js/forms.js index a1e74a9..775532d 100644 --- a/public/js/forms.js +++ b/public/js/forms.js @@ -4,6 +4,7 @@ import { createDriver, uploadProfilePicture, } from './api.js' +import { clearPreview } from './imagePreview.js' import { loadLeaderboard } from './leaderboard.js' import { closeAddDriverModal, closeEditDriverModal } from './modals.js' import { showNotification } from './notifications.js' @@ -76,6 +77,13 @@ export async function handleEditDriver(event) { try { await apiUpdateDriver(driverId, driverData) + + // If there's a new profile picture, upload it + const profilePictureFile = formData.get('profilePicture') + if (profilePictureFile && profilePictureFile.size > 0) { + await uploadProfilePictureForDriver(driverId, profilePictureFile) + } + closeEditDriverModal() await loadLeaderboard() showNotification('Driver updated successfully!', 'success') @@ -122,13 +130,47 @@ export async function deleteDriver(driverId) { // Upload profile picture for a driver async function uploadProfilePictureForDriver(driverId, file) { try { - await uploadProfilePicture(driverId, file) + const result = await uploadProfilePicture(driverId, file) + + // If we're in the edit modal, update the current picture display immediately + const editDriverId = document.getElementById('editDriverId') + if (editDriverId && editDriverId.value === driverId.toString()) { + updateEditModalProfilePicture(result.profilePicture) + // Clear the preview since we now show the actual uploaded image + clearPreview('editPicturePreviewContainer', 'editPicturePreview') + } + + // Also clear preview for add modal if applicable + const addModal = document.getElementById('addDriverModal') + if (addModal && addModal.style.display === 'block') { + clearPreview('addPicturePreviewContainer', 'addPicturePreview') + } } catch (_error) { showNotification('Failed to upload profile picture', 'warning') // Don't throw - we don't want to prevent driver creation if picture upload fails } } +// Update the profile picture display in the edit modal +function updateEditModalProfilePicture(profilePictureUrl) { + const currentPicture = document.getElementById('currentPicture') + const noPictureText = document.getElementById('noPictureText') + const deletePictureBtn = document.getElementById('deletePictureBtn') + + if (currentPicture && profilePictureUrl) { + currentPicture.src = profilePictureUrl + currentPicture.style.display = 'block' + + if (noPictureText) { + noPictureText.style.display = 'none' + } + + if (deletePictureBtn) { + deletePictureBtn.style.display = 'inline-flex' + } + } +} + // Delete profile picture export async function deleteProfilePicture() { const driverId = document.getElementById('editDriverId').value diff --git a/public/js/imagePreview.js b/public/js/imagePreview.js new file mode 100644 index 0000000..ba458c1 --- /dev/null +++ b/public/js/imagePreview.js @@ -0,0 +1,114 @@ +// Image preview functionality for profile pictures + +import { uploadCircuitImage } from './api.js' +import { showNotification } from './notifications.js' +import { loadRaceSettings, populateSettingsModal } from './settings.js' + +/** + * Setup image preview for file input + * @param {string} fileInputId - ID of the file input element + * @param {string} previewContainerId - ID of the preview container + * @param {string} previewImageId - ID of the preview image element + */ +export function setupImagePreview(fileInputId, previewContainerId, previewImageId) { + const fileInput = document.getElementById(fileInputId) + const previewContainer = document.getElementById(previewContainerId) + const previewImage = document.getElementById(previewImageId) + + if (!fileInput || !previewContainer || !previewImage) { + return + } + + fileInput.addEventListener('change', async (event) => { + const file = event.target.files[0] + + if (file) { + // Validate file type + if (!file.type.startsWith('image/')) { + showNotification('Please select a valid image file', 'error') + clearPreview(previewContainerId, previewImageId) + return + } + + // Validate file size (5MB limit) + const maxSize = 5 * 1024 * 1024 // 5MB in bytes + if (file.size > maxSize) { + showNotification('File size must be less than 5MB', 'error') + clearPreview(previewContainerId, previewImageId) + fileInput.value = '' // Clear the input + return + } + + // Create and display preview + const reader = new FileReader() + reader.onload = (e) => { + previewImage.src = e.target.result + previewContainer.style.display = 'block' + } + reader.readAsDataURL(file) + + // For circuit images, upload immediately + if (fileInputId === 'circuitImageUpload') { + try { + await uploadCircuitImage(file) + showNotification('Circuit image uploaded successfully!', 'success') + + // Refresh the race settings and modal + await loadRaceSettings() + populateSettingsModal() + + // Clear the preview since we now show the actual uploaded image + clearPreview(previewContainerId, previewImageId) + fileInput.value = '' // Clear the file input + } catch (_error) { + showNotification('Failed to upload circuit image. Please try again.', 'error') + clearPreview(previewContainerId, previewImageId) + fileInput.value = '' // Clear the file input + } + } + } else { + clearPreview(previewContainerId, previewImageId) + } + }) +} + +/** + * Clear image preview + * @param {string} previewContainerId - ID of the preview container + * @param {string} previewImageId - ID of the preview image element + */ +export function clearPreview(previewContainerId, previewImageId) { + const previewContainer = document.getElementById(previewContainerId) + const previewImage = document.getElementById(previewImageId) + + if (previewContainer) { + previewContainer.style.display = 'none' + } + + if (previewImage) { + previewImage.src = '' + } +} + +/** + * Clear all previews (useful when modals are closed) + */ +export function clearAllPreviews() { + clearPreview('addPicturePreviewContainer', 'addPicturePreview') + clearPreview('editPicturePreviewContainer', 'editPicturePreview') + clearPreview('circuitImagePreviewContainer', 'circuitImagePreview') +} + +/** + * Setup all image previews for the application + */ +export function initializeImagePreviews() { + // Setup preview for add driver modal + setupImagePreview('profilePictureAdd', 'addPicturePreviewContainer', 'addPicturePreview') + + // Setup preview for edit driver modal + setupImagePreview('profilePictureEdit', 'editPicturePreviewContainer', 'editPicturePreview') + + // Setup preview for circuit image upload + setupImagePreview('circuitImageUpload', 'circuitImagePreviewContainer', 'circuitImagePreview') +} diff --git a/public/js/main.js b/public/js/main.js index a07eff7..dfbdf52 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,5 +1,6 @@ import { checkAuthStatus, logout } from './auth.js' import { deleteDriver, deleteProfilePicture, setupFormEventListeners } from './forms.js' +import { initializeImagePreviews } from './imagePreview.js' import { loadLeaderboard } from './leaderboard.js' import { closeAddDriverModal, @@ -10,6 +11,7 @@ import { showEditDriverModal, showSettingsModal, triggerCircuitImageUpload, + triggerEditFileUpload, triggerFileUpload, } from './modals.js' import { @@ -26,6 +28,7 @@ function initializeApp() { loadRaceSettings() loadLeaderboard() setupEventListeners() + initializeImagePreviews() } // Setup all event listeners @@ -131,6 +134,9 @@ function handleAction(action) { case 'triggerFileUpload': triggerFileUpload() break + case 'triggerEditFileUpload': + triggerEditFileUpload() + break case 'deleteProfilePicture': deleteProfilePicture() break diff --git a/public/js/modals.js b/public/js/modals.js index e14a025..1f5f078 100644 --- a/public/js/modals.js +++ b/public/js/modals.js @@ -1,3 +1,4 @@ +import { clearPreview } from './imagePreview.js' import { showNotification } from './notifications.js' import { findDriverById, getIsAuthorized } from './state.js' @@ -16,6 +17,7 @@ export function showAddDriverModal() { export function closeAddDriverModal() { document.getElementById('addDriverModal').style.display = 'none' document.getElementById('addDriverForm').reset() + clearPreview('addPicturePreviewContainer', 'addPicturePreview') } // Show edit driver modal @@ -56,6 +58,7 @@ export function showEditDriverModal(driverId) { export function closeEditDriverModal() { document.getElementById('editDriverModal').style.display = 'none' document.getElementById('editDriverForm').reset() + clearPreview('editPicturePreviewContainer', 'editPicturePreview') } // Show settings modal @@ -83,6 +86,7 @@ export function showSettingsModal() { export function closeSettingsModal() { document.getElementById('settingsModal').style.display = 'none' document.getElementById('settingsForm').reset() + clearPreview('circuitImagePreviewContainer', 'circuitImagePreview') } // Setup modal event listeners @@ -110,6 +114,10 @@ export function triggerFileUpload() { document.getElementById('profilePictureInput').click() } +export function triggerEditFileUpload() { + document.getElementById('profilePictureEdit').click() +} + export function triggerCircuitImageUpload() { - document.getElementById('circuitImageInput').click() + document.getElementById('circuitImageUpload').click() } diff --git a/public/styles.css b/public/styles.css index 417944d..a92b62d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -817,6 +817,47 @@ html { margin-bottom: 10px; } +/* Picture Preview Styles */ +.picture-preview-container { + margin-bottom: 15px; + text-align: center; + padding: 15px; + border: 2px dashed #ff1e1e; + border-radius: 8px; + background: rgba(255, 30, 30, 0.1); + animation: fadeIn 0.3s ease-in; +} + +.picture-preview-container img { + max-width: 150px; + max-height: 150px; + border-radius: 8px; + border: 2px solid #ff1e1e; + object-fit: cover; + display: block; + margin: 0 auto; +} + +.preview-label { + color: #ff1e1e; + font-weight: 600; + font-size: 0.9em; + margin: 8px 0 0 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .form-group input:focus { outline: none; border-color: #ff1e1e; diff --git a/src/middleware/upload.js b/src/middleware/upload.js index 60b6c9e..86a07ce 100644 --- a/src/middleware/upload.js +++ b/src/middleware/upload.js @@ -1,15 +1,17 @@ const multer = require('multer') const path = require('node:path') const fs = require('node:fs') +const { v4: uuidv4 } = require('uuid') // Configure multer for file uploads const storage = multer.diskStorage({ destination: (_req, file, cb) => { let uploadDir if (file.fieldname === 'circuitImage') { - uploadDir = path.join(__dirname, '../../public/uploads/circuits') + uploadDir = path.join(__dirname, '../../public/uploads/images-circuit') } else { - uploadDir = path.join(__dirname, '../../public/uploads') + // For profilePicture and other driver-related images + uploadDir = path.join(__dirname, '../../public/uploads/images-driver') } // Ensure upload directory exists @@ -19,9 +21,10 @@ const storage = multer.diskStorage({ cb(null, uploadDir) }, filename: (_req, file, cb) => { - // Generate unique filename - const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}` - cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`) + // Generate GUID-based filename to avoid weird characters + const guid = uuidv4() + const extension = path.extname(file.originalname).toLowerCase() + cb(null, `${guid}${extension}`) }, }) diff --git a/src/routes/leaderboard.js b/src/routes/leaderboard.js index 797ec85..f937866 100644 --- a/src/routes/leaderboard.js +++ b/src/routes/leaderboard.js @@ -74,7 +74,7 @@ router.post( } // Update with new profile picture path - const profilePicturePath = `/uploads/${req.file.filename}` + const profilePicturePath = `/uploads/images-driver/${req.file.filename}` await entry.update({ profilePicture: profilePicturePath }) logger.logFileOperation('upload', req.file.filename, true, { diff --git a/src/routes/raceSettings.js b/src/routes/raceSettings.js index af0f73f..f80889a 100644 --- a/src/routes/raceSettings.js +++ b/src/routes/raceSettings.js @@ -95,7 +95,7 @@ router.post('/circuit-image', isAuthenticated, upload.single('circuitImage'), as } // Update with new circuit image path - const circuitImagePath = `/uploads/circuits/${req.file.filename}` + const circuitImagePath = `/uploads/images-circuit/${req.file.filename}` await settings.update({ circuitImage: circuitImagePath }) res.json({ diff --git a/src/utils/appConfig.js b/src/utils/appConfig.js index 85bdef6..1cf3b18 100644 --- a/src/utils/appConfig.js +++ b/src/utils/appConfig.js @@ -1,7 +1,10 @@ const express = require('express') const path = require('node:path') const session = require('express-session') +const pgSession = require('connect-pg-simple')(session) +const { Pool } = require('pg') const { passport } = require('../config/auth') +const logger = require('./logger') function configureApp() { const app = express() @@ -13,9 +16,32 @@ function configureApp() { app.use(express.json()) app.use(express.static(path.join(__dirname, '../../public'))) - // Session configuration + // Create PostgreSQL connection pool for sessions + const pgPool = new Pool({ + host: process.env.DB_HOST, + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + }) + + // Handle pool errors + pgPool.on('error', (err) => { + logger.logError(err, { context: 'Session store database connection error' }) + }) + + // Create session store + const sessionStore = new pgSession({ + pool: pgPool, + tableName: 'session', // Table name for storing sessions + createTableIfMissing: true, // Auto-create table if it doesn't exist + pruneSessionInterval: 60 * 15, // Prune expired sessions every 15 minutes + }) + + // Session configuration with PostgreSQL store app.use( session({ + store: sessionStore, secret: process.env.SESSION_SECRET || 'fallback-secret-key', resave: false, saveUninitialized: false, @@ -23,9 +49,12 @@ function configureApp() { secure: false, // Set to true in production with HTTPS maxAge: 24 * 60 * 60 * 1000, // 24 hours }, + name: 'sessionId', // Rename the session cookie for security }) ) + logger.info('Session store configured with PostgreSQL backend') + // Initialize Passport app.use(passport.initialize()) app.use(passport.session())