diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 57d664d..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..8457275 --- /dev/null +++ b/docker/docker-compose.yml @@ -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: diff --git a/docker/garage.toml b/docker/garage.toml new file mode 100644 index 0000000..c2388c1 --- /dev/null +++ b/docker/garage.toml @@ -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" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 84d9695..7cc0345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@aws-sdk/client-s3": "^3.1028.0", + "@aws-sdk/s3-request-presigner": "^3.1028.0", "@clerk/nextjs": "^7.0.7", "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.95.2", @@ -772,6 +773,25 @@ "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": { "version": "3.996.16", "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_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": { "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", diff --git a/package.json b/package.json index 6a3ffa4..d449b1d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1028.0", + "@aws-sdk/s3-request-presigner": "^3.1028.0", "@clerk/nextjs": "^7.0.7", "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.95.2", @@ -48,9 +49,9 @@ "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/jest": "^30.0.0", "@types/node": "^20", - "aws-sdk-client-mock": "^4.0.0", "@types/react": "^19", "@types/react-dom": "^19", + "aws-sdk-client-mock": "^4.0.0", "eslint": "^9", "eslint-config-next": "16.2.1", "eslint-config-prettier": "^10.1.8", diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts index 852344e..8397dcf 100644 --- a/src/lib/storage/storage.adapter.ts +++ b/src/lib/storage/storage.adapter.ts @@ -1,69 +1,25 @@ -import { - StorageProvider, - StorageResult, -} from '@/lib/storage/storage.interface'; +import { StorageProvider } from '@/lib/storage/storage.interface'; +import { TypedResult, wrap } from '@/utils/types/results'; import { DeleteObjectCommand, + ObjectCannedACL, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; -import { promises as fs } from 'fs'; -import path from 'path'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { z } from 'zod'; /** - * Local file system storage adapter - * Saves files to the public/uploads directory and returns local paths + * Configuration for S3 storage adapter */ -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 { - // 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 { - 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; - } - } - } -} +export const S3StorageConfig = z.object({ + endpoint: z.string(), + bucket: z.string(), + region: z.string(), + accessKey: z.string(), + secretKey: z.string(), +}); +export type S3StorageConfig = z.infer; /** * AWS S3 storage adapter @@ -71,68 +27,71 @@ export class LocalStorageAdapter implements StorageProvider { */ export class S3StorageAdapter implements StorageProvider { private readonly s3Client: S3Client; + private readonly endpoint: string; private readonly bucketName: string; - private readonly region: string; - constructor( - bucketName: string, - region: string = 'us-east-1', - s3Client?: S3Client - ) { - this.bucketName = bucketName; - this.region = region; + readonly get: ( + ...args: Parameters + ) => Promise>; + readonly put: ( + ...args: Parameters + ) => Promise>; + readonly delete: ( + ...args: Parameters + ) => Promise>; + + constructor(config: S3StorageConfig, s3Client?: S3Client) { + this.endpoint = config.endpoint; + this.bucketName = config.bucket; this.s3Client = s3Client || new S3Client({ - region: this.region, + endpoint: config.endpoint, + region: config.region, + forcePathStyle: true, credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', + accessKeyId: config.accessKey, + 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( - key: string, - file: Buffer | Blob, - contentType: string - ): Promise { - // Convert Blob to Buffer if needed - const buffer = - file instanceof Blob ? Buffer.from(await file.arrayBuffer()) : file; + private async _get(key: string): Promise { + return `${this.endpoint}/${this.bucketName}/${key}`; + } - // Upload to S3 + private async _put(key: string, contentType: string): Promise { + 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 { await this.s3Client.send( - new PutObjectCommand({ + new DeleteObjectCommand({ 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 { - 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); +}; diff --git a/src/lib/storage/storage.external.ts b/src/lib/storage/storage.external.ts new file mode 100644 index 0000000..8fef098 --- /dev/null +++ b/src/lib/storage/storage.external.ts @@ -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> => { + if (!storageProvider) { + storageProvider = storage; + } + return await storageProvider.get(key); +}; + +export const getPutUrl = async ( + key: string, + contentType: string, + storageProvider?: StorageProvider +): Promise> => { + if (!storageProvider) { + storageProvider = storage; + } + return await storageProvider.put(key, contentType); +}; + +export const deleteByKey = async ( + key: string, + storageProvider?: StorageProvider +): Promise> => { + if (!storageProvider) { + storageProvider = storage; + } + return await storageProvider.delete(key); +}; diff --git a/src/lib/storage/storage.factory.ts b/src/lib/storage/storage.factory.ts index d83314e..3693212 100644 --- a/src/lib/storage/storage.factory.ts +++ b/src/lib/storage/storage.factory.ts @@ -1,28 +1,13 @@ -import { - LocalStorageAdapter, - S3StorageAdapter, -} from '@/lib/storage/storage.adapter'; +import { new_s3_storage_adapter } 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); + const storage_provider = new_s3_storage_adapter(); + if (!storage_provider) { + throw new Error('Failed to create storage provider'); } - - // Default to local storage - return new LocalStorageAdapter(); + return storage_provider; } diff --git a/src/lib/storage/storage.interface.ts b/src/lib/storage/storage.interface.ts index f32d6c1..3cce43e 100644 --- a/src/lib/storage/storage.interface.ts +++ b/src/lib/storage/storage.interface.ts @@ -1,3 +1,5 @@ +import { TypedResult } from '@/utils/types/results'; + /** * Result returned from storage operations */ @@ -13,22 +15,23 @@ export interface StorageResult { */ 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 - Result containing storage metadata and public URL + * Gets a presigned url for the requested file + * @param key - The unique key/path to store the file under */ - put( - key: string, - file: Buffer | Blob, - contentType: string - ): Promise; + get(key: string): Promise>; + + /** + * Uploads a file to storage + * @param key - The unique key/path to store the file under + * @param contentType - The MIME type of the file being uploaded + * @returns Promise - The public URL of the uploaded file + */ + put(key: string, contentType: string): Promise>; /** * Deletes a file from storage * @param key - The unique key/path of the file to delete * @returns Promise */ - delete(key: string): Promise; + delete(key: string): Promise>; } diff --git a/src/lib/storage/storage.utils.ts b/src/lib/storage/storage.utils.ts new file mode 100644 index 0000000..214348a --- /dev/null +++ b/src/lib/storage/storage.utils.ts @@ -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; + +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, + }; +}); diff --git a/src/ui/components/internal/create-article-form.tsx b/src/ui/components/internal/create-article-form.tsx index 433105a..099a170 100644 --- a/src/ui/components/internal/create-article-form.tsx +++ b/src/ui/components/internal/create-article-form.tsx @@ -1,6 +1,7 @@ 'use client'; 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 { Button } from '@/ui/components/shadcn/button'; import { @@ -60,7 +61,7 @@ export const CreateArticleForm = () => { title: z.string().min(3).max(255), slug: z.string().min(3), 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 .string() .min(10, 'Article content must have at least 10 characters'), @@ -119,19 +120,27 @@ export const CreateArticleForm = () => { ); const handleCoverImageFileChange = useCallback( - (file: File | null) => { + async (file: File | null) => { if (coverImageUrlRef.current) { URL.revokeObjectURL(coverImageUrlRef.current); coverImageUrlRef.current = null; } setCoverImageFile(file); - if (file) { - const url = URL.createObjectURL(file); - coverImageUrlRef.current = url; - form.setValue('coverImageUrl', url); - } else { + if (!file) { + setCoverImageFile(null); 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] ); diff --git a/src/ui/components/internal/file-upload-field.tsx b/src/ui/components/internal/file-upload-field.tsx index 364bc7f..5504a24 100644 --- a/src/ui/components/internal/file-upload-field.tsx +++ b/src/ui/components/internal/file-upload-field.tsx @@ -21,7 +21,7 @@ import React, { useCallback } from 'react'; export interface FileUploadFieldProps { file: File | null; - onFileChange: (file: File | null) => void; + onFileChange: (file: File | null) => Promise; accept?: string; validate?: (file: File) => string | null; onFileReject?: (file: File, message: string) => void; @@ -45,7 +45,8 @@ export const FileUploadField: React.FC = ({ const handleAccept = useCallback( (files: File[]) => { const accepted = files[0]; - if (accepted) onFileChange(accepted); + if (!accepted) return; + onFileChange(accepted).then(() => {}); }, [onFileChange] ); diff --git a/src/utils/types/results.ts b/src/utils/types/results.ts new file mode 100644 index 0000000..c93eca8 --- /dev/null +++ b/src/utils/types/results.ts @@ -0,0 +1,37 @@ +export type Result = { ok: true; value: T } | { ok: false; error: E }; +export type TypedResult = Result; + +export function wrapBlocking< + F extends (...args: never[]) => unknown, + E = unknown, +>( + fn: F, + mapError: (e: unknown) => E = (e) => e as E +): (...args: Parameters) => Result, E> { + return (...args) => { + try { + return { ok: true, value: fn(...args) as ReturnType }; + } catch (e) { + return { ok: false, error: mapError(e) }; + } + }; +} + +export function wrap< + F extends (...args: never[]) => Promise, + E = unknown, +>( + fn: F, + mapError: (e: unknown) => E = (e) => e as E +): (...args: Parameters) => Promise>, E>> { + return async (...args) => { + try { + return { + ok: true, + value: (await fn(...args)) as Awaited>, + }; + } catch (e) { + return { ok: false, error: mapError(e) }; + } + }; +} diff --git a/tests/lib/storage/storage.local.test.ts b/tests/lib/storage/storage.local.test.ts deleted file mode 100644 index 42e1d28..0000000 --- a/tests/lib/storage/storage.local.test.ts +++ /dev/null @@ -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); - }); - }); -});