Cloudflare Workers から HMAC 認証を使って Google Cloud Storage を fetch してキャッシュする

# はじめに

バックエンドサーバーとして公開状態でないオブジェクトストレージを利用するケースで、

オリジンサーバーとして Google Cloud Storage のプライベートオブジェクトにアクセスできることを確認します。

# Google Cloud Storage で HMAC アクセスキーを発行

HMAC アクセスキーの発行は、こちらを参考にしました。

- [GCSでHMACアクセスキーを発行 - Qiita](https://qiita.com/yyyimai/items/87b7e294df70b9095e5b#gcs%E3%81%A7hmac%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%82%AD%E3%83%BC%E3%82%92%E7%99%BA%E8%A1%8C)

# Workers コード

- [kyouheicf/private-access-to-gcs](https://github.com/kyouheicf/private-access-to-gcs)

上記のコードを使います。ポイントは以下の点です

- `aws4fetch` を使います。R2 向けのサンプルコードが参考になります。
  - [aws4fetch · Cloudflare R2 docs](https://developers.cloudflare.com/r2/examples/aws/aws4fetch/)
  - [mhart/aws4fetch: A compact AWS client for modern JS environments](https://github.com/mhart/aws4fetch)
- HMAC 認証付きオリジンのようなケースでは `Authorization` ヘッダが付くため、通常の `fetch` だとキャッシュ対象外となります。
  - ["authorization" header causes "cf-cache-status: BYPASS" regardless of "cacheEverything" - Developers / Workers - Cloudflare Community](https://community.cloudflare.com/t/authorization-header-causes-cf-cache-status-bypass-regardless-of-cacheeverything/249692)
- そのため、[Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/) によるキャッシュをおこなう必要があります。こちらのサンプルコードが参考になります。
  - [Using the Cache API · Cloudflare Workers docs](https://developers.cloudflare.com/workers/examples/cache-api/#main)

```javascript:cache-api-workers.js
import { AwsClient } from 'aws4fetch'

const hostname = `${GCS_BUCKET}.storage.googleapis.com`

const aws = new AwsClient({
	accessKeyId: GCS_ACCESS_KEY_ID,
	secretAccessKey: GCS_SECRET_ACCESS_KEY,
	service: "s3",
});

addEventListener('fetch', function (event) {
	event.respondWith(handleRequest(event))
});

async function handleRequest(event) {
	const request = event.request
	const cacheUrl = new URL(request.url);
	const cacheKey = new Request(cacheUrl.toString(), request);
	const cache = caches.default;
	let response = await cache.match(cacheKey);

	if (!response) {
		console.log(
			`Response for request url: ${request.url} not present in cache. Fetching and caching request.`
		);
		const pathname = cacheUrl.pathname;
		//console.log(pathname);
		const gcsurl = `https://${hostname}${pathname}`
		const signedRequest = await aws.sign(gcsurl);
		response = await fetch(signedRequest, {
			/* This does not work because authorization header result cf-cache-status: BYPASS
			"cf": {
				// resolveOverride,
				cacheEverything: true,
				cacheTtl: 3,
			}*/
		})
		response = new Response(response.body, response);
		response.headers.set("Cache-Control", "s-maxage=30");
		//response.headers.append("Cloudflare-CDN-Cache-Control", "max-age=30")
		console.log(JSON.stringify([...request.headers]));
		console.log(JSON.stringify([...response.headers]));
		event.waitUntil(cache.put(cacheKey, response.clone()));
		console.log(`Response from Origin`);
	} else {
		console.log(`Cache hit for: ${request.url}.`);
	}
	return response;
}
```

# Workers デプロイ

GitHub からクローンしたコードと、シークレット変数設定をおこないます。

```shell:
git clone https://github.com/kyouheicf/private-access-to-gcs.git && cd $(basename $_ .git)

npm install aws4fetch
wrangler secret put GCS_BUCKET
wrangler secret put GCS_ACCESS_KEY_ID
wrangler secret put GCS_SECRET_ACCESS_KEY
```

また、以下のようにルート設定を追加します。

- [Routes - Set up a route in `wrangler.toml` · Cloudflare Workers docs](https://developers.cloudflare.com/workers/platform/triggers/routes/#set-up-a-route-in-wranglertoml)

```shell:
vi wrangler.toml
```

```toml:wrangler.toml
routes = [
	{ pattern = "private-access-to-gcs.example.com/*", zone_name = "example.com" }
]
```

以下のコマンドでデプロイします。

```shell:
wrangler publish src/cache-api-worker.js
```

# Cache API での確認

以下のコマンドで確認できます。

初回は `cf-cache-status: MISS` ですが、

```shell
% curl -sv https://private-access-to-gcs.example.com/private/Tokyo.png |& egrep "cf-cache-status|expires:|age:|cache-control"        
< cf-cache-status: MISS
< cache-control: s-maxage=30
< expires: Sun, 04 Jun 2023 19:11:34 GMT
```

次回以降は `cf-cache-status: HIT` となります。

```shell
% curl -sv https://private-access-to-gcs.example.com/private/Tokyo.png |& egrep "cf-cache-status|expires:|age:|cache-control" 
< cf-cache-status: HIT
< age: 5
< cache-control: public, max-age=14400
< expires: Sun, 04 Jun 2023 19:11:51 GMT
```

# Image Resizing リクエスト

Image Resizing Worker では `"origin-auth": "share-publicly"` のオプションが用意されているため、こちらを使います。

- [Control origin access - Authenticated origin · Cloudflare Image Optimization docs](https://developers.cloudflare.com/images/image-resizing/control-origin-access/#authenticated-origin)

Google Cloud Storage の場合、`Authorization`、`x-amz-content-sha256`、`x-amz-date` を使うため、問題ありません。

- [Simple migration from Amazon S3 to Cloud Storage - Use the Authorization header  |  Google Cloud](https://cloud.google.com/storage/docs/aws-simple-migration#authheader)

`aws4fetch` を使います。R2 向けのサンプルコードが参考になります。

- [kyouheicf/image-resizing-private-r2](https://github.com/kyouheicf/image-resizing-private-r2)

以下のコマンドでデプロイします。

```shell
wrangler publish src/image-resizing-private-gcs.js
```

```javascript:image-resizing-private-gcs.js 
import { AwsClient } from 'aws4fetch'

const hostname = `${GCS_BUCKET}.storage.googleapis.com`

const aws = new AwsClient({
	accessKeyId: GCS_ACCESS_KEY_ID,
	secretAccessKey: GCS_SECRET_ACCESS_KEY,
	service: "s3",
});

addEventListener('fetch', function (event) {
	event.respondWith(handleRequest(event.request))
});

async function handleRequest(request) {
	const url = new URL(request.url);
	const pathname = url.pathname;
	const gcsurl = `https://${hostname}${pathname}`
	const signedRequest = await aws.sign(gcsurl);
	console.log(JSON.stringify([...signedRequest.headers]));
	return await fetch(signedRequest, {
		"cf": {
			// resolveOverride,
			cacheEverything: true,
			cacheTtl: 30,
			image: {
				//anim: true,
				//background: "#RRGGBB",
				//blur: 50,
				//border: {color: "#FFFFFF", width: 10},
				//brightness: 0.5,
				//compression: "fast",
				//contrast: 0.5,
				//dpr: 1,
				//fit: "scale-down",
				//format: "webp",
				//gamma: 0.5,
				//gravity: "auto",
				//height: 250,
				//metadata: "keep",
				//onerror: "redirect",
				//quality: 50,
				rotate: 90,
				"origin-auth": "share-publicly"
				//sharpen: 2,
				//trim: {"top": 12,  "right": 78, "bottom": 34, "left": 56,},
				//width: 250,
			}
		}
	})
}
```

# Image Resizing でのキャッシュ確認

以下のコマンドで確認できます。

初回は `cf-cache-status: MISS` ですが、

```shell
% curl -sv https://private-access-to-gcs.example.com/private/Tokyo.png |& egrep "cf-cache-status|expires:|age:|cache-control|cf-resized:"
< cf-cache-status: MISS
< cache-control: private, max-age=14400
< cf-resized: internal=ok/b q=0 n=878+0 c=20+111 v=2023.5.0 l=90529
< warning: cf-images 299 "cache-control is too restrictive"
```

次回以降は `cf-cache-status: HIT` となります。

```shell
% curl -sv https://private-access-to-gcs.example.com/private/Tokyo.png |& egrep "cf-cache-status|expires:|age:|cache-control|cf-resized:"
< cf-cache-status: HIT
< cache-control: private, max-age=14400
< cf-resized: internal=ok/b q=0 n=878+0 c=20+111 v=2023.5.0 l=90529
< warning: cf-images 299 "cache-control is too restrictive"
```

この `warning` ヘッダの意味ですが、オリジンが `cache-control: private` を返しているため、オリジナルソース画像がキャッシュされない旨の内容です（リサイズ済み画像はキャッシュされる）。

`cf-resized: internal=ok/b` の `b` は、オリジナルソース画像のキャッシュステータスを表し、`b` は `BYPASS` の意味です。

（`cf-resized` は一度 `cf-cache-status： HIT` したら更新されず、リサイズパイプラインがトリガーされたときにのみ、cf-resizedヘッダが設定されます)

- [Troubleshooting - Caching and purging | Image Resizing · Cloudflare Image Optimization docs](https://developers.cloudflare.com/images/image-resizing/troubleshooting/#caching-and-purging)
> Source image is cached using regular caching rules.

- [Cloudflare cache responses](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/#cloudflare-cache-responses)
> BYPASS: The origin server instructed Cloudflare to bypass cache via a Cache-Control header set to no-cache,private, or max-age=0 even though Cloudflare originally preferred to cache the asset. BYPASS is returned when enabling Origin Cache-Control. Cloudflare also sets BYPASS when your origin web server sends cookies in the response header. If the Request to your origin includes an `Authorization` header, its response will be also BYPASS.

また、オリジンの Google Cloud Storage では、パブリック公開オブジェクト以外で `Cache-Control` をカスタム適用することができないため、今回のような認証付きプライベートオリジンに対しては、オリジナルソース画像へのリクエストが新規リサイズの必要に応じて都度発生する形になります。

- [Object metadata - Cache-Control  |  Cloud Storage  |  Google Cloud](https://cloud.google.com/storage/docs/metadata#caching_data)
> Cache-Control only applies when accessing objects that:
Are publicly accessible.

そのため、Image Resizing を使う場合には、`Cache-Control` を制御可能な形のオリジンを利用できることが推奨です。

# まとめ

バックエンドサーバーとして公開状態でないオブジェクトストレージを利用するケースで、オリジンサーバーとして Google Cloud Storage のプライベートオブジェクトにアクセスできることが無事確認できましたが、Image Resizing の際は留意する点があることが確認でき、よかったです。

# 参考リンク

- [osoderholm/gcs-cf-worker: Cloudflare Worker for accessing Google Cloud Storage objects](https://github.com/osoderholm/gcs-cf-worker)