Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions electron/drive/google-drive-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DriveApiError, GoogleDriveApi, videoMimeType } from "./google-drive-api";

type Call = { url: string; method: string; headers: Headers; body: unknown };

/** A fake Drive: records every call and answers from `routes` in order. */
function fakeDrive(routes: Array<(call: Call) => Response>) {
const calls: Call[] = [];
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
const call: Call = {
url: String(input),
method: init.method ?? "GET",
headers: new Headers(init.headers),
body: init.body,
};
calls.push(call);
const route = routes.shift();
if (!route) throw new Error(`unexpected call ${call.method} ${call.url}`);
return route(call);
});
return {
calls,
api: new GoogleDriveApi(async () => "token", fetchImpl as unknown as typeof fetch),
};
}

const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });

let video: string;
beforeEach(async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "drive-api-"));
video = path.join(dir, "take.mp4");
await writeFile(video, Buffer.alloc(1234, 1));
});

describe("videoMimeType", () => {
it("knows the video containers and nothing else", () => {
expect(videoMimeType("/a/b.MP4")).toBe("video/mp4");
expect(videoMimeType("/a/b.webm")).toBe("video/webm");
expect(videoMimeType("/a/b.gif")).toBeNull();
expect(videoMimeType("/a/b.json")).toBeNull();
});
});

describe("GoogleDriveApi.upload", () => {
it("creates a new file in the folder through a resumable session", async () => {
const { calls, api } = fakeDrive([
() =>
new Response(null, {
status: 200,
headers: { Location: "https://upload.example/session?upload_id=1" },
}),
() => json({ id: "file-1", webViewLink: "https://drive/file-1" }),
]);
const uploaded = await api.upload({ filePath: video, name: "Demo.mp4", folderId: "folder" });
expect(uploaded).toEqual({ id: "file-1", webViewLink: "https://drive/file-1" });
expect(calls[0].method).toBe("POST");
expect(calls[0].url).toContain("/upload/drive/v3/files?uploadType=resumable");
expect(calls[0].headers.get("authorization")).toBe("Bearer token");
expect(calls[0].headers.get("x-upload-content-length")).toBe("1234");
expect(JSON.parse(String(calls[0].body))).toEqual({
name: "Demo.mp4",
mimeType: "video/mp4",
parents: ["folder"],
});
expect(calls[1].method).toBe("PUT");
expect(new URL(calls[1].url).searchParams.get("upload_id")).toBe("1");
expect(calls[1].headers.get("content-length")).toBe("1234");
});

it("replaces an existing file's content in place, keeping its id", async () => {
const { calls, api } = fakeDrive([
() => new Response(null, { headers: { Location: "https://upload.example/s?upload_id=2" } }),
() => json({ id: "file-1", webViewLink: "https://drive/file-1" }),
]);
const uploaded = await api.upload({ filePath: video, name: "Demo.mp4", fileId: "file-1" });
expect(uploaded.id).toBe("file-1");
expect(calls[0].method).toBe("PATCH");
expect(calls[0].url).toContain("/upload/drive/v3/files/file-1?uploadType=resumable");
// A file keeps its folder: parents are only set when creating.
expect(JSON.parse(String(calls[0].body)).parents).toBeUndefined();
});

it("reports Drive's own error with its status", async () => {
const { api } = fakeDrive([() => json({ error: { message: "File not found: x." } }, 404)]);
const failure = await api
.upload({ filePath: video, name: "Demo.mp4", fileId: "x" })
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(DriveApiError);
expect((failure as DriveApiError).status).toBe(404);
expect((failure as Error).message).toBe("File not found: x.");
});

it("refuses anything that is not a video", async () => {
const { api } = fakeDrive([]);
await expect(api.upload({ filePath: "/x/notes.txt", name: "n" })).rejects.toThrow(/video/);
});
});

describe("GoogleDriveApi.setVisibility", () => {
it("adds the anyone-with-the-link permission once", async () => {
const { calls, api } = fakeDrive([
() => json({ permissions: [{ id: "owner", type: "user" }] }),
() => json({ id: "anyoneWithLink" }),
]);
await api.setVisibility("file-1", "anyone");
expect(calls[1].method).toBe("POST");
expect(JSON.parse(String(calls[1].body))).toEqual({ type: "anyone", role: "reader" });
});

it("removes it to make the file private again", async () => {
const { calls, api } = fakeDrive([
() =>
json({
permissions: [
{ id: "owner", type: "user" },
{ id: "anyoneWithLink", type: "anyone" },
],
}),
() => new Response(null, { status: 204 }),
]);
await api.setVisibility("file-1", "private");
expect(calls[1].method).toBe("DELETE");
expect(calls[1].url).toMatch(/permissions\/anyoneWithLink$/);
});
});

describe("GoogleDriveApi.ensureFolder", () => {
it("reuses the folder it created before", async () => {
const { calls, api } = fakeDrive([() => json({ files: [{ id: "folder-1" }] })]);
expect(await api.ensureFolder()).toBe("folder-1");
expect(calls).toHaveLength(1);
});

it("creates it when there is none", async () => {
const { calls, api } = fakeDrive([() => json({ files: [] }), () => json({ id: "folder-2" })]);
expect(await api.ensureFolder()).toBe("folder-2");
expect(JSON.parse(String(calls[1].body))).toEqual({
name: "OpenScreen",
mimeType: "application/vnd.google-apps.folder",
});
});
});

describe("the streamed upload, through Node's own fetch", () => {
it("sends every byte of the file in the PUT", async () => {
const { createServer } = await import("node:http");
let received = 0;
const server = createServer((req, res) => {
req.on("data", (chunk: Buffer) => {
received += chunk.length;
});
req.on("end", () => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: "file-1", webViewLink: "https://drive/file-1" }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as import("node:net").AddressInfo;
try {
// The session request goes to a fake Drive; the PUT to the session URL is real fetch.
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) =>
String(input).startsWith("https://www.googleapis.com/")
? new Response(null, {
headers: { Location: `http://127.0.0.1:${port}/upload?upload_id=3` },
})
: fetch(input, init)) as typeof fetch;
const api = new GoogleDriveApi(async () => "token", fetchImpl);
expect(await api.upload({ filePath: video, name: "Demo.mp4", folderId: "f" })).toEqual({
id: "file-1",
webViewLink: "https://drive/file-1",
});
expect(received).toBe(1234);
} finally {
server.close();
}
});
});
160 changes: 160 additions & 0 deletions electron/drive/google-drive-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// The handful of Drive v3 REST calls sync needs: a folder, a resumable upload that creates a
// file or replaces the content of an existing one, and the "anyone with the link" permission.
//
// Plain fetch rather than `googleapis`: five endpoints do not justify a large dependency in
// the main process. Replacing a file's content keeps its id, so its link stays the same
// across every export of a project — that is the whole point of updating rather than
// uploading a new file each time.

import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import type { DriveVisibility } from "../../src/native/contracts";
import type { FetchLike } from "./google-oauth";

const DRIVE_API = "https://www.googleapis.com/drive/v3";
const DRIVE_UPLOAD = "https://www.googleapis.com/upload/drive/v3";
export const DRIVE_FOLDER_NAME = "OpenScreen";
const FOLDER_MIME = "application/vnd.google-apps.folder";

const VIDEO_MIME_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
};

/** The video's MIME type, or null for anything sync does not upload. */
export function videoMimeType(filePath: string): string | null {
return VIDEO_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? null;
}

export class DriveApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "DriveApiError";
}
}

export interface DriveUploadedFile {
id: string;
webViewLink: string;
}

export class GoogleDriveApi {
constructor(
private readonly accessToken: () => Promise<string>,
private readonly fetchImpl: FetchLike = fetch,
) {}

private async request(url: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${await this.accessToken()}`);
const response = await this.fetchImpl(url, { ...init, headers });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
error?: { message?: string };
} | null;
throw new DriveApiError(
body?.error?.message ?? `Google Drive request failed: HTTP ${response.status}`,
response.status,
);
}
return response;
}

/** The `OpenScreen` folder this app created, or a new one. `drive.file` only lists files
* the app created, so a same-named folder of the user's own is never picked up. */
async ensureFolder(): Promise<string> {
const query = `mimeType='${FOLDER_MIME}' and name='${DRIVE_FOLDER_NAME}' and trashed=false`;
const list = (await (
await this.request(`${DRIVE_API}/files?q=${encodeURIComponent(query)}&fields=files(id)`)
).json()) as { files?: Array<{ id: string }> };
const existing = list.files?.[0]?.id;
if (existing) return existing;
const created = (await (
await this.request(`${DRIVE_API}/files?fields=id`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: DRIVE_FOLDER_NAME, mimeType: FOLDER_MIME }),
})
).json()) as { id: string };
return created.id;
}

/**
* Uploads `filePath` as a new file in `folderId`, or, with `fileId`, replaces that file's
* content and name in place. Resumable protocol, sent in one PUT: recordings run to
* gigabytes, and a multipart upload would hold the whole file in memory.
*/
async upload(options: {
filePath: string;
name: string;
folderId?: string;
fileId?: string;
}): Promise<DriveUploadedFile> {
const mimeType = videoMimeType(options.filePath);
if (!mimeType) throw new Error("Only video files are uploaded to Google Drive.");
const { size } = await stat(options.filePath);
const metadata: Record<string, unknown> = { name: options.name, mimeType };
if (!options.fileId && options.folderId) metadata.parents = [options.folderId];
const session = await this.request(
options.fileId
? `${DRIVE_UPLOAD}/files/${encodeURIComponent(options.fileId)}?uploadType=resumable`
: `${DRIVE_UPLOAD}/files?uploadType=resumable`,
{
method: options.fileId ? "PATCH" : "POST",
headers: {
"Content-Type": "application/json; charset=UTF-8",
"X-Upload-Content-Type": mimeType,
"X-Upload-Content-Length": String(size),
},
body: JSON.stringify(metadata),
},
);
const location = session.headers.get("location");
if (!location) throw new DriveApiError("Google Drive returned no upload session.", 0);
const uploadUrl = new URL(location);
uploadUrl.searchParams.set("fields", "id,webViewLink");
const uploaded = (await (
await this.request(uploadUrl.toString(), {
method: "PUT",
headers: { "Content-Type": mimeType, "Content-Length": String(size) },
body: Readable.toWeb(createReadStream(options.filePath)) as ReadableStream,
// Required by Node's fetch for a streamed request body.
duplex: "half",
} as RequestInit)
).json()) as Partial<DriveUploadedFile>;
if (!uploaded.id) throw new DriveApiError("Google Drive returned no file id.", 0);
return {
id: uploaded.id,
webViewLink: uploaded.webViewLink ?? `https://drive.google.com/file/d/${uploaded.id}/view`,
};
}

/** "anyone": anyone with the link can view. "private": only the owner and people they
* shared with by name, i.e. the "anyone" permission removed. */
async setVisibility(fileId: string, visibility: DriveVisibility): Promise<void> {
const base = `${DRIVE_API}/files/${encodeURIComponent(fileId)}/permissions`;
const { permissions = [] } = (await (
await this.request(`${base}?fields=permissions(id,type)`)
).json()) as { permissions?: Array<{ id: string; type: string }> };
const anyone = permissions.filter((permission) => permission.type === "anyone");
if (visibility === "anyone") {
if (anyone.length > 0) return;
await this.request(base, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "anyone", role: "reader" }),
});
return;
}
for (const permission of anyone) {
await this.request(`${base}/${encodeURIComponent(permission.id)}`, { method: "DELETE" });
}
}
}
Loading
Loading