added file domain logic, updated drizzle package
This commit is contained in:
484
packages/objectstorage/src/client.ts
Normal file
484
packages/objectstorage/src/client.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import type {
|
||||
FileMetadata,
|
||||
FileUploadConfig,
|
||||
PresignedUrlResult,
|
||||
UploadOptions,
|
||||
UploadResult,
|
||||
} from "./data";
|
||||
import {
|
||||
generateFileHash,
|
||||
generateObjectKey,
|
||||
isDocumentFile,
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
} from "./utils";
|
||||
import { processDocument } from "./processors/document-processor";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { processVideo } from "./processors/video-processor";
|
||||
import { processImage } from "./processors/image-processor";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import { getError, logger } from "@pkg/logger";
|
||||
import { validateFile } from "./validation";
|
||||
|
||||
export class R2StorageClient {
|
||||
private s3Client: S3Client;
|
||||
private config: FileUploadConfig;
|
||||
|
||||
constructor(config: FileUploadConfig) {
|
||||
this.config = config;
|
||||
this.s3Client = new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.secretKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file directly to R2
|
||||
*/
|
||||
async uploadFile(
|
||||
file: Buffer | Uint8Array,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
userId: string,
|
||||
options?: UploadOptions,
|
||||
): Promise<Result<UploadResult>> {
|
||||
try {
|
||||
// Validate file
|
||||
const validationResult = validateFile(
|
||||
file,
|
||||
originalName,
|
||||
mimeType,
|
||||
this.config,
|
||||
);
|
||||
if (!validationResult.isValid) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.VALIDATION_ERROR,
|
||||
message: "File validation failed",
|
||||
description: validationResult.errors.join(", "),
|
||||
detail: "File validation failed",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Generate file hash for deduplication
|
||||
const hash = generateFileHash(file);
|
||||
|
||||
// Generate unique filename and object key
|
||||
const fileId = crypto.randomUUID();
|
||||
const extension = originalName.split(".").pop() || "";
|
||||
const filename = `${fileId}.${extension}`;
|
||||
const objectKey = generateObjectKey(userId, filename);
|
||||
|
||||
let processedFile = Buffer.from(file);
|
||||
let thumbnailBuffer: Buffer | undefined;
|
||||
let metadata = options?.metadata ? { ...options?.metadata } : {};
|
||||
|
||||
// Process file based on type
|
||||
if (options?.processImage && isImageFile(mimeType)) {
|
||||
const processingResult = await processImage(file, {
|
||||
format: "webp",
|
||||
quality: 85,
|
||||
generateThumbnail: true,
|
||||
thumbnailSize: { width: 300, height: 300 },
|
||||
resize: {
|
||||
width: 1920,
|
||||
height: 1920,
|
||||
fit: "inside",
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
processingResult.processed &&
|
||||
processingResult.processedFile
|
||||
) {
|
||||
processedFile = Buffer.from(processingResult.processedFile);
|
||||
thumbnailBuffer = processingResult.thumbnail
|
||||
? Buffer.from(processingResult.thumbnail)
|
||||
: undefined;
|
||||
metadata = { ...metadata, ...processingResult.metadata };
|
||||
}
|
||||
} else if (options?.processDocument && isDocumentFile(mimeType)) {
|
||||
const processingResult = await processDocument(file, mimeType, {
|
||||
extractText: true,
|
||||
generatePreview: true,
|
||||
extractMetadata: true,
|
||||
});
|
||||
|
||||
if (processingResult.processed && processingResult.metadata) {
|
||||
metadata = { ...metadata, ...processingResult.metadata };
|
||||
}
|
||||
} else if (options?.processVideo && isVideoFile(mimeType)) {
|
||||
const processingResult = await processVideo(file, mimeType, {
|
||||
generateThumbnail: true,
|
||||
extractMetadata: true,
|
||||
thumbnailTimestamp: 1, // 1 second into video
|
||||
});
|
||||
|
||||
if (processingResult.processed && processingResult.metadata) {
|
||||
metadata = { ...metadata, ...processingResult.metadata };
|
||||
}
|
||||
}
|
||||
|
||||
// Upload main file to R2
|
||||
const uploadCommand = new PutObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: objectKey,
|
||||
Body: processedFile,
|
||||
ContentType: mimeType,
|
||||
Metadata: {
|
||||
originalName,
|
||||
userId,
|
||||
hash,
|
||||
uploadId: fileId,
|
||||
processed: "true",
|
||||
...Object.fromEntries(
|
||||
Object.entries(metadata).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value),
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await this.s3Client.send(uploadCommand);
|
||||
|
||||
// Upload thumbnail if generated
|
||||
if (thumbnailBuffer) {
|
||||
const thumbnailKey = `thumbnails/${userId}/${fileId}_thumb.webp`;
|
||||
const thumbnailCommand = new PutObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: thumbnailKey,
|
||||
Body: thumbnailBuffer,
|
||||
ContentType: "image/webp",
|
||||
Metadata: {
|
||||
originalFileId: fileId,
|
||||
type: "thumbnail",
|
||||
},
|
||||
});
|
||||
|
||||
await this.s3Client.send(thumbnailCommand);
|
||||
metadata.thumbnailKey = thumbnailKey;
|
||||
}
|
||||
|
||||
// Construct R2 URL
|
||||
const r2Url = `${this.config.publicUrl || this.config.endpoint}/${objectKey}`;
|
||||
|
||||
const fileMetadata: FileMetadata = {
|
||||
id: fileId,
|
||||
filename,
|
||||
originalName,
|
||||
mimeType,
|
||||
size: processedFile.length,
|
||||
hash,
|
||||
bucketName: this.config.bucketName,
|
||||
objectKey,
|
||||
r2Url,
|
||||
visibility: options?.visibility || "private",
|
||||
userId,
|
||||
metadata,
|
||||
tags: options?.tags,
|
||||
uploadedAt: new Date(),
|
||||
};
|
||||
|
||||
const result: UploadResult = {
|
||||
success: true,
|
||||
file: fileMetadata,
|
||||
uploadId: fileId,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`Successfully uploaded file ${fileId} for user ${userId}`,
|
||||
);
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
logger.error("File upload failed:", error);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.STORAGE_ERROR,
|
||||
message: "Upload failed",
|
||||
description: "Failed to upload file to storage",
|
||||
detail: "S3 upload operation failed",
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate presigned URL for direct upload
|
||||
*/
|
||||
async generatePresignedUploadUrl(
|
||||
objectKey: string,
|
||||
mimeType: string,
|
||||
expiresIn: number = 3600,
|
||||
): Promise<Result<PresignedUrlResult>> {
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: objectKey,
|
||||
ContentType: mimeType,
|
||||
});
|
||||
|
||||
const uploadUrl = await getSignedUrl(this.s3Client, command, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
const result: PresignedUrlResult = {
|
||||
uploadUrl,
|
||||
expiresIn,
|
||||
};
|
||||
|
||||
logger.info(`Generated presigned URL for ${objectKey}`);
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
logger.error("Failed to generate presigned URL:", error);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.STORAGE_ERROR,
|
||||
message: "Failed to generate presigned URL",
|
||||
description: "Could not create upload URL",
|
||||
detail: "S3 presigned URL generation failed",
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file from R2
|
||||
*/
|
||||
async getFile(objectKey: string): Promise<Result<Buffer>> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: objectKey,
|
||||
});
|
||||
|
||||
const response = await this.s3Client.send(command);
|
||||
const body = response.Body;
|
||||
|
||||
if (!body) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.NOT_FOUND,
|
||||
message: "File not found",
|
||||
description: "The requested file does not exist",
|
||||
detail: "S3 response body is empty",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle different response body types
|
||||
if (body instanceof Uint8Array) {
|
||||
return { data: Buffer.from(body) };
|
||||
}
|
||||
|
||||
// For Node.js Readable streams (AWS SDK v3)
|
||||
if (typeof body.transformToByteArray === "function") {
|
||||
const byteArray = await body.transformToByteArray();
|
||||
return { data: Buffer.from(byteArray) };
|
||||
}
|
||||
|
||||
// Fallback: treat as readable stream
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
// Type assertion to handle the stream properly
|
||||
const stream = body as NodeJS.ReadableStream;
|
||||
|
||||
return new Promise<Result<Buffer>>((resolve, reject) => {
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.on("end", () => {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
logger.info(`Successfully retrieved file ${objectKey}`);
|
||||
resolve({ data: buffer });
|
||||
});
|
||||
|
||||
stream.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get file ${objectKey}:`, error);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.STORAGE_ERROR,
|
||||
message: "Failed to get file",
|
||||
description: "Could not retrieve file from storage",
|
||||
detail: "S3 get operation failed",
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete file from R2
|
||||
*/
|
||||
async deleteFile(objectKey: string): Promise<Result<boolean>> {
|
||||
try {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: objectKey,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
logger.info(`Successfully deleted file ${objectKey}`);
|
||||
return { data: true };
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete file ${objectKey}:`, error);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.STORAGE_ERROR,
|
||||
message: "Failed to delete file",
|
||||
description: "Could not delete file from storage",
|
||||
detail: "S3 delete operation failed",
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata from R2
|
||||
*/
|
||||
async getFileMetadata(
|
||||
objectKey: string,
|
||||
): Promise<Result<Record<string, any>>> {
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: objectKey,
|
||||
});
|
||||
|
||||
const response = await this.s3Client.send(command);
|
||||
|
||||
const metadata = {
|
||||
size: response.ContentLength,
|
||||
lastModified: response.LastModified,
|
||||
contentType: response.ContentType,
|
||||
metadata: response.Metadata || {},
|
||||
};
|
||||
|
||||
logger.info(`Successfully retrieved metadata for ${objectKey}`);
|
||||
return { data: metadata };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to get file metadata for ${objectKey}:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.STORAGE_ERROR,
|
||||
message: "Failed to get file metadata",
|
||||
description: "Could not retrieve file information",
|
||||
detail: "S3 head operation failed",
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in R2
|
||||
*/
|
||||
async fileExists(objectKey: string): Promise<Result<boolean>> {
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: objectKey,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
return { data: true };
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.name === "NotFound" ||
|
||||
error.$metadata?.httpStatusCode === 404
|
||||
) {
|
||||
return { data: false };
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`Failed to check file existence for ${objectKey}:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.STORAGE_ERROR,
|
||||
message: "Failed to check file existence",
|
||||
description: "Could not verify if file exists",
|
||||
detail: "S3 head operation failed",
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file within R2
|
||||
*/
|
||||
async copyFile(
|
||||
sourceKey: string,
|
||||
destinationKey: string,
|
||||
): Promise<Result<boolean>> {
|
||||
try {
|
||||
const command = new CopyObjectCommand({
|
||||
Bucket: this.config.bucketName,
|
||||
Key: destinationKey,
|
||||
CopySource: `${this.config.bucketName}/${sourceKey}`,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
logger.info(
|
||||
`Successfully copied file from ${sourceKey} to ${destinationKey}`,
|
||||
);
|
||||
return { data: true };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to copy file from ${sourceKey} to ${destinationKey}:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.STORAGE_ERROR,
|
||||
message: "Failed to copy file",
|
||||
description: "Could not copy file in storage",
|
||||
detail: "S3 copy operation failed",
|
||||
},
|
||||
error,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user