fix: update the prefix for the variable

This commit is contained in:
2026-08-16 18:34:59 +07:00
parent 5ef501f9fb
commit 27d8fae0f0
13 changed files with 58 additions and 85 deletions
-5
View File
@@ -1,5 +0,0 @@
FROM ubuntu:rolling
RUN apt-get update && \
apt-get install -y git curl && \
su ubuntu sh -c 'curl -fsSL https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash -'
-44
View File
@@ -1,44 +0,0 @@
{
"name": "google-drive-s3",
"build": {
"dockerfile": "./Dockerfile"
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind",
"workspaceFolder": "/workspace",
"remoteUser": "ubuntu",
"overrideCommand": true,
"postCreateCommand": "git config --global pull.rebase false && pnpm env use --global 24",
"customizations": {
"vscode": {
"extensions": ["biomejs.biome"],
"settings": {
"editor.linkedEditing": true,
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"biome.lsp.bin": "pnpm biome"
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
# S3 Signature V4 credentials held by CLI clients or your BFF
ACCESS_KEY=replace-with-access-key-id
SECRET_KEY=replace-with-long-random-secret
REGION=auto
# Google OAuth credentials with Drive API access
GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id
GOOGLE_CLIENT_SECRET=replace-with-google-oauth-client-secret
GOOGLE_REFRESH_TOKEN=replace-with-google-refresh-token
# Comma-separated, exact bucket names. Unset denies every bucket.
ALLOWED_BUCKETS=assets
# Optional comma-separated subset of ALLOWED_BUCKETS that permits unsigned GET/HEAD.
PUBLIC_READ_BUCKETS=
# Durable Object multipart uploads and ETag result behavior
ALLOW_MULTIPART=true
ETAG_STYLE=md5
# Browser CORS: comma-separated exact origins or *. Leave empty to disable CORS.
CORS_ALLOWED_ORIGINS=http://localhost:5173
# Set false to disable /docs and /openapi.yaml
ENABLE_DOCS=true
+1 -2
View File
@@ -1,5 +1,4 @@
# google-drive-s3 # google-drive-s3
I.R.I.S. (Integrated Reliable Interop Storage)
Use Cloudflare Workers to turn your Google Drive into S3 object storage at no extra cost. Use Cloudflare Workers to turn your Google Drive into S3 object storage at no extra cost.
@@ -101,7 +100,7 @@ Google Drive's free tier has 15 GB total storage, and Google applies a 750 GB da
### 5. CORS Configuration ### 5. CORS Configuration
I.R.I.S. provides native, deny-by-default CORS handling. Set `CORS_ALLOWED_ORIGINS` to a comma-separated list of exact origins: The Worker provides native, deny-by-default CORS handling. Set `CORS_ALLOWED_ORIGINS` to a comma-separated list of exact origins:
```ini ```ini
CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173 CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173
+4 -4
View File
@@ -1,6 +1,6 @@
# I.R.I.S. frontend integration docs # Google Drive S3 frontend integration docs
I.R.I.S. exposes a focused S3-compatible API on top of Google Drive. The live API reference is available from every enabled Worker deployment at [`/docs`](/docs), with the source OpenAPI document at [`/openapi.yaml`](/openapi.yaml). This project exposes a focused S3-compatible API on top of Google Drive. The live API reference is available from every enabled Worker deployment at [`/docs`](/docs), with the source OpenAPI document at [`/openapi.yaml`](/openapi.yaml).
## Start here ## Start here
@@ -12,7 +12,7 @@ I.R.I.S. exposes a focused S3-compatible API on top of Google Drive. The live AP
| Document | Purpose | | Document | Purpose |
| --- | --- | | --- | --- |
| [Integration guide](./integration-guide.md) | Browser upload, multipart upload, listing, CORS, and recommended architecture. | | [Integration guide](./integration-guide.md) | Browser upload, multipart upload, listing, CORS, and recommended architecture. |
| [Authentication](./authentication.md) | The precise AWS Signature V4 behavior enforced by I.R.I.S. | | [Authentication](./authentication.md) | The precise AWS Signature V4 behavior enforced by the Worker. |
| [Limitations](./limitations.md) | Unsupported S3 features and Google Drive operational limits. | | [Limitations](./limitations.md) | Unsupported S3 features and Google Drive operational limits. |
| [`examples/bff-presign.ts`](./examples/bff-presign.ts) | A Workers BFF endpoint that creates short-lived PUT URLs. | | [`examples/bff-presign.ts`](./examples/bff-presign.ts) | A Workers BFF endpoint that creates short-lived PUT URLs. |
| [`examples/browser-upload.ts`](./examples/browser-upload.ts) | Single object upload from a browser. | | [`examples/browser-upload.ts`](./examples/browser-upload.ts) | Single object upload from a browser. |
@@ -23,7 +23,7 @@ I.R.I.S. exposes a focused S3-compatible API on top of Google Drive. The live AP
```text ```text
Browser ── POST /presign ──> BFF (holds SECRET_KEY) Browser ── POST /presign ──> BFF (holds SECRET_KEY)
│ │ │ │
└──── presigned S3 request ────┴──> I.R.I.S. Worker ──> Google Drive └──── presigned S3 request ────┴──> S3 Google Drive Worker ──> Google Drive
``` ```
The browser never receives `SECRET_KEY`. Presigned URLs must be short lived and scoped to the single object operation the browser needs. The browser never receives `SECRET_KEY`. Presigned URLs must be short lived and scoped to the single object operation the browser needs.
+2 -2
View File
@@ -1,6 +1,6 @@
# Authentication and Signature V4 # Authentication and Signature V4
I.R.I.S. accepts AWS Signature Version 4 in either form: The Worker accepts AWS Signature Version 4 in either form:
- an `Authorization: AWS4-HMAC-SHA256 …` header with `x-amz-date`; or - an `Authorization: AWS4-HMAC-SHA256 …` header with `x-amz-date`; or
- a presigned request with `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-SignedHeaders`, and `X-Amz-Signature` query parameters. - a presigned request with `X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-SignedHeaders`, and `X-Amz-Signature` query parameters.
@@ -28,7 +28,7 @@ The canonical request is built from:
5. the signed-header list; and 5. the signed-header list; and
6. `x-amz-content-sha256`, defaulting to `UNSIGNED-PAYLOAD`. 6. `x-amz-content-sha256`, defaulting to `UNSIGNED-PAYLOAD`.
I.R.I.S. deliberately canonicalizes a signed `accept-encoding` header to `identity`. Cloudflare can rewrite the received value at the edge; S3 SDKs that sign this header use `identity` for this reason. Browser code must not sign `accept-encoding` because browser networking controls it. The Worker deliberately canonicalizes a signed `accept-encoding` header to `identity`. Cloudflare can rewrite the received value at the edge; S3 SDKs that sign this header use `identity` for this reason. Browser code must not sign `accept-encoding` because browser networking controls it.
Payload hashes are **not** verified. Browser and BFF clients should use `x-amz-content-sha256: UNSIGNED-PAYLOAD`; this is a deliberate streaming limitation, not an integrity guarantee. Payload hashes are **not** verified. Browser and BFF clients should use `x-amz-content-sha256: UNSIGNED-PAYLOAD`; this is a deliberate streaming limitation, not an integrity guarantee.
+13 -13
View File
@@ -2,11 +2,11 @@ import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMult
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
interface Env { interface Env {
IRIS_ENDPOINT: string; S3_ENDPOINT: string;
IRIS_ACCESS_KEY: string; S3_ACCESS_KEY: string;
IRIS_SECRET_KEY: string; S3_SECRET_KEY: string;
IRIS_REGION: string; S3_REGION: string;
IRIS_BUCKET: string; S3_BUCKET: string;
} }
interface PresignRequest { interface PresignRequest {
@@ -33,10 +33,10 @@ function json(data: unknown, status = 200): Response {
function s3(env: Env): S3Client { function s3(env: Env): S3Client {
return new S3Client({ return new S3Client({
endpoint: env.IRIS_ENDPOINT, endpoint: env.S3_ENDPOINT,
region: env.IRIS_REGION, region: env.S3_REGION,
forcePathStyle: true, forcePathStyle: true,
credentials: { accessKeyId: env.IRIS_ACCESS_KEY, secretAccessKey: env.IRIS_SECRET_KEY }, credentials: { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY },
}); });
} }
@@ -62,13 +62,13 @@ export default {
if (operation === "put") { if (operation === "put") {
const contentType = input.contentType || "application/octet-stream"; const contentType = input.contentType || "application/octet-stream";
const url = await getSignedUrl(client, new PutObjectCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn }); const url = await getSignedUrl(client, new PutObjectCommand({ Bucket: env.S3_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn });
return json({ url, method: "PUT", headers: { "Content-Type": contentType }, expiresIn }); return json({ url, method: "PUT", headers: { "Content-Type": contentType }, expiresIn });
} }
if (operation === "createMultipart") { if (operation === "createMultipart") {
const contentType = input.contentType || "application/octet-stream"; const contentType = input.contentType || "application/octet-stream";
const url = await getSignedUrl(client, new CreateMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn }); const url = await getSignedUrl(client, new CreateMultipartUploadCommand({ Bucket: env.S3_BUCKET, Key: input.key, ContentType: contentType }), { expiresIn });
return json({ url, method: "POST", headers: { "Content-Type": contentType }, expiresIn }); return json({ url, method: "POST", headers: { "Content-Type": contentType }, expiresIn });
} }
@@ -76,17 +76,17 @@ export default {
if (operation === "uploadPart") { if (operation === "uploadPart") {
const partNumber = input.partNumber; const partNumber = input.partNumber;
if (!Number.isInteger(partNumber) || partNumber === undefined || partNumber < 1 || partNumber > 10_000) return json({ error: "partNumber must be 1 through 10000" }, 400); if (!Number.isInteger(partNumber) || partNumber === undefined || partNumber < 1 || partNumber > 10_000) return json({ error: "partNumber must be 1 through 10000" }, 400);
const url = await getSignedUrl(client, new UploadPartCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId, PartNumber: partNumber }), { expiresIn }); const url = await getSignedUrl(client, new UploadPartCommand({ Bucket: env.S3_BUCKET, Key: input.key, UploadId: input.uploadId, PartNumber: partNumber }), { expiresIn });
return json({ url, method: "PUT", expiresIn }); return json({ url, method: "PUT", expiresIn });
} }
if (operation === "completeMultipart") { if (operation === "completeMultipart") {
const body = completionXml(input.parts); const body = completionXml(input.parts);
if (!body) return json({ error: "parts must have sequential PartNumber values beginning at 1 and non-empty ETag values" }, 400); if (!body) return json({ error: "parts must have sequential PartNumber values beginning at 1 and non-empty ETag values" }, 400);
const url = await getSignedUrl(client, new CompleteMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId, MultipartUpload: { Parts: input.parts } }), { expiresIn }); const url = await getSignedUrl(client, new CompleteMultipartUploadCommand({ Bucket: env.S3_BUCKET, Key: input.key, UploadId: input.uploadId, MultipartUpload: { Parts: input.parts } }), { expiresIn });
return json({ url, method: "POST", headers: { "Content-Type": "application/xml" }, body, expiresIn }); return json({ url, method: "POST", headers: { "Content-Type": "application/xml" }, body, expiresIn });
} }
if (operation === "abortMultipart") { if (operation === "abortMultipart") {
const url = await getSignedUrl(client, new AbortMultipartUploadCommand({ Bucket: env.IRIS_BUCKET, Key: input.key, UploadId: input.uploadId }), { expiresIn }); const url = await getSignedUrl(client, new AbortMultipartUploadCommand({ Bucket: env.S3_BUCKET, Key: input.key, UploadId: input.uploadId }), { expiresIn });
return json({ url, method: "DELETE", expiresIn }); return json({ url, method: "DELETE", expiresIn });
} }
+1 -1
View File
@@ -21,7 +21,7 @@ function uploadIdFromXml(xml: string): string {
return value; return value;
} }
/** Uploads parts in order. I.R.I.S. does not allow parallel or out-of-order part uploads. */ /** Uploads parts in order. The Worker does not allow parallel or out-of-order part uploads. */
export async function uploadLargeFile(file: File, key: string, partSize = 16 * 1024 * 1024): Promise<void> { export async function uploadLargeFile(file: File, key: string, partSize = 16 * 1024 * 1024): Promise<void> {
const contentType = file.type || "application/octet-stream"; const contentType = file.type || "application/octet-stream";
const create = await presign({ key, contentType, operation: "createMultipart" }); const create = await presign({ key, contentType, operation: "createMultipart" });
+8 -8
View File
@@ -5,7 +5,7 @@
Use a backend-for-frontend (BFF) as the trust boundary: Use a backend-for-frontend (BFF) as the trust boundary:
```text ```text
Browser → BFF (holds ACCESS_KEY + SECRET_KEY, issues presigned URLs) → I.R.I.S. Worker → Google Drive Browser → BFF (holds ACCESS_KEY + SECRET_KEY, issues presigned URLs) → S3 Google Drive Worker → Google Drive
``` ```
Never put `SECRET_KEY` in browser code, a static site, or a public environment variable. The browser receives only a short-lived presigned URL for one method and object key. Never put `SECRET_KEY` in browser code, a static site, or a public environment variable. The browser receives only a short-lived presigned URL for one method and object key.
@@ -18,7 +18,7 @@ Configure the Worker with an explicit allow-list of web origins:
CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173 CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173
``` ```
Set it to `*` only for a genuinely public, credential-free integration. Leaving it unset emits no CORS headers. I.R.I.S. exposes `ETag`, `Content-Range`, `Content-Length`, `Last-Modified`, `Accept-Ranges`, and `x-amz-request-id`, so browser code can inspect the headers that form the effective S3 response payload. Set it to `*` only for a genuinely public, credential-free integration. Leaving it unset emits no CORS headers. The Worker exposes `ETag`, `Content-Range`, `Content-Length`, `Last-Modified`, `Accept-Ranges`, and `x-amz-request-id`, so browser code can inspect the headers that form the effective S3 response payload.
### Server-to-server compatibility ### Server-to-server compatibility
@@ -28,18 +28,18 @@ An `OPTIONS` request with no `Origin`, or a preflight from a disallowed origin,
## Presign in the BFF ## Presign in the BFF
Use `@aws-sdk/s3-request-presigner` and send a limited expiry. I.R.I.S. validates `X-Amz-Expires` and rejects URLs after their signing time plus that value. Use `@aws-sdk/s3-request-presigner` and send a limited expiry. The Worker validates `X-Amz-Expires` and rejects URLs after their signing time plus that value.
```ts ```ts
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const client = new S3Client({ const client = new S3Client({
endpoint: env.IRIS_ENDPOINT, endpoint: env.S3_ENDPOINT,
region: env.IRIS_REGION, region: env.S3_REGION,
credentials: { credentials: {
accessKeyId: env.IRIS_ACCESS_KEY, accessKeyId: env.S3_ACCESS_KEY,
secretAccessKey: env.IRIS_SECRET_KEY, secretAccessKey: env.S3_SECRET_KEY,
}, },
forcePathStyle: true, forcePathStyle: true,
}); });
@@ -78,7 +78,7 @@ const etag = response.headers.get("ETag");
Cloudflare request limits mean a single PUT is best kept below roughly 100 MB. For larger files, have the BFF initiate the upload, issue a presigned URL for each part, and complete the upload after the browser returns all ETags. Cloudflare request limits mean a single PUT is best kept below roughly 100 MB. For larger files, have the BFF initiate the upload, issue a presigned URL for each part, and complete the upload after the browser returns all ETags.
Use 16 MB parts as a practical baseline. Upload parts **strictly sequentially starting at 1**. I.R.I.S. waits up to 20 seconds for an earlier part; out-of-order work then receives `503 SlowDown`. Requests more than 64 part numbers ahead are rejected immediately. Use 16 MB parts as a practical baseline. Upload parts **strictly sequentially starting at 1**. The Worker waits up to 20 seconds for an earlier part; out-of-order work then receives `503 SlowDown`. Requests more than 64 part numbers ahead are rejected immediately.
1. BFF signs `POST /bucket/key?uploads` and starts the multipart upload. 1. BFF signs `POST /bucket/key?uploads` and starts the multipart upload.
2. BFF issues a signed `PUT` URL for part 1; browser uploads it and records the `ETag`. 2. BFF issues a signed `PUT` URL for part 1; browser uploads it and records the `ETag`.
+3 -3
View File
@@ -1,10 +1,10 @@
# Limitations # Limitations
I.R.I.S. is a narrow S3 compatibility layer. Design frontend features around the behavior below. This project is a narrow S3 compatibility layer. Design frontend features around the behavior below.
## Listing is not pageable ## Listing is not pageable
`max-keys`, `continuation-token`, and `marker` are ignored. I.R.I.S. scans at most 5000 Google Drive nodes and then sets `IsTruncated=true`; it does not provide a continuation token, so additional results cannot be requested. The XML value `MaxKeys=1000` is a fixed compatibility value, not the real scan limit. `max-keys`, `continuation-token`, and `marker` are ignored. The Worker scans at most 5000 Google Drive nodes and then sets `IsTruncated=true`; it does not provide a continuation token, so additional results cannot be requested. The XML value `MaxKeys=1000` is a fixed compatibility value, not the real scan limit.
Use narrow prefixes and `delimiter=/` for a file browser. Do not build an infinite-scrolling full-drive browser. Use narrow prefixes and `delimiter=/` for a file browser. Do not build an infinite-scrolling full-drive browser.
@@ -24,7 +24,7 @@ Bucket `?acl`, `?versioning`, and `?location` requests are currently treated as
## Directories and objects ## Directories and objects
Directories are physical Google Drive folders created from object-key path segments. I.R.I.S. does not create zero-byte directory marker objects. An empty prefix can therefore exist as a Drive folder without an S3 marker object. Directories are physical Google Drive folders created from object-key path segments. The Worker does not create zero-byte directory marker objects. An empty prefix can therefore exist as a Drive folder without an S3 marker object.
## Multipart behavior ## Multipart behavior
+1 -1
View File
@@ -1,6 +1,6 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: I.R.I.S. Google Drive S3 API title: Google Drive S3 API
version: 1.0.0 version: 1.0.0
description: | description: |
An intentionally small S3-compatible object-storage API backed by Google Drive. An intentionally small S3-compatible object-storage API backed by Google Drive.
+1 -1
View File
@@ -19,7 +19,7 @@ export function docsResponse(): Response {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>I.R.I.S. S3 API reference</title> <title>Google Drive S3 API reference</title>
</head> </head>
<body> <body>
<script id="api-reference" data-url="${OPENAPI_PATH}"></script> <script id="api-reference" data-url="${OPENAPI_PATH}"></script>
+1 -1
View File
@@ -4,7 +4,7 @@
*/ */
{ {
"$schema": "node_modules/wrangler/config-schema.json", "$schema": "node_modules/wrangler/config-schema.json",
"name": "iris", "name": "s3-google-drive",
"main": "src/index.ts", "main": "src/index.ts",
"compatibility_date": "2025-09-27", "compatibility_date": "2025-09-27",
"vars": { "vars": {