Skip to main content
Sonamu makes file uploads easy to handle with the @upload decorator and UploadedFile class.

File Upload Flow

@upload Decorator

Used when creating file upload APIs.

Single File Upload

import { BaseModel, api, upload, Sonamu } from "sonamu";

class UserModelClass extends BaseModel {
  @upload()
  async uploadAvatar(ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // Use first file

    if (!file) {
      throw new Error('No file provided');
    }

    // Save file
    const url = await file.saveToDisk(`avatars/${ctx.user.id}.png`);

    return { url };
  }
}
Client request:
const formData = new FormData();
formData.append('file', file);

await fetch('/api/user/uploadAvatar', {
  method: 'POST',
  body: formData,
});

Multiple File Upload

class PostModelClass extends BaseModel {
  @upload()
  async uploadImages(postId: number) {
    const { files } = Sonamu.getContext();

    if (files.length === 0) {
      throw new Error('No files provided');
    }

    // Save all files
    const urls = await Promise.all(
      files.map(async (file, index) => {
        const key = `posts/${postId}/image-${index}.${file.extname}`;
        return await file.saveToDisk(key);
      })
    );

    return { urls };
  }
}
Client request:
const formData = new FormData();
formData.append('file', file1);
formData.append('file', file2);
formData.append('file', file3);

await fetch('/api/post/uploadImages?postId=1', {
  method: 'POST',
  body: formData,
});

Accessing Files

Access uploaded files through the files property of Sonamu.getContext().
// Access uploaded files from Context
const { files } = Sonamu.getContext();

// files is UploadedFile[] (optional array)
type Context = {
  // ...
  files?: UploadedFile[];
};
Single file access: Use the first element of the array
const file = files?.[0];  // First file
Multiple file access: Iterate through the array
for (const file of files) {
  // Process each file
}

Usage Example

@upload()
async upload() {
  const { files } = Sonamu.getContext();
  const file = files?.[0]; // Use first file

  if (!file) {
    throw new Error('File is required');
  }

  // file is an UploadedFile instance
  console.log(file.filename);   // Original filename
  console.log(file.mimetype);   // MIME type
  console.log(file.size);       // File size

  return await file.saveToDisk('uploads/file.pdf');
}

UploadedFile Class

A class that wraps uploaded files.

Properties

Original filename
const { files } = Sonamu.getContext();
const file = files?.[0]; // Use first file
console.log(file.filename);  // 'avatar.png'

Methods

toBuffer()

Converts the file to a Buffer.
const { files } = Sonamu.getContext();
const file = files?.[0]; // Use first file
const buffer = await file.toBuffer();

console.log(buffer instanceof Buffer);  // true
console.log(buffer.length);  // File size (bytes)
Caching: Cached after first call (fast on subsequent calls)

md5()

Calculates the MD5 hash of the file.
const { files } = Sonamu.getContext();
const file = files?.[0]; // Use first file
const hash = await file.md5();

console.log(hash);  // '5d41402abc4b2a76b9719d911017c592'
Use cases:
  • File deduplication check
  • File integrity verification
  • Unique filename generation

saveToDisk(key, diskName?)

Saves the file to disk.
const { files } = Sonamu.getContext();
const file = files?.[0]; // Use first file

// Save to default disk
const url = await file.saveToDisk('uploads/file.pdf');

// Save to specific disk
const url = await file.saveToDisk('uploads/file.pdf', 's3');
Return value: URL of the saved file (unsigned)

Practical Examples

1. Profile Photo Upload

class UserModelClass extends BaseModel {
  @upload()
  @api({ httpMethod: 'POST' })
  async uploadAvatar(ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // Use first file

    if (!file) {
      throw new Error('No file provided');
    }

    // Allow only image files
    if (!file.mimetype.startsWith('image/')) {
      throw new Error('Only image files can be uploaded');
    }

    // File size limit (5MB)
    const maxSize = 5 * 1024 * 1024;  // 5MB
    if (file.size > maxSize) {
      throw new Error('File size must be 5MB or less');
    }

    // Filename: user-{id}-{timestamp}.{ext}
    const timestamp = Date.now();
    const ext = file.extname || 'png';
    const key = `avatars/user-${ctx.user.id}-${timestamp}.${ext}`;

    // Save
    const url = await file.saveToDisk(key);

    // Update DB
    await this.updateOne(['id', ctx.user.id], {
      avatar_url: url,
      updated_at: new Date(),
    });

    return { url };
  }
}

2. Multiple Image Upload

class PostModelClass extends BaseModel {
  @upload()
  @api({ httpMethod: 'POST' })
  async uploadImages(postId: number, ctx: Context) {
    const { files } = Sonamu.getContext();

    // Limit number of files
    if (files.length > 10) {
      throw new Error('Maximum 10 files can be uploaded');
    }

    // Filter images only
    const imageFiles = files.filter(file =>
      file.mimetype.startsWith('image/')
    );

    if (imageFiles.length === 0) {
      throw new Error('No image files found');
    }

    // Save all images
    const urls = await Promise.all(
      imageFiles.map(async (file, index) => {
        const timestamp = Date.now();
        const ext = file.extname || 'png';
        const key = `posts/${postId}/image-${index}-${timestamp}.${ext}`;
        return await file.saveToDisk(key);
      })
    );

    return { urls, count: urls.length };
  }
}

3. File Type Validation

class FileModelClass extends BaseModel {
  @upload()
  @api({ httpMethod: 'POST' })
  async uploadDocument(type: 'pdf' | 'excel' | 'word') {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // Use first file

    if (!file) {
      throw new Error('No file provided');
    }

    // MIME type validation
    const allowedMimeTypes = {
      pdf: ['application/pdf'],
      excel: [
        'application/vnd.ms-excel',
        'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
      ],
      word: [
        'application/msword',
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
      ],
    };

    if (!allowedMimeTypes[type].includes(file.mimetype)) {
      throw new Error(`Only ${type} files can be uploaded`);
    }

    // Save file
    const key = `documents/${type}/${Date.now()}-${file.filename}`;
    const url = await file.saveToDisk(key);

    return { url, type, filename: file.filename };
  }
}

4. Deduplication Check with MD5 Hash

class MediaModelClass extends BaseModel {
  @upload()
  @api({ httpMethod: 'POST' })
  async uploadMedia() {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // Use first file

    if (!file) {
      throw new Error('No file provided');
    }

    // Calculate MD5 hash
    const hash = await file.md5();

    // Check if file already exists
    const existing = await this.findOne(['md5_hash', hash]);

    if (existing) {
      // Return existing URL if already exists
      return {
        url: existing.url,
        duplicate: true,
      };
    }

    // Save new file
    const key = `media/${hash}.${file.extname}`;
    const url = await file.saveToDisk(key);

    // Save to DB
    await this.saveOne({
      md5_hash: hash,
      url,
      filename: file.filename,
      size: file.size,
      mimetype: file.mimetype,
    });

    return { url, duplicate: false };
  }
}

5. Disk Selection

class FileModelClass extends BaseModel {
  @upload()
  @api({ httpMethod: 'POST' })
  async upload(visibility: 'public' | 'private', ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // Use first file

    if (!file) {
      throw new Error('No file provided');
    }

    // Use different disk based on visibility
    const disk = visibility === 'public' ? 'public' : 'private';
    const key = `${visibility}/${ctx.user.id}/${Date.now()}-${file.filename}`;

    // Save
    const url = await file.saveToDisk(key, disk);

    // Save to DB
    await this.saveOne({
      user_id: ctx.user.id,
      url,
      visibility,
      filename: file.filename,
    });

    return { url, visibility };
  }
}

File Retrieval

Use Storage Manager to retrieve saved files.

Reading Files

import { Sonamu } from "sonamu";

@api({ httpMethod: 'GET' })
async getFile(key: string) {
  const disk = Sonamu.storage.use();

  // Check file existence
  const exists = await disk.exists(key);
  if (!exists) {
    throw new Error('File not found');
  }

  // Read file
  const content = await disk.get(key);

  return content;  // Buffer
}

File Download API

@api({
  httpMethod: 'GET',
  contentType: 'application/octet-stream',
})
async downloadFile(key: string, ctx: Context) {
  const disk = Sonamu.storage.use();

  // Check file existence
  const exists = await disk.exists(key);
  if (!exists) {
    throw new Error('File not found');
  }

  // Read file
  const buffer = await disk.get(key);

  // Extract filename
  const filename = key.split('/').pop() || 'download';

  // Set download headers
  ctx.reply.header('Content-Disposition', `attachment; filename="${filename}"`);

  return buffer;
}

Getting URL

@api({ httpMethod: 'GET' })
async getFileUrl(key: string) {
  const disk = Sonamu.storage.use();

  // Check file existence
  const exists = await disk.exists(key);
  if (!exists) {
    throw new Error('File not found');
  }

  // Get URL
  const url = await disk.getUrl(key);

  return { url };
}

Signed URL (S3)

@api({ httpMethod: 'GET' })
async getSignedUrl(key: string) {
  const disk = Sonamu.storage.use('s3');

  // Generate Signed URL (valid for 1 hour)
  const signedUrl = await disk.getSignedUrl(key, {
    expiresIn: '1h'
  });

  return { url: signedUrl };
}

File Deletion

@api({ httpMethod: 'DELETE' })
async deleteFile(key: string) {
  const disk = Sonamu.storage.use();

  // Check file existence
  const exists = await disk.exists(key);
  if (!exists) {
    throw new Error('File not found');
  }

  // Delete file
  await disk.delete(key);

  return { success: true };
}

Cautions

Cautions when uploading files:
  1. File validation is required: Validate type, size, and extension
    // βœ… Validation
    if (!file.mimetype.startsWith('image/')) {
      throw new Error('Images only');
    }
    if (file.size > 5 * 1024 * 1024) {
      throw new Error('5MB or less');
    }
    
  2. Use unique filenames: Prevent overwrites
    // ❌ Using original filename
    await file.saveToDisk(`uploads/${file.filename}`);
    
    // βœ… Add timestamp
    await file.saveToDisk(`uploads/${Date.now()}-${file.filename}`);
    
    // βœ… Use UUID
    await file.saveToDisk(`uploads/${uuid()}.${file.extname}`);
    
  3. Path validation: Prevent directory traversal
    // ❌ Dangerous: Can access parent directories
    const key = userInput;  // '../../../etc/passwd'
    
    // βœ… Safe: Path validation
    const key = `uploads/${userInput.replace(/\.\./g, '')}`;
    
  4. File size limit: Prevent memory overflow
    const maxSize = 10 * 1024 * 1024;  // 10MB
    if (file.size > maxSize) {
      throw new Error('File is too large');
    }
    
  5. MIME type validation: Don’t trust only the extension
    // ❌ Only checking extension
    if (file.filename.endsWith('.png')) { ... }
    
    // βœ… Check MIME type
    if (file.mimetype === 'image/png') { ... }
    

Next Steps