feature/adds-admin-add-article #1
1760
package-lock.json
generated
1760
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1028.0",
|
||||||
"@clerk/nextjs": "^7.0.7",
|
"@clerk/nextjs": "^7.0.7",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"aws-sdk-client-mock": "^4.0.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
138
src/lib/storage/storage.adapter.ts
Normal file
138
src/lib/storage/storage.adapter.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
StorageProvider,
|
||||||
|
StorageResult,
|
||||||
|
} from '@/lib/storage/storage.interface';
|
||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local file system storage adapter
|
||||||
|
* Saves files to the public/uploads directory and returns local paths
|
||||||
|
*/
|
||||||
|
export class LocalStorageAdapter implements StorageProvider {
|
||||||
|
private readonly uploadsDir: string;
|
||||||
|
|
||||||
|
constructor(uploadsDir: string = 'public/uploads') {
|
||||||
|
this.uploadsDir = uploadsDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(
|
||||||
|
key: string,
|
||||||
|
file: Buffer | Blob,
|
||||||
|
_contentType: string
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(this.uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Convert Blob to Buffer if needed
|
||||||
|
const buffer =
|
||||||
|
file instanceof Blob ? Buffer.from(await file.arrayBuffer()) : file;
|
||||||
|
|
||||||
|
// Write file to disk
|
||||||
|
const filePath = path.join(this.uploadsDir, key);
|
||||||
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Return local path as public URL
|
||||||
|
const publicUrl = `/${this.uploadsDir}/${key}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'local',
|
||||||
|
provider: null,
|
||||||
|
key,
|
||||||
|
publicUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
const filePath = path.join(this.uploadsDir, key);
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
// File might not exist, silently ignore
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
'code' in error &&
|
||||||
|
error.code !== 'ENOENT'
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AWS S3 storage adapter
|
||||||
|
* Uploads files to S3 and returns public URLs
|
||||||
|
*/
|
||||||
|
export class S3StorageAdapter implements StorageProvider {
|
||||||
|
private readonly s3Client: S3Client;
|
||||||
|
private readonly bucketName: string;
|
||||||
|
private readonly region: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
bucketName: string,
|
||||||
|
region: string = 'us-east-1',
|
||||||
|
s3Client?: S3Client
|
||||||
|
) {
|
||||||
|
this.bucketName = bucketName;
|
||||||
|
this.region = region;
|
||||||
|
this.s3Client =
|
||||||
|
s3Client ||
|
||||||
|
new S3Client({
|
||||||
|
region: this.region,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
||||||
|
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(
|
||||||
|
key: string,
|
||||||
|
file: Buffer | Blob,
|
||||||
|
contentType: string
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
// Convert Blob to Buffer if needed
|
||||||
|
const buffer =
|
||||||
|
file instanceof Blob ? Buffer.from(await file.arrayBuffer()) : file;
|
||||||
|
|
||||||
|
// Upload to S3
|
||||||
|
await this.s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.bucketName,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: contentType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate public URL
|
||||||
|
const publicUrl = `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 's3',
|
||||||
|
provider: 'aws-s3',
|
||||||
|
key,
|
||||||
|
publicUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.s3Client.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: this.bucketName,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't throw to avoid cascading failures
|
||||||
|
console.error(`Failed to delete S3 object: ${key}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/lib/storage/storage.factory.ts
Normal file
28
src/lib/storage/storage.factory.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
LocalStorageAdapter,
|
||||||
|
S3StorageAdapter,
|
||||||
|
} from '@/lib/storage/storage.adapter';
|
||||||
|
import { StorageProvider } from '@/lib/storage/storage.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create the appropriate storage provider based on environment
|
||||||
|
*/
|
||||||
|
export function createStorageProvider(): StorageProvider {
|
||||||
|
const storageType = process.env.STORAGE_TYPE || 'local';
|
||||||
|
|
||||||
|
if (storageType === 's3') {
|
||||||
|
const bucketName = process.env.S3_BUCKET_NAME;
|
||||||
|
const region = process.env.S3_REGION || 'us-east-1';
|
||||||
|
|
||||||
|
if (!bucketName) {
|
||||||
|
throw new Error(
|
||||||
|
'S3_BUCKET_NAME environment variable is required when STORAGE_TYPE=s3'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new S3StorageAdapter(bucketName, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to local storage
|
||||||
|
return new LocalStorageAdapter();
|
||||||
|
}
|
||||||
34
src/lib/storage/storage.interface.ts
Normal file
34
src/lib/storage/storage.interface.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Result returned from storage operations
|
||||||
|
*/
|
||||||
|
export interface StorageResult {
|
||||||
|
type: 'local' | 's3';
|
||||||
|
provider: string | null;
|
||||||
|
key: string;
|
||||||
|
publicUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage provider interface for abstracting different storage implementations
|
||||||
|
*/
|
||||||
|
export interface StorageProvider {
|
||||||
|
/**
|
||||||
|
* Uploads a file to storage
|
||||||
|
* @param key - The unique key/path for the file
|
||||||
|
* @param file - The file content as Buffer or Blob
|
||||||
|
* @param contentType - MIME type of the file
|
||||||
|
* @returns Promise<StorageResult> - Result containing storage metadata and public URL
|
||||||
|
*/
|
||||||
|
put(
|
||||||
|
key: string,
|
||||||
|
file: Buffer | Blob,
|
||||||
|
contentType: string
|
||||||
|
): Promise<StorageResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file from storage
|
||||||
|
* @param key - The unique key/path of the file to delete
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
delete(key: string): Promise<void>;
|
||||||
|
}
|
||||||
9
src/lib/storage/storage.ts
Normal file
9
src/lib/storage/storage.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createStorageProvider } from '@/lib/storage/storage.factory';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance of the configured storage provider
|
||||||
|
*/
|
||||||
|
export const storage: ReturnType<typeof createStorageProvider> =
|
||||||
|
createStorageProvider();
|
||||||
|
|
||||||
|
export type { StorageResult } from '@/lib/storage/storage.interface';
|
||||||
146
tests/lib/storage/storage.local.test.ts
Normal file
146
tests/lib/storage/storage.local.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { LocalStorageAdapter } from '@/lib/storage/storage.adapter';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe('LocalStorageAdapter', () => {
|
||||||
|
const testUploadDir = 'tests/tmp/uploads';
|
||||||
|
let adapter: LocalStorageAdapter;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
adapter = new LocalStorageAdapter(testUploadDir);
|
||||||
|
// Clean up test directory before each test
|
||||||
|
try {
|
||||||
|
await fs.rm(testUploadDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist yet
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up test directory after each test
|
||||||
|
try {
|
||||||
|
await fs.rm(testUploadDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Directory might not exist
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('put', () => {
|
||||||
|
it('should create uploads directory if it does not exist', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
const file = Buffer.from('test file content');
|
||||||
|
|
||||||
|
await adapter.put(key, file, 'image/jpeg');
|
||||||
|
|
||||||
|
const dirExists = await fs
|
||||||
|
.stat(testUploadDir)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
expect(dirExists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save file to disk with provided key', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
const fileContent = 'test file content';
|
||||||
|
const file = Buffer.from(fileContent);
|
||||||
|
|
||||||
|
await adapter.put(key, file, 'image/jpeg');
|
||||||
|
|
||||||
|
const savedContent = await fs.readFile(
|
||||||
|
path.join(testUploadDir, key),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
expect(savedContent).toBe(fileContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return StorageResult with local type', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
const file = Buffer.from('test file content');
|
||||||
|
|
||||||
|
const result = await adapter.put(key, file, 'image/jpeg');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'local',
|
||||||
|
provider: null,
|
||||||
|
key,
|
||||||
|
publicUrl: `/tests/tmp/uploads/test-image.jpg`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Blob input', async () => {
|
||||||
|
const key = 'test-blob.jpg';
|
||||||
|
const fileContent = 'blob file content';
|
||||||
|
const blob = new Blob([fileContent], { type: 'image/jpeg' });
|
||||||
|
|
||||||
|
const result = await adapter.put(key, blob, 'image/jpeg');
|
||||||
|
|
||||||
|
const savedContent = await fs.readFile(
|
||||||
|
path.join(testUploadDir, key),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
expect(savedContent).toBe(fileContent);
|
||||||
|
expect(result.type).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create nested directories for keys with paths', async () => {
|
||||||
|
const key = 'articles/2026/04/image.jpg';
|
||||||
|
const file = Buffer.from('nested file content');
|
||||||
|
|
||||||
|
const result = await adapter.put(key, file, 'image/jpeg');
|
||||||
|
|
||||||
|
const savedContent = await fs.readFile(
|
||||||
|
path.join(testUploadDir, key),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
expect(savedContent).toBe('nested file content');
|
||||||
|
expect(result.publicUrl).toBe(
|
||||||
|
`/tests/tmp/uploads/articles/2026/04/image.jpg`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete file from disk', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
const file = Buffer.from('test file content');
|
||||||
|
|
||||||
|
await adapter.put(key, file, 'image/jpeg');
|
||||||
|
const filePathBeforeDelete = path.join(testUploadDir, key);
|
||||||
|
expect(
|
||||||
|
await fs
|
||||||
|
.stat(filePathBeforeDelete)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await adapter.delete(key);
|
||||||
|
|
||||||
|
const fileExists = await fs
|
||||||
|
.stat(filePathBeforeDelete)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
expect(fileExists).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw error if file does not exist', async () => {
|
||||||
|
const key = 'non-existent-file.jpg';
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await expect(adapter.delete(key)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete nested files', async () => {
|
||||||
|
const key = 'articles/2026/04/image.jpg';
|
||||||
|
const file = Buffer.from('nested file content');
|
||||||
|
|
||||||
|
await adapter.put(key, file, 'image/jpeg');
|
||||||
|
await adapter.delete(key);
|
||||||
|
|
||||||
|
const fileExists = await fs
|
||||||
|
.stat(path.join(testUploadDir, key))
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
expect(fileExists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
143
tests/lib/storage/storage.s3.test.ts
Normal file
143
tests/lib/storage/storage.s3.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { S3StorageAdapter } from '@/lib/storage/storage.adapter';
|
||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { mockClient } from 'aws-sdk-client-mock';
|
||||||
|
|
||||||
|
describe('S3StorageAdapter', () => {
|
||||||
|
let s3Mock: ReturnType<typeof mockClient>;
|
||||||
|
let mockS3Client: S3Client;
|
||||||
|
let adapter: S3StorageAdapter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
s3Mock = mockClient(S3Client);
|
||||||
|
mockS3Client = new S3Client({ region: 'us-east-1' });
|
||||||
|
adapter = new S3StorageAdapter(
|
||||||
|
'test-bucket',
|
||||||
|
'us-east-1',
|
||||||
|
mockS3Client
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
s3Mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('put', () => {
|
||||||
|
it('should upload file to S3 with correct parameters', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
const fileContent = 'test file content';
|
||||||
|
const file = Buffer.from(fileContent);
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({});
|
||||||
|
|
||||||
|
await adapter.put(key, file, 'image/jpeg');
|
||||||
|
|
||||||
|
expect(s3Mock.call(0).args[0]).toBeInstanceOf(PutObjectCommand);
|
||||||
|
const command = s3Mock.call(0).args[0] as PutObjectCommand;
|
||||||
|
expect(command.input.Bucket).toBe('test-bucket');
|
||||||
|
expect(command.input.Key).toBe(key);
|
||||||
|
expect(command.input.ContentType).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return StorageResult with s3 type', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
const file = Buffer.from('test file content');
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({});
|
||||||
|
|
||||||
|
const result = await adapter.put(key, file, 'image/jpeg');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 's3',
|
||||||
|
provider: 'aws-s3',
|
||||||
|
key,
|
||||||
|
publicUrl: `https://test-bucket.s3.us-east-1.amazonaws.com/${key}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct public URL with different regions', async () => {
|
||||||
|
const adapter2 = new S3StorageAdapter(
|
||||||
|
'my-bucket',
|
||||||
|
'eu-west-1',
|
||||||
|
mockS3Client
|
||||||
|
);
|
||||||
|
const key = 'my-image.png';
|
||||||
|
const file = Buffer.from('test');
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({});
|
||||||
|
|
||||||
|
const result = await adapter2.put(key, file, 'image/png');
|
||||||
|
|
||||||
|
expect(result.publicUrl).toBe(
|
||||||
|
`https://my-bucket.s3.eu-west-1.amazonaws.com/${key}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Blob input', async () => {
|
||||||
|
const key = 'test-blob.jpg';
|
||||||
|
const fileContent = 'blob content';
|
||||||
|
const blob = new Blob([fileContent], { type: 'image/jpeg' });
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({});
|
||||||
|
|
||||||
|
const result = await adapter.put(key, blob, 'image/jpeg');
|
||||||
|
|
||||||
|
expect(result.type).toBe('s3');
|
||||||
|
expect(result.key).toBe(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested keys', async () => {
|
||||||
|
const key = 'articles/2026/04/image.jpg';
|
||||||
|
const file = Buffer.from('test');
|
||||||
|
|
||||||
|
s3Mock.on(PutObjectCommand).resolves({});
|
||||||
|
|
||||||
|
const result = await adapter.put(key, file, 'image/jpeg');
|
||||||
|
|
||||||
|
expect(result.key).toBe(key);
|
||||||
|
expect(result.publicUrl).toBe(
|
||||||
|
`https://test-bucket.s3.us-east-1.amazonaws.com/${key}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete object from S3 with correct parameters', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
|
||||||
|
s3Mock.on(DeleteObjectCommand).resolves({});
|
||||||
|
|
||||||
|
await adapter.delete(key);
|
||||||
|
|
||||||
|
expect(s3Mock.call(0).args[0]).toBeInstanceOf(DeleteObjectCommand);
|
||||||
|
const command = s3Mock.call(0).args[0] as DeleteObjectCommand;
|
||||||
|
expect(command.input.Bucket).toBe('test-bucket');
|
||||||
|
expect(command.input.Key).toBe(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw error if deletion fails', async () => {
|
||||||
|
const key = 'test-image.jpg';
|
||||||
|
|
||||||
|
s3Mock.on(DeleteObjectCommand).rejects(new Error('S3 error'));
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(async () => {
|
||||||
|
await adapter.delete(key);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested keys', async () => {
|
||||||
|
const key = 'articles/2026/04/image.jpg';
|
||||||
|
|
||||||
|
s3Mock.on(DeleteObjectCommand).resolves({});
|
||||||
|
|
||||||
|
await adapter.delete(key);
|
||||||
|
|
||||||
|
const command = s3Mock.call(0).args[0] as DeleteObjectCommand;
|
||||||
|
expect(command.input.Key).toBe(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user