Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion install-database.sql
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ CREATE TABLE qsl_images (
-- Storage information
storage_path VARCHAR(500) NOT NULL,
storage_url VARCHAR(500),
storage_type VARCHAR(20) DEFAULT 'azure_blob' CHECK (storage_type IN ('azure_blob', 'aws_s3')),
storage_type VARCHAR(20) DEFAULT 'azure_blob' CHECK (storage_type IN ('azure_blob', 'aws_s3', 'local_storage')),

-- Image dimensions (optional, for display optimization)
width INTEGER,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
144 changes: 93 additions & 51 deletions src/app/admin/storage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertCircle, CheckCircle, Database, Eye, EyeOff, Trash2, Edit } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';

Expand Down Expand Up @@ -40,7 +41,7 @@ export default function StorageConfigPage() {

// Form state for new/edit config
const [formData, setFormData] = useState({
config_type: 'azure_blob',
config_type: 'local_storage',
account_name: '',
account_key: '',
container_name: '',
Expand Down Expand Up @@ -235,71 +236,112 @@ export default function StorageConfigPage() {
<CardHeader>
<CardTitle>{editingConfig ? 'Edit Storage Configuration' : 'Add Storage Configuration'}</CardTitle>
<CardDescription>
Configure Azure Blob Storage for file uploads and backups
Configure storage for file uploads and backups
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="account_name">Storage Account Name *</Label>
<Input
id="account_name"
value={formData.account_name}
onChange={(e) => setFormData(prev => ({ ...prev, account_name: e.target.value }))}
placeholder="mystorageaccount"
required
/>
</div>
<div>
<Label htmlFor="config_type">Storage Type *</Label>
<Select
value={formData.config_type}
onValueChange={(value) => setFormData(prev => ({ ...prev, config_type: value }))}
disabled={!!editingConfig}
>
<SelectTrigger>
<SelectValue placeholder="Select storage type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="local_storage">Local File Storage</SelectItem>
<SelectItem value="azure_blob">Azure Blob Storage</SelectItem>
<SelectItem value="aws_s3">AWS S3 (Coming Soon)</SelectItem>
</SelectContent>
</Select>
{editingConfig && (
<p className="text-sm text-muted-foreground mt-1">
Storage type cannot be changed after creation
</p>
)}
</div>

{formData.config_type === 'local_storage' ? (
<div>
<Label htmlFor="container_name">Container Name *</Label>
<Label htmlFor="container_name">Directory Name *</Label>
<Input
id="container_name"
value={formData.container_name}
onChange={(e) => setFormData(prev => ({ ...prev, container_name: e.target.value }))}
placeholder="nextlog-files"
placeholder="uploads"
required
/>
<p className="text-sm text-muted-foreground mt-1">
Files will be stored in public/{formData.container_name || 'uploads'}/
</p>
</div>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="account_name">Storage Account Name *</Label>
<Input
id="account_name"
value={formData.account_name}
onChange={(e) => setFormData(prev => ({ ...prev, account_name: e.target.value }))}
placeholder="mystorageaccount"
required={formData.config_type === 'azure_blob'}
/>
</div>

<div>
<Label htmlFor="account_key">Account Key {editingConfig ? '(leave empty to keep current)' : '*'}</Label>
<div className="relative">
<Input
id="account_key"
type={showPassword ? "text" : "password"}
value={formData.account_key}
onChange={(e) => setFormData(prev => ({ ...prev, account_key: e.target.value }))}
placeholder={editingConfig ? "Leave empty to keep current key" : "Enter your Azure storage account key"}
required={!editingConfig}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div>
<Label htmlFor="container_name">Container Name *</Label>
<Input
id="container_name"
value={formData.container_name}
onChange={(e) => setFormData(prev => ({ ...prev, container_name: e.target.value }))}
placeholder="nextlog-files"
required
/>
</div>
</div>

<div>
<Label htmlFor="endpoint_url">Custom Endpoint (Optional)</Label>
<Input
id="endpoint_url"
value={formData.endpoint_url}
onChange={(e) => setFormData(prev => ({ ...prev, endpoint_url: e.target.value }))}
placeholder="https://mystorageaccount.blob.core.windows.net"
/>
</div>
<div>
<Label htmlFor="account_key">Account Key {editingConfig ? '(leave empty to keep current)' : '*'}</Label>
<div className="relative">
<Input
id="account_key"
type={showPassword ? "text" : "password"}
value={formData.account_key}
onChange={(e) => setFormData(prev => ({ ...prev, account_key: e.target.value }))}
placeholder={editingConfig ? "Leave empty to keep current key" : "Enter your storage account key"}
required={!editingConfig && formData.config_type === 'azure_blob'}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>

<div>
<Label htmlFor="endpoint_url">Custom Endpoint (Optional)</Label>
<Input
id="endpoint_url"
value={formData.endpoint_url}
onChange={(e) => setFormData(prev => ({ ...prev, endpoint_url: e.target.value }))}
placeholder="https://mystorageaccount.blob.core.windows.net"
/>
</div>
</>
)}

<div className="flex items-center space-x-2">
<Switch
Expand Down
71 changes: 56 additions & 15 deletions src/app/api/admin/storage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ async function validateAzureBlobCredentials(config: StorageConfig): Promise<bool
}
}

/**
* Validate local storage configuration
*/
async function validateLocalStorageConfig(config: StorageConfig): Promise<boolean> {
try {
// For local storage, we only need a valid directory name
if (!config.container_name) {
return false;
}

// Validate the directory name doesn't contain dangerous characters
const containerName = config.container_name;
if (containerName.includes('..') || containerName.includes('/') || containerName.includes('\\')) {
return false;
}

return true;
} catch (error) {
console.error('Local storage validation error:', error);
return false;
}
}

/**
* GET /api/admin/storage - Get storage configurations
*/
Expand Down Expand Up @@ -108,21 +131,39 @@ export const POST = requirePermission(Permission.EDIT_STORAGE_CONFIG)(
}

// Validate credentials if enabling
if (is_enabled && config_type === 'azure_blob') {
const isValid = await validateAzureBlobCredentials({
config_type,
account_name,
account_key,
container_name,
endpoint_url,
is_enabled
});

if (!isValid) {
return NextResponse.json(
{ error: 'Invalid Azure Blob Storage credentials' },
{ status: 400 }
);
if (is_enabled) {
if (config_type === 'azure_blob') {
const isValid = await validateAzureBlobCredentials({
config_type,
account_name,
account_key,
container_name,
endpoint_url,
is_enabled
});

if (!isValid) {
return NextResponse.json(
{ error: 'Invalid Azure Blob Storage credentials' },
{ status: 400 }
);
}
} else if (config_type === 'local_storage') {
const isValid = await validateLocalStorageConfig({
config_type,
account_name,
account_key,
container_name,
endpoint_url,
is_enabled
});

if (!isValid) {
return NextResponse.json(
{ error: 'Invalid local storage configuration. Directory name must be provided and cannot contain path separators.' },
{ status: 400 }
);
}
}
}

Expand Down
81 changes: 80 additions & 1 deletion src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// Cloud storage service for handling file uploads
import { query } from './db';
import { decrypt } from './crypto';
import { promises as fs } from 'fs';
import path from 'path';

interface StorageConfig {
id: number;
config_type: 'azure_blob' | 'aws_s3';
config_type: 'azure_blob' | 'aws_s3' | 'local_storage';
account_name: string;
account_key: string;
container_name: string;
Expand Down Expand Up @@ -160,6 +162,51 @@ async function uploadToS3(
}
}

/**
* Upload file to local storage
*/
async function uploadToLocalStorage(
config: StorageConfig,
filename: string,
buffer: Buffer,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_mimeType: string
): Promise<UploadResult> {
try {
// Use container_name as the base directory for local storage
const baseDir = config.container_name || 'uploads';
const uploadsDir = path.join(process.cwd(), 'public', baseDir);
const filePath = path.join(uploadsDir, filename);
const fileDir = path.dirname(filePath);

// Ensure the directory exists
await fs.mkdir(fileDir, { recursive: true });

// Write the file
await fs.writeFile(filePath, buffer);

const storage_path = filename;
const storage_url = `/${baseDir}/${filename}`;

console.log(`Successfully uploaded to local storage: ${storage_url}`);

return {
success: true,
storage_path,
storage_url,
storage_type: 'local_storage'
};
} catch (error) {
console.error('Local storage upload error:', error);
return {
success: false,
storage_path: '',
storage_type: 'local_storage',
error: `Failed to upload to local storage: ${error instanceof Error ? error.message : String(error)}`
};
}
}

/**
* Upload file to configured cloud storage
*/
Expand Down Expand Up @@ -188,6 +235,8 @@ export async function uploadFile(
return uploadToAzureBlob(config, filename, buffer, mimeType);
case 'aws_s3':
return uploadToS3(config, filename, buffer, mimeType);
case 'local_storage':
return uploadToLocalStorage(config, filename, buffer, mimeType);
default:
return {
success: false,
Expand Down Expand Up @@ -232,6 +281,34 @@ async function deleteFromAzureBlob(config: StorageConfig, storagePath: string):
}
}

/**
* Delete file from local storage
*/
async function deleteFromLocalStorage(config: StorageConfig, storagePath: string): Promise<boolean> {
try {
const baseDir = config.container_name || 'uploads';
const filePath = path.join(process.cwd(), 'public', baseDir, storagePath);

// Check if file exists before trying to delete
try {
await fs.access(filePath);
await fs.unlink(filePath);
console.log(`Successfully deleted from local storage: ${storagePath}`);
return true;
} catch (error) {
// File doesn't exist, consider it successfully deleted
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
console.log(`File not found, considering it deleted: ${storagePath}`);
return true;
}
throw error;
}
} catch (error) {
console.error('Local storage delete error:', error);
return false;
}
}

/**
* Delete file from cloud storage
*/
Expand All @@ -250,6 +327,8 @@ export async function deleteFile(storagePath: string, storageType: string): Prom
// TODO: Implement AWS S3 deletion
console.log(`AWS S3 deletion not implemented: ${storagePath}`);
return true;
case 'local_storage':
return await deleteFromLocalStorage(config, storagePath);
default:
console.log(`Unknown storage type: ${storageType}`);
return false;
Expand Down