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.
This commit is contained in:
Geert Rademakes 2025-08-06 15:32:50 +02:00
parent 1f235d8fa8
commit 15cad58c80
4 changed files with 663 additions and 0 deletions

View File

@ -6,6 +6,7 @@ import { songsRouter } from './routes/songs.js';
import { playlistsRouter } from './routes/playlists.js'; import { playlistsRouter } from './routes/playlists.js';
import { musicRouter } from './routes/music.js'; import { musicRouter } from './routes/music.js';
import { matchingRouter } from './routes/matching.js'; import { matchingRouter } from './routes/matching.js';
import { configRouter } from './routes/config.js';
import { Song } from './models/Song.js'; import { Song } from './models/Song.js';
import { Playlist } from './models/Playlist.js'; import { Playlist } from './models/Playlist.js';
import { MusicFile } from './models/MusicFile.js'; import { MusicFile } from './models/MusicFile.js';
@ -65,6 +66,7 @@ app.use('/api/songs', songsRouter);
app.use('/api/playlists', playlistsRouter); app.use('/api/playlists', playlistsRouter);
app.use('/api/music', musicRouter); app.use('/api/music', musicRouter);
app.use('/api/matching', matchingRouter); app.use('/api/matching', matchingRouter);
app.use('/api/config', configRouter);
app.listen(port, () => { app.listen(port, () => {
console.log(`Server is running on port ${port}`); console.log(`Server is running on port ${port}`);

View File

@ -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 };

View File

@ -7,6 +7,7 @@ import { PlaylistManager } from "./components/PlaylistManager";
import { SongDetails } from "./components/SongDetails"; import { SongDetails } from "./components/SongDetails";
import { Configuration } from "./pages/Configuration"; import { Configuration } from "./pages/Configuration";
import { MusicStorage } from "./pages/MusicStorage"; import { MusicStorage } from "./pages/MusicStorage";
import { S3Configuration } from "./pages/S3Configuration";
import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer"; import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer";
import { useXmlParser } from "./hooks/useXmlParser"; import { useXmlParser } from "./hooks/useXmlParser";
import { usePaginatedSongs } from "./hooks/usePaginatedSongs"; import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
@ -515,6 +516,18 @@ export default function RekordboxReader() {
🎵 Music Storage 🎵 Music Storage
</Button> </Button>
{/* S3 Configuration Button */}
<Button
size="sm"
variant="ghost"
color="gray.300"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
onClick={() => navigate('/s3-config')}
mr={2}
>
S3 Config
</Button>
{/* Export Library Button */} {/* Export Library Button */}
<IconButton <IconButton
icon={<DownloadIcon />} icon={<DownloadIcon />}
@ -543,6 +556,7 @@ export default function RekordboxReader() {
<Routes> <Routes>
<Route path="/config" element={<Configuration />} /> <Route path="/config" element={<Configuration />} />
<Route path="/music-storage" element={<MusicStorage />} /> <Route path="/music-storage" element={<MusicStorage />} />
<Route path="/s3-config" element={<S3Configuration />} />
<Route <Route
path="*" path="*"
element={ element={

View File

@ -0,0 +1,481 @@
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Heading,
FormControl,
FormLabel,
Input,
Button,
useToast,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Card,
CardBody,
CardHeader,
Spinner,
Divider,
Badge,
Icon,
Switch,
FormHelperText,
} from '@chakra-ui/react';
import { FiCheck, FiX, FiSettings, FiTestTube, FiSave } from 'react-icons/fi';
interface S3Config {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
useSSL: boolean;
}
interface TestResult {
success: boolean;
message: string;
details?: any;
}
export const S3Configuration: React.FC = () => {
const [config, setConfig] = useState<S3Config>({
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<TestResult | null>(null);
const [currentConfig, setCurrentConfig] = useState<S3Config | null>(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 (
<Box p={8}>
<VStack spacing={4} align="center">
<Spinner size="xl" />
<Text>Loading S3 configuration...</Text>
</VStack>
</Box>
);
}
return (
<Box p={8} maxW="800px" mx="auto">
<VStack spacing={8} align="stretch">
{/* Header */}
<VStack spacing={2} align="start">
<HStack spacing={3}>
<Icon as={FiSettings} w={6} h={6} color="blue.400" />
<Heading size="lg" color="white">S3 Configuration</Heading>
</HStack>
<Text color="gray.400">
Configure your S3-compatible storage connection for music file storage and playback.
</Text>
</VStack>
{/* Configuration Form */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<Heading size="md" color="white">Connection Settings</Heading>
</CardHeader>
<CardBody>
<VStack spacing={6} align="stretch">
{/* Endpoint */}
<FormControl>
<FormLabel color="white">S3 Endpoint</FormLabel>
<Input
value={config.endpoint}
onChange={(e) => 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' }}
/>
<FormHelperText color="gray.400">
For AWS S3, use: https://s3.amazonaws.com. For MinIO: http://localhost:9000
</FormHelperText>
</FormControl>
{/* Region */}
<FormControl>
<FormLabel color="white">Region</FormLabel>
<Input
value={config.region}
onChange={(e) => 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' }}
/>
<FormHelperText color="gray.400">
AWS region (e.g., us-east-1, eu-west-1) or 'us-east-1' for MinIO
</FormHelperText>
</FormControl>
{/* Access Key */}
<FormControl>
<FormLabel color="white">Access Key ID</FormLabel>
<Input
value={config.accessKeyId}
onChange={(e) => 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' }}
/>
</FormControl>
{/* Secret Key */}
<FormControl>
<FormLabel color="white">Secret Access Key</FormLabel>
<Input
type="password"
value={config.secretAccessKey}
onChange={(e) => 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' }}
/>
</FormControl>
{/* Bucket Name */}
<FormControl>
<FormLabel color="white">Bucket Name</FormLabel>
<Input
value={config.bucketName}
onChange={(e) => 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' }}
/>
<FormHelperText color="gray.400">
The S3 bucket where music files will be stored
</FormHelperText>
</FormControl>
{/* Use SSL */}
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="use-ssl" mb="0" color="white">
Use SSL/TLS
</FormLabel>
<Switch
id="use-ssl"
isChecked={config.useSSL}
onChange={(e) => handleInputChange('useSSL', e.target.checked)}
colorScheme="blue"
/>
<FormHelperText color="gray.400" ml={3}>
Enable for HTTPS connections (recommended for production)
</FormHelperText>
</FormControl>
</VStack>
</CardBody>
</Card>
{/* Test Connection */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiTestTube} w={5} h={5} color="blue.400" />
<Heading size="md" color="white">Test Connection</Heading>
</HStack>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
<Text color="gray.400">
Test your S3 configuration to ensure it's working properly before saving.
</Text>
<Button
leftIcon={isTesting ? <Spinner size="sm" /> : <FiTestTube />}
onClick={testConnection}
isLoading={isTesting}
loadingText="Testing..."
colorScheme="blue"
_hover={{ bg: "blue.700" }}
>
Test Connection
</Button>
{testResult && (
<Alert
status={testResult.success ? 'success' : 'error'}
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
height="auto"
py={4}
>
<AlertIcon boxSize="24px" />
<AlertTitle mt={2} mb={1} fontSize="lg">
{testResult.success ? 'Connection Successful' : 'Connection Failed'}
</AlertTitle>
<AlertDescription maxWidth="sm">
{testResult.message}
</AlertDescription>
{testResult.details && (
<Box mt={2} p={3} bg="gray.700" borderRadius="md" fontSize="sm">
<Text color="gray.300" fontWeight="bold">Details:</Text>
<Text color="gray.400" fontFamily="mono">
{JSON.stringify(testResult.details, null, 2)}
</Text>
</Box>
)}
</Alert>
)}
</VStack>
</CardBody>
</Card>
{/* Save Configuration */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<HStack spacing={3}>
<Icon as={FiSave} w={5} h={5} color="green.400" />
<Heading size="md" color="white">Save Configuration</Heading>
</HStack>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
<Text color="gray.400">
Save your S3 configuration to use it for music file storage and playback.
</Text>
<HStack spacing={3}>
<Button
leftIcon={isSaving ? <Spinner size="sm" /> : <FiSave />}
onClick={saveConfiguration}
isLoading={isSaving}
loadingText="Saving..."
colorScheme="green"
isDisabled={!hasChanges()}
_hover={{ bg: "green.700" }}
>
Save Configuration
</Button>
{currentConfig && (
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
Configuration Loaded
</Badge>
)}
</HStack>
{!hasChanges() && currentConfig && (
<Alert status="info" variant="subtle">
<AlertIcon />
<Text color="gray.300">No changes to save</Text>
</Alert>
)}
</VStack>
</CardBody>
</Card>
{/* Help Section */}
<Card bg="gray.800" borderColor="gray.700">
<CardHeader>
<Heading size="md" color="white">Configuration Help</Heading>
</CardHeader>
<CardBody>
<VStack spacing={4} align="stretch">
<Text color="gray.400">
<strong>For AWS S3:</strong>
</Text>
<Box pl={4} borderLeft="2px" borderColor="gray.600">
<Text color="gray.400" fontSize="sm">
Endpoint: https://s3.amazonaws.com<br/>
Region: Your AWS region (e.g., us-east-1)<br/>
Access Key: Your AWS access key<br/>
Secret Key: Your AWS secret key<br/>
Bucket: Your S3 bucket name
</Text>
</Box>
<Text color="gray.400" mt={4}>
<strong>For MinIO (Local Development):</strong>
</Text>
<Box pl={4} borderLeft="2px" borderColor="gray.600">
<Text color="gray.400" fontSize="sm">
Endpoint: http://localhost:9000<br/>
Region: us-east-1<br/>
Access Key: minioadmin<br/>
Secret Key: minioadmin<br/>
Bucket: Create a bucket named 'music-files'
</Text>
</Box>
<Text color="gray.400" mt={4}>
<strong>For Other S3-Compatible Services:</strong>
</Text>
<Box pl={4} borderLeft="2px" borderColor="gray.600">
<Text color="gray.400" fontSize="sm">
Endpoint: Your service's endpoint URL<br/>
Region: Your service's region<br/>
Access Key: Your service access key<br/>
Secret Key: Your service secret key<br/>
Bucket: Your bucket name
</Text>
</Box>
</VStack>
</CardBody>
</Card>
</VStack>
</Box>
);
};