Services

Google Cloud Storage links

Google Cloud Storage V4 signed URLs from a service-account key: browsers upload and download directly, signed without a network call.

Version 1.0.0 · 3 functions · 3 crates

#uploads#storage#gcp#files#signing

Functions

What use gcs_sign.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use gcs_download_url, gcs_upload_url from gcs_sign (Quillo 0.6).

gcs_download_url(service_account: str, bucket: str, object: str, seconds: int, at: str) → stror an error

A link that downloads object from bucket for seconds (1 to 604,800: 7 days), signed with a service account (its JSON key's text, or the file's path). at is "" for now, or a fixed start like 20260301T090000Z. Put it in an <a>, an <img> or a redirect.

gcs_upload_url(service_account: str, bucket: str, object: str, content_type: str, seconds: int, at: str) → stror an error

A link the browser PUTs a file to, as object, for seconds. content_type ("" for any) is signed too: the upload must then send that same Content-Type header, so a link for an image cannot upload a page.

gcs_signed_url(service_account: str, method: str, bucket: str, object: str, seconds: int, at: str) → stror an error

A signed URL for any method ("GET", "PUT", "DELETE", "HEAD"), for calls the server makes itself with http.request.

Get it

$ quillo install gcs_sign               # gcs_sign.qwer into this folder$ quillo install gcs_sign --example     # and its example, gcs_sign.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/gcs_sign.qwer$ curl -O https://quillolang.org/plugins/files/gcs_sign.qwe$ curl -O https://quillolang.org/plugins/files/gcs_sign_sample.json

Then, in your program, all of it or only what you call:

main.qwe
use gcs_sign.qweruse gcs_download_url, gcs_upload_url from gcs_sign      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
rsa = "0.9"sha2 = { version = "0.10", features = ["oid"] }hex = "0.4"

Example

gcs_sign.qwe
// Photos uploaded by the browser straight to a Google Cloud Storage bucket, and shown from it.////   GCS_SERVICE_ACCOUNT=key.json GCS_BUCKET=my-photos quillo run gcs_sign.qwe      then open http://localhost:8080/upload-link//   (gcs_sign_sample.json is a made-up account: its links are well formed, Google would refuse them)use "gcs_sign.qwer"let SA = env("GCS_SERVICE_ACCOUNT", "gcs_sign_sample.json")let BUCKET = env("GCS_BUCKET", "example-photos")// The page then does: fetch(put, { method: "PUT", headers: { "content-type": "image/jpeg" }, body: file })route GET /upload-link {    let key = `photos/${uuid()}.jpg`    { key, put: gcs_upload_url(SA, BUCKET, key, "image/jpeg", 600, "")?, get: gcs_download_url(SA, BUCKET, key, 3600, "")? }}route GET /photo/:name = redirect(gcs_download_url(SA, BUCKET, `photos/${name}`, 300, "")?)// A fixed time gives the same link each time: what the signature covers is easy to see.route GET /fixed = { url: gcs_download_url(SA, BUCKET, "reports/2026 Q1.pdf", 900, "20260301T090000Z")? }
$ quillo run gcs_sign.qwe$ curl 'localhost:8080/upload-link'$ curl 'localhost:8080/fixed'

Source

gcs_sign.qwer
//! Google Cloud Storage V4 signed URLs from a service-account key: browsers download or upload a file directly, for minutes or up to 7 days, with no Google library and no network call to sign.//! version: 1.0.0//!//! [dependencies]//! rsa = "0.9"//! sha2 = { version = "0.10", features = ["oid"] }//! hex = "0.4"use rsa::pkcs1v15::SigningKey;use rsa::pkcs8::DecodePrivateKey;use rsa::signature::{SignatureEncoding, Signer};use serde_json::Value;use sha2::{Digest, Sha256};const HOST: &str = "storage.googleapis.com";fn enc(s: &str, keep_slash: bool) -> String {    let mut out = String::new();    for b in s.bytes() {        if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) || (keep_slash && b == b'/') {            out.push(b as char);        } else {            out.push_str(&format!("%{:02X}", b));        }    }    out}fn civil(z: i64) -> (i64, i64, i64) {    let z = z + 719468;    let era = z.div_euclid(146097);    let doe = z - era * 146097;    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);    let mp = (5 * doy + 2) / 153;    let d = doy - (153 * mp + 2) / 5 + 1;    let m = if mp < 10 { mp + 3 } else { mp - 9 };    (yoe + era * 400 + if m <= 2 { 1 } else { 0 }, m, d)}/// `20260301T090000Z`, now or from `at` given in that form or as `2026-03-01T09:00:00Z`.fn when(at: &str) -> Result<String, String> {    if at.is_empty() {        let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);        let (y, m, d) = civil(secs.div_euclid(86400));        let r = secs.rem_euclid(86400);        return Ok(format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", y, m, d, r / 3600, r % 3600 / 60, r % 60));    }    let digits: String = at.chars().filter(|c| c.is_ascii_digit()).collect();    if digits.len() < 14 {        return Err(format!("`{}` is not a time like 20260301T090000Z", at));    }    Ok(format!("{}T{}Z", &digits[..8], &digits[8..14]))}/// The service account: its JSON text, or the path of its JSON file.fn account(service_account: &str) -> Result<(String, SigningKey<Sha256>), String> {    let text = if service_account.trim_start().starts_with('{') {        service_account.to_string()    } else {        std::fs::read_to_string(service_account).map_err(|e| format!("{}: {}", service_account, e))?    };    let v: Value = serde_json::from_str(&text).map_err(|e| format!("the service account is not JSON: {}", e))?;    let email = v["client_email"].as_str().ok_or("the service account has no client_email")?.to_string();    let pem = v["private_key"].as_str().ok_or("the service account has no private_key")?;    let key = rsa::RsaPrivateKey::from_pkcs8_pem(pem).map_err(|e| format!("the private key: {}", e))?;    Ok((email, SigningKey::<Sha256>::new(key)))}fn sign(service_account: &str, method: &str, bucket: &str, object: &str, headers: &[(&str, &str)], seconds: i64, at: &str) -> Result<String, String> {    if bucket.is_empty() || object.is_empty() {        return Err("a signed URL needs a bucket and an object name".into());    }    let (email, key) = account(service_account)?;    let stamp = when(at)?;    let scope = format!("{}/auto/storage/goog4_request", &stamp[..8]);    let mut hs: Vec<(String, String)> = headers.iter().map(|(k, v)| (k.to_lowercase(), v.trim().to_string())).collect();    hs.push(("host".into(), HOST.into()));    hs.sort();    let signed = hs.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>().join(";");    let mut q = vec![        ("X-Goog-Algorithm".to_string(), "GOOG4-RSA-SHA256".to_string()),        ("X-Goog-Credential".to_string(), format!("{}/{}", email, scope)),        ("X-Goog-Date".to_string(), stamp.clone()),        ("X-Goog-Expires".to_string(), seconds.clamp(1, 604_800).to_string()),        ("X-Goog-SignedHeaders".to_string(), signed.clone()),    ];    q.sort();    let query = q.iter().map(|(k, v)| format!("{}={}", enc(k, false), enc(v, false))).collect::<Vec<_>>().join("&");    let path = format!("/{}/{}", enc(bucket, false), enc(object, true));    let canonical = format!(        "{}\n{}\n{}\n{}\n{}\nUNSIGNED-PAYLOAD",        method.to_uppercase(), path, query, hs.iter().map(|(k, v)| format!("{}:{}\n", k, v)).collect::<String>(), signed    );    let to_sign = format!("GOOG4-RSA-SHA256\n{}\n{}\n{}", stamp, scope, hex::encode(Sha256::digest(canonical.as_bytes())));    let sig = key.try_sign(to_sign.as_bytes()).map_err(|e| e.to_string())?;    Ok(format!("https://{}{}?{}&X-Goog-Signature={}", HOST, path, query, hex::encode(sig.to_bytes())))}/// A link that downloads `object` from `bucket` for `seconds` (1 to 604,800: 7 days), signed with a service account (its JSON key's text, or the file's path)./// `at` is "" for now, or a fixed start like `20260301T090000Z`. Put it in an `<a>`, an `<img>` or a redirect.pub fn gcs_download_url(service_account: &str, bucket: &str, object: &str, seconds: i64, at: &str) -> Result<String, String> {    sign(service_account, "GET", bucket, object, &[], seconds, at)}/// A link the browser PUTs a file to, as `object`, for `seconds`. `content_type` ("" for any) is signed too:/// the upload must then send that same `Content-Type` header, so a link for an image cannot upload a page.pub fn gcs_upload_url(service_account: &str, bucket: &str, object: &str, content_type: &str, seconds: i64, at: &str) -> Result<String, String> {    if content_type.is_empty() {        sign(service_account, "PUT", bucket, object, &[], seconds, at)    } else {        sign(service_account, "PUT", bucket, object, &[("content-type", content_type)], seconds, at)    }}/// A signed URL for any method ("GET", "PUT", "DELETE", "HEAD"), for calls the server makes itself with `http.request`.pub fn gcs_signed_url(service_account: &str, method: &str, bucket: &str, object: &str, seconds: i64, at: &str) -> Result<String, String> {    if !["GET", "PUT", "DELETE", "HEAD", "POST"].contains(&method.to_uppercase().as_str()) {        return Err(format!("{} is not a method signed URLs take", method));    }    sign(service_account, method, bucket, object, &[], seconds, at)}
Azure Blob SAS links azure_sas.qwer · 1.0.0Azure Blob Storage SAS links signed from the account key: browsers upload and download blobs directly, with the permissions and lifetime you choose.uploadsstorageazurefilessigning8 views · 7 downloadsDropbox dropbox.qwer · 1.0.0Dropbox over its HTTP API: uploads in one request or by upload sessions that recover a lost answer, downloads, folders listed with cursors, shared links made or found again, a refresh token traded for short-lived tokens, and 429s waited out as Retry-After says.filesstorageoauthuploads2 views · 10 downloadsS3 links s3.qwer · 1.0.0Signed upload and download links for S3, R2, MinIO and the like: browsers move the bytes, not your server.uploadsstorages3r2files8 views · 9 downloadsContent-addressed files blob_store.qwer · 1.0.0Uploads kept in a folder by their SHA-256, as Git keeps objects: the same file stored once, names pointing at contents, atomic writes, every file verified against its hash, and contents nothing names swept away.filesstorageuploadsdedupeintegrity6 views · 7 downloadsCloudinary cloudinary.qwer · 1.0.0Cloudinary without an SDK: signed uploads from a URL or a file, the signature a browser needs for a direct upload, delivery URLs with transformations and URL signatures, webhook and upload signatures checked, and assets listed by cursor.imagesuploadscdnwebhookssigning3 views · 9 downloadsGoogle Drive google_drive.qwer · 1.0.0Google Drive's v3 API: files listed with quoted queries and page tokens, uploads by multipart or a resumable session that recovers a lost piece, downloads and Google Doc exports, sharing, and service-account JWT sign-in.filesgoogleuploadsservice-account3 views · 7 downloads
Thread
68Useful? Like it, or add to its thread below.

Thread 0 posts

On Google Cloud Storage links: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.