485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
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,
|
|
),
|
|
};
|
|
}
|
|
}
|
|
}
|