這是 pre-signed URL 學習路線圖的 Level 4,最實用的一站。Level 1~3 已把理論講透:它是什麼、為什麼點了就能存取私有檔卻不帶帳密(Level 1)、下載用 GET 上傳用 PUT(Level 2)、SigV4 那段簽章怎麼算怎麼驗(Level 3)。這篇不重講理論——只把那套理論落地成 .NET 你真的會敲的 code,並用 curl 親手驗一條 URL 能不能動。
前三站你已經「懂」了 pre-signed URL。這一站要讓你「會做」——打開 IDE,用 SDK 產出一條真的能下載、能上傳的網址,再用 curl 證明它真的通。
路線有兩條:官方 AWS SDK for .NET,以及 MinIO 官方 .NET SDK。讀完你要能用兩套各產一條 GET 與 PUT URL、用 curl 成功收發、說清兩套差在哪、以及怎麼把 AWS SDK 指向 MinIO。
這幾題就是本文重點,也是讀完你該能用自己的話答出來的問題。先想想現在答不答得出來,帶著它們往下讀——讀完再回來點「看答案」對照。
X-Amz-Algorithm / Credential / Date / Expires / SignedHeaders / Signature)。所以 AWS SDK 產的 URL MinIO 認得、MinIO SDK 產的 URL 真 S3 也認得。選哪套只是「習慣/生態」問題。
GetPreSignedUrlRequest,填好屬性後交給 s3Client.GetPreSignedURL(req)。切動作靠 Verb 屬性:不填預設就是 HttpVerb.GET(下載);要上傳就設 Verb = HttpVerb.PUT。
GetPreSignedUrlRequest 的 code 嗎?該設哪兩個東西?ForcePathStyle 又是幹嘛?
AmazonS3Config 上設兩個:ServiceURL(指向 MinIO 位址,如 http://localhost:9000)、ForcePathStyle = true。ForcePathStyle 是強制用 path-style 定址(bucket 放路徑裡 endpoint/my-bucket/photo.jpg),而不是把 bucket 塞進 host——MinIO 這類自架 store 幾乎都吃 path-style(回扣 Level 3 全程用的就是它)。
TimeSpan,15 分鐘寫 TimeSpan.FromMinutes(15);AWS SDK 的 Expires 吃絕對時間點(DateTime),15 分鐘寫 DateTime.UtcNow.AddMinutes(15)。一個是「一段長度」、一個是「到哪個時間點為止」。
curl 驗一條當初簽了 content-type 的 PUT URL,為什麼一定要帶對 -H "Content-Type: ..."?帶錯會怎樣?
SignedHeaders。所以上傳時送的 Content-Type 值必須一字不差;漏了或寫錯(如 image/png)會回 SignatureDoesNotMatch。這正是 Level 3 那句「被簽進去的東西改一個字元、簽章就對不上」的實際體現。反之若沒簽 content-type,就別硬加。
在 .NET 產 pre-signed URL 有兩條主流路線:
AWSSDK.S3)。Minio)。先講一個最重要、也最讓人安心的事實:兩條路產出的都是一模一樣的標準 SigV4 URL。就是 Level 3 拆過的那串 X-Amz-Algorithm / X-Amz-Credential / X-Amz-Date / X-Amz-Expires / X-Amz-SignedHeaders / X-Amz-Signature。
所以這兩套 SDK 對 AWS S3、對 MinIO、對 Cloudflare R2 都能用,差別只在你把 endpoint 指去哪。(endpoint 就是服務的網址入口——真 S3 是 s3.amazonaws.com,MinIO 是你自己架的那台,例如 http://localhost:9000。)這也回扣了整條路線的核心認知:pre-signed URL 是 S3/SigV4 的公開標準,不是誰家的專屬招式(完整可遷移性是 Level 6 的題目)。
一句話抓重點:選哪套 SDK 是「習慣問題」,不是「相容問題」。用 AWS SDK 也能對 MinIO 產 URL,用 MinIO SDK 產的 URL 拿去打 AWS S3 也認得——因為它們吐出來的都是同一種標準簽章網址。
核心只有一個類別:GetPreSignedUrlRequest。你填幾個屬性,交給 client 的 GetPreSignedURL(...) 就拿到一條 URL。
先認識這幾個關鍵屬性(都是 GetPreSignedUrlRequest 上的直接屬性,經 AWS SDK for .NET V3 API 文件確認):
| 屬性 | 型別 | 作用 |
|---|---|---|
BucketName | string | 哪個 bucket |
Key | string | 物件在 bucket 裡的路徑/檔名 |
Verb | HttpVerb | 動作,HttpVerb.GET 或 HttpVerb.PUT;預設就是 GET |
Expires | DateTime | 到期的「絕對時間點」,通常寫 DateTime.UtcNow.Add... |
ContentType | string | (選填)綁定上傳時的 content-type |
注意 Expires 是一個絕對時間點(DateTime),不是「幾秒後過期」。這是它跟路線 B 最容易搞混的地方,記住這裡填的是「到什麼時候為止」。
下面的範例都會用到一個 s3Client;連真 AWS S3、指定 region,這樣建:
var s3Client = new AmazonS3Client("accessKey", "secretKey", RegionEndpoint.APNortheast1);
region 是 AWS 的機房區域(APNortheast1 就是東京,代號 ap-northeast-1)——也就是 Level 3 X-Amz-Credential 那串裡的「區域」欄位。因為簽章跟區域是綁在一起算的,產 URL 前得先告訴 SDK 你用哪個 region。(指到 MinIO 時 region 通常隨便填、慣例填 us-east-1 即可。)
憑證也可以不寫死、走預設憑證鏈(例如放環境變數),這裡先不展開。(要改成連 MinIO,只換建 client 這步,見本節最後。)
Verb 不填就是 GET,最精簡:
// 下載連結(presigned GET),15 分鐘後過期
var req = new GetPreSignedUrlRequest {
BucketName = "my-bucket",
Key = "photo.jpg",
Expires = DateTime.UtcNow.AddMinutes(15) // Verb 省略 = GET
};
string url = s3Client.GetPreSignedURL(req);
上傳要把 Verb 改成 HttpVerb.PUT。這裡示範多綁一個 ContentType:
// 上傳連結(presigned PUT),10 分鐘後過期,且限定對方送 image/jpeg
var putReq = new GetPreSignedUrlRequest {
BucketName = "my-bucket",
Key = "uploads/photo.jpg",
Verb = HttpVerb.PUT,
Expires = DateTime.UtcNow.AddMinutes(10),
ContentType = "image/jpeg" // 綁 content-type
};
string uploadUrl = s3Client.GetPreSignedURL(putReq);
一旦你在這裡填了 ContentType,它就會被簽進去(進入 Level 3 講的 SignedHeaders)。那對方用這條 URL 上傳時,HTTP 的 Content-Type header 就必須送一模一樣的值,否則會被拒。這個「簽了就得對上」的坑,等下 curl 那節會實際踩一次;為什麼會這樣,回頭看 Level 3「改任一被簽參數就 SignatureDoesNotMatch」就懂了。(要不要綁、綁了的安全含意,是 Level 5 的題目。)
需要非同步版本就用 GetPreSignedURLAsync(request),參數與行為一致。
同樣的 GetPreSignedUrlRequest code 一個字都不用改,只在建 AmazonS3Client 時換設定,就能對 MinIO 產 URL:
var config = new AmazonS3Config {
ServiceURL = "http://localhost:9000", // 指向你的 MinIO endpoint
ForcePathStyle = true // 用 path-style 定址
};
var s3Client = new AmazonS3Client("accessKey", "secretKey", config);
兩個關鍵設定(都在 AmazonS3Config 上,經 AWS SDK for .NET V3 API 文件確認):
ServiceURL:把預設的 s3.amazonaws.com 換成你的 MinIO 位址(例如本機 http://localhost:9000)。ForcePathStyle = true:強制用 path-style(bucket 放在路徑裡:endpoint/my-bucket/photo.jpg),而不是把 bucket 塞進 host 的 virtual-hosted-style。這正好回扣 Level 3——那站我們全程用的就是 path-style,MinIO 這類自架 store 也幾乎都吃 path-style。MinIO 官方 SDK 的兩個核心方法名很直覺(經 minio-dotnet v7 原始碼 IMinioClient 確認):
PresignedGetObjectAsync(...) → 下載連結PresignedPutObjectAsync(...) → 上傳連結下面用現行最新的 v7(NuGet Minio 7.0.0,2025-11 發布)示範。v7 的 presigned 方法吃位置參數(bucket、key、expiry):expiry 的型別是 TimeSpan(15 分鐘寫 TimeSpan.FromMinutes(15)),而且回傳的是 Uri 不是 string(所以接住它要用 Uri,寫 string url = await ... 會直接編不過)。
// 建 client(v7:MinioClientBuilder + WithStaticCredentials)
var minio = new MinioClientBuilder("http://localhost:9000")
.WithStaticCredentials("accessKey", "secretKey")
.Build();
// 下載連結(GET),有效 15 分鐘
Uri url = await minio.PresignedGetObjectAsync(
"my-bucket", "photo.jpg", TimeSpan.FromMinutes(15));
// 上傳連結(PUT),有效 10 分鐘
Uri uploadUrl = await minio.PresignedPutObjectAsync(
"my-bucket", "uploads/photo.jpg", TimeSpan.FromMinutes(10));
版本落差很大,務必以你專案鎖定的 NuGet 版本為準:MinIO .NET SDK 在 v7(2025-11)把 presigned API 整組換掉了。v7 之前的 6.x 用的是「一連串.WithXxx(...)串起來的 builder(鏈式呼叫)」——new PresignedGetObjectArgs().WithBucket(...).WithObject(...).WithExpiry(int 秒),expiry 是整數秒、且回傳string;建 client 則是new MinioClient().WithEndpoint(...).WithCredentials(...).Build()。所以從 6.x 升到 v7,這段程式要整段改寫——沒有「跨版本不變的寫法」,一律照你實際裝的版本走。
| 路線 A:AWS SDK(AWSSDK.S3) | 路線 B:MinIO SDK(Minio,v7) | |
|---|---|---|
| 產物 | 標準 SigV4 URL | 標準 SigV4 URL(一樣) |
| 核心 API | GetPreSignedUrlRequest + GetPreSignedURL()(回 string) | Presigned{Get,Put}ObjectAsync(位置參數、回 Uri) |
| 到期怎麼填 | Expires = 絕對時間點(DateTime) | expiry = TimeSpan |
| 指向 MinIO | 設 ServiceURL + ForcePathStyle=true | 本來就是連 MinIO;MinioClientBuilder 指位址 |
| 適合 | 專案已用 AWS 生態、或要同時接真 S3 | 專案主要就吃 MinIO、想用官方對應 SDK |
(MinIO 6.x 的寫法跟 v7 不同,詳見上面路線 B 的 caveat;表格與範例都以 v7 為準。)
選法很簡單:專案已經在用 AWS SDK(或哪天可能搬去真 S3),就用路線 A,一套 SDK 打天下;專案就是繞著 MinIO 轉、想用「原廠」對應工具,就用路線 B。兩者產物相容,不存在「選錯就不能用」。
拿到一條 URL 先別急著寫整合,用 curl 打一發最快知道它到底行不行。
驗 GET(下載):直接把 URL 餵給 curl -o,能存出檔案就成功。
curl -o out.jpg "<presigned-get-url>"
驗 PUT(上傳):要用 -X PUT 並且 --upload-file 把要傳的檔案帶上(PUT 一定要有 body)。
curl -X PUT --upload-file ./photo.jpg "<presigned-put-url>"
如果這條 PUT URL 當初簽了 content-type(就是路線 A 那個 ContentType = "image/jpeg"),那你 curl 時必須補上對應的 header,值要一字不差:
curl -X PUT --upload-file ./photo.jpg \
-H "Content-Type: image/jpeg" "<presigned-put-url>"
漏了、或值不一樣(例如寫成 image/png),server 就會回 SignatureDoesNotMatch。這不是 bug,正是 Level 3 講的「被簽進去的東西改一個字元,簽章就對不上」——content-type 既然被簽進 SignedHeaders,你送的值就得跟簽的時候完全一致。反過來說,如果當初沒簽 content-type,就別在 curl 硬加,加了反而多送一個沒被簽的期待值、也可能對不上。
讀完這站,你要能:
curl 成功下載(GET)與上傳(PUT);DateTime、MinIO v7 用 TimeSpan);ServiceURL + ForcePathStyle=true)。TimeSpan、回 Task<Uri>)*Args builder,供對照升級差異)