Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ pids
*.pid
*.seed
*.pid.lock

*.md
*.txt
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

Expand Down
115 changes: 115 additions & 0 deletions src/config/s3-secondary.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
type PutObjectCommandInput,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
import { FolderNameEnum } from "@/config/s3.config";

// export enum SecondaryFolderNameEnum {
// FILES = "files",
// VIDEOS = "videos",
// IMAGES = "images",
// DOCUMENTS = "documents",
// RECORDINGS = "recordings"
// }

export class SecondaryS3Service {
private s3Client: S3Client;
private bucketName: string;

constructor() {
this.bucketName = process.env.SECONDARY_S3_BUCKET_NAME!;

this.s3Client = new S3Client({
endpoint: process.env.SECONDARY_S3_ENDPOINT!,
region: process.env.SECONDARY_S3_REGION || 'auto',
credentials: {
accessKeyId: process.env.SECONDARY_S3_TOKEN_ID!,
secretAccessKey: process.env.SECONDARY_S3_SECRET_KEY!,
},
forcePathStyle: true,
requestHandler: {
requestTimeout: 300000, // 5 minutes timeout
} as any,
});
}

public async listFiles(folder?: FolderNameEnum, maxKeys: number = 1000, continuationToken?: string) {
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: folder ? `${folder}/` : undefined,
MaxKeys: maxKeys,
ContinuationToken: continuationToken,
});

const response = await this.s3Client.send(command);
return {
files: response.Contents?.map(obj => ({
key: obj.Key!,
size: obj.Size!,
lastModified: obj.LastModified!,
etag: obj.ETag!,
})) || [],
isTruncated: response.IsTruncated || false,
nextContinuationToken: response.NextContinuationToken,
};
}

public async getFile(key: string): Promise<Buffer> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});

const response = await this.s3Client.send(command);

if (!response.Body) {
throw new Error(`No body returned for key: ${key}`);
}

// Convert stream to buffer
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as any) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}

public async getFileStream(key: string) {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});

const response = await this.s3Client.send(command);

if (!response.Body) {
throw new Error(`No body returned for key: ${key}`);
}

return response.Body;
}

public async getMetadata(key: string) {
const command = new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const response = await this.s3Client.send(command);
return {
contentType: response.ContentType,
contentLength: response.ContentLength,
lastModified: response.LastModified,
metadata: response.Metadata,
etag: response.ETag,
};
}
}

export default new SecondaryS3Service();

56 changes: 52 additions & 4 deletions src/config/s3.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class S3Service {
}

public buildCDNUrl(key: string): string {
return `https://${config.CLOUDFLARE.CDN_DOMAIN}/${key}`;
return `https://fileflow.fsn1.your-objectstorage.com/${key}`;
}

/**
Expand All @@ -78,7 +78,7 @@ export class S3Service {
public async uploadFile(file: IFile): Promise<string> {
// Sanitize original name for HTTP header compatibility
const sanitizedOriginalName = this.sanitizeMetadataValue(file.originalName);

const params: PutObjectCommandInput = {
Bucket: config.S3.BUCKET_NAME,
Key: `${FolderNameEnum.FILES}/${file.filename}`,
Expand Down Expand Up @@ -106,7 +106,7 @@ export class S3Service {
const path = `${folder}/${key}`;
// Sanitize original name for HTTP header compatibility
const sanitizedOriginalName = this.sanitizeMetadataValue(fileName);

const params = {
Bucket: config.S3.BUCKET_NAME,
Key: path,
Expand All @@ -124,6 +124,35 @@ export class S3Service {
return this.buildCDNUrl(path);
}

public async uploadStream(
key: string,
stream: any,
fileName: string,
mimeType: string,
size: number,
folder: FolderNameEnum = FolderNameEnum.FILES
): Promise<string> {
const path = `${folder}/${key}`;
const sanitizedOriginalName = this.sanitizeMetadataValue(fileName);

const params = {
Bucket: config.S3.BUCKET_NAME,
Key: path,
Body: stream,
ContentType: mimeType,
ContentLength: size, // Required for streaming
Metadata: {
originalName: sanitizedOriginalName,
uploadedAt: new Date().toISOString(),
size: size.toString(),
},
CacheControl: 'public, max-age=31536000',
};

await this.s3Client.send(new PutObjectCommand(params));
return this.buildCDNUrl(path);
}

public async getFileUrl(key: string, expiresIn: number = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: config.S3.BUCKET_NAME,
Expand Down Expand Up @@ -182,13 +211,32 @@ export class S3Service {
size: obj.Size!,
lastModified: obj.LastModified!,
etag: obj.ETag!,
cdnUrl: this.buildCDNUrl(obj.Key!)
storageClass: obj.StorageClass,
owner: obj.Owner ? {
displayName: obj.Owner.DisplayName,
id: obj.Owner.ID
} : undefined,
cdnUrl: this.buildCDNUrl(obj.Key!),
// Include all other S3 properties
...(obj as any)
})) || [],
isTruncated: response.IsTruncated || false,
nextContinuationToken: response.NextContinuationToken,
keyCount: response.KeyCount,
maxKeys: response.MaxKeys,
prefix: response.Prefix,
delimiter: response.Delimiter,
encodingType: response.EncodingType,
commonPrefixes: response.CommonPrefixes,
};
}

public async getAllFiles(folder?: string, maxKeys: number = 100, continuationToken?: string) {
// Convert string folder name to FolderNameEnum if provided
const folderEnum = folder ? (folder as FolderNameEnum) : undefined;
return await this.listFiles(folderEnum, maxKeys, continuationToken);
}

public async initiateMultipartUpload(fileName: string, mimeType: string): Promise<{ uploadId: string | undefined; key: string }> {
const safeName = slugify(fileName, {
replacement: "_", // replace invalid chars with underscore
Expand Down
40 changes: 36 additions & 4 deletions src/controllers/upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const uploadFile = async (c: Context) => {
if (!user?.id) {
return res.FailureResponse(c, 400, { message: "User not found" })
}
try {
try {

const formData = await c.req.formData()
const files = formData.getAll("files") as File[]
Expand All @@ -33,7 +33,7 @@ const uploadFile = async (c: Context) => {
data: { results },
related_user_id: user.id,
})

// Track upload analytics for each file
files.forEach((file, index) => {
const result = results[index];
Expand Down Expand Up @@ -177,7 +177,7 @@ const completeUpload = async (c: Context) => {
file_type: metadata.contentType || '',
storage_path: key
}

if (user?.id) {
const fileName = key.split('/').pop() || 'Unknown file'
addToNotificationQueue({
Expand All @@ -195,7 +195,7 @@ const completeUpload = async (c: Context) => {
return res.SuccessResponse(c, 200, {
message: "Upload completed successfully",
data: result
})
})
} catch (error: any) {
if (user?.id) {
const fileName = key.split('/').pop() || 'Unknown file'
Expand Down Expand Up @@ -287,7 +287,39 @@ const getPartsByUploadKey = async (c: Context) => {
}
}

const getAllFiles = async (c: Context) => {
try {
const validatedQuery = c.get('validatedQuery') as {
folder?: string;
maxKeys?: number;
continuationToken?: string
};

const { folder, maxKeys = 100, continuationToken } = validatedQuery || {};

const result = await s3Service.getAllFiles(folder, maxKeys, continuationToken);
return res.SuccessResponse(c, 200, {
message: "All files retrieved successfully",
data: {
files: result.files,
pagination: {
hasMore: result.isTruncated,
nextContinuationToken: result.nextContinuationToken || null,
maxKeys: maxKeys,
currentCount: result.files.length
}
},
})
} catch (error: any) {
return res.FailureResponse(c, 500, {
message: "Failed to get all files",
error: error.message,
})
}
}

export default {
getAllFiles,
getPartsByUploadKey,
initiateUpload,
uploadFile,
Expand Down
2 changes: 2 additions & 0 deletions src/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import redisConn from "@/config/redis.config";
import redisConstants from "@/global/redis-constants";
import { getValidPinSession } from "@/core/session";
import crypto from "crypto";
import { UserRole } from "@/models/User.model";



Expand Down Expand Up @@ -150,6 +151,7 @@ const pinSessionMiddleware: MiddlewareHandler = async (c: Context, next: Next) =
}
};


export default {
authMiddleware,
pinSessionMiddleware,
Expand Down
5 changes: 5 additions & 0 deletions src/routes/upload.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export class UploadRouter {
validateParams(uploadValidation.fileNameValidation),
uploadController.deleteFile
)
this.router.get('/file/get-all-files',
Middleware.authMiddleware,
validateQuery(uploadValidation.getAllFilesValidation),
uploadController.getAllFiles
)
}

public getRouter() {
Expand Down
Loading