This commit is contained in:
nexryai
2026-01-08 04:13:18 +00:00
committed by GitHub
parent 869398048b
commit f377a974b0
3 changed files with 515 additions and 81 deletions
+300 -46
View File
@@ -1,10 +1,11 @@
/**
* S3-Compatible API Server on Cloudflare Workers
* Supports both Header-based and Presigned URL authentication
* Backend: Google Drive with streaming support
*/
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const start = performance.now();
try {
const isValid = await verifySignature(request, env);
if (!isValid) {
@@ -14,21 +15,73 @@ export default {
const url = new URL(request.url);
const method = request.method;
// ここでバックエンドストレージとの接続処理を実装
// 例: R2, KV, または外部ストレージへのプロキシ
// パスからバケット名とオブジェクトキーを抽出
const pathParts = url.pathname.split("/").filter((p) => p);
const bucket = pathParts[0] || "";
const objectKey = pathParts.slice(1).join("/");
const accessToken = await getAccessToken(env);
if (method === "PUT" || method === "POST") {
// ストリーミングアップロード処理
// await uploadToBackend(request.body, url.pathname);
return new Response(`Verified ${method} for ${url.pathname}`, { status: 200 });
// ストリーミングアップロード
if (!objectKey) {
return new Response("Object key required", { status: 400 });
}
const contentType = request.headers.get("Content-Type") || "application/octet-stream";
const result = await streamUploadToDrive(accessToken, request.body, bucket, objectKey, contentType, env);
return new Response(JSON.stringify(result), {
status: 200,
headers: {
"Content-Type": "application/json",
ETag: `"${result.id}"`, // Google DriveのファイルIDをETagとして使用
},
});
} else if (method === "GET") {
// ストリーミングダウンロード処理
// const stream = await downloadFromBackend(url.pathname);
// return new Response(stream, { status: 200 });
return new Response(`Verified ${method} for ${url.pathname}`, { status: 200 });
// ストリーミングダウンロード
if (!objectKey) {
// バケット(フォルダ)の一覧を返す
const files = await listFiles(accessToken, bucket, env);
return new Response(generateListBucketResult(files, bucket), {
status: 200,
headers: { "Content-Type": "application/xml" },
});
}
const fileStream = await streamDownloadFromDrive(accessToken, bucket, objectKey, env);
return new Response(fileStream.body, {
status: 200,
headers: {
"Content-Type": fileStream.contentType,
"Content-Length": fileStream.size.toString(),
ETag: `"${fileStream.id}"`,
},
});
} else if (method === "DELETE") {
// 204 No Content はボディを持てない
// ファイル削除
if (!objectKey) {
return new Response("Object key required", { status: 400 });
}
await deleteFromDrive(accessToken, bucket, objectKey, env);
return new Response(null, { status: 204 });
} else if (method === "HEAD") {
// メタデータ取得
if (!objectKey) {
return new Response(null, { status: 400 });
}
const metadata = await getFileMetadata(accessToken, bucket, objectKey, env);
return new Response(null, {
status: 200,
headers: {
"Content-Type": metadata.mimeType,
"Content-Length": metadata.size.toString(),
ETag: `"${metadata.id}"`,
},
});
}
return new Response("Method not allowed", { status: 405 });
@@ -36,6 +89,11 @@ export default {
const error = e as Error;
console.error("Error:", error);
return new Response(error.message, { status: 500 });
} finally {
const end = performance.now();
const cpuUsed = end - start;
console.log(`CPU time used: ${cpuUsed}ms`);
}
},
} satisfies ExportedHandler<Env>;
@@ -44,14 +102,242 @@ interface Env {
ACCESS_KEY: string;
SECRET_KEY: string;
REGION: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GOOGLE_REFRESH_TOKEN: string;
AUTH_KV: KVNamespace;
FOLDER_CACHE: KVNamespace; // バケット名→フォルダIDのキャッシュ
}
// ========================================
// Google Drive API Functions
// ========================================
async function getAccessToken(env: Env): Promise<string> {
const cacheKey = "google_access_token";
const cachedToken = await env.AUTH_KV.get(cacheKey);
if (cachedToken) {
return cachedToken;
}
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
refresh_token: env.GOOGLE_REFRESH_TOKEN,
grant_type: "refresh_token",
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(`Token Error: ${data.error_description}`);
}
await env.AUTH_KV.put(cacheKey, data.access_token, {
expirationTtl: data.expires_in - 60,
});
return data.access_token;
}
async function getOrCreateFolder(accessToken: string, folderName: string, env: Env): Promise<string> {
// キャッシュを確認
const cached = await env.FOLDER_CACHE.get(folderName);
if (cached) return cached;
// フォルダを検索
const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=name='${encodeURIComponent(folderName)}' and mimeType='application/vnd.google-apps.folder' and trashed=false`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const searchData = await searchRes.json();
if (searchData.files && searchData.files.length > 0) {
const folderId = searchData.files[0].id;
await env.FOLDER_CACHE.put(folderName, folderId, { expirationTtl: 3600 });
return folderId;
}
// フォルダが存在しない場合は作成
const createRes = await fetch("https://www.googleapis.com/drive/v3/files", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: folderName,
mimeType: "application/vnd.google-apps.folder",
}),
});
const createData = await createRes.json();
await env.FOLDER_CACHE.put(folderName, createData.id, { expirationTtl: 3600 });
return createData.id;
}
async function streamUploadToDrive(accessToken: string, stream: ReadableStream | null, bucket: string, objectKey: string, mimeType: string, env: Env): Promise<any> {
if (!stream) {
throw new Error("Request body is required");
}
// バケット名に対応するフォルダIDを取得
const folderId = await getOrCreateFolder(accessToken, bucket, env);
// Resumable uploadの初期化
const initRes = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"X-Upload-Content-Type": mimeType,
"Content-Type": "application/json; charset=UTF-8",
},
body: JSON.stringify({
name: objectKey,
parents: [folderId],
}),
});
const uploadUrl = initRes.headers.get("Location");
if (!uploadUrl) {
throw new Error("Failed to get upload URL");
}
// ストリーミングアップロード
const uploadRes = await fetch(uploadUrl, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: stream,
duplex: "half" as any,
});
if (!uploadRes.ok) {
const errorText = await uploadRes.text();
throw new Error(`Upload failed: ${errorText}`);
}
return await uploadRes.json();
}
async function findFileInFolder(accessToken: string, folderId: string, fileName: string): Promise<any> {
const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=name='${encodeURIComponent(fileName)}' and '${folderId}' in parents and trashed=false&fields=files(id,name,mimeType,size)`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await searchRes.json();
return data.files && data.files.length > 0 ? data.files[0] : null;
}
async function streamDownloadFromDrive(accessToken: string, bucket: string, objectKey: string, env: Env): Promise<{ body: ReadableStream; contentType: string; size: number; id: string }> {
const folderId = await getOrCreateFolder(accessToken, bucket, env);
const file = await findFileInFolder(accessToken, folderId, objectKey);
if (!file) {
throw new Error("File not found");
}
const downloadRes = await fetch(`https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!downloadRes.ok) {
throw new Error("Download failed");
}
return {
body: downloadRes.body!,
contentType: file.mimeType || "application/octet-stream",
size: parseInt(file.size || "0"),
id: file.id,
};
}
async function deleteFromDrive(accessToken: string, bucket: string, objectKey: string, env: Env): Promise<void> {
const folderId = await getOrCreateFolder(accessToken, bucket, env);
const file = await findFileInFolder(accessToken, folderId, objectKey);
if (!file) {
throw new Error("File not found");
}
const deleteRes = await fetch(`https://www.googleapis.com/drive/v3/files/${file.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!deleteRes.ok) {
throw new Error("Delete failed");
}
}
async function getFileMetadata(accessToken: string, bucket: string, objectKey: string, env: Env): Promise<{ id: string; mimeType: string; size: number }> {
const folderId = await getOrCreateFolder(accessToken, bucket, env);
const file = await findFileInFolder(accessToken, folderId, objectKey);
if (!file) {
throw new Error("File not found");
}
return {
id: file.id,
mimeType: file.mimeType || "application/octet-stream",
size: parseInt(file.size || "0"),
};
}
async function listFiles(accessToken: string, bucket: string, env: Env): Promise<any[]> {
const folderId = await getOrCreateFolder(accessToken, bucket, env);
const listRes = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}' in parents and trashed=false&fields=files(id,name,mimeType,size,modifiedTime)`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await listRes.json();
return data.files || [];
}
function generateListBucketResult(files: any[], bucket: string): string {
const contents = files
.map(
(f) => `
<Contents>
<Key>${escapeXml(f.name)}</Key>
<LastModified>${f.modifiedTime || new Date().toISOString()}</LastModified>
<ETag>"${f.id}"</ETag>
<Size>${f.size || 0}</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>`,
)
.join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>${escapeXml(bucket)}</Name>
<Prefix></Prefix>
<MaxKeys>1000</MaxKeys>
<IsTruncated>false</IsTruncated>
${contents}
</ListBucketResult>`;
}
function escapeXml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
// ========================================
// AWS Signature V4 Verification
// ========================================
async function verifySignature(request: Request, env: Env): Promise<boolean> {
const start = performance.now();
const url = new URL(request.url);
const headers = request.headers;
// Query-based auth (Presigned URL) かどうか判定
const isQueryAuth = url.searchParams.has("X-Amz-Algorithm");
let algorithm: string;
@@ -66,27 +352,22 @@ async function verifySignature(request: Request, env: Env): Promise<boolean> {
return false;
}
// 日時情報の取得
const datetime = (isQueryAuth ? url.searchParams.get("X-Amz-Date") : headers.get("x-amz-date")) ?? "";
if (!datetime) return false;
const date = datetime.substring(0, 8);
// Canonical Request の生成
const canonicalRequest = await createCanonicalRequest(request, isQueryAuth);
const hashedCanonicalRequest = await sha256(canonicalRequest);
// String to Sign の生成
const credentialScope = `${date}/${env.REGION}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", datetime, credentialScope, hashedCanonicalRequest].join("\n");
// 署名の計算
const signingKey = await getSigningKey(env.SECRET_KEY, date, env.REGION, "s3");
const signature = await hmacSha256(signingKey, stringToSign);
const signatureHex = bufToHex(signature);
// 期待される署名の取得
let expectedSignature = "";
if (isQueryAuth) {
expectedSignature = url.searchParams.get("X-Amz-Signature") ?? "";
@@ -96,38 +377,18 @@ async function verifySignature(request: Request, env: Env): Promise<boolean> {
expectedSignature = match ? match[1] : "";
}
// デバッグ用
if (signatureHex !== expectedSignature) {
console.log("Signature mismatch!");
console.log("Canonical Request:", canonicalRequest);
console.log("String to Sign:", stringToSign);
console.log("Computed:", signatureHex);
console.log("Expected:", expectedSignature);
}
const end = performance.now();
const cpuUsed = end - start;
console.debug(`CPU time used: ${cpuUsed}ms`);
return signatureHex === expectedSignature;
}
async function createCanonicalRequest(request: Request, isQueryAuth: boolean): Promise<string> {
const url = new URL(request.url);
// HTTPメソッド
const method = request.method;
// Canonical URI (パス部分)
const canonicalUri = url.pathname || "/";
// Canonical Query String
// AWS署名バージョン4では、パラメータ名の大文字小文字を区別してソート
const params = Array.from(url.searchParams.entries())
.filter(([key]) => key !== "X-Amz-Signature") // 署名自体は除外
.filter(([key]) => key !== "X-Amz-Signature")
.sort(([a], [b]) => {
// バイナリソート(大文字小文字を区別)
if (a < b) return -1;
if (a > b) return 1;
return 0;
@@ -135,7 +396,6 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
.map(([key, val]) => `${encodeRFC3986(key)}=${encodeRFC3986(val)}`)
.join("&");
// Signed Headers の取得
let signedHeadersList: string[];
if (isQueryAuth) {
signedHeadersList = (url.searchParams.get("X-Amz-SignedHeaders") ?? "host").split(";");
@@ -145,16 +405,13 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
signedHeadersList = match ? match[1].split(";") : ["host"];
}
// Canonical Headers の生成
const canonicalHeaders = signedHeadersList
.map((h) => {
const headerName = h.toLowerCase();
let headerValue = "";
if (headerName === "host") {
// ホストヘッダーをそのまま使用(URLのhostnameを使用)
headerValue = url.hostname;
// 非標準ポートの場合はポート番号を追加
const port = url.port;
if (port && !((url.protocol === "https:" && port === "443") || (url.protocol === "http:" && port === "80"))) {
headerValue += `:${port}`;
@@ -168,14 +425,11 @@ async function createCanonicalRequest(request: Request, isQueryAuth: boolean): P
.join("");
const signedHeaders = signedHeadersList.join(";");
// Payload Hash
const payloadHash = request.headers.get("x-amz-content-sha256") ?? (isQueryAuth ? "UNSIGNED-PAYLOAD" : "UNSIGNED-PAYLOAD");
return [method, canonicalUri, params, canonicalHeaders, signedHeaders, payloadHash].join("\n");
}
// RFC3986に準拠したURLエンコーディング
function encodeRFC3986(str: string): string {
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
}
+203 -33
View File
@@ -1,13 +1,19 @@
import { describe, it, expect, vi } from "vitest";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AwsClient } from "aws4fetch";
import { beforeEach, describe, expect, it, vi } from "vitest";
import worker from "../src/index";
const ENV = {
ACCESS_KEY: "test-access-key",
SECRET_KEY: "test-secret-key",
REGION: "auto",
GOOGLE_CLIENT_ID: "test-client-id",
GOOGLE_CLIENT_SECRET: "test-client-secret",
GOOGLE_REFRESH_TOKEN: "test-refresh-token",
AUTH_KV: createMockKV(),
FOLDER_CACHE: createMockKV(),
};
const CTX = {
@@ -15,10 +21,105 @@ const CTX = {
passThroughOnException: vi.fn(),
};
describe("S3 API Server Authentication", () => {
function createMockKV() {
const storage = new Map<string, string>();
return {
get: vi.fn(async (key: string) => storage.get(key) || null),
put: vi.fn(async (key: string, value: string, options?: any) => {
storage.set(key, value);
}),
delete: vi.fn(async (key: string) => storage.delete(key)),
list: vi.fn(async () => ({ keys: [] })),
};
}
// Google API のモック
global.fetch = vi.fn(async (url: string | URL | Request, init?: any) => {
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
// OAuth トークン取得
if (urlStr.includes("oauth2.googleapis.com/token")) {
return new Response(
JSON.stringify({
access_token: "mock-access-token",
expires_in: 3600,
}),
{ status: 200 },
);
}
// フォルダ検索
if (urlStr.includes("drive/v3/files") && urlStr.includes("mimeType='application/vnd.google-apps.folder'")) {
return new Response(
JSON.stringify({
files: [{ id: "folder-id-123", name: "test-bucket" }],
}),
{ status: 200 },
);
}
// ファイル検索
if (urlStr.includes("drive/v3/files") && urlStr.includes("in parents")) {
return new Response(
JSON.stringify({
files: [
{
id: "file-id-456",
name: "test-file.txt",
mimeType: "text/plain",
size: "11",
},
],
}),
{ status: 200 },
);
}
// Resumable upload 初期化
if (urlStr.includes("uploadType=resumable") && init?.method === "POST") {
return new Response(null, {
status: 200,
headers: { Location: "https://www.googleapis.com/upload/drive/v3/files/uploadid123" },
});
}
// Resumable upload 実行
if (urlStr.includes("upload/drive/v3/files/uploadid")) {
return new Response(
JSON.stringify({
id: "new-file-id-789",
name: "uploaded-file.txt",
mimeType: "text/plain",
}),
{ status: 200 },
);
}
// ファイルダウンロード
if (urlStr.includes("alt=media")) {
return new Response("Hello World", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
// ファイル削除
if (init?.method === "DELETE") {
return new Response(null, { status: 204 });
}
return new Response("Not Found", { status: 404 });
}) as any;
describe("S3 API Server with Google Drive Backend", () => {
const endpoint = "https://s3-api.example.com";
it("should verify requests from aws4fetch (Header Auth)", async () => {
beforeEach(() => {
vi.clearAllMocks();
});
// 1. PUT (Upload) テスト
it("should upload file via PUT with Header Auth", async () => {
const aws4 = new AwsClient({
accessKeyId: ENV.ACCESS_KEY,
secretAccessKey: ENV.SECRET_KEY,
@@ -33,14 +134,83 @@ describe("S3 API Server Authentication", () => {
"Content-Type": "text/plain",
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
},
body: "hello world",
body: "Hello World",
});
const response = await worker.fetch(signedReq, ENV, CTX);
expect(response.status).toBe(200);
expect(await response.text()).toContain("Verified PUT");
const result = await response.json();
expect(result).toHaveProperty("id");
});
// 2. GET (Download) テスト
it("should download file via GET with Header Auth", async () => {
const aws4 = new AwsClient({
accessKeyId: ENV.ACCESS_KEY,
secretAccessKey: ENV.SECRET_KEY,
region: ENV.REGION,
service: "s3",
});
const requestUrl = `${endpoint}/test-bucket/test-file.txt`;
const signedReq = await aws4.sign(requestUrl, {
method: "GET",
headers: {
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
},
});
const response = await worker.fetch(signedReq, ENV, CTX);
expect(response.status).toBe(200);
expect(await response.text()).toBe("Hello World");
});
// 3. DELETE テスト
it("should delete file via DELETE", async () => {
const aws4 = new AwsClient({
accessKeyId: ENV.ACCESS_KEY,
secretAccessKey: ENV.SECRET_KEY,
region: ENV.REGION,
service: "s3",
});
const requestUrl = `${endpoint}/test-bucket/test-file.txt`;
const signedReq = await aws4.sign(requestUrl, {
method: "DELETE",
headers: {
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
},
});
const response = await worker.fetch(signedReq, ENV, CTX);
expect(response.status).toBe(204);
});
// 4. HEAD (Metadata) テスト
it("should get file metadata via HEAD", async () => {
const aws4 = new AwsClient({
accessKeyId: ENV.ACCESS_KEY,
secretAccessKey: ENV.SECRET_KEY,
region: ENV.REGION,
service: "s3",
});
const requestUrl = `${endpoint}/test-bucket/test-file.txt`;
const signedReq = await aws4.sign(requestUrl, {
method: "HEAD",
headers: {
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
},
});
const response = await worker.fetch(signedReq, ENV, CTX);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/plain");
expect(response.headers.get("Content-Length")).toBe("11");
});
// 5. Presigned URL テスト
it("should verify presigned URLs from @aws-sdk/s3-request-presigner", async () => {
const s3 = new S3Client({
region: ENV.REGION,
@@ -56,9 +226,6 @@ describe("S3 API Server Authentication", () => {
});
const url = await getSignedUrl(s3, command, { expiresIn: 3600 });
console.log("Presigned URL:", url);
const parsedUrl = new URL(url);
const testUrl = new URL(endpoint);
@@ -70,27 +237,9 @@ describe("S3 API Server Authentication", () => {
const response = await worker.fetch(request, ENV, CTX);
expect(response.status).toBe(200);
expect(await response.text()).toContain("Verified GET");
});
it("should verify requests from aws4fetch with query parameters", async () => {
const aws4 = new AwsClient({
accessKeyId: ENV.ACCESS_KEY,
secretAccessKey: ENV.SECRET_KEY,
region: ENV.REGION,
service: "s3",
});
const requestUrl = `${endpoint}/test-bucket/aws4-fetch-test`;
const signedReq = await aws4.sign(requestUrl, {
method: "GET",
aws: { signQuery: true }, // クエリパラメータ署名
});
const response = await worker.fetch(signedReq, ENV, CTX);
expect(response.status).toBe(200);
});
// 6. 不正な署名のテスト
it("should reject invalid signatures", async () => {
const request = new Request(`${endpoint}/hack`, {
method: "GET",
@@ -104,8 +253,8 @@ describe("S3 API Server Authentication", () => {
expect(response.status).toBe(403);
});
// 5. DELETE メソッドのテスト
it("should verify DELETE requests", async () => {
// 7. バケット一覧テスト (LIST)
it("should list files in bucket", async () => {
const aws4 = new AwsClient({
accessKeyId: ENV.ACCESS_KEY,
secretAccessKey: ENV.SECRET_KEY,
@@ -113,15 +262,36 @@ describe("S3 API Server Authentication", () => {
service: "s3",
});
const requestUrl = `${endpoint}/test-bucket/file-to-delete.txt`;
// ファイル一覧を返すようにモックを更新
(global.fetch as any).mockImplementationOnce(async (url: string) => {
if (url.includes("drive/v3/files") && url.includes("in parents")) {
return new Response(
JSON.stringify({
files: [
{ id: "1", name: "file1.txt", size: "100", modifiedTime: "2024-01-01T00:00:00Z" },
{ id: "2", name: "file2.txt", size: "200", modifiedTime: "2024-01-02T00:00:00Z" },
],
}),
{ status: 200 },
);
}
return new Response("Not Found", { status: 404 });
});
const requestUrl = `${endpoint}/test-bucket/`;
const signedReq = await aws4.sign(requestUrl, {
method: "DELETE",
method: "GET",
headers: {
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
},
});
const response = await worker.fetch(signedReq, ENV, CTX);
expect(response.status).toBe(204);
expect(response.status).toBe(200);
const xmlText = await response.text();
expect(xmlText).toContain("<ListBucketResult");
expect(xmlText).toContain("file1.txt");
expect(xmlText).toContain("file2.txt");
});
});
+12 -2
View File
@@ -4,12 +4,22 @@
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "google-drive-s3",
"name": "iris",
"main": "src/index.ts",
"compatibility_date": "2025-09-27",
"observability": {
"enabled": true
}
},
"kv_namespaces": [
{
"binding": "AUTH_KV",
"id": "ba953a1ce2cd41a7ba1d348075d34171"
},
{
"binding": "FOLDER_CACHE",
"id": "6332b0674a8342e4b270ca3368d3c52a"
}
]
/**
* Smart Placement
* https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement