Storage

文件上传

如何使用预签名 URL、分片上传和 Tus 断点续传通过 @nebutra/uploads 上传文件。

@nebutra/uploads 支持三种上传策略。100 MB 以下的简单文件使用预签名 URL,大文件使用分片上传,网络不稳定时使用 Tus 断点续传。

预签名 URL 上传

最常用的方案。服务端生成一个短期有效的 URL,客户端直接上传到存储提供商,不经过你的服务器。

设置存储提供商的凭据。完整变量说明请参阅提供商

AWS_BUCKET_NAME=nebutra-uploads
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...

在服务端生成预签名 URL,并限定在已认证租户的范围内。

// app/api/upload/presigned/route.ts
import { getUploadProvider } from "@nebutra/uploads";
import { getCurrentTenant } from "@nebutra/tenant";
import { z } from "zod";

const bodySchema = z.object({
  filename: z.string().min(1).max(255),
  contentType: z.string().min(1),
  size: z.number().int().positive().max(100 * 1024 * 1024), // 100 MB
});

export async function POST(request: Request) {
  const tenant = getCurrentTenant();
  const body = bodySchema.parse(await request.json());

  // 在服务端校验文件类型
  const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
  if (!allowedTypes.includes(body.contentType)) {
    return Response.json({ error: "File type not allowed" }, { status: 422 });
  }

  const uploads = await getUploadProvider();
  const key = `${tenant.tenantId}/${Date.now()}-${body.filename}`;

  const { url, headers } = await uploads.createPresignedUpload({
    bucket: process.env.AWS_BUCKET_NAME!,
    key,
    contentType: body.contentType,
    tenantId: tenant.tenantId,
    expiresIn: 3600,
  });

  return Response.json({ url, headers, key });
}

使用预签名 URL 直接上传到存储提供商。

async function uploadFile(file: File): Promise<string> {
  // 1. 从服务端获取预签名 URL
  const res = await fetch("/api/upload/presigned", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
      size: file.size,
    }),
  });

  if (!res.ok) throw new Error("Failed to get upload URL");
  const { url, headers, key } = await res.json();

  // 2. 直接上传到 S3/R2
  const uploadRes = await fetch(url, {
    method: "PUT",
    headers: {
      "Content-Type": file.type,
      ...headers,
    },
    body: file,
  });

  if (!uploadRes.ok) throw new Error("Upload failed");
  return key;
}

上传完成后,将文件键存入数据库以便后续检索。

const key = await uploadFile(file);

await db.attachment.create({
  data: {
    key,
    filename: file.name,
    contentType: file.type,
    size: file.size,
    tenantId: tenant.tenantId,
  },
});

带进度的 React Hook

使用此 hook 在 UI 组件中追踪上传进度。

// hooks/use-file-upload.ts
import { useState, useCallback } from "react";

interface UploadState {
  progress: number;
  status: "idle" | "uploading" | "done" | "error";
  key: string | null;
  error: string | null;
}

export function useFileUpload() {
  const [state, setState] = useState<UploadState>({
    progress: 0,
    status: "idle",
    key: null,
    error: null,
  });

  const upload = useCallback(async (file: File) => {
    setState({ progress: 0, status: "uploading", key: null, error: null });

    try {
      const res = await fetch("/api/upload/presigned", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        }),
      });

      if (!res.ok) throw new Error("Failed to get upload URL");
      const { url, headers, key } = await res.json();

      // 使用 XMLHttpRequest 获取进度事件
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = (event) => {
          if (event.lengthComputable) {
            setState((prev) => ({
              ...prev,
              progress: Math.round((event.loaded / event.total) * 100),
            }));
          }
        };

        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) resolve();
          else reject(new Error(`Upload failed with status ${xhr.status}`));
        };

        xhr.onerror = () => reject(new Error("Network error"));

        xhr.open("PUT", url);
        Object.entries(headers ?? {}).forEach(([k, v]) =>
          xhr.setRequestHeader(k, v as string)
        );
        xhr.setRequestHeader("Content-Type", file.type);
        xhr.send(file);
      });

      setState({ progress: 100, status: "done", key, error: null });
    } catch (err) {
      setState({
        progress: 0,
        status: "error",
        key: null,
        error: err instanceof Error ? err.message : "Upload failed",
      });
    }
  }, []);

  return { ...state, upload };
}

分片上传(超过 10 MB 的文件)

对于大文件,将上传拆分为多个分片。各分片并行上传后由存储提供商合并。

import { getUploadProvider } from "@nebutra/uploads";

const uploads = await getUploadProvider();

// 发起分片上传(10 个分片)
const mp = await uploads.createMultipartUpload(
  {
    bucket: "nebutra-uploads",
    key: `${tenantId}/videos/demo.mp4`,
  },
  10 // 分片数量
);

// mp.uploadId       — 分片上传会话标识符
// mp.parts          — [{ partNumber, presignedUrl }] 数组

// 从客户端上传每个分片
const uploadedParts = await Promise.all(
  mp.parts.map(async ({ partNumber, presignedUrl }) => {
    const start = (partNumber - 1) * partSize;
    const end = Math.min(start + partSize, file.size);
    const chunk = file.slice(start, end);

    const res = await fetch(presignedUrl, { method: "PUT", body: chunk });
    const etag = res.headers.get("ETag")!;
    return { partNumber, etag };
  })
);

// 完成分片上传
await uploads.completeMultipartUpload(
  "nebutra-uploads",
  mp.key,
  mp.uploadId,
  uploadedParts
);

每个分片必须至少 5 MB(最后一个分片除外)。建议的最小分片大小为 10 * 1024 * 1024(10 MB)。

Tus 断点续传

@nebutra/uploads 的 Tus 适配器正在开发中,将遵循 tus-js-client 接口规范,并集成到 UploadProvider 抽象层中。

在适配器正式发布之前,对于超过 100 MB 的文件请使用上方的分片上传方案。如需立即支持断点续传,可直接调用 S3/R2 分片上传 API:

// 持久化 uploadId 和 ETags,以便浏览器刷新后恢复上传
const { uploadId, key } = await uploads.createMultipartUpload(
  { bucket: "nebutra-uploads", key: "videos/demo.mp4" },
  10  // 分片数量
);

// 将 uploadId 存储在 localStorage 或 sessionStorage 中
localStorage.setItem("pendingUpload", JSON.stringify({ uploadId, key, etags: [] }));

// 恢复时,从存储中加载并使用收集到的 ETags 调用 completeMultipartUpload

可关注 更新日志packages/integrations/uploads/ 目录中的 tus.ts 适配器文件以跟踪进度。

服务端文件校验

在签发预签名 URL 之前,务必在服务端校验文件类型和大小。不要仅依赖客户端提供的 MIME 类型。

import { z } from "zod";

const ALLOWED_MIME_TYPES = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/gif",
  "application/pdf",
  "text/csv",
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);

const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB

const uploadRequestSchema = z.object({
  filename: z
    .string()
    .min(1)
    .max(255)
    .regex(/^[\w\-. ]+$/, "Filename contains invalid characters"),
  contentType: z.string().refine(
    (type) => ALLOWED_MIME_TYPES.has(type),
    "File type is not allowed"
  ),
  size: z
    .number()
    .int()
    .positive()
    .max(MAX_FILE_SIZE, "File exceeds the 100 MB limit"),
});

仅靠 MIME 类型校验是不够的。对于敏感上传场景,文件落入存储桶后,还应在服务端使用魔法字节检测(如 file-type npm 包)进行二次校验。

How is this guide?

目录