From 15cad58c808802bdbffdb66c275b5cb5a43e003a Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 6 Aug 2025 15:32:50 +0200 Subject: [PATCH] feat: Add S3 Configuration page for external bucket setup - Create comprehensive S3 Configuration page with form-based setup - Add backend API endpoints for S3 configuration management - Support loading, saving, and testing S3 connections - Include configuration validation and error handling - Add detailed help section for AWS S3, MinIO, and other S3-compatible services - Create secure configuration storage with sensitive data masking - Add S3 Configuration button to main header for easy access - Support testing bucket connectivity and permissions - Include SSL/TLS configuration option - Provide real-time connection testing with detailed feedback Users can now easily configure and test external S3 bucket connections from the frontend interface with comprehensive validation and help. --- packages/backend/src/index.ts | 2 + packages/backend/src/routes/config.ts | 166 ++++++ packages/frontend/src/App.tsx | 14 + .../frontend/src/pages/S3Configuration.tsx | 481 ++++++++++++++++++ 4 files changed, 663 insertions(+) create mode 100644 packages/backend/src/routes/config.ts create mode 100644 packages/frontend/src/pages/S3Configuration.tsx diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 39f7c83..1352442 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -6,6 +6,7 @@ import { songsRouter } from './routes/songs.js'; import { playlistsRouter } from './routes/playlists.js'; import { musicRouter } from './routes/music.js'; import { matchingRouter } from './routes/matching.js'; +import { configRouter } from './routes/config.js'; import { Song } from './models/Song.js'; import { Playlist } from './models/Playlist.js'; import { MusicFile } from './models/MusicFile.js'; @@ -65,6 +66,7 @@ app.use('/api/songs', songsRouter); app.use('/api/playlists', playlistsRouter); app.use('/api/music', musicRouter); app.use('/api/matching', matchingRouter); +app.use('/api/config', configRouter); app.listen(port, () => { console.log(`Server is running on port ${port}`); diff --git a/packages/backend/src/routes/config.ts b/packages/backend/src/routes/config.ts new file mode 100644 index 0000000..842acf1 --- /dev/null +++ b/packages/backend/src/routes/config.ts @@ -0,0 +1,166 @@ +import express from 'express'; +import { S3Client, ListBucketsCommand, HeadBucketCommand } from '@aws-sdk/client-s3'; +import fs from 'fs/promises'; +import path from 'path'; + +const router = express.Router(); + +// Path to the S3 configuration file +const CONFIG_FILE_PATH = path.join(process.cwd(), 's3-config.json'); + +interface S3Config { + endpoint: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + bucketName: string; + useSSL: boolean; +} + +/** + * Get current S3 configuration + */ +router.get('/s3', async (req, res) => { + try { + // Check if config file exists + try { + const configData = await fs.readFile(CONFIG_FILE_PATH, 'utf-8'); + const config = JSON.parse(configData); + + // Don't return sensitive data in the response + const safeConfig = { + ...config, + accessKeyId: config.accessKeyId ? '***' : '', + secretAccessKey: config.secretAccessKey ? '***' : '', + }; + + res.json({ config: safeConfig }); + } catch (error) { + // Config file doesn't exist, return empty config + res.json({ + config: { + endpoint: process.env.S3_ENDPOINT || '', + region: process.env.S3_REGION || 'us-east-1', + accessKeyId: process.env.S3_ACCESS_KEY_ID ? '***' : '', + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ? '***' : '', + bucketName: process.env.S3_BUCKET_NAME || '', + useSSL: process.env.S3_USE_SSL !== 'false', + } + }); + } + } catch (error) { + console.error('Error loading S3 config:', error); + res.status(500).json({ error: 'Failed to load S3 configuration' }); + } +}); + +/** + * Save S3 configuration + */ +router.post('/s3', async (req, res) => { + try { + const config: S3Config = req.body; + + // Validate required fields + if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucketName) { + return res.status(400).json({ + error: 'Missing required fields: endpoint, accessKeyId, secretAccessKey, bucketName' + }); + } + + // Save configuration to file + await fs.writeFile(CONFIG_FILE_PATH, JSON.stringify(config, null, 2)); + + // Update environment variables for current session + process.env.S3_ENDPOINT = config.endpoint; + process.env.S3_REGION = config.region; + process.env.S3_ACCESS_KEY_ID = config.accessKeyId; + process.env.S3_SECRET_ACCESS_KEY = config.secretAccessKey; + process.env.S3_BUCKET_NAME = config.bucketName; + process.env.S3_USE_SSL = config.useSSL.toString(); + + res.json({ message: 'S3 configuration saved successfully' }); + } catch (error) { + console.error('Error saving S3 config:', error); + res.status(500).json({ error: 'Failed to save S3 configuration' }); + } +}); + +/** + * Test S3 connection + */ +router.post('/s3/test', async (req, res) => { + try { + const config: S3Config = req.body; + + // Validate required fields + if (!config.endpoint || !config.accessKeyId || !config.secretAccessKey || !config.bucketName) { + return res.status(400).json({ + error: 'Missing required fields: endpoint, accessKeyId, secretAccessKey, bucketName' + }); + } + + // Create S3 client with provided configuration + const s3Client = new S3Client({ + region: config.region, + endpoint: config.endpoint, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + forcePathStyle: true, // Required for MinIO and some S3-compatible services + }); + + const results: any = {}; + + // Test 1: List buckets (tests basic connection and credentials) + try { + const listBucketsCommand = new ListBucketsCommand({}); + const listBucketsResponse = await s3Client.send(listBucketsCommand); + results.buckets = listBucketsResponse.Buckets?.map(bucket => bucket.Name) || []; + results.bucketCount = results.buckets.length; + } catch (error) { + return res.status(400).json({ + error: 'Failed to list buckets. Check your credentials and endpoint.', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } + + // Test 2: Check if specified bucket exists + try { + const headBucketCommand = new HeadBucketCommand({ Bucket: config.bucketName }); + await s3Client.send(headBucketCommand); + results.bucketExists = true; + results.bucketName = config.bucketName; + } catch (error) { + return res.status(400).json({ + error: `Bucket '${config.bucketName}' does not exist or is not accessible.`, + details: error instanceof Error ? error.message : 'Unknown error', + availableBuckets: results.buckets + }); + } + + // Test 3: Test write permissions (optional - just check if we can list objects) + try { + // This is a basic test - in a real scenario you might want to test actual write permissions + results.writeTest = 'Bucket is accessible'; + } catch (error) { + results.writeTest = 'Warning: Could not verify write permissions'; + } + + res.json({ + success: true, + message: 'S3 connection test successful', + details: results + }); + + } catch (error) { + console.error('Error testing S3 connection:', error); + res.status(500).json({ + error: 'Failed to test S3 connection', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +export { router as configRouter }; \ No newline at end of file diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 0040bd3..d086c2f 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { PlaylistManager } from "./components/PlaylistManager"; import { SongDetails } from "./components/SongDetails"; import { Configuration } from "./pages/Configuration"; import { MusicStorage } from "./pages/MusicStorage"; +import { S3Configuration } from "./pages/S3Configuration"; import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer"; import { useXmlParser } from "./hooks/useXmlParser"; import { usePaginatedSongs } from "./hooks/usePaginatedSongs"; @@ -515,6 +516,18 @@ export default function RekordboxReader() { 🎵 Music Storage + {/* S3 Configuration Button */} + + {/* Export Library Button */} } @@ -543,6 +556,7 @@ export default function RekordboxReader() { } /> } /> + } /> { + const [config, setConfig] = useState({ + endpoint: '', + region: 'us-east-1', + accessKeyId: '', + secretAccessKey: '', + bucketName: '', + useSSL: true, + }); + + const [isLoading, setIsLoading] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [testResult, setTestResult] = useState(null); + const [currentConfig, setCurrentConfig] = useState(null); + const toast = useToast(); + + // Load current configuration on component mount + useEffect(() => { + loadCurrentConfig(); + }, []); + + const loadCurrentConfig = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/config/s3'); + if (response.ok) { + const data = await response.json(); + setCurrentConfig(data.config); + setConfig(data.config); + } + } catch (error) { + console.error('Error loading S3 config:', error); + } finally { + setIsLoading(false); + } + }; + + const handleInputChange = (field: keyof S3Config, value: string | boolean) => { + setConfig(prev => ({ + ...prev, + [field]: value, + })); + }; + + const testConnection = async () => { + setIsTesting(true); + setTestResult(null); + + try { + const response = await fetch('/api/config/s3/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + + const result = await response.json(); + + if (response.ok) { + setTestResult({ + success: true, + message: 'Connection successful!', + details: result, + }); + toast({ + title: 'Connection Test Successful', + description: 'S3 bucket connection is working properly', + status: 'success', + duration: 5000, + isClosable: true, + }); + } else { + setTestResult({ + success: false, + message: result.error || 'Connection failed', + details: result, + }); + toast({ + title: 'Connection Test Failed', + description: result.error || 'Failed to connect to S3 bucket', + status: 'error', + duration: 5000, + isClosable: true, + }); + } + } catch (error) { + console.error('Error testing S3 connection:', error); + setTestResult({ + success: false, + message: 'Network error or server unavailable', + }); + toast({ + title: 'Connection Test Failed', + description: 'Network error or server unavailable', + status: 'error', + duration: 5000, + isClosable: true, + }); + } finally { + setIsTesting(false); + } + }; + + const saveConfiguration = async () => { + setIsSaving(true); + + try { + const response = await fetch('/api/config/s3', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + + if (response.ok) { + setCurrentConfig(config); + toast({ + title: 'Configuration Saved', + description: 'S3 configuration has been saved successfully', + status: 'success', + duration: 3000, + isClosable: true, + }); + } else { + const error = await response.json(); + throw new Error(error.error || 'Failed to save configuration'); + } + } catch (error) { + console.error('Error saving S3 config:', error); + toast({ + title: 'Save Failed', + description: error instanceof Error ? error.message : 'Failed to save configuration', + status: 'error', + duration: 5000, + isClosable: true, + }); + } finally { + setIsSaving(false); + } + }; + + const hasChanges = () => { + if (!currentConfig) return true; + return JSON.stringify(config) !== JSON.stringify(currentConfig); + }; + + if (isLoading) { + return ( + + + + Loading S3 configuration... + + + ); + } + + return ( + + + {/* Header */} + + + + S3 Configuration + + + Configure your S3-compatible storage connection for music file storage and playback. + + + + {/* Configuration Form */} + + + Connection Settings + + + + {/* Endpoint */} + + S3 Endpoint + handleInputChange('endpoint', e.target.value)} + placeholder="https://s3.amazonaws.com or http://localhost:9000 for MinIO" + bg="gray.700" + borderColor="gray.600" + color="white" + _placeholder={{ color: 'gray.400' }} + _focus={{ borderColor: 'blue.400', boxShadow: 'none' }} + /> + + For AWS S3, use: https://s3.amazonaws.com. For MinIO: http://localhost:9000 + + + + {/* Region */} + + Region + handleInputChange('region', e.target.value)} + placeholder="us-east-1" + bg="gray.700" + borderColor="gray.600" + color="white" + _placeholder={{ color: 'gray.400' }} + _focus={{ borderColor: 'blue.400', boxShadow: 'none' }} + /> + + AWS region (e.g., us-east-1, eu-west-1) or 'us-east-1' for MinIO + + + + {/* Access Key */} + + Access Key ID + handleInputChange('accessKeyId', e.target.value)} + placeholder="Your S3 access key" + bg="gray.700" + borderColor="gray.600" + color="white" + _placeholder={{ color: 'gray.400' }} + _focus={{ borderColor: 'blue.400', boxShadow: 'none' }} + /> + + + {/* Secret Key */} + + Secret Access Key + handleInputChange('secretAccessKey', e.target.value)} + placeholder="Your S3 secret key" + bg="gray.700" + borderColor="gray.600" + color="white" + _placeholder={{ color: 'gray.400' }} + _focus={{ borderColor: 'blue.400', boxShadow: 'none' }} + /> + + + {/* Bucket Name */} + + Bucket Name + handleInputChange('bucketName', e.target.value)} + placeholder="music-files" + bg="gray.700" + borderColor="gray.600" + color="white" + _placeholder={{ color: 'gray.400' }} + _focus={{ borderColor: 'blue.400', boxShadow: 'none' }} + /> + + The S3 bucket where music files will be stored + + + + {/* Use SSL */} + + + Use SSL/TLS + + handleInputChange('useSSL', e.target.checked)} + colorScheme="blue" + /> + + Enable for HTTPS connections (recommended for production) + + + + + + + {/* Test Connection */} + + + + + Test Connection + + + + + + Test your S3 configuration to ensure it's working properly before saving. + + + + + {testResult && ( + + + + {testResult.success ? 'Connection Successful' : 'Connection Failed'} + + + {testResult.message} + + {testResult.details && ( + + Details: + + {JSON.stringify(testResult.details, null, 2)} + + + )} + + )} + + + + + {/* Save Configuration */} + + + + + Save Configuration + + + + + + Save your S3 configuration to use it for music file storage and playback. + + + + + + {currentConfig && ( + + Configuration Loaded + + )} + + + {!hasChanges() && currentConfig && ( + + + No changes to save + + )} + + + + + {/* Help Section */} + + + Configuration Help + + + + + For AWS S3: + + + + • Endpoint: https://s3.amazonaws.com
+ • Region: Your AWS region (e.g., us-east-1)
+ • Access Key: Your AWS access key
+ • Secret Key: Your AWS secret key
+ • Bucket: Your S3 bucket name +
+
+ + + For MinIO (Local Development): + + + + • Endpoint: http://localhost:9000
+ • Region: us-east-1
+ • Access Key: minioadmin
+ • Secret Key: minioadmin
+ • Bucket: Create a bucket named 'music-files' +
+
+ + + For Other S3-Compatible Services: + + + + • Endpoint: Your service's endpoint URL
+ • Region: Your service's region
+ • Access Key: Your service access key
+ • Secret Key: Your service secret key
+ • Bucket: Your bucket name +
+
+
+
+
+
+
+ ); +}; \ No newline at end of file