feat: add api for manage bucket

This commit is contained in:
2026-08-21 12:24:59 +07:00
parent 4d0856eb76
commit c4873840d4
12 changed files with 1808 additions and 345 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ describe("CORS", () => {
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN);
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, HEAD, PUT, POST, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, HEAD, PUT, POST, PATCH, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("content-type,x-amz-date");
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
expect(response.headers.get("Vary")).toBe("Origin");
+216
View File
@@ -0,0 +1,216 @@
import { expect } from "vitest";
export interface StoredFile {
id: string;
name: string;
parent: string;
mimeType: string;
data: Uint8Array;
md5Checksum: string;
modifiedTime: string;
trashed?: boolean;
}
export const FAKE_MODIFIED_TIME = "2026-08-16T08:00:00.000Z";
export interface StoredFolder {
id: string;
name: string;
parent: string;
trashed?: boolean;
}
export const FOLDER_MIME = "application/vnd.google-apps.folder";
export interface UploadSession {
id: string;
fileId?: string;
name: string;
parent: string;
mimeType: string;
committed: Uint8Array;
}
export class FakeDrive {
readonly files = new Map<string, StoredFile>();
readonly folders = new Map<string, StoredFolder>();
readonly sessions = new Map<string, UploadSession>();
private nextId = 1;
async handle(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const request = input instanceof Request ? input : new Request(input, init);
const url = new URL(request.url);
if (url.hostname === "oauth2.googleapis.com") return Response.json({ access_token: "token", expires_in: 3600 });
if (url.hostname !== "www.googleapis.com") return new Response("Not Found", { status: 404 });
if (url.pathname === "/drive/v3/files" && request.method === "GET") return this.search(url);
if (url.pathname === "/drive/v3/files" && request.method === "POST") return this.createMetadata(await request.json<Record<string, unknown>>());
if (url.pathname.startsWith("/drive/v3/files/") && url.searchParams.get("alt") === "media") return this.download(url, request);
if (url.pathname.startsWith("/drive/v3/files/") && request.method === "PATCH") {
const fileId = url.pathname.split("/").at(-1);
if (!fileId) return new Response("Not Found", { status: 404 });
const body = await request.json<Record<string, unknown>>();
return this.patchFile(fileId, body, url);
}
if (url.pathname.startsWith("/drive/v3/files/") && request.method === "DELETE") {
const fileId = url.pathname.split("/").at(-1);
if (!fileId) return new Response("Not Found", { status: 404 });
this.files.delete(fileId);
this.folders.delete(fileId);
return new Response(null, { status: 204 });
}
if (url.pathname.startsWith("/upload/drive/v3/files") && url.searchParams.get("uploadType") === "resumable") return this.initialize(url, request);
if (url.pathname.startsWith("/upload/session/")) return this.upload(url, request);
return new Response("Not Found", { status: 404 });
}
private search(url: URL): Response {
const q = url.searchParams.get("q") ?? "";
const hasNameFilter = /name='/.test(q);
const name = /name='((?:\\.|[^'])*)'/.exec(q)?.[1]?.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
const parent = /'([^']+)' in parents/.exec(q)?.[1] ?? "root";
if (q.includes(FOLDER_MIME)) {
const match = [...this.folders.values()].find((folder) => (!hasNameFilter || folder.name === name) && folder.parent === parent);
return Response.json({ files: match ? [{ id: match.id, name: match.name, mimeType: FOLDER_MIME }] : [] });
}
const files = [...this.files.values()].filter((file) => (!hasNameFilter || file.name === name) && file.parent === parent);
const folders = [...this.folders.values()].filter((folder) => (!hasNameFilter || folder.name === name) && folder.parent === parent);
return Response.json({
files: [...files.map((file) => ({ ...file, size: String(file.data.byteLength), data: undefined })), ...folders.map((folder) => ({ id: folder.id, name: folder.name, mimeType: FOLDER_MIME }))],
});
}
private createMetadata(metadata: Record<string, unknown>): Response {
const mimeType = String(metadata.mimeType ?? "application/octet-stream");
const parent = String((metadata.parents as string[] | undefined)?.[0] ?? "root");
if (mimeType === FOLDER_MIME) {
const id = `folder-${this.nextId++}`;
this.folders.set(id, { id, name: String(metadata.name), parent, trashed: false });
return Response.json({ id, name: metadata.name, mimeType });
}
const id = `file-${this.nextId++}`;
const file: StoredFile = {
id,
name: String(metadata.name),
parent,
mimeType,
data: new Uint8Array(),
md5Checksum: "d41d8cd98f00b204e9800998ecf8427e",
modifiedTime: FAKE_MODIFIED_TIME,
trashed: false,
};
this.files.set(id, file);
return Response.json({ ...file, size: "0", data: undefined });
}
private patchFile(fileId: string, body: Record<string, unknown>, url: URL): Response {
if (this.folders.has(fileId)) {
const folder = this.folders.get(fileId)!;
if (body.trashed !== undefined) folder.trashed = Boolean(body.trashed);
if (body.name !== undefined) folder.name = String(body.name);
return Response.json({ id: folder.id, name: folder.name, mimeType: FOLDER_MIME, trashed: folder.trashed });
}
if (this.files.has(fileId)) {
const file = this.files.get(fileId)!;
if (body.trashed !== undefined) file.trashed = Boolean(body.trashed);
if (body.name !== undefined) file.name = String(body.name);
return Response.json({ id: file.id, name: file.name, mimeType: file.mimeType, size: String(file.data.byteLength), trashed: file.trashed });
}
return new Response("Not Found", { status: 404 });
}
private async initialize(url: URL, request: Request): Promise<Response> {
const metadata = await request.json<{ name: string; parents?: string[] }>();
const pathId = url.pathname.split("/").at(-1);
const fileId = pathId === "files" ? undefined : pathId;
const id = `session-${this.nextId++}`;
this.sessions.set(id, {
id,
fileId,
name: metadata.name,
parent: metadata.parents?.[0] ?? (fileId ? this.files.get(fileId)?.parent : "") ?? "",
mimeType: request.headers.get("X-Upload-Content-Type") ?? "application/octet-stream",
committed: new Uint8Array(),
});
return new Response(null, { status: 200, headers: { Location: `https://www.googleapis.com/upload/session/${id}` } });
}
private async upload(url: URL, request: Request): Promise<Response> {
const id = url.pathname.split("/").at(-1);
if (!id) return new Response(null, { status: 404 });
const session = this.sessions.get(id);
if (!session) return new Response(null, { status: 404 });
if (request.method === "DELETE") {
this.sessions.delete(id);
return new Response(null, { status: 499 });
}
const range = request.headers.get("Content-Range");
if (range === "bytes */*") return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
const body = new Uint8Array(await request.arrayBuffer());
if (!range) return this.finalize(session, body);
const match = /^bytes (\d+)-(\d+)\/(\*|\d+)$/.exec(range);
if (!match) return new Response("Bad range", { status: 400 });
const start = Number(match[1]);
const end = Number(match[2]);
const total = match[3] === "*" ? null : Number(match[3]);
expect(start).toBe(session.committed.byteLength);
expect(end - start + 1).toBe(body.byteLength);
if (total === null) expect(body.byteLength % (256 * 1024)).toBe(0);
session.committed = concat(session.committed, body);
if (total === null) return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
expect(session.committed.byteLength).toBe(total);
return this.finalize(session, session.committed);
}
private rangeHeaders(length: number): HeadersInit {
return length === 0 ? {} : { Range: `bytes=0-${length - 1}` };
}
private finalize(session: UploadSession, data: Uint8Array): Response {
const id = session.fileId ?? `file-${this.nextId++}`;
const file: StoredFile = { id, name: session.name, parent: session.parent, mimeType: session.mimeType, data, md5Checksum: fakeMd5(data), modifiedTime: FAKE_MODIFIED_TIME, trashed: false };
this.files.set(id, file);
this.sessions.delete(session.id);
return Response.json({ ...file, size: String(data.byteLength), data: undefined });
}
private download(url: URL, request: Request): Response {
const fileId = url.pathname.split("/").at(-1);
if (!fileId) return new Response(null, { status: 404 });
const file = this.files.get(fileId);
if (!file) return new Response(null, { status: 404 });
const range = request.headers.get("Range");
if (!range) return new Response(file.data, { headers: { "Content-Length": String(file.data.byteLength) } });
const match = /^bytes=(\d+)-(\d+)?$/.exec(range);
if (!match) return new Response(null, { status: 416 });
const start = Number(match[1]);
const end = Math.min(file.data.byteLength - 1, match[2] ? Number(match[2]) : file.data.byteLength - 1);
const data = file.data.slice(start, end + 1);
return new Response(data, { status: 206, headers: { "Content-Length": String(data.byteLength), "Content-Range": `bytes ${start}-${end}/${file.data.byteLength}` } });
}
}
export function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const output = new Uint8Array(a.byteLength + b.byteLength);
output.set(a);
output.set(b, a.byteLength);
return output;
}
export function fakeMd5(data: Uint8Array): string {
let state = 0x811c9dc5;
for (const byte of data) state = Math.imul(state ^ byte, 0x01000193);
return (state >>> 0).toString(16).padStart(8, "0").repeat(4);
}
export function bytes(length: number, seed = 17): Uint8Array {
const output = new Uint8Array(length);
let state = seed;
for (let index = 0; index < length; index++) {
state = (Math.imul(state, 1664525) + 1013904223) | 0;
output[index] = state >>> 24;
}
return output;
}
+346
View File
@@ -0,0 +1,346 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import worker from "../src/index";
import type { Env } from "../src/types";
import { bytes, FakeDrive, fakeMd5 } from "./fake-drive";
import { env } from "cloudflare:test";
const ENV = env as unknown as Env;
const ENDPOINT = "https://s3-api.example.com";
const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext;
let drive: FakeDrive;
let validToken: string;
beforeEach(async () => {
drive = new FakeDrive();
const rootFolderId = "folder-root";
drive.folders.set(rootFolderId, { id: rootFolderId, name: "s3-storage", parent: "root" });
const testBucketId = "folder-test-bucket";
drive.folders.set(testBucketId, { id: testBucketId, name: "test-bucket", parent: rootFolderId });
const emptyBucketId = "folder-empty-bucket";
drive.folders.set(emptyBucketId, { id: emptyBucketId, name: "empty-bucket", parent: rootFolderId });
vi.stubGlobal(
"fetch",
vi.fn((input, init) => drive.handle(input, init)),
);
await ENV.AUTH_KV.delete("google_access_token");
for (const { name } of (await ENV.FOLDER_CACHE.list()).keys) await ENV.FOLDER_CACHE.delete(name);
// Create session token
const token = "test-session-token-12345";
const data = new TextEncoder().encode(token);
const hash = await crypto.subtle.digest("SHA-256", data);
const hashHex = Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, "0")).join("");
await ENV.AUTH_KV.put(`session:${hashHex}`, JSON.stringify({ createdAt: Date.now() }), { expirationTtl: 3600 });
validToken = token;
});
describe("Object API (/api/objects)", () => {
it("rejects requests without valid auth", async () => {
const res = await worker.fetch(new Request(`${ENDPOINT}/api/objects?bucket=test-bucket`), ENV, CTX);
expect(res.status).toBe(401);
});
it("returns 404 for unknown bucket", async () => {
const res = await worker.fetch(
new Request(`${ENDPOINT}/api/objects?bucket=nonexistent`, {
headers: { Authorization: `Bearer ${validToken}` },
}),
ENV,
CTX,
);
expect(res.status).toBe(404);
const data = await res.json<{ message: string }>();
expect(data.message).toContain("not found");
});
it("lists objects and folders with delimiter", async () => {
// Create files in test-bucket
drive.files.set("file-1", {
id: "file-1",
name: "root-file.txt",
parent: "folder-test-bucket",
mimeType: "text/plain",
data: new TextEncoder().encode("hello"),
md5Checksum: fakeMd5(new TextEncoder().encode("hello")),
modifiedTime: "2026-08-16T08:00:00.000Z",
trashed: false,
});
drive.folders.set("folder-sub", {
id: "folder-sub",
name: "photos",
parent: "folder-test-bucket",
trashed: false,
});
drive.files.set("file-2", {
id: "file-2",
name: "pic.jpg",
parent: "folder-sub",
mimeType: "image/jpeg",
data: new Uint8Array([1, 2, 3]),
md5Checksum: fakeMd5(new Uint8Array([1, 2, 3])),
modifiedTime: "2026-08-16T08:00:00.000Z",
trashed: false,
});
const res = await worker.fetch(
new Request(`${ENDPOINT}/api/objects?bucket=test-bucket&prefix=&delimiter=/`, {
headers: { Authorization: `Bearer ${validToken}` },
}),
ENV,
CTX,
);
expect(res.status).toBe(200);
const data = await res.json<{
bucket: string;
prefix: string;
delimiter: string;
folders: Array<{ prefix: string; name: string }>;
objects: Array<{ key: string; name: string; size: number; contentType: string }>;
truncated: boolean;
}>();
expect(data.bucket).toBe("test-bucket");
expect(data.folders).toEqual([{ prefix: "photos/", name: "photos" }]);
expect(data.objects).toHaveLength(1);
expect(data.objects[0].name).toBe("root-file.txt");
expect(data.objects[0].key).toBe("root-file.txt");
expect(data.truncated).toBe(false);
// List nested folder
const subRes = await worker.fetch(
new Request(`${ENDPOINT}/api/objects?bucket=test-bucket&prefix=photos/&delimiter=/`, {
headers: { Authorization: `Bearer ${validToken}` },
}),
ENV,
CTX,
);
expect(subRes.status).toBe(200);
const subData = await subRes.json<{
folders: Array<{ prefix: string; name: string }>;
objects: Array<{ key: string; name: string; size: number }>;
}>();
expect(subData.folders).toEqual([]);
expect(subData.objects).toHaveLength(1);
expect(subData.objects[0].name).toBe("pic.jpg");
expect(subData.objects[0].key).toBe("photos/pic.jpg");
});
it("gets object metadata", async () => {
drive.files.set("file-1", {
id: "file-1",
name: "test.txt",
parent: "folder-test-bucket",
mimeType: "text/plain",
data: new TextEncoder().encode("content"),
md5Checksum: fakeMd5(new TextEncoder().encode("content")),
modifiedTime: "2026-08-16T08:00:00.000Z",
trashed: false,
});
const res = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/metadata?bucket=test-bucket&key=test.txt`, {
headers: { Authorization: `Bearer ${validToken}` },
}),
ENV,
CTX,
);
expect(res.status).toBe(200);
const data = await res.json<{ key: string; name: string; size: number; contentType: string }>();
expect(data.key).toBe("test.txt");
expect(data.name).toBe("test.txt");
expect(data.size).toBe(7);
expect(data.contentType).toBe("text/plain");
});
it("direct PUT and GET content", async () => {
const payload = new Uint8Array([10, 20, 30, 40]);
const putRes = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=binary.bin`, {
method: "PUT",
headers: {
Authorization: `Bearer ${validToken}`,
"Content-Type": "application/octet-stream",
},
body: payload,
}),
ENV,
CTX,
);
expect(putRes.status).toBe(200);
const putData = await putRes.json<{ key: string; etag: string; size: number }>();
expect(putData.key).toBe("binary.bin");
const getRes = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=binary.bin`, {
headers: { Authorization: `Bearer ${validToken}` },
}),
ENV,
CTX,
);
expect(getRes.status).toBe(200);
expect(getRes.headers.get("Content-Disposition")).toContain("binary.bin");
const body = new Uint8Array(await getRes.arrayBuffer());
expect(body).toEqual(payload);
});
it("creates and deletes folders with empty / non-empty checks", async () => {
// Create folder
const createRes = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/folder`, {
method: "POST",
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ bucket: "test-bucket", prefix: "my-folder/" }),
}),
ENV,
CTX,
);
expect(createRes.status).toBe(201);
// Put a file inside folder
await worker.fetch(
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=my-folder/item.txt`, {
method: "PUT",
headers: { Authorization: `Bearer ${validToken}` },
body: "hello",
}),
ENV,
CTX,
);
// Try deleting non-empty folder without recursive=1 -> 409
const delRes409 = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/folder?bucket=test-bucket&prefix=my-folder/`, {
method: "DELETE",
headers: { Authorization: `Bearer ${validToken}` },
}),
ENV,
CTX,
);
expect(delRes409.status).toBe(409);
// Delete with recursive=1 -> 204
const delRes204 = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/folder?bucket=test-bucket&prefix=my-folder/&recursive=1`, {
method: "DELETE",
headers: { Authorization: `Bearer ${validToken}` },
}),
ENV,
CTX,
);
expect(delRes204.status).toBe(204);
});
it("supports download tickets without session token", async () => {
await worker.fetch(
new Request(`${ENDPOINT}/api/objects/content?bucket=test-bucket&key=ticket-doc.pdf`, {
method: "PUT",
headers: { Authorization: `Bearer ${validToken}` },
body: "pdf contents here",
}),
ENV,
CTX,
);
// Create download ticket
const ticketRes = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/download-ticket`, {
method: "POST",
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ bucket: "test-bucket", key: "ticket-doc.pdf" }),
}),
ENV,
CTX,
);
expect(ticketRes.status).toBe(201);
const { ticket, downloadUrl } = await ticketRes.json<{ ticket: string; downloadUrl: string }>();
expect(ticket).toBeDefined();
// Download without Authorization header using ticket
const dlRes = await worker.fetch(new Request(`${ENDPOINT}${downloadUrl}`), ENV, CTX);
expect(dlRes.status).toBe(200);
expect(await dlRes.text()).toBe("pdf contents here");
// Second attempt with same ticket should fail (single use)
const dlRes2 = await worker.fetch(new Request(`${ENDPOINT}${downloadUrl}`), ENV, CTX);
expect(dlRes2.status).toBe(403);
});
it("performs full multipart upload flow via JSON API with non-aligned parts", async () => {
// 1. Initiate multipart
const initRes = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/uploads`, {
method: "POST",
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ bucket: "test-bucket", key: "large-video.mp4", contentType: "video/mp4" }),
}),
ENV,
CTX,
);
expect(initRes.status).toBe(201);
const { uploadId, partSize } = await initRes.json<{ uploadId: string; partSize: number }>();
expect(uploadId).toBeDefined();
expect(partSize).toBe(8 * 1024 * 1024);
// 2. Upload part 1: 300 KiB (non-aligned to 256 KiB)
const part1Data = bytes(300 * 1024, 11);
const part1Res = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/uploads/part?bucket=test-bucket&key=large-video.mp4&uploadId=${encodeURIComponent(uploadId)}&partNumber=1`, {
method: "PUT",
headers: { Authorization: `Bearer ${validToken}`, "Content-Length": String(part1Data.byteLength) },
body: part1Data,
}),
ENV,
CTX,
);
expect(part1Res.status).toBe(200);
const part1Json = await part1Res.json<{ partNumber: number; etag: string }>();
// 3. Upload part 2: 200 KiB (non-aligned)
const part2Data = bytes(200 * 1024, 22);
const part2Res = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/uploads/part?bucket=test-bucket&key=large-video.mp4&uploadId=${encodeURIComponent(uploadId)}&partNumber=2`, {
method: "PUT",
headers: { Authorization: `Bearer ${validToken}`, "Content-Length": String(part2Data.byteLength) },
body: part2Data,
}),
ENV,
CTX,
);
expect(part2Res.status).toBe(200);
const part2Json = await part2Res.json<{ partNumber: number; etag: string }>();
// 4. Complete multipart
const completeRes = await worker.fetch(
new Request(`${ENDPOINT}/api/objects/uploads/complete`, {
method: "POST",
headers: { Authorization: `Bearer ${validToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
bucket: "test-bucket",
key: "large-video.mp4",
uploadId,
parts: [
{ partNumber: 1, etag: part1Json.etag },
{ partNumber: 2, etag: part2Json.etag },
],
}),
}),
ENV,
CTX,
);
expect(completeRes.status).toBe(200);
const completeJson = await completeRes.json<{ key: string; etag: string }>();
expect(completeJson.key).toBe("large-video.mp4");
// Verify stored data
const storedFile = [...drive.files.values()].find((f) => f.name === "large-video.mp4");
expect(storedFile).toBeDefined();
const expectedBytes = new Uint8Array(part1Data.byteLength + part2Data.byteLength);
expectedBytes.set(part1Data, 0);
expectedBytes.set(part2Data, part1Data.byteLength);
expect(storedFile!.data).toEqual(expectedBytes);
});
});
+1 -189
View File
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { decodedBodyChunks } from "../src/aws-chunked";
import worker from "../src/index";
import type { Env } from "../src/types";
import { bytes, FAKE_MODIFIED_TIME, fakeMd5, FakeDrive } from "./fake-drive";
import { env } from "cloudflare:test";
@@ -11,195 +12,6 @@ const ENV = env as unknown as Env;
const ENDPOINT = "https://s3-api.example.com";
const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext;
interface StoredFile {
id: string;
name: string;
parent: string;
mimeType: string;
data: Uint8Array;
md5Checksum: string;
modifiedTime: string;
}
const FAKE_MODIFIED_TIME = "2026-08-16T08:00:00.000Z";
interface StoredFolder {
id: string;
name: string;
parent: string;
}
const FOLDER_MIME = "application/vnd.google-apps.folder";
interface UploadSession {
id: string;
fileId?: string;
name: string;
parent: string;
mimeType: string;
committed: Uint8Array;
}
class FakeDrive {
readonly files = new Map<string, StoredFile>();
readonly folders = new Map<string, StoredFolder>();
readonly sessions = new Map<string, UploadSession>();
private nextId = 1;
async handle(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const request = input instanceof Request ? input : new Request(input, init);
const url = new URL(request.url);
if (url.hostname === "oauth2.googleapis.com") return Response.json({ access_token: "token", expires_in: 3600 });
if (url.hostname !== "www.googleapis.com") return new Response("Not Found", { status: 404 });
if (url.pathname === "/drive/v3/files" && request.method === "GET") return this.search(url);
if (url.pathname === "/drive/v3/files" && request.method === "POST") return this.createMetadata(await request.json<Record<string, unknown>>());
if (url.pathname.startsWith("/drive/v3/files/") && url.searchParams.get("alt") === "media") return this.download(url, request);
if (url.pathname.startsWith("/drive/v3/files/") && request.method === "DELETE") {
const fileId = url.pathname.split("/").at(-1);
if (!fileId) return new Response("Not Found", { status: 404 });
this.files.delete(fileId);
return new Response(null, { status: 204 });
}
if (url.pathname.startsWith("/upload/drive/v3/files") && url.searchParams.get("uploadType") === "resumable") return this.initialize(url, request);
if (url.pathname.startsWith("/upload/session/")) return this.upload(url, request);
return new Response("Not Found", { status: 404 });
}
private search(url: URL): Response {
const q = url.searchParams.get("q") ?? "";
const hasNameFilter = /name='/.test(q);
const name = /name='((?:\\.|[^'])*)'/.exec(q)?.[1]?.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
const parent = /'([^']+)' in parents/.exec(q)?.[1] ?? "root";
if (q.includes(FOLDER_MIME)) {
const match = [...this.folders.values()].find((folder) => folder.name === name && folder.parent === parent);
return Response.json({ files: match ? [{ id: match.id, name: match.name, mimeType: FOLDER_MIME }] : [] });
}
const files = [...this.files.values()].filter((file) => (!hasNameFilter || file.name === name) && file.parent === parent);
const folders = [...this.folders.values()].filter((folder) => (!hasNameFilter || folder.name === name) && folder.parent === parent);
return Response.json({
files: [...files.map((file) => ({ ...file, size: String(file.data.byteLength), data: undefined })), ...folders.map((folder) => ({ id: folder.id, name: folder.name, mimeType: FOLDER_MIME }))],
});
}
private createMetadata(metadata: Record<string, unknown>): Response {
const mimeType = String(metadata.mimeType ?? "application/octet-stream");
const parent = String((metadata.parents as string[] | undefined)?.[0] ?? "root");
if (mimeType === FOLDER_MIME) {
const id = `folder-${this.nextId++}`;
this.folders.set(id, { id, name: String(metadata.name), parent });
return Response.json({ id, name: metadata.name, mimeType });
}
const id = `file-${this.nextId++}`;
const file: StoredFile = {
id,
name: String(metadata.name),
parent,
mimeType,
data: new Uint8Array(),
md5Checksum: "d41d8cd98f00b204e9800998ecf8427e",
modifiedTime: FAKE_MODIFIED_TIME,
};
this.files.set(id, file);
return Response.json({ ...file, size: "0", data: undefined });
}
private async initialize(url: URL, request: Request): Promise<Response> {
const metadata = await request.json<{ name: string; parents?: string[] }>();
const pathId = url.pathname.split("/").at(-1);
const fileId = pathId === "files" ? undefined : pathId;
const id = `session-${this.nextId++}`;
this.sessions.set(id, {
id,
fileId,
name: metadata.name,
parent: metadata.parents?.[0] ?? (fileId ? this.files.get(fileId)?.parent : "") ?? "",
mimeType: request.headers.get("X-Upload-Content-Type") ?? "application/octet-stream",
committed: new Uint8Array(),
});
return new Response(null, { status: 200, headers: { Location: `https://www.googleapis.com/upload/session/${id}` } });
}
private async upload(url: URL, request: Request): Promise<Response> {
const id = url.pathname.split("/").at(-1);
if (!id) return new Response(null, { status: 404 });
const session = this.sessions.get(id);
if (!session) return new Response(null, { status: 404 });
if (request.method === "DELETE") {
this.sessions.delete(id);
return new Response(null, { status: 499 });
}
const range = request.headers.get("Content-Range");
if (range === "bytes */*") return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
const body = new Uint8Array(await request.arrayBuffer());
if (!range) return this.finalize(session, body);
const match = /^bytes (\d+)-(\d+)\/(\*|\d+)$/.exec(range);
if (!match) return new Response("Bad range", { status: 400 });
const start = Number(match[1]);
const end = Number(match[2]);
const total = match[3] === "*" ? null : Number(match[3]);
expect(start).toBe(session.committed.byteLength);
expect(end - start + 1).toBe(body.byteLength);
if (total === null) expect(body.byteLength % (256 * 1024)).toBe(0);
session.committed = concat(session.committed, body);
if (total === null) return new Response(null, { status: 308, headers: this.rangeHeaders(session.committed.byteLength) });
expect(session.committed.byteLength).toBe(total);
return this.finalize(session, session.committed);
}
private rangeHeaders(length: number): HeadersInit {
return length === 0 ? {} : { Range: `bytes=0-${length - 1}` };
}
private finalize(session: UploadSession, data: Uint8Array): Response {
const id = session.fileId ?? `file-${this.nextId++}`;
const file: StoredFile = { id, name: session.name, parent: session.parent, mimeType: session.mimeType, data, md5Checksum: fakeMd5(data), modifiedTime: FAKE_MODIFIED_TIME };
this.files.set(id, file);
this.sessions.delete(session.id);
return Response.json({ ...file, size: String(data.byteLength), data: undefined });
}
private download(url: URL, request: Request): Response {
const fileId = url.pathname.split("/").at(-1);
if (!fileId) return new Response(null, { status: 404 });
const file = this.files.get(fileId);
if (!file) return new Response(null, { status: 404 });
const range = request.headers.get("Range");
if (!range) return new Response(file.data, { headers: { "Content-Length": String(file.data.byteLength) } });
const match = /^bytes=(\d+)-(\d+)?$/.exec(range);
if (!match) return new Response(null, { status: 416 });
const start = Number(match[1]);
const end = Math.min(file.data.byteLength - 1, match[2] ? Number(match[2]) : file.data.byteLength - 1);
const data = file.data.slice(start, end + 1);
return new Response(data, { status: 206, headers: { "Content-Length": String(data.byteLength), "Content-Range": `bytes ${start}-${end}/${file.data.byteLength}` } });
}
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const output = new Uint8Array(a.byteLength + b.byteLength);
output.set(a);
output.set(b, a.byteLength);
return output;
}
function fakeMd5(data: Uint8Array): string {
let state = 0x811c9dc5;
for (const byte of data) state = Math.imul(state ^ byte, 0x01000193);
return (state >>> 0).toString(16).padStart(8, "0").repeat(4);
}
function bytes(length: number, seed = 17): Uint8Array {
const output = new Uint8Array(length);
let state = seed;
for (let index = 0; index < length; index++) {
state = (Math.imul(state, 1664525) + 1013904223) | 0;
output[index] = state >>> 24;
}
return output;
}
async function signed(path: string, init: RequestInit): Promise<Request> {
const aws = new AwsClient({ accessKeyId: ENV.ACCESS_KEY, secretAccessKey: ENV.SECRET_KEY, region: ENV.REGION, service: "s3" });
const bodyLength = typeof init.body === "string" ? new TextEncoder().encode(init.body).byteLength : init.body instanceof Uint8Array ? init.body.byteLength : undefined;