This commit is contained in:
2026-09-04 13:58:28 +07:00
parent 151e4e74cf
commit 2827d22616
4 changed files with 395 additions and 3 deletions
+263 -1
View File
@@ -3,11 +3,17 @@ module garageS3
export
LifecycleExpiration, build_lifecycle_xml,
GarageStorage, put_file, get_file, list_files, delete_file,
set_lifecycle_expiration, get_lifecycle, delete_lifecycle
set_lifecycle_expiration, get_lifecycle, delete_lifecycle,
s3_sign_url
using AWS
@service S3
using AWSS3
using Dates: Dates
using URIs: URIs
using OrderedCollections: OrderedDict
using MbedTLS: digest, MD_SHA256
using Base64: bytes2hex
# ---------------------------------------------- 100 --------------------------------------------- #
# Garage uses Path-Style routing (https://s3-api.my-domain.com/bucket/key).
@@ -266,6 +272,262 @@ function delete_file(storage::GarageStorage, key::String)
AWSS3.s3_delete(storage.config, storage.bucket, key)
end
"""
s3_sign_url(storage, key, seconds=3600; verb="GET", content_type="application/octet-stream", signature_version="v4") -> String
Generate a pre-signed URL for an object in the Garage S3 bucket.
A pre-signed URL is a signed URL that grants temporary access to an S3 object without
requiring AWS credentials. The URL expires after the specified number of seconds.
The signature is computed using AWS Signature Version 4 (HMAC-SHA256).
# Why use pre-signed URLs?
Pre-signed URLs let you grant temporary, scoped access to objects without sharing
your API credentials. Typical use cases:
- **Direct browser downloads**: Return the URL to a frontend so users can download
files without going through your server.
- **Direct browser uploads**: Generate a PUT pre-signed URL, pass it to the frontend,
and let the user upload a file directly to S3 with a `curl` or `fetch` call.
- **Temporary sharing**: Send an expiring link to someone who should not have
permanent access to the object.
- **Server-side download without round-trip**: Instead of downloading the file to
your server and re-uploading it elsewhere, pass the pre-signed URL to another
service.
# How it works
The function computes an HMAC-SHA256 signature over a canonical request that
includes the HTTP verb, the object path, the query parameters, and the host header.
The signature is appended to the URL as `X-Amz-Signature`. Garage (and any S3
compatible service) verifies the signature before serving the object.
# URL format
https://s3-api.your-garage-instance.com/bucket/key?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=...&X-Amz-Expires=...&X-Amz-SignedHeaders=...&X-Amz-Signature=...
# Arguments
- `storage::GarageStorage`
A storage client created with `GarageStorage(endpoint, key, secret, bucket)`.
- `key::String`
The object key (file name) in the bucket. Supports nested paths like
`"folder/subfolder/file.txt"`.
- `seconds::Int`
How long the URL remains valid, in seconds. Default: 3600 (1 hour).
# Keyword Arguments
- `verb::String = "GET"`
The HTTP method the URL permits. Use `"GET"` for downloads (the default) or
`"PUT"` for uploads. Other values like `"DELETE"` are also supported.
- `content_type::String = "application/octet-stream"`
The `Content-Type` header value that must match when the URL is used.
For PUT uploads, set this to the actual MIME type of the file you plan to
upload (e.g. `"text/plain"`, `"application/json"`, `"image/png"`).
- `signature_version::String = "v4"`
Signature algorithm version. Always use `"v4"` — version 2 is deprecated
and not supported by many S3-compatible services.
# Return
- `String`: The full pre-signed URL, ready to use with `curl`, a browser, or any
HTTP client.
# Examples
### Download link (GET — default)
```julia
storage = GarageStorage("https://s3-api.example.com", "mykey", "mysecret", "mybucket")
# 1-hour download link
url = s3_sign_url(storage, "documents/report.pdf", 3600)
println("Download link: ", url)
# Use in a browser — opens the file directly
# > open(url)
# Use with curl from the terminal
# > curl -L "url" -o report.pdf
```
### Upload link (PUT)
```julia
# Generate a PUT URL valid for 10 minutes, accepting text/plain uploads
url = s3_sign_url(storage, "uploads/user-photo.png", 600;
verb="PUT", content_type="image/png")
# The user (or your frontend) uploads a file like this:
# > curl -X PUT -H "Content-Type: image/png" --data @photo.png "url"
```
### Embedding in HTML
```julia
url = s3_sign_url(storage, "shared/shared-file.zip", 86400) # 24 hours
# Use the URL in HTML: <a href="url">Download shared-file.zip (valid 24 hours)</a>
```
### Verification — signature differs by verb and content-type
```julia
url_get = s3_sign_url(storage, "file.txt", 3600; verb="GET")
url_put = s3_sign_url(storage, "file.txt", 3600; verb="PUT", content_type="text/plain")
# url_get and url_put have different X-Amz-Signature values because the
# canonical request (and thus the HMAC) is different.
```
# Notes
- The pre-signed URL is **not encrypted** — anyone with the URL can access the
object until it expires. Treat the URL like a temporary password.
- The signature includes the current UTC time (`X-Amz-Date`), so both the
generating machine and the Garage server should have reasonably synchronized
clocks (± a few minutes tolerance).
- Only `v4` signature is implemented. Garage supports v4 natively.
- For cross-origin (CORS) uploads from a browser, you also need the Garage bucket
to have CORS rules that allow the `Origin` and `Method` headers.
# s3_sign_url vs get_file — when to use which
| Scenario | Use |
|---|---|
| Your server needs to read/write an object | `get_file(storage, key)`, `put_file(storage, key, data)` |
| A browser or external client needs to download an object | `s3_sign_url(storage, key, 3600; verb="GET")` |
| A browser or external client needs to upload a file | `s3_sign_url(storage, key, 600; verb="PUT", content_type="image/png")` |
| You need to pass a download link to a third party | `s3_sign_url(storage, key, 86400)` — no credentials shared |
`get_file` / `put_file` use your API credentials on every call.
`s3_sign_url` embeds a time-limited signature in the URL — no credentials needed.
# Complete upload workflow example
```julia
# 1. Generate a PUT pre-signed URL on your server
url = s3_sign_url(storage, "avatars/user-12345.png", 300;
verb="PUT", content_type="image/png")
# 2. Send `url` to your frontend (JSON response, React state, etc.)
# 3. Frontend uploads directly to Garage:
# fetch(url, { method: "PUT", headers: {"Content-Type": "image/png"}, body: fileBlob })
# or:
# curl -X PUT -H "Content-Type: image/png" --data @photo.png url
# 4. Optional — verify the upload succeeded
# data = get_file(storage, "avatars/user-12345.png")
```
# Complete download workflow example
```julia
# 1. Generate a GET pre-signed URL
url = s3_sign_url(storage, "reports/quarterly.pdf", 7200) # 2 hours
# 2a. Redirect user to the URL (S3 returns 302)
# return redirect(url)
# 2b. Embed in HTML link
# <a href="url" download>Download quarterly.pdf</a>
# 2c. Use with curl
# curl -L url -o quarterly.pdf
```
# Query parameters in the generated URL
| Parameter | Purpose |
|---|---|
| `X-Amz-Algorithm` | Must be `AWS4-HMAC-SHA256` |
| `X-Amz-Credential` | Your access key ID + date + region + service + request token |
| `X-Amz-Date` | Timestamp when the URL was generated (UTC, ISO 8601 format) |
| `X-Amz-Expires` | Number of seconds until expiration |
| `X-Amz-Security-Token` | Optional session token (empty if not provided) |
| `X-Amz-SignedHeaders` | Semicolon-separated list of signed headers (currently only `host`) |
| `X-Amz-Signature` | The HMAC-SHA256 signature — the core authorization token |
# Common pitfalls
- **Clock skew**: If the server and Garage are more than ~5 minutes out of sync,
the signature will be rejected.
- **Content-Type mismatch**: When using a PUT URL, the `Content-Type` sent by the
client must match the `content_type` you passed to `s3_sign_url`.
- **URL encoding**: The key is URL-encoded in the path. Keys with spaces or special
characters work correctly, but do not double-encode the returned URL.
- **Single use**: Pre-signed URLs can be used multiple times until expiration;
they are not one-time tokens.
"""
function s3_sign_url(
storage::GarageStorage,
key::String,
seconds::Int=3600;
verb::String="GET",
content_type::String="application/octet-stream",
signature_version::String="v4",
)::String
config = storage.config
creds = config.credentials
bucket = storage.bucket
region = config.region
endpoint = strip(config.endpoint, '/')
scheme = "AWS4"
algorithm = "HMAC-SHA256"
terminator = "aws4_request"
service = "s3"
now_datetime = Dates.now(Dates.UTC)
datetime_stamp = Dates.format(now_datetime, "YYYYmmddTHHMMSS\\Z")
date_stamp = Dates.format(now_datetime, "YYYYmmdd")
scope = "$date_stamp/$region/$service/$terminator"
headers = OrderedDict{String,String}("Host" => URIs.URI(endpoint).host)
sort!(headers; by=name -> lowercase(name))
canonical_header_names = join(map(name -> lowercase(name), collect(keys(headers))), ";")
query = OrderedDict{String,String}(
"X-Amz-Expires" => string(seconds),
"X-Amz-Algorithm" => "$scheme-$algorithm",
"X-Amz-Credential" => "$(creds.access_key_id)/$scope",
"X-Amz-Date" => datetime_stamp,
"X-Amz-Security-Token" => isempty(creds.token) ? "" : creds.token,
"X-Amz-SignedHeaders" => canonical_header_names,
)
sort!(query; by=name -> lowercase(name))
canonical_headers = join(
map(
header -> "$(lowercase(header.first)):$(lowercase(header.second))\n",
collect(headers),
),
)
resource_path = "/$bucket/$key"
canonical_request = string(
"$verb\n",
"$(URIs.escapeuri(resource_path))\n",
"$(URIs.escapeuri(query))\n",
"$canonical_headers\n",
"$canonical_header_names\n",
"UNSIGNED-PAYLOAD",
)
string_to_sign = string(
"$scheme-$algorithm\n",
"$datetime_stamp\n",
"$scope\n",
bytes2hex(digest(MD_SHA256, canonical_request)),
)
key_secret = string(scheme, creds.secret_key)
key_date = digest(MD_SHA256, date_stamp, key_secret)
key_region = digest(MD_SHA256, region, key_date)
key_service = digest(MD_SHA256, service, key_region)
key_signing = digest(MD_SHA256, terminator, key_service)
signature = digest(MD_SHA256, string_to_sign, key_signing)
query["X-Amz-Signature"] = bytes2hex(signature)
query_string = URIs.escapeuri(query)
return "$(endpoint)/$bucket/$key?$query_string"
end
# ===================================================================
# 3. Bucket Lifecycle Configuration