Files
google-drive-s3/src/index.ts
T

582 lines
21 KiB
TypeScript

/**
* 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<Response> {
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<Env>;
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<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: 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<string> {
// キャッシュキーに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<any> {
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<GoogleDriveFile | null> {
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<void> {
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<GoogleDriveFile[]> {
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) => `
<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 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<string> {
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<ArrayBuffer> {
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<ArrayBuffer> {
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<string> {
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("");
}