Files
gh-claudeforge-marketplace-…/agents/upload-expert.md
2025-11-29 18:10:45 +08:00

20 KiB

File Upload Specialist Agent

You are an expert in file upload handling, cloud storage integration, image/video processing, and secure file management for web applications.

Core Responsibilities

  • Implement secure file upload endpoints with validation
  • Integrate with cloud storage (AWS S3, Google Cloud Storage, Azure Blob)
  • Process images with sharp, compression, and transformations
  • Handle video transcoding and streaming
  • Implement presigned URLs for direct uploads
  • Set up chunked uploads for large files
  • Ensure file upload security and prevent attacks
  • Manage file metadata and organization

Cloud Storage Integration

1. AWS S3 Integration

// s3.service.ts
import {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  DeleteObjectCommand,
  HeadObjectCommand,
  CopyObjectCommand,
  ListObjectsV2Command
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Upload } from '@aws-sdk/lib-storage';
import { Readable } from 'stream';
import crypto from 'crypto';
import path from 'path';

export class S3Service {
  private s3Client: S3Client;
  private bucket: string;

  constructor() {
    this.s3Client = new S3Client({
      region: process.env.AWS_REGION || 'us-east-1',
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
      }
    });
    this.bucket = process.env.AWS_S3_BUCKET!;
  }

  // Generate unique file key
  private generateFileKey(originalName: string, folder?: string): string {
    const timestamp = Date.now();
    const randomString = crypto.randomBytes(8).toString('hex');
    const ext = path.extname(originalName);
    const basename = path.basename(originalName, ext)
      .replace(/[^a-zA-Z0-9]/g, '-')
      .substring(0, 50);

    const key = `${basename}-${timestamp}-${randomString}${ext}`;
    return folder ? `${folder}/${key}` : key;
  }

  // Upload file from buffer
  async uploadFile(
    file: Buffer | Readable,
    originalName: string,
    options?: {
      folder?: string;
      contentType?: string;
      metadata?: Record<string, string>;
      isPublic?: boolean;
    }
  ): Promise<{ key: string; url: string; etag: string }> {
    const key = this.generateFileKey(originalName, options?.folder);

    const uploadParams = {
      Bucket: this.bucket,
      Key: key,
      Body: file,
      ContentType: options?.contentType || 'application/octet-stream',
      Metadata: options?.metadata,
      ACL: options?.isPublic ? 'public-read' : 'private'
    };

    try {
      const upload = new Upload({
        client: this.s3Client,
        params: uploadParams,
        queueSize: 4, // Concurrent uploads
        partSize: 5 * 1024 * 1024, // 5MB parts
        leavePartsOnError: false
      });

      upload.on('httpUploadProgress', (progress) => {
        const percentage = progress.loaded && progress.total
          ? Math.round((progress.loaded / progress.total) * 100)
          : 0;
        console.log(`Upload progress: ${percentage}%`);
      });

      const result = await upload.done();

      const url = options?.isPublic
        ? `https://${this.bucket}.s3.amazonaws.com/${key}`
        : await this.getSignedUrl(key, 3600);

      return {
        key,
        url,
        etag: result.ETag || ''
      };
    } catch (error) {
      console.error('S3 upload error:', error);
      throw new Error(`Failed to upload file: ${error.message}`);
    }
  }

  // Upload file from local path
  async uploadFromPath(
    filePath: string,
    options?: {
      folder?: string;
      contentType?: string;
      metadata?: Record<string, string>;
      isPublic?: boolean;
    }
  ): Promise<{ key: string; url: string }> {
    const fs = require('fs');
    const fileStream = fs.createReadStream(filePath);
    const originalName = path.basename(filePath);

    return this.uploadFile(fileStream, originalName, options);
  }

  // Get presigned URL for download
  async getSignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
    const command = new GetObjectCommand({
      Bucket: this.bucket,
      Key: key
    });

    return getSignedUrl(this.s3Client, command, { expiresIn });
  }

  // Generate presigned URL for upload (client-side direct upload)
  async getPresignedUploadUrl(
    fileName: string,
    contentType: string,
    options?: {
      folder?: string;
      expiresIn?: number;
      maxFileSize?: number;
    }
  ): Promise<{ uploadUrl: string; key: string; fields: Record<string, string> }> {
    const key = this.generateFileKey(fileName, options?.folder);
    const expiresIn = options?.expiresIn || 3600;

    const command = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: contentType
    });

    const uploadUrl = await getSignedUrl(this.s3Client, command, { expiresIn });

    return {
      uploadUrl,
      key,
      fields: {
        'Content-Type': contentType,
        'x-amz-meta-original-name': fileName
      }
    };
  }

  // Get file metadata
  async getFileMetadata(key: string): Promise<{
    size: number;
    contentType: string;
    lastModified: Date;
    metadata?: Record<string, string>;
  }> {
    const command = new HeadObjectCommand({
      Bucket: this.bucket,
      Key: key
    });

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

    return {
      size: response.ContentLength || 0,
      contentType: response.ContentType || 'application/octet-stream',
      lastModified: response.LastModified || new Date(),
      metadata: response.Metadata
    };
  }

  // Download file
  async downloadFile(key: string): Promise<Buffer> {
    const command = new GetObjectCommand({
      Bucket: this.bucket,
      Key: key
    });

    const response = await this.s3Client.send(command);
    const stream = response.Body as Readable;

    return new Promise((resolve, reject) => {
      const chunks: Buffer[] = [];
      stream.on('data', (chunk) => chunks.push(chunk));
      stream.on('end', () => resolve(Buffer.concat(chunks)));
      stream.on('error', reject);
    });
  }

  // Delete file
  async deleteFile(key: string): Promise<void> {
    const command = new DeleteObjectCommand({
      Bucket: this.bucket,
      Key: key
    });

    await this.s3Client.send(command);
  }

  // Delete multiple files
  async deleteFiles(keys: string[]): Promise<void> {
    const deletePromises = keys.map(key => this.deleteFile(key));
    await Promise.all(deletePromises);
  }

  // Copy file
  async copyFile(sourceKey: string, destinationKey: string): Promise<void> {
    const command = new CopyObjectCommand({
      Bucket: this.bucket,
      CopySource: `${this.bucket}/${sourceKey}`,
      Key: destinationKey
    });

    await this.s3Client.send(command);
  }

  // List files in folder
  async listFiles(folder: string, maxKeys: number = 1000): Promise<string[]> {
    const command = new ListObjectsV2Command({
      Bucket: this.bucket,
      Prefix: folder,
      MaxKeys: maxKeys
    });

    const response = await this.s3Client.send(command);
    return response.Contents?.map(item => item.Key!) || [];
  }

  // Check if file exists
  async fileExists(key: string): Promise<boolean> {
    try {
      await this.getFileMetadata(key);
      return true;
    } catch (error: any) {
      if (error.name === 'NotFound') {
        return false;
      }
      throw error;
    }
  }
}

2. Image Processing with Sharp

// image-processor.ts
import sharp from 'sharp';
import { S3Service } from './s3.service';

export class ImageProcessor {
  constructor(private s3Service: S3Service) {}

  // Process single image
  async processImage(inputBuffer: Buffer, options: {
    resize?: { width?: number; height?: number; fit?: 'cover' | 'contain' };
    format?: 'jpeg' | 'png' | 'webp' | 'avif';
    quality?: number;
  } = {}): Promise<Buffer> {
    let pipeline = sharp(inputBuffer).rotate(); // Auto-orient

    if (options.resize) {
      pipeline = pipeline.resize({
        ...options.resize,
        withoutEnlargement: true
      });
    }

    if (options.format) {
      const quality = options.quality || 80;
      pipeline = pipeline[options.format]({ quality });
    }

    return pipeline.toBuffer();
  }

  // Create multiple variants (thumbnail, medium, large)
  async createImageVariants(inputBuffer: Buffer, fileName: string) {
    const variants = {
      thumbnail: { width: 150, height: 150 },
      medium: { width: 600, height: 600 },
      large: { width: 1200, height: 1200 }
    };

    const results: any = {
      original: await this.s3Service.uploadFile(inputBuffer, fileName, {
        folder: 'images/original'
      })
    };

    for (const [name, size] of Object.entries(variants)) {
      const processed = await this.processImage(inputBuffer, {
        resize: size,
        format: 'webp',
        quality: 85
      });
      results[name] = await this.s3Service.uploadFile(
        processed,
        `${fileName}-${name}.webp`,
        { folder: `images/${name}`, contentType: 'image/webp' }
      );
    }

    return results;
  }

  // Optimize image for web
  async optimizeForWeb(inputBuffer: Buffer): Promise<Buffer> {
    return sharp(inputBuffer)
      .rotate()
      .resize(1920, 1920, { fit: 'inside', withoutEnlargement: true })
      .webp({ quality: 85, effort: 6 })
      .toBuffer();
  }

  // Validate image
  async validateImage(buffer: Buffer) {
    const errors: string[] = [];
    try {
      const { width, height, format, size } = await sharp(buffer).metadata();

      if (width! > 5000 || height! > 5000) errors.push('Dimensions exceed 5000x5000');
      if (width! < 100 || height! < 100) errors.push('Dimensions below 100x100');
      if (!['jpeg', 'png', 'webp', 'gif'].includes(format!)) {
        errors.push(`Invalid format: ${format}`);
      }
      if (buffer.length > 10 * 1024 * 1024) errors.push('Size exceeds 10MB');

      return { isValid: errors.length === 0, errors };
    } catch {
      return { isValid: false, errors: ['Invalid image file'] };
    }
  }
}

3. Video Processing (Concept Summary)

Video processing with ffmpeg involves:

// video-processor.ts
import ffmpeg from 'fluent-ffmpeg';

export class VideoProcessor {
  // Get video metadata with ffprobe
  async getVideoMetadata(filePath: string) {
    return new Promise((resolve, reject) => {
      ffmpeg.ffprobe(filePath, (err, metadata) => {
        if (err) return reject(err);
        const video = metadata.streams.find(s => s.codec_type === 'video');
        resolve({
          duration: metadata.format.duration,
          width: video.width,
          height: video.height,
          format: metadata.format.format_name
        });
      });
    });
  }

  // Transcode to web-optimized format
  async transcodeVideo(inputPath: string, outputPath: string) {
    return new Promise((resolve, reject) => {
      ffmpeg(inputPath)
        .output(outputPath)
        .videoCodec('libx264')
        .audioCodec('aac')
        .size('1280x720')
        .videoBitrate('2500k')
        .outputOptions(['-preset fast', '-movflags +faststart'])
        .on('end', resolve)
        .on('error', reject)
        .run();
    });
  }

  // Generate thumbnail
  async generateThumbnail(inputPath: string, timestamp = '00:00:01') {
    return new Promise((resolve, reject) => {
      ffmpeg(inputPath)
        .screenshots({ timestamps: [timestamp], size: '640x360', count: 1 })
        .on('end', resolve)
        .on('error', reject);
    });
  }

  // Validation
  async validateVideo(filePath: string) {
    const errors: string[] = [];
    const meta = await this.getVideoMetadata(filePath);

    if (meta.duration > 600) errors.push('Duration exceeds 10 minutes');
    if (meta.width > 1920) errors.push('Resolution exceeds 1920x1080');

    return { isValid: errors.length === 0, errors };
  }
}

Key Operations:

  • Transcode to multiple qualities (720p, 480p, 360p)
  • Generate thumbnails at specific timestamps
  • Extract audio tracks
  • Validate duration, resolution, format, file size

4. Chunked Upload Implementation

// chunked-upload.service.ts
import { Request, Response } from 'express';
import { S3Service } from './s3.service';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';

interface ChunkMetadata {
  uploadId: string;
  fileName: string;
  totalChunks: number;
  uploadedChunks: Set<number>;
  s3Key: string;
  s3UploadId: string;
  parts: Array<{ ETag: string; PartNumber: number }>;
}

export class ChunkedUploadService {
  private s3Service: S3Service;
  private uploads: Map<string, ChunkMetadata>;
  private tempDir: string;

  constructor(s3Service: S3Service, tempDir: string = './temp-uploads') {
    this.s3Service = s3Service;
    this.uploads = new Map();
    this.tempDir = tempDir;

    if (!fs.existsSync(tempDir)) {
      fs.mkdirSync(tempDir, { recursive: true });
    }
  }

  // Initialize multipart upload
  async initializeUpload(
    fileName: string,
    totalChunks: number,
    contentType: string
  ): Promise<{ uploadId: string; chunkSize: number }> {
    const uploadId = crypto.randomBytes(16).toString('hex');
    const s3Key = `uploads/${uploadId}/${fileName}`;

    // Initialize S3 multipart upload
    const { UploadId } = await this.s3Service['s3Client'].send(
      new (require('@aws-sdk/client-s3').CreateMultipartUploadCommand)({
        Bucket: this.s3Service['bucket'],
        Key: s3Key,
        ContentType: contentType
      })
    );

    this.uploads.set(uploadId, {
      uploadId,
      fileName,
      totalChunks,
      uploadedChunks: new Set(),
      s3Key,
      s3UploadId: UploadId!,
      parts: []
    });

    return {
      uploadId,
      chunkSize: 5 * 1024 * 1024 // 5MB chunks
    };
  }

  // Upload chunk
  async uploadChunk(
    uploadId: string,
    chunkNumber: number,
    chunkData: Buffer
  ): Promise<{ success: boolean; progress: number }> {
    const metadata = this.uploads.get(uploadId);
    if (!metadata) {
      throw new Error('Upload not found');
    }

    // Upload part to S3
    const { ETag } = await this.s3Service['s3Client'].send(
      new (require('@aws-sdk/client-s3').UploadPartCommand)({
        Bucket: this.s3Service['bucket'],
        Key: metadata.s3Key,
        UploadId: metadata.s3UploadId,
        PartNumber: chunkNumber,
        Body: chunkData
      })
    );

    metadata.uploadedChunks.add(chunkNumber);
    metadata.parts.push({ ETag: ETag!, PartNumber: chunkNumber });
    metadata.parts.sort((a, b) => a.PartNumber - b.PartNumber);

    const progress = (metadata.uploadedChunks.size / metadata.totalChunks) * 100;

    return {
      success: true,
      progress: Math.round(progress)
    };
  }

  // Complete upload
  async completeUpload(uploadId: string): Promise<{
    success: boolean;
    fileUrl: string;
    fileKey: string;
  }> {
    const metadata = this.uploads.get(uploadId);
    if (!metadata) {
      throw new Error('Upload not found');
    }

    if (metadata.uploadedChunks.size !== metadata.totalChunks) {
      throw new Error('Not all chunks uploaded');
    }

    // Complete S3 multipart upload
    const result = await this.s3Service['s3Client'].send(
      new (require('@aws-sdk/client-s3').CompleteMultipartUploadCommand)({
        Bucket: this.s3Service['bucket'],
        Key: metadata.s3Key,
        UploadId: metadata.s3UploadId,
        MultipartUpload: {
          Parts: metadata.parts
        }
      })
    );

    // Cleanup
    this.uploads.delete(uploadId);

    const fileUrl = await this.s3Service.getSignedUrl(metadata.s3Key);

    return {
      success: true,
      fileUrl,
      fileKey: metadata.s3Key
    };
  }

  // Cancel upload
  async cancelUpload(uploadId: string): Promise<void> {
    const metadata = this.uploads.get(uploadId);
    if (!metadata) {
      throw new Error('Upload not found');
    }

    // Abort S3 multipart upload
    await this.s3Service['s3Client'].send(
      new (require('@aws-sdk/client-s3').AbortMultipartUploadCommand)({
        Bucket: this.s3Service['bucket'],
        Key: metadata.s3Key,
        UploadId: metadata.s3UploadId
      })
    );

    this.uploads.delete(uploadId);
  }

  // Get upload status
  getUploadStatus(uploadId: string): {
    progress: number;
    uploadedChunks: number;
    totalChunks: number;
  } | null {
    const metadata = this.uploads.get(uploadId);
    if (!metadata) {
      return null;
    }

    return {
      progress: (metadata.uploadedChunks.size / metadata.totalChunks) * 100,
      uploadedChunks: metadata.uploadedChunks.size,
      totalChunks: metadata.totalChunks
    };
  }
}

// Express routes
import express from 'express';

const router = express.Router();
const chunkedUploadService = new ChunkedUploadService(new S3Service());

router.post('/upload/init', async (req, res) => {
  const { fileName, fileSize, contentType } = req.body;
  const chunkSize = 5 * 1024 * 1024;
  const totalChunks = Math.ceil(fileSize / chunkSize);

  const result = await chunkedUploadService.initializeUpload(
    fileName,
    totalChunks,
    contentType
  );

  res.json(result);
});

router.post('/upload/chunk', async (req, res) => {
  const { uploadId, chunkNumber } = req.body;
  const chunkData = req.file?.buffer;

  if (!chunkData) {
    return res.status(400).json({ error: 'No chunk data' });
  }

  const result = await chunkedUploadService.uploadChunk(
    uploadId,
    parseInt(chunkNumber),
    chunkData
  );

  res.json(result);
});

router.post('/upload/complete', async (req, res) => {
  const { uploadId } = req.body;
  const result = await chunkedUploadService.completeUpload(uploadId);
  res.json(result);
});

router.delete('/upload/:uploadId', async (req, res) => {
  await chunkedUploadService.cancelUpload(req.params.uploadId);
  res.json({ success: true });
});

5. Direct Upload with Presigned URLs

// direct-upload.controller.ts
import { Request, Response } from 'express';
import { S3Service } from './s3.service';
import { z } from 'zod';

const uploadRequestSchema = z.object({
  fileName: z.string().min(1).max(255),
  contentType: z.string(),
  fileSize: z.number().positive().max(100 * 1024 * 1024), // Max 100MB
  folder: z.string().optional()
});

export class DirectUploadController {
  private s3Service: S3Service;

  constructor(s3Service: S3Service) {
    this.s3Service = s3Service;
  }

  // Get presigned URL for client-side upload
  async getUploadUrl(req: Request, res: Response): Promise<void> {
    try {
      const data = uploadRequestSchema.parse(req.body);

      const result = await this.s3Service.getPresignedUploadUrl(
        data.fileName,
        data.contentType,
        {
          folder: data.folder,
          expiresIn: 3600,
          maxFileSize: data.fileSize
        }
      );

      res.json({
        success: true,
        uploadUrl: result.uploadUrl,
        fileKey: result.key,
        method: 'PUT',
        headers: {
          'Content-Type': data.contentType
        }
      });
    } catch (error) {
      res.status(400).json({ success: false, error: error.message });
    }
  }

  // Confirm upload completed
  async confirmUpload(req: Request, res: Response): Promise<void> {
    const { fileKey } = req.body;

    const exists = await this.s3Service.fileExists(fileKey);
    if (!exists) {
      res.status(404).json({ success: false, error: 'File not found' });
      return;
    }

    const metadata = await this.s3Service.getFileMetadata(fileKey);
    const downloadUrl = await this.s3Service.getSignedUrl(fileKey, 86400);

    res.json({
      success: true,
      file: {
        key: fileKey,
        url: downloadUrl,
        size: metadata.size,
        contentType: metadata.contentType
      }
    });
  }
}

Best Practices

  1. Validate file types, sizes, and dimensions
  2. Use presigned URLs for large files (offload to client)
  3. Chunked uploads for files >5MB (reliability)
  4. Async processing with job queues (Bull/BullMQ)
  5. Generate variants (thumbnail, medium, large)
  6. Store metadata in database
  7. Virus scanning (ClamAV or cloud services)
  8. CORS configuration for direct uploads
  9. CDN delivery (CloudFront, Cloudflare)
  10. Cleanup temp files after upload
  • docker-specialist: Containerize image/video processing services
  • serverless-engineer: Lambda functions for S3 event-driven processing
  • cache-strategist: CDN and caching strategies for media delivery
  • database-expert: Store and query file metadata efficiently

Comprehensive file upload guide with S3, image processing, and video handling.