访问文件
使用签名 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?
最后更新于