Skip to content

Commit 76e6012

Browse files
Copilotpatrickrb
andauthored
Add local file storage as an option in admin storage settings (#128)
* Initial plan * Add local file storage implementation Co-authored-by: patrickrb <[email protected]> * Complete local file storage implementation with database schema fix Co-authored-by: patrickrb <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: patrickrb <[email protected]>
1 parent 876ea38 commit 76e6012

File tree

5 files changed

+231
-68
lines changed

5 files changed

+231
-68
lines changed

install-database.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ CREATE TABLE qsl_images (
380380
-- Storage information
381381
storage_path VARCHAR(500) NOT NULL,
382382
storage_url VARCHAR(500),
383-
storage_type VARCHAR(20) DEFAULT 'azure_blob' CHECK (storage_type IN ('azure_blob', 'aws_s3')),
383+
storage_type VARCHAR(20) DEFAULT 'azure_blob' CHECK (storage_type IN ('azure_blob', 'aws_s3', 'local_storage')),
384384

385385
-- Image dimensions (optional, for display optimization)
386386
width INTEGER,
Lines changed: 1 addition & 0 deletions
Loading

src/app/admin/storage/page.tsx

Lines changed: 93 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
1010
import { Label } from '@/components/ui/label';
1111
import { Switch } from '@/components/ui/switch';
1212
import { Badge } from '@/components/ui/badge';
13+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
1314
import { AlertCircle, CheckCircle, Database, Eye, EyeOff, Trash2, Edit } from 'lucide-react';
1415
import { Alert, AlertDescription } from '@/components/ui/alert';
1516

@@ -40,7 +41,7 @@ export default function StorageConfigPage() {
4041

4142
// Form state for new/edit config
4243
const [formData, setFormData] = useState({
43-
config_type: 'azure_blob',
44+
config_type: 'local_storage',
4445
account_name: '',
4546
account_key: '',
4647
container_name: '',
@@ -235,71 +236,112 @@ export default function StorageConfigPage() {
235236
<CardHeader>
236237
<CardTitle>{editingConfig ? 'Edit Storage Configuration' : 'Add Storage Configuration'}</CardTitle>
237238
<CardDescription>
238-
Configure Azure Blob Storage for file uploads and backups
239+
Configure storage for file uploads and backups
239240
</CardDescription>
240241
</CardHeader>
241242
<CardContent>
242243
<form onSubmit={handleSubmit} className="space-y-4">
243-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
244-
<div>
245-
<Label htmlFor="account_name">Storage Account Name *</Label>
246-
<Input
247-
id="account_name"
248-
value={formData.account_name}
249-
onChange={(e) => setFormData(prev => ({ ...prev, account_name: e.target.value }))}
250-
placeholder="mystorageaccount"
251-
required
252-
/>
253-
</div>
244+
<div>
245+
<Label htmlFor="config_type">Storage Type *</Label>
246+
<Select
247+
value={formData.config_type}
248+
onValueChange={(value) => setFormData(prev => ({ ...prev, config_type: value }))}
249+
disabled={!!editingConfig}
250+
>
251+
<SelectTrigger>
252+
<SelectValue placeholder="Select storage type" />
253+
</SelectTrigger>
254+
<SelectContent>
255+
<SelectItem value="local_storage">Local File Storage</SelectItem>
256+
<SelectItem value="azure_blob">Azure Blob Storage</SelectItem>
257+
<SelectItem value="aws_s3">AWS S3 (Coming Soon)</SelectItem>
258+
</SelectContent>
259+
</Select>
260+
{editingConfig && (
261+
<p className="text-sm text-muted-foreground mt-1">
262+
Storage type cannot be changed after creation
263+
</p>
264+
)}
265+
</div>
254266

267+
{formData.config_type === 'local_storage' ? (
255268
<div>
256-
<Label htmlFor="container_name">Container Name *</Label>
269+
<Label htmlFor="container_name">Directory Name *</Label>
257270
<Input
258271
id="container_name"
259272
value={formData.container_name}
260273
onChange={(e) => setFormData(prev => ({ ...prev, container_name: e.target.value }))}
261-
placeholder="nextlog-files"
274+
placeholder="uploads"
262275
required
263276
/>
277+
<p className="text-sm text-muted-foreground mt-1">
278+
Files will be stored in public/{formData.container_name || 'uploads'}/
279+
</p>
264280
</div>
265-
</div>
281+
) : (
282+
<>
283+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
284+
<div>
285+
<Label htmlFor="account_name">Storage Account Name *</Label>
286+
<Input
287+
id="account_name"
288+
value={formData.account_name}
289+
onChange={(e) => setFormData(prev => ({ ...prev, account_name: e.target.value }))}
290+
placeholder="mystorageaccount"
291+
required={formData.config_type === 'azure_blob'}
292+
/>
293+
</div>
266294

267-
<div>
268-
<Label htmlFor="account_key">Account Key {editingConfig ? '(leave empty to keep current)' : '*'}</Label>
269-
<div className="relative">
270-
<Input
271-
id="account_key"
272-
type={showPassword ? "text" : "password"}
273-
value={formData.account_key}
274-
onChange={(e) => setFormData(prev => ({ ...prev, account_key: e.target.value }))}
275-
placeholder={editingConfig ? "Leave empty to keep current key" : "Enter your Azure storage account key"}
276-
required={!editingConfig}
277-
/>
278-
<Button
279-
type="button"
280-
variant="ghost"
281-
size="sm"
282-
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
283-
onClick={() => setShowPassword(!showPassword)}
284-
>
285-
{showPassword ? (
286-
<EyeOff className="h-4 w-4" />
287-
) : (
288-
<Eye className="h-4 w-4" />
289-
)}
290-
</Button>
291-
</div>
292-
</div>
295+
<div>
296+
<Label htmlFor="container_name">Container Name *</Label>
297+
<Input
298+
id="container_name"
299+
value={formData.container_name}
300+
onChange={(e) => setFormData(prev => ({ ...prev, container_name: e.target.value }))}
301+
placeholder="nextlog-files"
302+
required
303+
/>
304+
</div>
305+
</div>
293306

294-
<div>
295-
<Label htmlFor="endpoint_url">Custom Endpoint (Optional)</Label>
296-
<Input
297-
id="endpoint_url"
298-
value={formData.endpoint_url}
299-
onChange={(e) => setFormData(prev => ({ ...prev, endpoint_url: e.target.value }))}
300-
placeholder="https://mystorageaccount.blob.core.windows.net"
301-
/>
302-
</div>
307+
<div>
308+
<Label htmlFor="account_key">Account Key {editingConfig ? '(leave empty to keep current)' : '*'}</Label>
309+
<div className="relative">
310+
<Input
311+
id="account_key"
312+
type={showPassword ? "text" : "password"}
313+
value={formData.account_key}
314+
onChange={(e) => setFormData(prev => ({ ...prev, account_key: e.target.value }))}
315+
placeholder={editingConfig ? "Leave empty to keep current key" : "Enter your storage account key"}
316+
required={!editingConfig && formData.config_type === 'azure_blob'}
317+
/>
318+
<Button
319+
type="button"
320+
variant="ghost"
321+
size="sm"
322+
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
323+
onClick={() => setShowPassword(!showPassword)}
324+
>
325+
{showPassword ? (
326+
<EyeOff className="h-4 w-4" />
327+
) : (
328+
<Eye className="h-4 w-4" />
329+
)}
330+
</Button>
331+
</div>
332+
</div>
333+
334+
<div>
335+
<Label htmlFor="endpoint_url">Custom Endpoint (Optional)</Label>
336+
<Input
337+
id="endpoint_url"
338+
value={formData.endpoint_url}
339+
onChange={(e) => setFormData(prev => ({ ...prev, endpoint_url: e.target.value }))}
340+
placeholder="https://mystorageaccount.blob.core.windows.net"
341+
/>
342+
</div>
343+
</>
344+
)}
303345

304346
<div className="flex items-center space-x-2">
305347
<Switch

src/app/api/admin/storage/route.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ async function validateAzureBlobCredentials(config: StorageConfig): Promise<bool
3535
}
3636
}
3737

38+
/**
39+
* Validate local storage configuration
40+
*/
41+
async function validateLocalStorageConfig(config: StorageConfig): Promise<boolean> {
42+
try {
43+
// For local storage, we only need a valid directory name
44+
if (!config.container_name) {
45+
return false;
46+
}
47+
48+
// Validate the directory name doesn't contain dangerous characters
49+
const containerName = config.container_name;
50+
if (containerName.includes('..') || containerName.includes('/') || containerName.includes('\\')) {
51+
return false;
52+
}
53+
54+
return true;
55+
} catch (error) {
56+
console.error('Local storage validation error:', error);
57+
return false;
58+
}
59+
}
60+
3861
/**
3962
* GET /api/admin/storage - Get storage configurations
4063
*/
@@ -108,21 +131,39 @@ export const POST = requirePermission(Permission.EDIT_STORAGE_CONFIG)(
108131
}
109132

110133
// Validate credentials if enabling
111-
if (is_enabled && config_type === 'azure_blob') {
112-
const isValid = await validateAzureBlobCredentials({
113-
config_type,
114-
account_name,
115-
account_key,
116-
container_name,
117-
endpoint_url,
118-
is_enabled
119-
});
120-
121-
if (!isValid) {
122-
return NextResponse.json(
123-
{ error: 'Invalid Azure Blob Storage credentials' },
124-
{ status: 400 }
125-
);
134+
if (is_enabled) {
135+
if (config_type === 'azure_blob') {
136+
const isValid = await validateAzureBlobCredentials({
137+
config_type,
138+
account_name,
139+
account_key,
140+
container_name,
141+
endpoint_url,
142+
is_enabled
143+
});
144+
145+
if (!isValid) {
146+
return NextResponse.json(
147+
{ error: 'Invalid Azure Blob Storage credentials' },
148+
{ status: 400 }
149+
);
150+
}
151+
} else if (config_type === 'local_storage') {
152+
const isValid = await validateLocalStorageConfig({
153+
config_type,
154+
account_name,
155+
account_key,
156+
container_name,
157+
endpoint_url,
158+
is_enabled
159+
});
160+
161+
if (!isValid) {
162+
return NextResponse.json(
163+
{ error: 'Invalid local storage configuration. Directory name must be provided and cannot contain path separators.' },
164+
{ status: 400 }
165+
);
166+
}
126167
}
127168
}
128169

src/lib/storage.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Cloud storage service for handling file uploads
22
import { query } from './db';
33
import { decrypt } from './crypto';
4+
import { promises as fs } from 'fs';
5+
import path from 'path';
46

57
interface StorageConfig {
68
id: number;
7-
config_type: 'azure_blob' | 'aws_s3';
9+
config_type: 'azure_blob' | 'aws_s3' | 'local_storage';
810
account_name: string;
911
account_key: string;
1012
container_name: string;
@@ -160,6 +162,51 @@ async function uploadToS3(
160162
}
161163
}
162164

165+
/**
166+
* Upload file to local storage
167+
*/
168+
async function uploadToLocalStorage(
169+
config: StorageConfig,
170+
filename: string,
171+
buffer: Buffer,
172+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
173+
_mimeType: string
174+
): Promise<UploadResult> {
175+
try {
176+
// Use container_name as the base directory for local storage
177+
const baseDir = config.container_name || 'uploads';
178+
const uploadsDir = path.join(process.cwd(), 'public', baseDir);
179+
const filePath = path.join(uploadsDir, filename);
180+
const fileDir = path.dirname(filePath);
181+
182+
// Ensure the directory exists
183+
await fs.mkdir(fileDir, { recursive: true });
184+
185+
// Write the file
186+
await fs.writeFile(filePath, buffer);
187+
188+
const storage_path = filename;
189+
const storage_url = `/${baseDir}/${filename}`;
190+
191+
console.log(`Successfully uploaded to local storage: ${storage_url}`);
192+
193+
return {
194+
success: true,
195+
storage_path,
196+
storage_url,
197+
storage_type: 'local_storage'
198+
};
199+
} catch (error) {
200+
console.error('Local storage upload error:', error);
201+
return {
202+
success: false,
203+
storage_path: '',
204+
storage_type: 'local_storage',
205+
error: `Failed to upload to local storage: ${error instanceof Error ? error.message : String(error)}`
206+
};
207+
}
208+
}
209+
163210
/**
164211
* Upload file to configured cloud storage
165212
*/
@@ -188,6 +235,8 @@ export async function uploadFile(
188235
return uploadToAzureBlob(config, filename, buffer, mimeType);
189236
case 'aws_s3':
190237
return uploadToS3(config, filename, buffer, mimeType);
238+
case 'local_storage':
239+
return uploadToLocalStorage(config, filename, buffer, mimeType);
191240
default:
192241
return {
193242
success: false,
@@ -232,6 +281,34 @@ async function deleteFromAzureBlob(config: StorageConfig, storagePath: string):
232281
}
233282
}
234283

284+
/**
285+
* Delete file from local storage
286+
*/
287+
async function deleteFromLocalStorage(config: StorageConfig, storagePath: string): Promise<boolean> {
288+
try {
289+
const baseDir = config.container_name || 'uploads';
290+
const filePath = path.join(process.cwd(), 'public', baseDir, storagePath);
291+
292+
// Check if file exists before trying to delete
293+
try {
294+
await fs.access(filePath);
295+
await fs.unlink(filePath);
296+
console.log(`Successfully deleted from local storage: ${storagePath}`);
297+
return true;
298+
} catch (error) {
299+
// File doesn't exist, consider it successfully deleted
300+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
301+
console.log(`File not found, considering it deleted: ${storagePath}`);
302+
return true;
303+
}
304+
throw error;
305+
}
306+
} catch (error) {
307+
console.error('Local storage delete error:', error);
308+
return false;
309+
}
310+
}
311+
235312
/**
236313
* Delete file from cloud storage
237314
*/
@@ -250,6 +327,8 @@ export async function deleteFile(storagePath: string, storageType: string): Prom
250327
// TODO: Implement AWS S3 deletion
251328
console.log(`AWS S3 deletion not implemented: ${storagePath}`);
252329
return true;
330+
case 'local_storage':
331+
return await deleteFromLocalStorage(config, storagePath);
253332
default:
254333
console.log(`Unknown storage type: ${storageType}`);
255334
return false;

0 commit comments

Comments
 (0)