DEV Community

A0mineTV
A0mineTV

Posted on

Building a Secure File Upload API with Nuxt 3: Complete Implementation Guide

File uploads are a common requirement in modern web applications, but implementing them securely can be tricky. In this guide, we'll build a robust file upload API using Nuxt 3's server routes that handles security, validation, and file management properly.

Why Security Matters in File Uploads

File uploads are one of the most common attack vectors for web applications. Without proper validation and sanitization, attackers can:

  • Upload malicious files (executables, scripts)
  • Overwrite existing files
  • Consume server storage space
  • Execute arbitrary code

Our implementation addresses these concerns with multiple layers of security.

The Complete Upload API Implementation

Let's build a secure file upload endpoint at /server/api/upload.post.ts:

import { extname } from 'node:path'
import { mkdir, writeFile } from 'node:fs/promises'

const UPLOAD_DIR = 'public/uploads'
const MAX_BYTES = 5 * 1024 * 1024 // 5MB limit
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif'])

function sanitizeBase(name: string) {
  // Remove extension and dangerous characters
  return name.toLowerCase()
    .replace(/\.[^/.]+$/, '')
    .replace(/[^a-z0-9-_]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

function pickExt(filename: string, mime?: string) {
  const fromName = extname(filename || '').toLowerCase()
  if (fromName) return fromName

  // Fallback to MIME type
  if (mime === "image/jpeg") return ".jpg"
  if (mime === "image/png") return ".png"
  if (mime === "image/webp") return ".webp"
  if (mime === "image/avif") return ".avif"

  return ".bin"
}

export default defineEventHandler(async (event) => {
  const parts = await readMultipartFormData(event)

  if (!parts?.length) {
    throw createError({ statusCode: 400, statusMessage: 'No file received' })
  }

  // Ensure upload directory exists
  await mkdir(UPLOAD_DIR, { recursive: true })

  const files = parts.filter(p => p.filename && p.data)
  if (files.length === 0) {
    throw createError({ statusCode: 400, statusMessage: 'Missing files' })
  }

  const results: Array<{ url: string, bytes: number, type?: string, name: string }> = []

  for (const p of files) {
    const { filename = "file", type, data } = p
    const bytes = data.length

    // MIME type validation
    if (type && !ALLOWED.has(type)) {
      throw createError({ statusCode: 400, statusMessage: 'Unsupported file type' })
    }

    // File size validation
    if (bytes > MAX_BYTES) {
      throw createError({ statusCode: 413, statusMessage: `File too large (> ${MAX_BYTES} bytes)` })
    }

    // Generate safe filename
    const base = sanitizeBase(filename) || 'upload'
    const ext = pickExt(filename, type)
    const id = crypto.randomUUID()
    const safeName = `${base}-${id}${ext}`

    // Write file to disk
    await writeFile(`${UPLOAD_DIR}/${safeName}`, data)

    results.push({
      name: filename,
      type,
      bytes,
      url: `/uploads/${safeName}`
    })
  }

  return results
})
Enter fullscreen mode Exit fullscreen mode

Key Security Features Explained

1. File Type Validation

const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif'])

if (type && !ALLOWED.has(type)) {
  throw createError({ statusCode: 400, statusMessage: 'Unsupported file type' })
}
Enter fullscreen mode Exit fullscreen mode

We whitelist only safe image formats, preventing upload of executables or scripts.

2. File Size Limits

const MAX_BYTES = 5 * 1024 * 1024 // 5MB

if (bytes > MAX_BYTES) {
  throw createError({ statusCode: 413, statusMessage: `File too large (> ${MAX_BYTES} bytes)` })
}
Enter fullscreen mode Exit fullscreen mode

Prevents large files from consuming server storage and bandwidth.

3. Filename Sanitization

function sanitizeBase(name: string) {
  return name.toLowerCase()
    .replace(/\.[^/.]+$/, '')        // Remove extension
    .replace(/[^a-z0-9-_]+/g, '-')   // Replace unsafe chars
    .replace(/^-+|-+$/g, '')         // Trim leading/trailing dashes
}
Enter fullscreen mode Exit fullscreen mode

This prevents directory traversal attacks and ensures safe filenames.

4. Unique File Names

const id = crypto.randomUUID()
const safeName = `${base}-${id}${ext}`
Enter fullscreen mode Exit fullscreen mode

Using UUIDs prevents filename collisions and makes files harder to guess.

Complete Frontend Implementation

Create a comprehensive upload demonstration page at app/pages/upload/index.vue:

<template>
  <main class="container">
    <h1>Secure File Upload Demo</h1>
    <p class="description">
      Upload images (JPEG, PNG, WebP, AVIF) up to 5MB. Files are validated and sanitized for security.
    </p>

    <div class="upload-section">
      <div class="upload-zone" :class="{ 'dragover': isDragOver }" @drop="handleDrop" @dragover="handleDragOver" @dragleave="handleDragLeave">
        <input
          ref="fileInput"
          type="file"
          multiple
          accept="image/*"
          @change="handleFileSelect"
          class="file-input"
        >

        <div class="upload-content">
          <svg class="upload-icon" fill="none" stroke="currentColor" viewBox="0 0 48 48">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" />
          </svg>
          <h3>Drop images here or click to browse</h3>
          <p>Support for JPEG, PNG, WebP, AVIF (max 5MB each)</p>
        </div>
      </div>

      <button
        @click="triggerFileInput"
        class="upload-btn"
        :disabled="uploading"
      >
        {{ uploading ? 'Uploading...' : 'Choose Files' }}
      </button>
    </div>

    <div v-if="error" class="error-message">
      {{ error }}
    </div>

    <div v-if="uploading" class="progress-section">
      <div class="progress-bar">
        <div class="progress-fill" :style="{ width: uploadProgress + '%' }"></div>
      </div>
      <p>Uploading {{ uploadProgress }}%</p>
    </div>

    <div v-if="uploadedFiles.length" class="results-section">
      <h2>Uploaded Files ({{ uploadedFiles.length }})</h2>
      <div class="file-grid">
        <div v-for="file in uploadedFiles" :key="file.url" class="file-card">
          <div class="image-container">
            <NuxtImg
              :src="file.url"
              :alt="file.name"
              class="uploaded-image"
              placeholder="blur"
              :modifiers="{ width: 300, height: 200, fit: 'cover', quality: 80 }"
            />
          </div>
          <div class="file-info">
            <h3>{{ file.name }}</h3>
            <p>{{ formatBytes(file.bytes) }} β€’ {{ file.type }}</p>
            <div class="file-actions">
              <button @click="copyUrl(file.url)" class="copy-btn">Copy URL</button>
              <button @click="deleteFile(file)" class="delete-btn">Delete</button>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div class="stats-section">
      <h3>Upload Statistics</h3>
      <div class="stats-grid">
        <div class="stat-item">
          <span class="stat-value">{{ uploadedFiles.length }}</span>
          <span class="stat-label">Files Uploaded</span>
        </div>
        <div class="stat-item">
          <span class="stat-value">{{ formatBytes(totalBytes) }}</span>
          <span class="stat-label">Total Size</span>
        </div>
        <div class="stat-item">
          <span class="stat-value">{{ successRate }}%</span>
          <span class="stat-label">Success Rate</span>
        </div>
      </div>
    </div>
  </main>
</template>

<script setup>
const fileInput = ref()
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadedFiles = ref([])
const isDragOver = ref(false)
const error = ref('')
const uploadAttempts = ref(0)
const successfulUploads = ref(0)

const totalBytes = computed(() => {
  return uploadedFiles.value.reduce((sum, file) => sum + file.bytes, 0)
})

const successRate = computed(() => {
  return uploadAttempts.value === 0 ? 100 : Math.round((successfulUploads.value / uploadAttempts.value) * 100)
})

function triggerFileInput() {
  fileInput.value?.click()
}

function handleFileSelect(event) {
  const files = Array.from(event.target.files || [])
  if (files.length) {
    uploadFiles(files)
  }
}

function handleDragOver(event) {
  event.preventDefault()
  isDragOver.value = true
}

function handleDragLeave(event) {
  event.preventDefault()
  isDragOver.value = false
}

function handleDrop(event) {
  event.preventDefault()
  isDragOver.value = false

  const files = Array.from(event.dataTransfer?.files || [])
  if (files.length) {
    uploadFiles(files)
  }
}

async function uploadFiles(files) {
  if (uploading.value) return

  uploading.value = true
  uploadProgress.value = 0
  error.value = ''
  uploadAttempts.value++

  try {
    const formData = new FormData()
    files.forEach(file => {
      formData.append('files', file)
    })

    // Simulate progress
    const progressInterval = setInterval(() => {
      if (uploadProgress.value < 90) {
        uploadProgress.value += Math.random() * 20
      }
    }, 200)

    const response = await $fetch('/api/upload', {
      method: 'POST',
      body: formData
    })

    clearInterval(progressInterval)
    uploadProgress.value = 100

    uploadedFiles.value.push(...response)
    successfulUploads.value++

    // Reset file input
    if (fileInput.value) {
      fileInput.value.value = ''
    }

    // Success notification
    setTimeout(() => {
      uploadProgress.value = 0
    }, 1000)

  } catch (err) {
    error.value = err.data?.message || 'Upload failed. Please try again.'
    console.error('Upload failed:', err)
  } finally {
    uploading.value = false
  }
}

function formatBytes(bytes) {
  if (bytes === 0) return '0 Bytes'
  const k = 1024
  const sizes = ['Bytes', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}

async function copyUrl(url) {
  try {
    const fullUrl = window.location.origin + url
    await navigator.clipboard.writeText(fullUrl)
    console.log('URL copied to clipboard:', fullUrl)
  } catch (err) {
    console.error('Failed to copy URL:', err)
  }
}

function deleteFile(file) {
  const index = uploadedFiles.value.indexOf(file)
  if (index > -1) {
    uploadedFiles.value.splice(index, 1)
  }
}

// SEO and meta
useSeoMeta({
  title: 'Secure File Upload Demo - Nuxt 3',
  description: 'Demonstration of secure file upload implementation with image validation, sanitization, and optimization.'
})
</script>

<style scoped>
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

h1 {
  font-size: 2.5rem;
  margin-bottom: 1rem;
  color: #1a202c;
}

.description {
  font-size: 1.1rem;
  color: #4a5568;
  margin-bottom: 2rem;
}

.upload-section {
  margin-bottom: 2rem;
}

.upload-zone {
  border: 2px dashed #cbd5e0;
  border-radius: 12px;
  padding: 3rem;
  text-align: center;
  background: #f7fafc;
  transition: all 0.3s ease;
  cursor: pointer;
  position: relative;
}

.upload-zone:hover,
.upload-zone.dragover {
  border-color: #3182ce;
  background: #ebf8ff;
  transform: translateY(-2px);
}

.file-input {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
}

.upload-content h3 {
  margin: 1rem 0 0.5rem;
  color: #2d3748;
}

.upload-content p {
  color: #718096;
}

.upload-icon {
  width: 64px;
  height: 64px;
  color: #a0aec0;
}

.upload-btn {
  background: #3182ce;
  color: white;
  border: none;
  padding: 0.75rem 1.5rem;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  margin-top: 1rem;
  transition: background 0.2s;
}

.upload-btn:hover:not(:disabled) {
  background: #2c5aa0;
}

.upload-btn:disabled {
  background: #a0aec0;
  cursor: not-allowed;
}

.error-message {
  background: #fed7d7;
  color: #c53030;
  padding: 1rem;
  border-radius: 8px;
  margin-bottom: 1rem;
}

.progress-section {
  margin-bottom: 2rem;
}

.progress-bar {
  width: 100%;
  height: 8px;
  background: #e2e8f0;
  border-radius: 4px;
  overflow: hidden;
  margin-bottom: 0.5rem;
}

.progress-fill {
  height: 100%;
  background: #3182ce;
  transition: width 0.3s ease;
}

.results-section {
  margin-bottom: 2rem;
}

.file-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 1.5rem;
  margin-top: 1rem;
}

.file-card {
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  overflow: hidden;
  background: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s;
}

.file-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.image-container {
  position: relative;
  height: 200px;
  overflow: hidden;
}

.uploaded-image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.file-info {
  padding: 1rem;
}

.file-info h3 {
  margin: 0 0 0.5rem;
  font-size: 1.1rem;
  color: #2d3748;
  word-break: break-word;
}

.file-info p {
  color: #718096;
  font-size: 0.9rem;
  margin: 0 0 1rem;
}

.file-actions {
  display: flex;
  gap: 0.5rem;
}

.copy-btn, .delete-btn {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.9rem;
  transition: background 0.2s;
}

.copy-btn {
  background: #edf2f7;
  color: #4a5568;
}

.copy-btn:hover {
  background: #e2e8f0;
}

.delete-btn {
  background: #fed7d7;
  color: #c53030;
}

.delete-btn:hover {
  background: #fbb6ce;
}

.stats-section {
  background: #f7fafc;
  padding: 2rem;
  border-radius: 12px;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 1.5rem;
  margin-top: 1rem;
}

.stat-item {
  text-align: center;
}

.stat-value {
  display: block;
  font-size: 2rem;
  font-weight: 700;
  color: #3182ce;
}

.stat-label {
  display: block;
  color: #718096;
  font-size: 0.9rem;
  margin-top: 0.25rem;
}

@media (max-width: 768px) {
  .container {
    padding: 1rem;
  }

  .upload-zone {
    padding: 2rem 1rem;
  }

  .file-grid {
    grid-template-columns: 1fr;
  }

  .stats-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}
</style>
Enter fullscreen mode Exit fullscreen mode

Key Frontend Features

🎯 Drag & Drop Interface: Intuitive file dropping with visual feedback
πŸ“Š Progress Tracking: Real-time upload progress with visual indicators
πŸ–ΌοΈ Image Preview: Optimized display using NuxtImg with automatic resizing
πŸ“ˆ Statistics: Upload metrics including success rate and total size
⚑ Error Handling: Clear error messages for failed uploads
πŸ“± Responsive Design: Mobile-friendly interface with adaptive layouts

Advanced Features to Consider

1. Image Processing

Integrate with Sharp for automatic resizing and optimization:

import sharp from 'sharp'

// Resize and optimize images
const processedBuffer = await sharp(data)
  .resize(1200, 800, { fit: 'inside', withoutEnlargement: true })
  .jpeg({ quality: 80 })
  .toBuffer()
Enter fullscreen mode Exit fullscreen mode

2. Cloud Storage Integration

For production apps, consider using cloud storage:

// Example with AWS S3
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'

const s3Client = new S3Client({ region: 'us-east-1' })

await s3Client.send(new PutObjectCommand({
  Bucket: 'my-uploads-bucket',
  Key: safeName,
  Body: data,
  ContentType: type
}))
Enter fullscreen mode Exit fullscreen mode

3. Rate Limiting

Add rate limiting to prevent abuse:

// Using a simple in-memory store (use Redis for production)
const uploadCounts = new Map()

const clientIP = getClientIP(event)
const count = uploadCounts.get(clientIP) || 0

if (count > 10) { // 10 uploads per hour
  throw createError({ statusCode: 429, statusMessage: 'Rate limit exceeded' })
}

uploadCounts.set(clientIP, count + 1)
Enter fullscreen mode Exit fullscreen mode

Error Handling and User Experience

Always provide clear error messages and handle edge cases:

try {
  await writeFile(`${UPLOAD_DIR}/${safeName}`, data)
} catch (error) {
  throw createError({
    statusCode: 500,
    statusMessage: 'Failed to save file'
  })
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a secure file upload API requires careful consideration of multiple security aspects:

  • Validation: File type and size restrictions
  • Sanitization: Safe filename generation
  • Storage: Secure file placement
  • Error Handling: Graceful failure management

This implementation provides a solid foundation that you can extend with additional features like image processing, cloud storage, or advanced validation rules.

The key is to start with security as a primary concern, then add features while maintaining that security posture.

Top comments (0)