Cloudflare WorkersでGCSの署名付きURLを作成する
GCS に画像をアップロードする場合サーバー側で Google Cloud SDK を使って署名付き URL を作成すれば簡単に実装できます。
import {Storage} from '@google-cloud/storage';
const options = {
version: 'v4',
action: 'write',
expires: Date.now() + 15 * 60 * 1000, // 15 minutes
contentType: 'image/png',
};
const [url] = await storage
.bucket('my-bucket-name')
.file('example.png')
.getSignedUrl(options);
ただ、@google-cloud/storage
は Node.js 環境向けなのでfs
などの Node.js 組み込み関数が使われており、Cloudflare Workers/Cloudflare Pages Functions では実行できませんでした。
Hono のルートを作成する
今回は公式のドキュメントにある 独自のプログラムを使用した V4 署名プロセス を参考に自前で署名付き URL の作成を行います。
/src/routes/api.ts
import {zValidator} from '@hono/zod-validator';
import {Hono} from 'hono';
import {env} from 'hono/adapter';
import {z} from 'zod';
import {generateSignedUrl} from '../services/cloudStorage';
type Bindings = {
SERVICE_ACCOUNT_KEY_JSON: string;
};
export const apiRoutes = new Hono<{Bindings: Bindings}>().get(
'/signed-url',
zValidator(
'query',
z.object({'object-name': z.string(), 'file-type': z.string()})
),
async c => {
const {SERVICE_ACCOUNT_KEY_JSON} = env(c);
const serviceAccountKey = JSON.parse(SERVICE_ACCOUNT_KEY_JSON);
const signedUrl = await generateSignedUrl({
bucketName: 'my-bucket-name', // 保存したいバケットの名前
objectName: c.req.valid('query')['object-name'], // queryで受け取ったファイル名 (例: example.png)
fileType: c.req.valid('query')['file-type'], // queryで受け取ったContent-Type (例: 'image/png)
serviceAccountKey,
});
return c.json({signedUrl});
}
);
Hono を使ってルートを追加する例です。hono/adapter
を使用すると、異なる環境での環境変数の呼び出し方が統一されます。
Google Cloud SDK を使用せずに署名付き URL を発行するため、GOOGLE_APPLICATION_CREDENTIALS=./service-account-key.json
のようにファイルを読み込む必要がない点が便利です。
.dev.vars
SERVICE_ACCOUNT_KEY_JSON = {"type":"service_account","project_id": // 以下略
ローカルでは.dev.vars
に上記のように書き込み、Cloudflare 上では設定の環境変数からSERVICE_ACCOUNT_KEY_JSON
を設定します。
改行なしで 1 行にしておいてください。
署名付き URL を発行する
services/cloudStorage.ts
interface ServiceAccountKey {
type: string;
project_id: string;
private_key_id: string;
private_key: string;
client_email: string;
client_id: string;
auth_uri: string;
token_uri: string;
auth_provider_x509_cert_url: string;
client_x509_cert_url: string;
universe_domain: string;
}
export const generateSignedUrl = async ({
bucketName,
objectName,
fileType,
expiration = 15 * 60, // 15 minutes
httpMethod = 'PUT',
serviceAccountKey,
}: GenerateSignedUrlOptions): Promise<string> => {
if (expiration > 15 * 60) {
throw new Error(
"Expiration Time can't be longer than 900 seconds (15 minutes)."
);
}
const datetimeNow = new Date();
const requestTimestamp = datetimeNow
.toISOString()
.replace(/[:-]|\.\d{3}/g, '');
const datestamp = requestTimestamp.slice(0, 8);
const clientEmail = serviceAccountKey.client_email;
const credentialScope = `${datestamp}/auto/storage/goog4_request`;
const credential = `${clientEmail}/${credentialScope}`;
const canonicalUri = `/${objectName}`;
const host = `${bucketName}.storage.googleapis.com`;
const canonicalHeaders = `content-type:${fileType}\nhost:${host}\n`;
const signedHeaders = 'content-type;host';
const queryParameters = {
'X-Goog-Algorithm': 'GOOG4-RSA-SHA256',
'X-Goog-Credential': credential,
'X-Goog-Date': requestTimestamp,
'X-Goog-Expires': expiration.toString(),
'X-Goog-SignedHeaders': signedHeaders,
};
const canonicalQueryString = new URLSearchParams(queryParameters).toString();
const canonicalRequest = [
httpMethod,
canonicalUri,
canonicalQueryString,
canonicalHeaders,
signedHeaders,
'UNSIGNED-PAYLOAD',
].join('\n');
const canonicalRequestHash = SHA256(canonicalRequest).toString(enc.Hex);
const stringToSign = [
'GOOG4-RSA-SHA256',
requestTimestamp,
credentialScope,
canonicalRequestHash,
].join('\n');
const sign = await signString(serviceAccountKey.private_key, stringToSign); // signString()は後述
// 出来上がった署名付きURL
const signedUrl = `https://${bucketName}.storage.googleapis.com/${objectName}?${canonicalQueryString}&x-goog-signature=${sign}`;
return signedUrl;
};
公式の Python コードを参考に TypeScript で再実装していきます。
この辺りは仕様通りに詰めていくだけなので print()
や console.log()
しながら進めればスムーズに実装できます。
signString()
の部分がが難しく、Python と同じように実装するのが困難です。また、 Node.js と V8(Cloudflare Workers) で実行環境を考慮する必要があります。
この関数では、SHA256 でハッシュ化した値とサービスアカウントの private_key を使用します。
services/cloudStorage.ts
const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
const binaryString = ((): string => {
if (typeof Buffer !== 'undefined') {
// NOTE: BufferはNode.jsの関数なのでWorkersのV8環境には存在しない
return Buffer.from(base64, 'base64').toString('binary');
// biome-ignore lint/style/noUselessElse: <explanation>
} else {
// Cloudflare Workers and browsers (V8)
return atob(base64);
}
})();
const bytes = new Uint8Array(binaryString.length);
const uint8Array = binaryString.split('').reduce((acc, char, index) => {
acc[index] = char.charCodeAt(0);
return acc;
}, bytes);
return uint8Array.buffer;
};
const importPrivateKey = async (pem: string): Promise<CryptoKey> => {
const pemHeader = '-----BEGIN PRIVATE KEY-----';
const pemFooter = '-----END PRIVATE KEY-----';
const pemContents = pem.substring(
pemHeader.length,
pem.length - pemFooter.length - 1
);
const binaryDer = base64ToArrayBuffer(pemContents.replace(/\s/g, ''));
return crypto.subtle.importKey(
'pkcs8',
binaryDer,
{name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256'},
true,
['sign']
);
};
const signString = async (
privateKeyPem: string,
stringToSign: string
): Promise<string> => {
const privateKey = await importPrivateKey(privateKeyPem);
const encoder = new TextEncoder();
const data = encoder.encode(stringToSign);
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
privateKey,
data
);
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
};
渡されたハッシュ値を PKCS#1 v1.5 パディングを適用します。これでハッシュ値に特定の形式のパディングが追加され署名に適した長さになります。
signString()
は RSA プライベートキーを使用して文字列に署名する関数 signString を示しています。署名は RSA-SHA256 アルゴリズムと PKCS1v1.5 パディングを使用して生成され、その結果を 16 進数の文字列として返します。
importPrivateKey()
は privateKeyPem から prefix,suffix を取り除いて CryptoKey オブジェクトにしています。
binaryString()
は Base64 文字列を ArrayBuffer に変換する関数です。
Vite で実行した Node.js 環境と wrangler/Cloudflare で実行してる V8 で場合分けしています。
以上で Cloudflare Workers で署名付き URL が作成できるようになりました。
署名付き URL を使って画像をアップロードする
クライアント側では上記で作成した API から署名付き URL を取得し、fetch()
でファイルを PUT で送れば GCS にアップロードができます。
const name: string = 'example.png'; // アップロード後のファイル名
const file: File; // Fileオブジェクトを入れる
honoClient.api['signed-url']
.$get({query: {'object-name': name, 'file-type': file.type}})
.then(async res => {
const {signedUrl} = await res.json();
fetch(signedUrl, {
method: 'PUT',
headers: {'Content-Type': file.type},
body: file,
}).then(() => {
console.log('success');
});
});
Discussion