/** * S3-Compatible API Server on Cloudflare Workers * Backend: Google Drive with streaming support and nested directory structure */ export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { try { if (request.method === "OPTIONS") { return new Response(null, { status: 204 }); } const url = new URL(request.url); const method = request.method; // パスからバケット名とオブジェクトキーを抽出 const pathParts = url.pathname.split("/").filter((p) => p); const bucket = pathParts[0] || ""; const objectKey = pathParts.slice(1).join("/"); if (!isAllowedBucket(bucket, env)) { return new Response("Access denied to this bucket", { status: 403 }); } const isPublicRead = isPublicReadBucket(bucket, env); const isReadMethod = method === "GET" || method === "HEAD"; if (!(isPublicRead && isReadMethod)) { const isValid = await verifySignature(request, env); if (!isValid) { return new Response("Invalid Signature", { status: 403 }); } } const accessToken = await getAccessToken(env); if (method === "PUT" || method === "POST") { // ストリーミングアップロード 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") { // ストリーミングダウンロード if (!objectKey) { // バケット(フォルダ)の一覧を返す const files = await listFiles(accessToken, bucket, env); return new Response(generateListBucketResult(files, bucket), { status: 200, headers: { "Content-Type": "application/xml" }, }); } try { 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(), "Cache-Control": "s-maxage=300, no-store", ETag: `"${fileStream.id}"`, }, }); } catch (e) { const error = e as Error; if (error.message === "File not found") { return new Response("NoSuchKey", { status: 404 }); } throw e; } } else if (method === "DELETE") { // ファイル削除 if (!objectKey) { return new Response("Object key required", { status: 400 }); } try { await deleteFromDrive(accessToken, bucket, objectKey, env); return new Response(null, { status: 204 }); } catch (e) { const error = e as Error; if (error.message === "File not found") { return new Response(null, { status: 404 }); } throw e; } } else if (method === "HEAD") { // メタデータ取得 if (!objectKey) { return new Response(null, { status: 400 }); } try { 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}"`, }, }); } catch (e) { const error = e as Error; if (error.message === "File not found") { return new Response(null, { status: 404 }); } throw e; } } return new Response("Method not allowed", { status: 405 }); } catch (e) { const error = e as Error; console.error("Error:", error); return new Response(error.message, { status: 500 }); } }, } satisfies ExportedHandler; 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; ALLOWED_BUCKETS?: string; PUBLIC_READ_BUCKETS?: string; } interface GoogleDriveFile { id: string; name: string; mimeType: string; size: string; modifiedTime?: string; } interface GoogleDriveSearchResponse { files?: GoogleDriveFile[]; } function isAllowedBucket(bucket: string, env: Env): boolean { console.log(bucket); // 許可リストが設定されていない場合はすべて拒否 if (!env.ALLOWED_BUCKETS) { return false; } const allowedBuckets = env.ALLOWED_BUCKETS.split(",") .map((b) => b.trim()) .filter((b) => b); // 空の許可リストの場合もすべて拒否 if (allowedBuckets.length === 0) { return false; } // バケット名が許可リストに含まれているかチェック return allowedBuckets.includes(bucket); } function isPublicReadBucket(bucket: string, env: Env): boolean { if (!env.PUBLIC_READ_BUCKETS) { return false; } const publicReadBuckets = env.PUBLIC_READ_BUCKETS.split(",") .map((b) => b.trim()) .filter((b) => b); return publicReadBuckets.includes(bucket); } // ======================================== // Google Drive API Functions // ======================================== async function getAccessToken(env: Env): Promise { 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: any = 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, parentId: string | null, env: Env): Promise { // キャッシュキーにparentIdを含める const cacheKey = parentId ? `${parentId}/${folderName}` : folderName; const cached = await env.FOLDER_CACHE.get(cacheKey); if (cached) return cached; // フォルダを検索(親フォルダを指定) const parentQuery = parentId ? ` and '${parentId}' in parents` : ""; 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${parentQuery}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); const searchData: GoogleDriveSearchResponse = await searchRes.json(); if (searchData.files && searchData.files.length > 0) { const folderId = searchData.files[0].id; await env.FOLDER_CACHE.put(cacheKey, folderId, { expirationTtl: 3600 }); return folderId; } // フォルダが存在しない場合は作成 const createBody: any = { name: folderName, mimeType: "application/vnd.google-apps.folder", }; if (parentId) { createBody.parents = [parentId]; } const createRes = await fetch("https://www.googleapis.com/drive/v3/files", { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(createBody), }); const createData: any = await createRes.json(); await env.FOLDER_CACHE.put(cacheKey, createData.id, { expirationTtl: 3600 }); return createData.id; } async function resolvePathToFolderAndFile(accessToken: string, bucket: string, objectKey: string, env: Env): Promise<{ parentFolderId: string; fileName: string }> { // バケットのルートフォルダを取得 let currentFolderId = await getOrCreateFolder(accessToken, bucket, null, env); // objectKeyを/で分割 const parts = objectKey.split("/").filter((p) => p); if (parts.length === 0) { throw new Error("Invalid object key"); } const fileName = parts[parts.length - 1]; const directories = parts.slice(0, -1); for (const dir of directories) { currentFolderId = await getOrCreateFolder(accessToken, dir, currentFolderId, env); } return { parentFolderId: currentFolderId, fileName: fileName, }; } async function streamUploadToDrive(accessToken: string, stream: ReadableStream | null, bucket: string, objectKey: string, mimeType: string, env: Env): Promise { if (!stream) { throw new Error("Request body is required"); } // パスを解析してフォルダ階層を作成 const { parentFolderId, fileName } = await resolvePathToFolderAndFile(accessToken, bucket, objectKey, 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: fileName, parents: [parentFolderId], }), }); const uploadUrl = initRes.headers.get("Location"); if (!uploadUrl) { console.error(initRes.status); console.error(await initRes.text()); throw new Error("Failed to get upload URL"); } // ストリーミングアップロード const uploadRes = await fetch(uploadUrl, { method: "PUT", headers: { Authorization: `Bearer ${accessToken}`, }, body: stream, duplex: "half", } as RequestInit); 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 { 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: GoogleDriveSearchResponse = 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 { parentFolderId, fileName } = await resolvePathToFolderAndFile(accessToken, bucket, objectKey, env); const file = await findFileInFolder(accessToken, parentFolderId, fileName); if (!file) { throw new Error("File not found"); } const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, 5000); const downloadRes = await fetch(`https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`, { headers: { Authorization: `Bearer ${accessToken}` }, signal: controller.signal, }); clearTimeout(timeout); if (!downloadRes.ok) { console.error(downloadRes.status); console.error(await downloadRes.text()); 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 { const { parentFolderId, fileName } = await resolvePathToFolderAndFile(accessToken, bucket, objectKey, env); const file = await findFileInFolder(accessToken, parentFolderId, fileName); 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 { parentFolderId, fileName } = await resolvePathToFolderAndFile(accessToken, bucket, objectKey, env); const file = await findFileInFolder(accessToken, parentFolderId, fileName); 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 { const folderId = await getOrCreateFolder(accessToken, bucket, null, 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: GoogleDriveSearchResponse = await listRes.json(); return data.files || []; } function generateListBucketResult(files: any[], bucket: string): string { const contents = files .map( (f) => ` ${escapeXml(f.name)} ${f.modifiedTime || new Date().toISOString()} "${f.id}" ${f.size || 0} STANDARD `, ) .join(""); return ` ${escapeXml(bucket)} 1000 false ${contents} `; } function escapeXml(str: string): string { return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } // ======================================== // AWS Signature V4 Verification // ======================================== async function verifySignature(request: Request, env: Env): Promise { const url = new URL(request.url); const headers = request.headers; const isQueryAuth = url.searchParams.has("X-Amz-Algorithm"); let algorithm: string; if (isQueryAuth) { algorithm = url.searchParams.get("X-Amz-Algorithm") ?? ""; } else { const authHeader = headers.get("Authorization") ?? ""; algorithm = authHeader.split(" ")[0]; } if (!algorithm || !algorithm.includes("AWS4-HMAC-SHA256")) { 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); const canonicalRequest = await createCanonicalRequest(request, isQueryAuth); const hashedCanonicalRequest = await sha256(canonicalRequest); 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") ?? ""; } else { const authHeader = headers.get("Authorization") ?? ""; const match = authHeader.match(/Signature=([a-f0-9]+)/); expectedSignature = match ? match[1] : ""; } return signatureHex === expectedSignature; } async function createCanonicalRequest(request: Request, isQueryAuth: boolean): Promise { const url = new URL(request.url); const method = request.method; const canonicalUri = url.pathname || "/"; const params = Array.from(url.searchParams.entries()) .filter(([key]) => key !== "X-Amz-Signature") .sort(([a], [b]) => { if (a < b) return -1; if (a > b) return 1; return 0; }) .map(([key, val]) => `${encodeRFC3986(key)}=${encodeRFC3986(val)}`) .join("&"); let signedHeadersList: string[]; if (isQueryAuth) { signedHeadersList = (url.searchParams.get("X-Amz-SignedHeaders") ?? "host").split(";"); } else { const authHeader = request.headers.get("Authorization") ?? ""; const match = authHeader.match(/SignedHeaders=([^,\s]+)/); signedHeadersList = match ? match[1].split(";") : ["host"]; } const canonicalHeaders = signedHeadersList .map((h) => { const headerName = h.toLowerCase(); let headerValue = ""; if (headerName === "host") { headerValue = url.hostname; const port = url.port; if (port && !((url.protocol === "https:" && port === "443") || (url.protocol === "http:" && port === "80"))) { headerValue += `:${port}`; } } else { headerValue = request.headers.get(headerName)?.trim() ?? ""; } return `${headerName}:${headerValue}\n`; }) .join(""); const signedHeaders = signedHeadersList.join(";"); const payloadHash = request.headers.get("x-amz-content-sha256") ?? (isQueryAuth ? "UNSIGNED-PAYLOAD" : "UNSIGNED-PAYLOAD"); return [method, canonicalUri, params, canonicalHeaders, signedHeaders, payloadHash].join("\n"); } function encodeRFC3986(str: string): string { return encodeURIComponent(str).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); } async function getSigningKey(secret: string, date: string, region: string, service: string): Promise { const kDate = await hmacSha256("AWS4" + secret, date); const kRegion = await hmacSha256(kDate, region); const kService = await hmacSha256(kRegion, service); return await hmacSha256(kService, "aws4_request"); } async function hmacSha256(key: string | ArrayBuffer, data: string): Promise { const keyData = typeof key === "string" ? new TextEncoder().encode(key) : key; const cryptoKey = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); return await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data)); } async function sha256(data: string): Promise { const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)); return bufToHex(hash); } function bufToHex(buf: ArrayBuffer): string { return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); }