feat: integrate S3 storage adapter and update file upload functionality
This commit is contained in:
@@ -1,17 +0,0 @@
|
|||||||
name: hideyoshi-blog
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: local_db
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
volumes:
|
|
||||||
- db_data:/var/lib/postgresql/data
|
|
||||||
ports:
|
|
||||||
- '5332:5432'
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
37
docker/docker-compose.yml
Normal file
37
docker/docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: hideyoshi-blog
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: local_db
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '5332:5432'
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
storage:
|
||||||
|
image: rustfs/rustfs:latest
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
RUSTFS_ACCESS_KE: rustfsadmin
|
||||||
|
RUSTFS_SECRET_KE: rustfsadmin
|
||||||
|
ports:
|
||||||
|
- '9000:9000'
|
||||||
|
- '9001:9001'
|
||||||
|
volumes:
|
||||||
|
- storage_data:/data
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
storage_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
27
docker/garage.toml
Normal file
27
docker/garage.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "lmdb"
|
||||||
|
|
||||||
|
replication_factor = 1
|
||||||
|
|
||||||
|
# idk why rcp_ is needed without replication but ok
|
||||||
|
rpc_bind_addr = "[::]:3901"
|
||||||
|
rpc_public_addr = "127.0.0.1:3901"
|
||||||
|
rpc_secret = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"
|
||||||
|
|
||||||
|
compression_level = 2
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "garage"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
root_domain = "api.s3.su6.nl"
|
||||||
|
|
||||||
|
[s3_web]
|
||||||
|
bind_addr = "[::]:3902"
|
||||||
|
root_domain = "web.s3.su6.nl"
|
||||||
|
index = "index.html"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "0.0.0.0:3903"
|
||||||
|
metrics_token = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"
|
||||||
|
admin_token = "617a87858dc8d608dfb82db3ebc933b05687fe876c7bc0520afd2afb786c6d50"
|
||||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1028.0",
|
"@aws-sdk/client-s3": "^3.1028.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^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",
|
||||||
@@ -772,6 +773,25 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||||
|
"version": "3.1028.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1028.0.tgz",
|
||||||
|
"integrity": "sha512-T46EyIIHUaF/I2zhjtSUO4/87QdhqobClCscJawrxe2h/8nZJoO3DQDVZ4tMDl5UV/B5SPz6+xwki8jGylTF4Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.996.16",
|
||||||
|
"@aws-sdk/types": "^3.973.7",
|
||||||
|
"@aws-sdk/util-format-url": "^3.972.9",
|
||||||
|
"@smithy/middleware-endpoint": "^4.4.29",
|
||||||
|
"@smithy/protocol-http": "^5.3.13",
|
||||||
|
"@smithy/smithy-client": "^4.12.9",
|
||||||
|
"@smithy/types": "^4.14.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
"version": "3.996.16",
|
"version": "3.996.16",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz",
|
||||||
@@ -848,6 +868,21 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/util-format-url": {
|
||||||
|
"version": "3.972.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.9.tgz",
|
||||||
|
"integrity": "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/types": "^3.973.7",
|
||||||
|
"@smithy/querystring-builder": "^4.2.13",
|
||||||
|
"@smithy/types": "^4.14.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/util-locate-window": {
|
"node_modules/@aws-sdk/util-locate-window": {
|
||||||
"version": "3.965.5",
|
"version": "3.965.5",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1028.0",
|
"@aws-sdk/client-s3": "^3.1028.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^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",
|
||||||
@@ -48,9 +49,9 @@
|
|||||||
"@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",
|
||||||
|
"aws-sdk-client-mock": "^4.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.1",
|
"eslint-config-next": "16.2.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
|||||||
@@ -1,69 +1,25 @@
|
|||||||
import {
|
import { StorageProvider } from '@/lib/storage/storage.interface';
|
||||||
StorageProvider,
|
import { TypedResult, wrap } from '@/utils/types/results';
|
||||||
StorageResult,
|
|
||||||
} from '@/lib/storage/storage.interface';
|
|
||||||
import {
|
import {
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
|
ObjectCannedACL,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { promises as fs } from 'fs';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import path from 'path';
|
import { z } from 'zod';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local file system storage adapter
|
* Configuration for S3 storage adapter
|
||||||
* Saves files to the public/uploads directory and returns local paths
|
|
||||||
*/
|
*/
|
||||||
export class LocalStorageAdapter implements StorageProvider {
|
export const S3StorageConfig = z.object({
|
||||||
private readonly uploadsDir: string;
|
endpoint: z.string(),
|
||||||
|
bucket: z.string(),
|
||||||
constructor(uploadsDir: string = 'public/uploads') {
|
region: z.string(),
|
||||||
this.uploadsDir = uploadsDir;
|
accessKey: z.string(),
|
||||||
}
|
secretKey: z.string(),
|
||||||
|
});
|
||||||
async put(
|
export type S3StorageConfig = z.infer<typeof S3StorageConfig>;
|
||||||
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
|
* AWS S3 storage adapter
|
||||||
@@ -71,68 +27,71 @@ export class LocalStorageAdapter implements StorageProvider {
|
|||||||
*/
|
*/
|
||||||
export class S3StorageAdapter implements StorageProvider {
|
export class S3StorageAdapter implements StorageProvider {
|
||||||
private readonly s3Client: S3Client;
|
private readonly s3Client: S3Client;
|
||||||
|
private readonly endpoint: string;
|
||||||
private readonly bucketName: string;
|
private readonly bucketName: string;
|
||||||
private readonly region: string;
|
|
||||||
|
|
||||||
constructor(
|
readonly get: (
|
||||||
bucketName: string,
|
...args: Parameters<StorageProvider['get']>
|
||||||
region: string = 'us-east-1',
|
) => Promise<TypedResult<string>>;
|
||||||
s3Client?: S3Client
|
readonly put: (
|
||||||
) {
|
...args: Parameters<StorageProvider['put']>
|
||||||
this.bucketName = bucketName;
|
) => Promise<TypedResult<string>>;
|
||||||
this.region = region;
|
readonly delete: (
|
||||||
|
...args: Parameters<StorageProvider['delete']>
|
||||||
|
) => Promise<TypedResult<void>>;
|
||||||
|
|
||||||
|
constructor(config: S3StorageConfig, s3Client?: S3Client) {
|
||||||
|
this.endpoint = config.endpoint;
|
||||||
|
this.bucketName = config.bucket;
|
||||||
this.s3Client =
|
this.s3Client =
|
||||||
s3Client ||
|
s3Client ||
|
||||||
new S3Client({
|
new S3Client({
|
||||||
region: this.region,
|
endpoint: config.endpoint,
|
||||||
|
region: config.region,
|
||||||
|
forcePathStyle: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
|
accessKeyId: config.accessKey,
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
|
secretAccessKey: config.secretKey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.get = wrap(this._get.bind(this));
|
||||||
|
this.put = wrap(this._put.bind(this));
|
||||||
|
this.delete = wrap(this._delete.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(
|
private async _get(key: string): Promise<string> {
|
||||||
key: string,
|
return `${this.endpoint}/${this.bucketName}/${key}`;
|
||||||
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
|
private async _put(key: string, contentType: string): Promise<string> {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: this.bucketName,
|
||||||
|
Key: key,
|
||||||
|
ContentType: contentType,
|
||||||
|
ACL: ObjectCannedACL.public_read,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _delete(key: string): Promise<void> {
|
||||||
await this.s3Client.send(
|
await this.s3Client.send(
|
||||||
new PutObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: this.bucketName,
|
Bucket: this.bucketName,
|
||||||
Key: key,
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const new_s3_storage_adapter = (): S3StorageAdapter => {
|
||||||
|
const config = S3StorageConfig.parse({
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
bucket: process.env.S3_BUCKET_NAME,
|
||||||
|
region: process.env.S3_REGION,
|
||||||
|
accessKey: process.env.S3_ACCESS_KEY,
|
||||||
|
secretKey: process.env.S3_SECRET_KEY,
|
||||||
|
});
|
||||||
|
return new S3StorageAdapter(config);
|
||||||
|
};
|
||||||
|
|||||||
38
src/lib/storage/storage.external.ts
Normal file
38
src/lib/storage/storage.external.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createStorageProvider } from '@/lib/storage/storage.factory';
|
||||||
|
import { StorageProvider } from '@/lib/storage/storage.interface';
|
||||||
|
import { TypedResult } from '@/utils/types/results';
|
||||||
|
|
||||||
|
const storage: StorageProvider = createStorageProvider();
|
||||||
|
|
||||||
|
export const getSignedUrl = async (
|
||||||
|
key: string,
|
||||||
|
storageProvider?: StorageProvider
|
||||||
|
): Promise<TypedResult<string>> => {
|
||||||
|
if (!storageProvider) {
|
||||||
|
storageProvider = storage;
|
||||||
|
}
|
||||||
|
return await storageProvider.get(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPutUrl = async (
|
||||||
|
key: string,
|
||||||
|
contentType: string,
|
||||||
|
storageProvider?: StorageProvider
|
||||||
|
): Promise<TypedResult<string>> => {
|
||||||
|
if (!storageProvider) {
|
||||||
|
storageProvider = storage;
|
||||||
|
}
|
||||||
|
return await storageProvider.put(key, contentType);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteByKey = async (
|
||||||
|
key: string,
|
||||||
|
storageProvider?: StorageProvider
|
||||||
|
): Promise<TypedResult<void>> => {
|
||||||
|
if (!storageProvider) {
|
||||||
|
storageProvider = storage;
|
||||||
|
}
|
||||||
|
return await storageProvider.delete(key);
|
||||||
|
};
|
||||||
@@ -1,28 +1,13 @@
|
|||||||
import {
|
import { new_s3_storage_adapter } from '@/lib/storage/storage.adapter';
|
||||||
LocalStorageAdapter,
|
|
||||||
S3StorageAdapter,
|
|
||||||
} from '@/lib/storage/storage.adapter';
|
|
||||||
import { StorageProvider } from '@/lib/storage/storage.interface';
|
import { StorageProvider } from '@/lib/storage/storage.interface';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory function to create the appropriate storage provider based on environment
|
* Factory function to create the appropriate storage provider based on environment
|
||||||
*/
|
*/
|
||||||
export function createStorageProvider(): StorageProvider {
|
export function createStorageProvider(): StorageProvider {
|
||||||
const storageType = process.env.STORAGE_TYPE || 'local';
|
const storage_provider = new_s3_storage_adapter();
|
||||||
|
if (!storage_provider) {
|
||||||
if (storageType === 's3') {
|
throw new Error('Failed to create storage provider');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
return storage_provider;
|
||||||
// Default to local storage
|
|
||||||
return new LocalStorageAdapter();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { TypedResult } from '@/utils/types/results';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result returned from storage operations
|
* Result returned from storage operations
|
||||||
*/
|
*/
|
||||||
@@ -13,22 +15,23 @@ export interface StorageResult {
|
|||||||
*/
|
*/
|
||||||
export interface StorageProvider {
|
export interface StorageProvider {
|
||||||
/**
|
/**
|
||||||
* Uploads a file to storage
|
* Gets a presigned url for the requested file
|
||||||
* @param key - The unique key/path for the file
|
* @param key - The unique key/path to store the file under
|
||||||
* @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(
|
get(key: string): Promise<TypedResult<string>>;
|
||||||
key: string,
|
|
||||||
file: Buffer | Blob,
|
/**
|
||||||
contentType: string
|
* Uploads a file to storage
|
||||||
): Promise<StorageResult>;
|
* @param key - The unique key/path to store the file under
|
||||||
|
* @param contentType - The MIME type of the file being uploaded
|
||||||
|
* @returns Promise<string> - The public URL of the uploaded file
|
||||||
|
*/
|
||||||
|
put(key: string, contentType: string): Promise<TypedResult<string>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a file from storage
|
* Deletes a file from storage
|
||||||
* @param key - The unique key/path of the file to delete
|
* @param key - The unique key/path of the file to delete
|
||||||
* @returns Promise<void>
|
* @returns Promise<void>
|
||||||
*/
|
*/
|
||||||
delete(key: string): Promise<void>;
|
delete(key: string): Promise<TypedResult<void>>;
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/lib/storage/storage.utils.ts
Normal file
41
src/lib/storage/storage.utils.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as storage from '@/lib/storage/storage.external';
|
||||||
|
import { StorageProvider } from '@/lib/storage/storage.interface';
|
||||||
|
import { wrap } from '@/utils/types/results';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const FileUploadResp = z.object({
|
||||||
|
signedUrl: z.string(),
|
||||||
|
key: z.string(),
|
||||||
|
});
|
||||||
|
export type FileUploadResp = z.infer<StorageProvider>;
|
||||||
|
|
||||||
|
export const uploadFile = wrap(async (file: File) => {
|
||||||
|
const uniqueFileKey = crypto.randomUUID();
|
||||||
|
const result = await storage.getPutUrl(uniqueFileKey, file.type);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error('File upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(result.value, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.type,
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const presignedUrl = await storage.getSignedUrl(uniqueFileKey);
|
||||||
|
if (!presignedUrl.ok) {
|
||||||
|
throw new Error('Failed to retrieve file URL');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
signedUrl: presignedUrl.value,
|
||||||
|
key: uniqueFileKey,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { saveArticle } from '@/lib/feature/article/article.external';
|
import { saveArticle } from '@/lib/feature/article/article.external';
|
||||||
|
import { uploadFile } from '@/lib/storage/storage.utils';
|
||||||
import { FileUploadField } from '@/ui/components/internal/file-upload-field';
|
import { FileUploadField } from '@/ui/components/internal/file-upload-field';
|
||||||
import { Button } from '@/ui/components/shadcn/button';
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
import {
|
import {
|
||||||
@@ -60,7 +61,7 @@ export const CreateArticleForm = () => {
|
|||||||
title: z.string().min(3).max(255),
|
title: z.string().min(3).max(255),
|
||||||
slug: z.string().min(3),
|
slug: z.string().min(3),
|
||||||
description: z.string().min(10),
|
description: z.string().min(10),
|
||||||
coverImageUrl: z.string().url('Cover image URL must be a valid URL'),
|
coverImageUrl: z.url('Cover image URL must be a valid URL'),
|
||||||
content: z
|
content: z
|
||||||
.string()
|
.string()
|
||||||
.min(10, 'Article content must have at least 10 characters'),
|
.min(10, 'Article content must have at least 10 characters'),
|
||||||
@@ -119,19 +120,27 @@ export const CreateArticleForm = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCoverImageFileChange = useCallback(
|
const handleCoverImageFileChange = useCallback(
|
||||||
(file: File | null) => {
|
async (file: File | null) => {
|
||||||
if (coverImageUrlRef.current) {
|
if (coverImageUrlRef.current) {
|
||||||
URL.revokeObjectURL(coverImageUrlRef.current);
|
URL.revokeObjectURL(coverImageUrlRef.current);
|
||||||
coverImageUrlRef.current = null;
|
coverImageUrlRef.current = null;
|
||||||
}
|
}
|
||||||
setCoverImageFile(file);
|
setCoverImageFile(file);
|
||||||
if (file) {
|
if (!file) {
|
||||||
const url = URL.createObjectURL(file);
|
setCoverImageFile(null);
|
||||||
coverImageUrlRef.current = url;
|
|
||||||
form.setValue('coverImageUrl', url);
|
|
||||||
} else {
|
|
||||||
form.setValue('coverImageUrl', '');
|
form.setValue('coverImageUrl', '');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const fileMetadataResult = await uploadFile(file);
|
||||||
|
if (!fileMetadataResult.ok) {
|
||||||
|
setCoverImageFile(null);
|
||||||
|
form.setValue('coverImageUrl', '');
|
||||||
|
toast((fileMetadataResult.error as Error).message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileMetadata = fileMetadataResult.value;
|
||||||
|
coverImageUrlRef.current = fileMetadata.signedUrl;
|
||||||
|
form.setValue('coverImageUrl', fileMetadata.signedUrl);
|
||||||
},
|
},
|
||||||
[form]
|
[form]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import React, { useCallback } from 'react';
|
|||||||
|
|
||||||
export interface FileUploadFieldProps {
|
export interface FileUploadFieldProps {
|
||||||
file: File | null;
|
file: File | null;
|
||||||
onFileChange: (file: File | null) => void;
|
onFileChange: (file: File | null) => Promise<void>;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
validate?: (file: File) => string | null;
|
validate?: (file: File) => string | null;
|
||||||
onFileReject?: (file: File, message: string) => void;
|
onFileReject?: (file: File, message: string) => void;
|
||||||
@@ -45,7 +45,8 @@ export const FileUploadField: React.FC<FileUploadFieldProps> = ({
|
|||||||
const handleAccept = useCallback(
|
const handleAccept = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
const accepted = files[0];
|
const accepted = files[0];
|
||||||
if (accepted) onFileChange(accepted);
|
if (!accepted) return;
|
||||||
|
onFileChange(accepted).then(() => {});
|
||||||
},
|
},
|
||||||
[onFileChange]
|
[onFileChange]
|
||||||
);
|
);
|
||||||
|
|||||||
37
src/utils/types/results.ts
Normal file
37
src/utils/types/results.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
|
export type TypedResult<T> = Result<T, Error>;
|
||||||
|
|
||||||
|
export function wrapBlocking<
|
||||||
|
F extends (...args: never[]) => unknown,
|
||||||
|
E = unknown,
|
||||||
|
>(
|
||||||
|
fn: F,
|
||||||
|
mapError: (e: unknown) => E = (e) => e as E
|
||||||
|
): (...args: Parameters<F>) => Result<ReturnType<F>, E> {
|
||||||
|
return (...args) => {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: fn(...args) as ReturnType<F> };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: mapError(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrap<
|
||||||
|
F extends (...args: never[]) => Promise<unknown>,
|
||||||
|
E = unknown,
|
||||||
|
>(
|
||||||
|
fn: F,
|
||||||
|
mapError: (e: unknown) => E = (e) => e as E
|
||||||
|
): (...args: Parameters<F>) => Promise<Result<Awaited<ReturnType<F>>, E>> {
|
||||||
|
return async (...args) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: (await fn(...args)) as Awaited<ReturnType<F>>,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: mapError(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user