266 lines
6.9 KiB
TypeScript

import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs/promises';
import path from 'path';
export interface S3Config {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
region: string;
useSSL?: boolean;
}
export interface UploadResult {
key: string;
url: string;
size: number;
contentType: string;
}
export interface S3FileInfo {
key: string;
size: number;
lastModified: Date;
contentType?: string;
}
export class S3Service {
private client: S3Client;
private bucketName: string;
constructor(config: S3Config) {
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: true, // Required for MinIO
});
this.bucketName = config.bucketName;
}
/**
* Load S3 configuration from s3-config.json file
*/
static async loadConfig(): Promise<S3Config> {
try {
const configPath = path.join(process.cwd(), 's3-config.json');
const configData = await fs.readFile(configPath, 'utf-8');
return JSON.parse(configData);
} catch (error) {
console.warn('Failed to load s3-config.json, using environment variables as fallback');
return {
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
accessKeyId: process.env.S3_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || 'minioadmin',
bucketName: process.env.S3_BUCKET_NAME || 'music-files',
region: process.env.S3_REGION || 'us-east-1',
useSSL: process.env.S3_USE_SSL !== 'false',
};
}
}
/**
* Create S3Service instance with configuration from file
*/
static async createFromConfig(): Promise<S3Service> {
const config = await this.loadConfig();
return new S3Service(config);
}
/**
* Upload a file to S3
*/
async uploadFile(
file: Buffer,
originalName: string,
contentType: string,
targetFolder?: string
): Promise<UploadResult> {
const fileExtension = originalName.split('.').pop();
const cleaned = typeof targetFolder === 'string' ? targetFolder.replace(/^\/+|\/+$/g, '') : '';
const safeFolder = cleaned;
const key = safeFolder
? `${safeFolder}/${uuidv4()}.${fileExtension}`
: `${uuidv4()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: file,
ContentType: contentType,
Metadata: {
originalName,
uploadedAt: new Date().toISOString(),
},
});
await this.client.send(command);
return {
key,
url: `${this.bucketName}/${key}`,
size: file.length,
contentType,
};
}
/**
* Recursively list all files in the S3 bucket
*/
async listAllFiles(prefix: string = ''): Promise<S3FileInfo[]> {
const files: S3FileInfo[] = [];
let continuationToken: string | undefined;
do {
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: prefix,
ContinuationToken: continuationToken,
});
const response = await this.client.send(command);
if (response.Contents) {
for (const object of response.Contents) {
if (object.Key && !object.Key.endsWith('/')) { // Skip directories
files.push({
key: object.Key,
size: object.Size || 0,
lastModified: object.LastModified || new Date(),
});
}
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
return files;
}
/**
* List all folders (prefixes) in the bucket. Recursively collects nested prefixes.
*/
async listAllFolders(prefix: string = ''): Promise<string[]> {
const folders = new Set<string>();
const queue: string[] = [prefix];
while (queue.length > 0) {
const currentPrefix = queue.shift() || '';
let continuationToken: string | undefined;
do {
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: currentPrefix,
Delimiter: '/',
ContinuationToken: continuationToken,
});
const response = await this.client.send(command);
const common = (response.CommonPrefixes || []).map(cp => cp.Prefix).filter(Boolean) as string[];
for (const p of common) {
// Normalize: strip trailing slash
const normalized = p.replace(/\/+$/, '');
if (!folders.has(normalized)) {
folders.add(normalized);
// Continue deeper
queue.push(p);
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
}
return Array.from(folders).sort();
}
/**
* Generate a presigned URL for secure file access
*/
async getPresignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
return await getSignedUrl(this.client, command, { expiresIn });
}
/**
* Delete a file from S3
*/
async deleteFile(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
});
await this.client.send(command);
}
/**
* Check if a file exists
*/
async fileExists(key: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
});
await this.client.send(command);
return true;
} catch (error) {
return false;
}
}
/**
* Get file metadata
*/
async getFileMetadata(key: string) {
const command = new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
});
return await this.client.send(command);
}
/**
* Get file content as buffer
*/
async getFileContent(key: string): Promise<Buffer> {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const response = await this.client.send(command);
if (!response.Body) {
throw new Error('File has no content');
}
// Convert stream to buffer
const chunks: Uint8Array[] = [];
const stream = response.Body as any;
return new Promise((resolve, reject) => {
stream.on('data', (chunk: Uint8Array) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
/**
* Get streaming URL for a file
*/
async getStreamingUrl(key: string): Promise<string> {
return `${process.env.S3_ENDPOINT}/${this.bucketName}/${key}`;
}
}