使用預簽名 URL 上傳
學習如何使用預簽名 URL 讓使用者直接上傳檔案到雲端儲存,減輕伺服器負擔並提升安全性
本文為希望允許使用者上傳影片和其他資源的 Web 應用程式提供指導。我們建議在伺服器端生成預簽名 URL,讓使用者可以直接將檔案上傳到你的雲端儲存,而無需透過你的伺服器傳輸檔案。
你可以設定限制條件,例如最大檔案大小和檔案類型、套用速率限制、要求身份驗證,以及預先定義儲存位置。
為什麼使用預簽名 URL?
傳統的檔案上傳實作方式是讓客戶端將檔案上傳到伺服器,伺服器再將檔案儲存到磁碟或轉發到雲端儲存。雖然這種方法有效,但並不理想,原因如下:
- 減少負載:如果許多客戶端同時在同一台伺服器上上傳大型檔案,該伺服器可能因負載過重而變慢甚至崩潰。透過預簽名工作流程,伺服器只需建立預簽名 URL,比處理檔案傳輸大幅減少伺服器負載。
- 減少濫用:為了防止使用者將你的上傳功能作為免費儲存空間,你可以在使用者超出配額時拒絕提供預簽名 URL。
- 資料安全:由於許多現代託管解決方案是無狀態或無伺服器的,檔案不應儲存在這些環境中。無法保證伺服器重啟後檔案仍然存在,且可能耗盡磁碟空間。
AWS 範例
以下是將使用者上傳儲存在 AWS S3 的範例。
權限設定
在 AWS 控制台的儲存桶中,前往 Permissions 並透過 CORS 允許 PUT 請求:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["PUT"],
"AllowedOrigins": ["*"],
"ExposeHeaders": [],
"MaxAgeSeconds": 3000
}
]提示:也可以透過 CORS 允許
GET方法,這樣你就可以在上傳後讀取資源。
你的 AWS 使用者政策至少必須具備放置物件並將其設為公開的能力:
{
"Sid": "Presign",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:PutObjectAcl"],
"Resource": ["arn:aws:s3:::{YOUR_BUCKET_NAME}/*"]
}生成預簽名 URL
首先,在前端接受一個檔案,例如使用 <input type="file">。你應該從 File 對象中獲取內容類型和內容長度:
import { interpolate } from 'remotion';
const file: File = {} as unknown as File;
const contentType = file.type || 'application/octet-stream';
const arrayBuffer = await file.arrayBuffer();
const contentLength = arrayBuffer.byteLength;此範例使用 @aws-sdk/s3-request-presigner 和從 @remotion/lambda 匯入的 AWS SDK。呼叫以下函數後,會生成兩個 URL:
presignedUrl是可以上傳檔案的 URLreadUrl是可以讀取檔案的 URL
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AwsRegion, getAwsClient } from '@remotion/lambda/client';
export const generatePresignedUrl = async (
contentType: string,
contentLength: number,
expiresIn: number,
bucketName: string,
region: AwsRegion
): Promise<{ presignedUrl: string; readUrl: string }> => {
// 驗證檔案大小
if (contentLength > 1024 * 1024 * 200) {
throw new Error(
`檔案不得超過 200MB。你的檔案大小為 ${contentLength} bytes。`
);
}
const { client, sdk } = getAwsClient({
region: process.env.REMOTION_AWS_REGION as AwsRegion,
service: 's3',
});
// 使用 UUID 作為檔案名稱以避免名稱衝突
const key = crypto.randomUUID();
const command = new sdk.PutObjectCommand({
Bucket: bucketName,
Key: key,
ACL: 'public-read',
ContentLength: contentLength,
ContentType: contentType,
});
const presignedUrl = await getSignedUrl(client, command, {
expiresIn,
});
// 上傳後資源的位置
const readUrl = `https://${bucketName}.s3.${region}.amazonaws.com/${key}`;
return { presignedUrl, readUrl };
};說明:
- 首先,上傳請求會被檢查是否符合限制條件。在此範例中,我們拒絕超過 200MB 的上傳。你可以添加更多限制或速率限制。
- 使用 getAwsClient() 匯入 AWS SDK。如果你不使用 Remotion Lambda,請直接安裝
@aws-sdk/client-s3套件。 - 使用 UUID 作為檔案名稱以避免名稱衝突。
- 最後,計算並返回預簽名 URL 和輸出 URL。
Next.js 範例程式碼
以下是 Next.js App Router 的範例程式碼。端點位於 api/upload/route.ts。
import { NextResponse } from 'next/server';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AwsRegion, getAwsClient } from '@remotion/lambda/client';
const generatePresignedUrl = async ({
contentType,
contentLength,
expiresIn,
bucketName,
region,
}: {
contentType: string;
contentLength: number;
expiresIn: number;
bucketName: string;
region: AwsRegion;
}): Promise<{ presignedUrl: string; readUrl: string }> => {
if (contentLength > 1024 * 1024 * 200) {
throw new Error(
`檔案不得超過 200MB。你的檔案大小為 ${contentLength} bytes。`
);
}
const { client, sdk } = getAwsClient({
region: process.env.REMOTION_AWS_REGION as AwsRegion,
service: 's3',
});
const key = crypto.randomUUID();
const command = new sdk.PutObjectCommand({
Bucket: bucketName,
Key: key,
ACL: 'public-read',
ContentLength: contentLength,
ContentType: contentType,
});
const presignedUrl = await getSignedUrl(client, command, { expiresIn });
const readUrl = `https://${bucketName}.s3.${region}.amazonaws.com/${key}`;
return { presignedUrl, readUrl };
};
export const POST = async (request: Request) => {
const { contentType, contentLength } = await request.json();
try {
const { presignedUrl, readUrl } = await generatePresignedUrl({
contentType,
contentLength,
expiresIn: 60 * 60, // 1 小時後過期
bucketName: process.env.REMOTION_S3_BUCKET_NAME as string,
region: process.env.REMOTION_AWS_REGION as AwsRegion,
});
return NextResponse.json({ presignedUrl, readUrl });
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 400 }
);
}
};前端上傳實作
import { useCallback, useState } from 'react';
import { Player } from '@remotion/player';
import { MyComposition } from '../remotion/MyComposition';
const uploadFile = async (file: File): Promise<string> => {
const contentType = file.type || 'application/octet-stream';
const arrayBuffer = await file.arrayBuffer();
const contentLength = arrayBuffer.byteLength;
// 步驟 1:從伺服器獲取預簽名 URL
const response = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentType, contentLength }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || '獲取上傳 URL 失敗');
}
const { presignedUrl, readUrl } = await response.json();
// 步驟 2:直接上傳到 S3
const uploadResponse = await fetch(presignedUrl, {
method: 'PUT',
headers: {
'Content-Type': contentType,
'Content-Length': contentLength.toString(),
},
body: arrayBuffer,
});
if (!uploadResponse.ok) {
throw new Error('上傳到 S3 失敗');
}
// 返回可公開讀取的 URL
return readUrl;
};
export const VideoUploader: React.FC = () => {
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
setError(null);
// 建立本地預覽
const blobUrl = URL.createObjectURL(file);
setVideoUrl(blobUrl);
try {
const cloudUrl = await uploadFile(file);
setVideoUrl(cloudUrl);
URL.revokeObjectURL(blobUrl);
} catch (err) {
setError((err as Error).message);
setVideoUrl(null);
URL.revokeObjectURL(blobUrl);
} finally {
setIsUploading(false);
}
},
[]
);
return (
<div>
{error && (
<div style={{ color: 'red', marginBottom: '10px' }}>{error}</div>
)}
{videoUrl && (
<Player
component={MyComposition}
inputProps={{ videoURL: videoUrl }}
durationInFrames={150}
fps={30}
compositionWidth={1920}
compositionHeight={1080}
style={{ width: '100%' }}
controls
/>
)}
<input
type="file"
accept="video/*"
onChange={handleFileChange}
disabled={isUploading}
/>
{isUploading && <span>上傳中...</span>}
</div>
);
};Google Cloud Storage 範例
如果你使用 Google Cloud Storage:
import { Storage } from '@google-cloud/storage';
const storage = new Storage();
export const generateGCSPresignedUrl = async (
bucketName: string,
fileName: string,
contentType: string,
expiresInMinutes: number = 60
): Promise<{ uploadUrl: string; readUrl: string }> => {
const bucket = storage.bucket(bucketName);
const file = bucket.file(fileName);
const [uploadUrl] = await file.generateSignedPostPolicyV4({
expires: Date.now() + expiresInMinutes * 60 * 1000,
conditions: [
['content-length-range', 0, 200 * 1024 * 1024], // 最大 200MB
['starts-with', '$Content-Type', 'video/'],
],
});
const readUrl = `https://storage.googleapis.com/${bucketName}/${fileName}`;
return { uploadUrl: uploadUrl.url, readUrl };
};