文件上传

本指南介绍 AdonisJS 中的文件上传,从基础的单文件上传到使用云存储提供商的进阶直接上传。你将学习如何:

  • 在应用中接收并验证文件上传
  • 使用 FlyDrive 永久存储文件
  • 处理多文件上传和直传云存储
  • 保护你的文件上传端点安全

概述

文件上传允许用户从浏览器向你的 AdonisJS 应用发送文件。与许多需要额外包才能支持此功能的 Node.js 框架不同,AdonisJS 内置支持解析 multipart 请求并通过其 bodyparser 处理文件上传。

当文件上传时,AdonisJS 会自动将其保存到服务器的 tmp 目录。从那里,你可以在控制器中验证文件,然后将其移动到永久存储。

对于永久存储,AdonisJS 集成了 FlyDrive ,它提供了一个统一的 API,可用于本地文件系统以及 Amazon S3、Cloudflare R2 和 Google Cloud Storage 等云存储解决方案。

上传你的第一个文件

我们将构建一个允许用户更新其个人资料头像的功能。这是一个常见的需求,并演示了所有核心概念。

  1. 创建上传表单

    首先,创建一个接受文件上传的表单。关键部分是将表单编码设置为 multipart/form-data。如果没有它,浏览器将无法正确发送文件。

    resources/views/pages/profile.edge
    @form({ route: 'profile_avatar.update', enctype: 'multipart/form-data' })
      @field.root({ name: 'avatar' })
        @!input.control({ type: 'file' })
        @!field.label({ text: 'Upload new avatar' })
        @!field.error()
      @end
    
      @!button({ type: 'Submit', text: 'Update Avatar' })
    @end
    inertia/pages/profile.tsx
    import { Form } from '@adonisjs/inertia/react'
    
    export default function Profile() {
      return (
        <Form route="profile_avatar.update" encType="multipart/form-data">
          {({ errors }) => (
            <>
              <div>
                <label htmlFor="avatar">Upload new avatar</label>
                <input type="file" name="avatar" id="avatar" />
                {errors.avatar && <div>{errors.avatar}</div>}
              </div>
              <button type="submit">Update Avatar</button>
            </>
          )}
        </Form>
      )
    }
  2. 注册路由

    接下来,注册一个路由来处理文件上传。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { controllers } from '#generated/controllers'
    
    router.put('/profile/avatar', [controllers.profileAvatar, 'update'])
  3. 创建控制器

    现在创建一个接受上传文件的控制器。request.file() 方法让你通过字段名访问上传的文件。

    app/controllers/profile_avatar_controller.ts
    import { HttpContext } from '@adonisjs/core/http'
    
    export default class ProfileAvatarController {
      async update({ request, response }: HttpContext) {
        const avatar = request.file('avatar')
        
        if (!avatar) {
          return response.badRequest('Please upload an avatar image')
        }
        
        console.log(avatar)
        
        return 'Avatar uploaded successfully'
      }
    }

    此时,你的应用已经可以接收文件上传了。当你访问它时,文件已经保存在 tmp 目录中。文件对象包含有用的属性:

    • tmpPath - 文件当前存储在服务器上的位置
    • clientName - 来自用户计算机的原始文件名
    • size - 文件大小(字节)
    • extname - 文件扩展名(如 'jpg'、'png')
    • type - MIME 类型(如 'image/jpeg')

验证上传的文件

接受任何未经验证的文件是危险的。用户可能会上传过大、格式不正确,甚至可能是恶意的文件。AdonisJS 提供了两种验证方法。

内联验证

你可以通过在 request.file() 调用中将验证选项作为第二个参数传入,直接在调用时验证文件。

app/controllers/profile_avatar_controller.ts
import { HttpContext } from '@adonisjs/core/http'

export default class ProfileAvatarController {
  async update({ request, response }: HttpContext) {
    const avatar = request.file('avatar', {
      size: '2mb',
      extnames: ['jpg', 'png', 'jpeg']
    })

    if (!avatar) {
      return response.badRequest('Please upload an avatar image')
    }
    
    if (avatar.hasErrors) {
      return response.badRequest(avatar.errors)
    }
    
    return 'Avatar uploaded and validated successfully'
  }
}

验证会在你调用 request.file() 时立即发生。如果文件过大或扩展名无效,avatar.hasErrors 属性将为 true,且 avatar.errors 数组将包含错误消息。

VineJS 验证

虽然内联验证可以工作,但推荐使用 VineJS 验证器,因为它能提供更好的错误消息、一致的验证模式,以及更轻松的测试。

首先,创建一个验证器文件。

app/validators/user.ts
import vine from '@vinejs/vine'

export const updateAvatarValidator = vine.create({
  avatar: vine.file({
    size: '2mb',
    extnames: ['jpg', 'png', 'jpeg']
  })
})

然后在你的控制器中使用该验证器。

app/controllers/profile_avatar_controller.ts
import { HttpContext } from '@adonisjs/core/http'
import { updateAvatarValidator } from '#validators/user'

export default class ProfileAvatarController {
  async update({ request }: HttpContext) {
    const payload = await request.validateUsing(updateAvatarValidator)    

    console.log(payload.avatar)    
    return 'Avatar uploaded and validated successfully'
  }
}

如果验证失败,AdonisJS 会自动返回带有详细错误消息的 422 响应。如果验证成功,你会在 payload 对象中获得已验证的数据。此时头像已经通过了大小和扩展名检查。

安全特性

AdonisJS 的一个关键安全特性是,它使用 魔数检测(magic number detection) 来验证文件类型。这意味着即使有人将 .exe 文件重命名为 .jpg,AdonisJS 也会检测到实际的文件类型并拒绝它。这保护了你的应用免受用户通过简单地更改文件扩展名来绕过验证的攻击。

将文件与其他字段组合

当你的表单同时包含文件上传和常规字段时,已验证的 payload 会同时包含两者。在将剩余数据传递给模型之前,先将文件字段单独解构出来——将 multipart 文件对象直接传递给 Model.create() 会导致错误:

app/validators/task.ts
import vine from '@vinejs/vine'

export const createTaskValidator = vine.create({
  title: vine.string(),
  description: vine.string().optional(),
  attachment: vine.file({ size: '5mb', extnames: ['pdf', 'jpg', 'png'] }).optional(),
})
app/controllers/tasks_controller.ts
import { HttpContext } from '@adonisjs/core/http'
import { createTaskValidator } from '#validators/task'

export default class TasksController {
  async store({ request }: HttpContext) {
    const { attachment, ...data } = await request.validateUsing(createTaskValidator)

    const task = await Task.create(data)

    if (attachment) {
      await attachment.moveToDisk(`tasks/${task.id}.${attachment.extname}`)
      task.attachmentFileName = `tasks/${task.id}.${attachment.extname}`
      await task.save()
    }

    return task
  }
}

{ attachment, ...data } 解构将文件与标量字段分离,这样你可以将 data 直接传递给模型,并单独处理文件。

存储和提供上传的文件

通过表单上传的文件会临时存储在 tmp 目录中。大多数操作系统会自动清理临时文件,因此你不能依赖它们持久存在。对于永久存储,你需要将文件移动到能够持久保存的位置。

AdonisJS 使用 FlyDrive 进行永久文件存储。FlyDrive 提供了一个统一的 API,在开发期间与本地文件系统配合工作,在生产环境中与 S3 或 R2 等云存储提供商配合工作。

运行以下命令安装并配置 FlyDrive:

node ace add @adonisjs/drive

该命令会安装 @adonisjs/drive 包,并创建一个已配置好本地磁盘存储、可直接使用的 config/drive.ts 配置文件。有关云存储配置(S3、R2、GCS),请参阅 Drive 文档

将文件移动到永久存储

安装 FlyDrive 后,你可以将已验证的文件从 tmp 目录移动到永久存储。@adonisjs/drive 包通过 moveToDisk() 方法扩展了文件对象,该方法会自动处理此操作。

app/controllers/profile_avatar_controller.ts
import { HttpContext } from '@adonisjs/core/http'
import string from '@adonisjs/core/helpers/string'
import { updateAvatarValidator } from '#validators/user'

export default class ProfileAvatarController {
  async update({ request, auth }: HttpContext) {
    const payload = await request.validateUsing(updateAvatarValidator)
    
    /**
     * 使用唯一的随机名称来存储文件
     */
    const fileName = `${string.uuid()}.${payload.avatar.extname}`
    
    /**
     * 使用预配置的 drive 磁盘移动文件。
     */
    await payload.avatar.moveToDisk(fileName)
    
    /**
     * 更新数据库中的用户行以反映
     * 新更新的头像文件名
     */
    const user = auth.getUserOrFail()
    user.avatarFileName = fileName
    await user.save()
    
    return 'Avatar uploaded and saved successfully'
  }
}
  • 我们使用 UUID 生成唯一的文件名,以防止冲突。多个用户可能上传名为 "avatar.jpg" 的文件,因此唯一的名称可以防止覆盖。

  • moveToDisk() 方法处理了从 tmp 到永久存储的传输。它使用配置的磁盘(开发期间为本地文件系统,生产期间为云存储)。

  • 移动后将文件名存储在数据库中。之后你需要它来显示头像或生成下载链接。

访问你上传的文件

@adonisjs/drive 包包含一个内置的文件服务器,会自动提供上传的文件。该文件服务器在 /uploads 下注册路由,后跟你的目录结构。

例如,如果你将一个文件存储为 avatars/123e4567.jpg,它可以通过以下地址访问:

http://localhost:3333/uploads/avatars/123e4567.jpg

现在,不要硬编码此路径,而是使用适合你应用类型的方法来生成 URL。这确保了无论你使用的是本地存储还是 S3 或 R2 等云提供商,都能返回正确的 URL。

在你的模板中使用 driveUrl Edge 辅助函数:

<img src="{{ await driveUrl(user.avatarFileName) }}" alt="User avatar">

在返回 JSON 或渲染 Inertia 页面的控制器中,使用 drive 服务来生成 URL:

import drive from '@adonisjs/drive/services/main'

// 在控制器或转换器中
const avatarUrl = await drive.use().getUrl(user.avatarFileName)

你可以将此 URL 包含在你的 API 响应或 Inertia props 中:

return inertia.render('profile', {
  user: {
    name: user.name,
    avatarUrl: await drive.use().getUrl(user.avatarFileName),
  }
})

上传多个文件

许多应用需要在单个请求中接受多个文件。例如,允许用户为项目上传多个文档,或一次性上传多个产品图片。

在表单中接受多个文件

要接受多个文件,请为你的文件输入添加 multiple 属性,并使用数组风格的字段名。

resources/views/pages/project.edge
@form({ route: 'projects.documents.store', enctype: 'multipart/form-data' })
  @field.root({ name: 'documents[]' })
    @!input.control({ type: 'file', multiple: true })
    @!field.label({ text: 'Upload project documents' })
    @!field.error()
  @end
  
  <button type="submit">Upload Documents</button>
@end

访问多个文件

使用 request.files()(复数)而不是 request.file() 来访问多个上传的文件。该方法返回一个文件对象数组,即使只上传了一个文件。

app/controllers/project_documents_controller.ts
import { HttpContext } from '@adonisjs/core/http'

export default class ProjectDocumentsController {
  async store({ request, response }: HttpContext) {
    const documents = request.files('documents')
    
    if (documents.length === 0) {
      return response.badRequest('Please upload at least one document')
    }
    
    console.log(`Received ${documents.length} documents`)
    
    return 'Documents uploaded successfully'
  }
}

验证多个文件

使用 VineJS 的 vine.array() 来验证文件数组。数组中的每个文件都必须满足指定的大小和扩展名要求。

app/validators/project.ts
import vine from '@vinejs/vine'

export const uploadDocumentsValidator = vine.create({
  documents: vine.array(
    vine.file({
      size: '5mb',
      extnames: ['pdf', 'doc', 'docx', 'txt'],
    })
  ),
})

处理多个文件

遍历已验证的文件,并将每个文件单独移动到永久存储。数组中的每个文件都具有与单文件相同的属性和方法,包括 moveToDisk()

app/controllers/project_documents_controller.ts
import { HttpContext } from '@adonisjs/core/http'
import string from '@adonisjs/core/helpers/string'
import { uploadDocumentsValidator } from '#validators/project'

export default class ProjectDocumentsController {
  async store({ request, params }: HttpContext) {
    const payload = await request.validateUsing(uploadDocumentsValidator)
    const fileNames: string[] = []
    
    for (const document of payload.documents) {
      const fileName = `${string.uuid()}.${document.extname}`
      await document.moveToDisk(fileName)
      fileNames.push(fileName)
    }
    
    return { message: 'Documents uploaded', count: fileNames.length }
  }
}

直接上传

直接上传允许文件从浏览器直接上传到 S3、R2 或 Google Cloud Storage 等云存储提供商,完全绕过你的 AdonisJS 服务器。

标准流程是文件从 浏览器 → 你的服务器 → 云存储,而直接上传则直接从 浏览器 → 云存储。你的服务器只生成一个短期的签名 URL,授予向特定位置上传的临时权限。

在处理大文件上传(通常超过 100MB)时,推荐使用这种模式。为大型文件构建一个容错且可恢复的上传服务器是复杂的工作。通过使用直接上传,你可以将这一责任转移给为此目的而设计的专门服务。

额外的好处包括减少服务器带宽使用、为用户提供更好的上传性能,以及云提供商内置的可恢复上传。

实现直接上传

你需要一个云存储提供商(如 Amazon S3、Cloudflare R2 或 Google Cloud Storage)的账户。确保 FlyDrive 已使用你的云提供商凭据进行配置(配置详情请参阅 Drive 参考指南)。

创建一个生成签名上传 URL 的端点。

start/routes.ts
import router from '@adonisjs/core/services/router'
import drive from '@adonisjs/drive/services/main'

router.post('/signed-upload-url', async ({ request }) => {
  const fileName = request.input('file_name')
  
  const url = await drive.use('r2').getSignedUploadUrl(fileName, {
    expiresIn: '30 mins',
  })
  
  return { signedUrl: url }
})

客户端提供他们想要上传的文件名。你应该考虑验证这一点并生成唯一的文件名以防止冲突。

签名 URL 在 30 分钟后过期,以防止长期的未授权访问。将 'r2' 替换为你配置的磁盘名(可能是 's3'、'gcs' 等)。

客户端实现

客户端代码比标准表单上传更复杂。你需要一个处理上传过程、进度跟踪和错误处理的 JavaScript 库。流行的库包括 Uppy.ioFilepond ,或者你可以使用带 Fetch API 的原生 JavaScript 进行自定义实现。

下面是一个使用 Fetch API 的高层示例。

async function uploadFile(file) {
  // 步骤 1:从你的服务器请求一个签名 URL
  const response = await fetch('/signed-upload-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ file_name: file.name })
  })
  
  const { signedUrl } = await response.json()
  
  // 步骤 2:使用签名 URL 直接将文件上传到云存储
  await fetch(signedUrl, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type
    }
  })
  
  console.log('File uploaded successfully!')
}

第一步从你的 AdonisJS 应用请求一个签名 URL。第二步使用该 URL 将文件直接上传到云存储。

对于生产应用,请考虑使用像 Uppy.io 这样的库,它提供了上传进度跟踪、自动重试、可恢复上传和用户友好界面等额外功能。

限制文件上传路由

既然你已经了解了如何实现文件上传,保护你的应用免受潜在滥用就很重要了。

默认情况下,AdonisJS 会在所有使用 POSTPUTPATCH 方法的路由上处理 multipart 请求(文件上传)。这意味着你应用中的任何端点都可能潜在地接收并处理文件上传,即使你本来不打算让它处理文件。这种不受限制的访问允许攻击者将你的应用中的任何端点作为目标来上传文件,可能会消耗服务器的资源、填满磁盘空间,或利用你的应用来分发恶意内容。

作为第一道安全措施,你必须仅在实际需要处理文件的特定路由上允许 multipart 自动处理。在 bodyparser 设置中进行配置。

config/bodyparser.ts
import { defineConfig } from '@adonisjs/core/bodyparser'

export default defineConfig({
  allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  
  multipart: {
    autoProcess: ['/profile/avatar', '/users/:id', '/projects/:id/files']
  },
})

此配置确保只有指定的路由会处理 multipart 请求。所有其他路由都会拒绝文件上传,防止攻击者向随机端点上传文件。

如果你有接受文件上传的公共端点(不需要身份验证的端点),请应用严格的限流以防止滥用。有关实现细节,请参阅 限流指南

有关完整的 bodyparser 配置选项,请参阅 BodyParser 指南