Remotion LabRemotion Lab
建構應用使用預簽名 URL 上傳

使用預簽名 URL 上傳

學習如何使用預簽名 URL 讓使用者直接上傳檔案到雲端儲存,減輕伺服器負擔並提升安全性

本文為希望允許使用者上傳影片和其他資源的 Web 應用程式提供指導。我們建議在伺服器端生成預簽名 URL,讓使用者可以直接將檔案上傳到你的雲端儲存,而無需透過你的伺服器傳輸檔案。

你可以設定限制條件,例如最大檔案大小和檔案類型、套用速率限制、要求身份驗證,以及預先定義儲存位置。

為什麼使用預簽名 URL?

傳統的檔案上傳實作方式是讓客戶端將檔案上傳到伺服器,伺服器再將檔案儲存到磁碟或轉發到雲端儲存。雖然這種方法有效,但並不理想,原因如下:

  • 減少負載:如果許多客戶端同時在同一台伺服器上上傳大型檔案,該伺服器可能因負載過重而變慢甚至崩潰。透過預簽名工作流程,伺服器只需建立預簽名 URL,比處理檔案傳輸大幅減少伺服器負載。
  • 減少濫用:為了防止使用者將你的上傳功能作為免費儲存空間,你可以在使用者超出配額時拒絕提供預簽名 URL。
  • 資料安全:由於許多現代託管解決方案是無狀態或無伺服器的,檔案不應儲存在這些環境中。無法保證伺服器重啟後檔案仍然存在,且可能耗盡磁碟空間。

AWS 範例

以下是將使用者上傳儲存在 AWS S3 的範例。

權限設定

在 AWS 控制台的儲存桶中,前往 Permissions 並透過 CORS 允許 PUT 請求:

跨來源資源共享 (CORS) 政策
[
  {
    "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 對象中獲取內容類型和內容長度:

App.tsx(取得檔案資訊)
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 是可以上傳檔案的 URL
  • readUrl 是可以讀取檔案的 URL
generate-presigned-url.ts
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

app/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 }
    );
  }
};

前端上傳實作

app/upload.tsx
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:

generate-gcs-presigned-url.ts
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 };
};

另請參閱