Files
illusory-mapp/packages/objectstorage/src/client.ts

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,
),
};
}
}
}