Storage

访问文件

使用签名 URL 检索私有文件,通过 CDN 提供公共资源,以及使用 @nebutra/uploads 分页列举文件。

文件上传到存储桶后,需要将其提供给用户访问。@nebutra/uploads 提供了签名 URL(私有文件)、公共 CDN 分发和分页文件列表等辅助功能。

私有文件的签名 URL

私有文件需要有时间限制的签名 URL。URL 在服务端生成,授予临时读取权限,无需暴露存储凭据。

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

const uploads = await getUploadProvider();

// 生成 1 小时(3600 秒)有效期的签名 URL
const url = await uploads.getSignedUrl(
  "nebutra-uploads",
  `${tenantId}/docs/report.pdf`,
  { expiresIn: 3600 }
);

// url → https://nebutra-uploads.s3.amazonaws.com/org_acme/docs/report.pdf?X-Amz-Expires=3600&...

签名 URL 有效期最佳实践

使用场景建议有效期
浏览器内联展示(图片、PDF)1 小时(3600 秒)
UI 中显示的下载链接15 分钟(900 秒)
邮件附件链接7 天(604800 秒)
携带文件的 Webhook 回调24 小时(86400 秒)

不要将签名 URL 存入数据库。它们会过期并失效。只存储文件键,在提供服务时按需生成签名 URL。

API 路由模式

提供服务端接口,在验证用户访问权限后生成签名 URL。

// app/api/files/[key]/url/route.ts
import { getUploadProvider } from "@nebutra/uploads";
import { getCurrentTenant } from "@nebutra/tenant";

export async function GET(
  _request: Request,
  { params }: { params: { key: string } }
) {
  const tenant = getCurrentTenant();
  const decodedKey = decodeURIComponent(params.key);

  // 强制租户隔离:键必须以该租户的前缀开头
  if (!decodedKey.startsWith(`${tenant.tenantId}/`)) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  const uploads = await getUploadProvider();
  const url = await uploads.getSignedUrl(
    process.env.AWS_BUCKET_NAME!,
    decodedKey,
    { expiresIn: 3600 }
  );

  return Response.json({ url, expiresIn: 3600 });
}

公共文件的 CDN 分发

对于需要公开访问的资源(个人头像、公共 Logo、营销图片),将存储桶配置为公开访问并通过 CDN 提供服务。

R2_PUBLIC_URL 设置为自定义域名或 r2.dev 子域名。公共文件无需签名即可直接访问:

// 直接构建公共 URL — 无需 API 调用
function getPublicUrl(key: string): string {
  const baseUrl = process.env.R2_PUBLIC_URL!.replace(/\/$/, "");
  return `${baseUrl}/${key}`;
}

const avatarUrl = getPublicUrl(`${tenantId}/avatars/user_123.jpg`);
// → https://assets.nebutra.com/org_acme/avatars/user_123.jpg

创建指向 S3 存储桶的 CloudFront 发行版。配置**源访问控制(OAC)**策略,允许 CloudFront 读取私有存储桶。

function getPublicUrl(key: string): string {
  const cloudfrontDomain = process.env.CLOUDFRONT_DOMAIN!;
  return `https://${cloudfrontDomain}/${key}`;
}

CloudFront 签名 URL/Cookie 可以对 CDN 提供的私有文件进行访问控制。对于高流量场景,这比 S3 签名 URL 性能更好,因为 CloudFront 在边缘节点进行缓存。

Next.js 图片优化

配合 next/image 使用存储域名,可自动获得尺寸调整、格式转换(WebP/AVIF)和懒加载功能。

// apps/landing/next.config.ts(或 apps/web/next.config.ts)
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      // AWS S3
      {
        protocol: "https",
        hostname: "nebutra-uploads.s3.us-east-1.amazonaws.com",
        pathname: "/**",
      },
      // Cloudflare R2 自定义域名
      {
        protocol: "https",
        hostname: "assets.nebutra.com",
        pathname: "/**",
      },
    ],
  },
};

export default nextConfig;
import Image from "next/image";

interface AvatarProps {
  fileKey: string;
  signedUrl: string;
  alt: string;
}

export function Avatar({ signedUrl, alt }: AvatarProps) {
  return (
    <Image
      src={signedUrl}
      alt={alt}
      width={64}
      height={64}
      className="rounded-full object-cover"
    />
  );
}

签名 URL 包含每次生成都会变化的查询参数。这会导致 Next.js 无法跨请求缓存优化后的图片。对于频繁展示的图片(头像、缩略图),请使用公共 CDN URL 而非签名 URL。

文件列表

检索租户命名空间内的分页文件列表。

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

const uploads = await getUploadProvider();

// 获取第一页(每次请求最多 1000 个文件)
const { files, nextCursor } = await uploads.listFiles({
  bucket: "nebutra-uploads",
  prefix: `${tenantId}/docs/`,
  limit: 50,
  cursor: undefined, // 传入上次响应的 nextCursor 以翻页
});

// files → [{ key, size, lastModified, contentType }, ...]
// nextCursor → string | null(无更多页时为 null)

API 路由中的分页列表

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

const querySchema = z.object({
  prefix: z.string().optional(),
  cursor: z.string().optional(),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

export async function GET(request: Request) {
  const tenant = getCurrentTenant();
  const { searchParams } = new URL(request.url);
  const { prefix, cursor, limit } = querySchema.parse(
    Object.fromEntries(searchParams)
  );

  const uploads = await getUploadProvider();
  const result = await uploads.listFiles({
    bucket: process.env.AWS_BUCKET_NAME!,
    prefix: `${tenant.tenantId}/${prefix ?? ""}`,
    limit,
    cursor,
  });

  return Response.json(result);
}

删除文件

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

const uploads = await getUploadProvider();

await uploads.deleteFile(
  "nebutra-uploads",
  `${tenantId}/docs/old-report.pdf`
);

务必在同一操作中同时删除存储对象和数据库记录,以避免产生孤立文件。

await Promise.all([
  uploads.deleteFile(bucket, attachment.key),
  db.attachment.delete({ where: { id: attachment.id } }),
]);

How is this guide?

目录