From 1a69cbcb53208ce2e3c3fd80f585433de78fa884 Mon Sep 17 00:00:00 2001 From: Matt Godbolt Date: Wed, 1 Jan 2025 21:12:19 +0000 Subject: [PATCH] Move to the new Google APIs for auth and drive (#465) Uses the new GAPI and drive v3. Should close #389 --- .idea/runConfigurations/Debug.xml | 2 +- src/google-drive.js | 286 ++++++++++++++---------------- src/main.js | 76 ++++---- 3 files changed, 165 insertions(+), 199 deletions(-) diff --git a/.idea/runConfigurations/Debug.xml b/.idea/runConfigurations/Debug.xml index b35c9a6..ac4ddb4 100644 --- a/.idea/runConfigurations/Debug.xml +++ b/.idea/runConfigurations/Debug.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/src/google-drive.js b/src/google-drive.js index fa325d7..92b36aa 100644 --- a/src/google-drive.js +++ b/src/google-drive.js @@ -1,169 +1,153 @@ "use strict"; + import _ from "underscore"; import * as utils from "./utils.js"; import { discFor } from "./fdc.js"; -export function GoogleDriveLoader() { - const self = this; - const MIME_TYPE = "application/vnd.jsbeeb.disc-image"; - const CLIENT_ID = "356883185894-bhim19837nroivv18p0j25gecora60r5.apps.googleusercontent.com"; - const SCOPES = "https://www.googleapis.com/auth/drive.file"; - let gapi = null; - - self.initialise = function () { - return new Promise(function (resolve) { - // https://github.com/google/google-api-javascript-client/issues/319 - const gapiScript = document.createElement("script"); - gapiScript.src = "https://apis.google.com/js/client.js?onload=__onGapiLoad__"; - window.__onGapiLoad__ = function onGapiLoad() { - gapi = window.gapi; - gapi.client.load("drive", "v2", function () { - console.log("Google Drive: available"); - resolve(true); - }); - }; - document.body.appendChild(gapiScript); +const MIME_TYPE = "application/vnd.jsbeeb.disc-image"; +const CLIENT_ID = "356883185894-bhim19837nroivv18p0j25gecora60r5.apps.googleusercontent.com"; +const SCOPES = "https://www.googleapis.com/auth/drive.file"; +const DISCOVERY_DOC = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; +const FILE_FIELDS = "id,name,capabilities"; +const PARENT_FOLDER_NAME = "jsbeeb disc images"; + +const boundary = "-------314159265358979323846"; +const delimiter = `\r\n--${boundary}\r\n`; +const close_delim = `\r\n--${boundary}--`; + +const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + +export class GoogleDriveLoader { + constructor() { + this.authorized = false; + this.parentFolderId = undefined; + this.driveClient = undefined; + } + + async initialise() { + console.log("Creating GAPI"); + const gapi = await this._loadScript("https://apis.google.com/js/api.js", () => window.gapi); + console.log("Got GAPI, creating token client"); + this.tokenClient = await this._loadScript("https://accounts.google.com/gsi/client", () => { + return window.google.accounts.oauth2.initTokenClient({ + client_id: CLIENT_ID, + scope: SCOPES, + error_callback: "", // defined later + callback: "", // defined later + }); }); - }; - - self.authorize = function (immediate) { - return new Promise(function (resolve, reject) { - console.log("Authorizing", immediate); - gapi.auth.authorize( - { - client_id: CLIENT_ID, - scope: SCOPES, - immediate: immediate, - }, - function (authResult) { - if (authResult && !authResult.error) { - console.log("Google Drive: authorized"); - resolve(true); - } else if (authResult && authResult.error && !immediate) { - reject(new Error(authResult.error)); - } else { - console.log("Google Drive: Need to auth"); - resolve(false); - } - }, - ); + console.log("Token client created, loading client"); + + await gapi.load("client", async () => { + console.log("Client loaded; initialising GAPI"); + await gapi.client.init({ discoveryDocs: [DISCOVERY_DOC] }); + console.log("GAPI initialised"); + this.driveClient = gapi.client.drive; }); - }; - - const boundary = "-------314159265358979323846"; - const delimiter = "\r\n--" + boundary + "\r\n"; - const close_delim = "\r\n--" + boundary + "--"; - - function listFiles() { - return new Promise(function (resolve) { - const retrievePageOfFiles = function (request, result) { - request.execute(function (resp) { - result = result.concat(resp.items); - const nextPageToken = resp.nextPageToken; - if (nextPageToken) { - request = gapi.client.drive.files.list({ - pageToken: nextPageToken, - }); - retrievePageOfFiles(request, result); - } else { - resolve(result); - } - }); + console.log("Google Drive: available"); + return true; + } + + _loadScript(src, onload) { + // https://github.com/google/google-api-javascript-client/issues/319 + return new Promise((resolve) => { + const script = document.createElement("script"); + script.src = src; + script.onload = () => resolve(onload()); + document.body.appendChild(script); + }); + } + + authorize(imm) { + if (this.authorized) return true; + if (imm) return false; + return new Promise((resolve, reject) => { + console.log("Authorizing..."); + this.tokenClient.callback = (resp) => { + if (resp.error !== undefined) reject(resp); + console.log("Authorized OK"); + this.authorized = true; + resolve(true); + }; + this.tokenClient.error_callback = (resp) => { + console.log(`Token client failure: ${resp.type}; failed to authorize`); + reject(new Error(`Token client failure: ${resp.type}; failed to authorize`)); }; - retrievePageOfFiles( - gapi.client.drive.files.list({ - q: "mimeType = '" + MIME_TYPE + "'", - }), - [], - ); + this.tokenClient.requestAccessToken({ select_account: false }); + }); + } + + async listFiles() { + let response = await this.driveClient.files.list({ q: `mimeType = '${MIME_TYPE}' and trashed = false` }); + let result = response.result.files; + while (response.result.nextPageToken) { + response = await this.driveClient.files.list({ pageToken: response.result.nextPageToken }); + result = result.concat(response.result.files); + } + return result; + } + + async _findOrCreateParentFolder() { + const list = await this.driveClient.files.list({ + q: `name = '${PARENT_FOLDER_NAME}' and mimeType = '${FOLDER_MIME_TYPE}' and trashed = false`, + corpora: "user", }); + if (list.result.files.length === 1) { + console.log("Found existing parent folder"); + return list.result.files[0].id; + } + console.log(`Creating parent folder ${PARENT_FOLDER_NAME}`); + const file = await this.driveClient.files.create({ + resource: { name: PARENT_FOLDER_NAME, mimeType: FOLDER_MIME_TYPE }, + fields: "id", + }); + console.log("Folder Id:", file.result.id); + return file.result.id; } - function saveFile(name, data, idOrNone) { - const metadata = { - title: name, - parents: ["jsbeeb disc images"], // TODO: parents doesn't work; also should probably prevent overwriting this on every save - mimeType: MIME_TYPE, - }; + async saveFile(name, data, idOrNone) { + if (this.parentFolderId === undefined) { + this.parentFolderId = await this._findOrCreateParentFolder(); + } + const metadata = { name, mimeType: MIME_TYPE }; + if (!idOrNone) metadata.parents = [this.parentFolderId]; - const str = utils.uint8ArrayToString(data); - const base64Data = btoa(str); + const base64Data = btoa(utils.uint8ArrayToString(data)); const multipartRequestBody = - delimiter + - "Content-Type: application/json\r\n\r\n" + - JSON.stringify(metadata) + - delimiter + - "Content-Type: " + - MIME_TYPE + - "\r\n" + - "Content-Transfer-Encoding: base64\r\n" + - "\r\n" + - base64Data + - close_delim; - - return gapi.client.request({ - path: "/upload/drive/v2/files" + (idOrNone ? "/" + idOrNone : ""), - method: idOrNone ? "PUT" : "POST", - params: { uploadType: "multipart", newRevision: false }, - headers: { - "Content-Type": 'multipart/mixed; boundary="' + boundary + '"', - }, + `${delimiter}Content-Type: application/json\r\n\r\n` + + `${JSON.stringify(metadata)}${delimiter}` + + `Content-Type: ${MIME_TYPE}\r\nContent-Transfer-Encoding: base64\r\n\r\n` + + `${base64Data}${close_delim}`; + + return this.gapi.client.request({ + path: `/upload/drive/v3/files${idOrNone ? `/${idOrNone}` : ""}`, + method: idOrNone ? "PATCH" : "POST", + params: { uploadType: "multipart", newRevision: false, fields: FILE_FIELDS }, + headers: { "Content-Type": `multipart/mixed; boundary="${boundary}"` }, body: multipartRequestBody, }); } - function loadMetadata(fileId) { - return gapi.client.drive.files.get({ fileId: fileId }); - } - - self.create = function (fdc, name) { - console.log("Google Drive: creating disc image: '" + name + "'"); + async create(fdc, name) { + console.log(`Google Drive: creating disc image: '${name}'`); const byteSize = utils.discImageSize(name).byteSize; const data = new Uint8Array(byteSize); utils.setDiscName(data, name); - return saveFile(name, data).then(function (response) { - const meta = response.result; - return { fileId: meta.id, disc: makeDisc(fdc, data, meta) }; - }); - }; - - function downloadFile(file) { - if (file.downloadUrl) { - return new Promise(function (resolve, reject) { - const accessToken = gapi.auth.getToken().access_token; - const xhr = new XMLHttpRequest(); - xhr.open("GET", file.downloadUrl, true); - xhr.setRequestHeader("Authorization", "Bearer " + accessToken); - xhr.overrideMimeType("text/plain; charset=x-user-defined"); - - xhr.onload = function () { - if (xhr.status !== 200) { - reject(new Error("Unable to load '" + file.title + "', http code " + xhr.status)); - } else if (typeof xhr.response !== "string") { - resolve(xhr.response); - } else { - resolve(utils.stringToUint8Array(xhr.response)); - } - }; - xhr.onerror = function () { - reject(new Error("Error sending request for " + file)); - }; - xhr.send(); - }); - } else { - return Promise.resolve(null); - } + const response = await this.saveFile(name, data); + const meta = response.result; + return { fileId: meta.id, disc: this.makeDisc(fdc, data, meta) }; } - function makeDisc(fdc, data, meta) { + makeDisc(fdc, data, meta) { let flusher = null; - const name = meta.title; - if (meta.editable) { + const name = meta.name; + const id = meta.id; + if (meta.capabilities.canEdit) { console.log("Making editable disc"); - flusher = _.debounce(function () { - saveFile(this.name, this.data, meta.id).then(function () { - console.log("Saved ok"); - }); + flusher = _.debounce(async (changedData) => { + console.log("Data changed..."); + await this.saveFile(name, changedData, id); + console.log("Saved ok"); }, 200); } else { console.log("Making read-only disc"); @@ -171,17 +155,9 @@ export function GoogleDriveLoader() { return discFor(fdc, name, data, flusher); } - self.load = function (fdc, fileId) { - let meta = false; - return loadMetadata(fileId) - .then(function (response) { - meta = response.result; - return downloadFile(response.result); - }) - .then(function (data) { - return makeDisc(fdc, data, meta); - }); - }; - - self.cat = listFiles; + async load(fdc, fileId) { + const meta = (await this.driveClient.files.get({ fileId, fields: FILE_FIELDS })).result; + const data = (await this.driveClient.files.get({ fileId, alt: "media" })).body; + return this.makeDisc(fdc, data, meta); + } } diff --git a/src/main.js b/src/main.js index d494292..1415822 100644 --- a/src/main.js +++ b/src/main.js @@ -916,12 +916,12 @@ function loadDiscImage(discImage) { } if (schema === "gd") { const splat = discImage.match(/([^/]+)\/?(.*)/); - let title = "(unknown)"; + let name = "(unknown)"; if (splat) { discImage = splat[1]; - title = splat[2]; + name = splat[2]; } - return gdLoad({ title: title, id: discImage }); + return gdLoad({ name, id: discImage }); } if (schema === "b64data") { const ssdData = atob(discImage); @@ -1051,21 +1051,15 @@ function loadingFinished(error) { } } -let gdAuthed = false; const googleDrive = new GoogleDriveLoader(); -function gdAuth(imm) { - return googleDrive.authorize(imm).then( - function (authed) { - gdAuthed = authed; - console.log("authed =", authed); - return authed; - }, - function (err) { - console.log("Error handling google auth: " + err); - $googleDrive.find(".loading").text("There was an error accessing your Google Drive account: " + err); - }, - ); +async function gdAuth(imm) { + try { + return await googleDrive.authorize(imm); + } catch (err) { + console.log("Error handling google auth: " + err); + $googleDrive.find(".loading").text("There was an error accessing your Google Drive account: " + err); + } } let googleDriveLoadingResolve, googleDriveLoadingReject; @@ -1085,7 +1079,7 @@ function gdLoad(cat) { return confirm("Do you really want to close?"); }); */ - popupLoading("Loading '" + cat.title + "' from Google Drive"); + popupLoading("Loading '" + cat.name + "' from Google Drive"); return googleDrive .initialise() .then(function (available) { @@ -1123,41 +1117,36 @@ $(".if-drive-available").hide(); googleDrive.initialise().then(function (available) { if (available) { $(".if-drive-available").show(); - gdAuth(true); + gdAuth(true).then(); } }); const $googleDrive = $("#google-drive"); const $googleDriveModal = new bootstrap.Modal($googleDrive[0]); $("#open-drive-link").on("click", function () { - if (gdAuthed) { - $googleDriveModal.show(); - } else { - gdAuth(false).then(function (authed) { - if (authed) { - $googleDriveModal.hide(); - } - }); - } + gdAuth(false).then(function (authed) { + if (authed) { + $googleDriveModal.show(); + } + }); return false; }); -$googleDrive[0].addEventListener("show.bs.modal", function () { +$googleDrive[0].addEventListener("show.bs.modal", async function () { $googleDrive.find(".loading").text("Loading...").show(); $googleDrive.find("li").not(".template").remove(); - googleDrive.cat().then(function (cat) { - const dbList = $googleDrive.find(".list"); - $googleDrive.find(".loading").hide(); - const template = dbList.find(".template"); - $.each(cat, function (_, cat) { - const row = template.clone().removeClass("template").appendTo(dbList); - row.find(".name").text(cat.title); - $(row).on("click", function () { - utils.noteEvent("google-drive", "click", cat.title); - setDisc1Image("gd:" + cat.id + "/" + cat.title); - gdLoad(cat).then(function (ssd) { - processor.fdc.loadDisc(0, ssd); - }); - $googleDriveModal.hide(); + const cat = await googleDrive.listFiles(); + const dbList = $googleDrive.find(".list"); + $googleDrive.find(".loading").hide(); + const template = dbList.find(".template"); + $.each(cat, function (_, cat) { + const row = template.clone().removeClass("template").appendTo(dbList); + row.find(".name").text(cat.name); + $(row).on("click", function () { + utils.noteEvent("google-drive", "click", cat.name); + setDisc1Image(`gd:${cat.id}/${cat.name}`); + gdLoad(cat).then(function (ssd) { + processor.fdc.loadDisc(0, ssd); }); + $googleDriveModal.hide(); }); }); }); @@ -1191,7 +1180,8 @@ $("#google-drive form").on("submit", function (e) { loadingFinished(); }, function (error) { - loadingFinished(error); + console.log(`Error in creating: ${error} | ${JSON.stringify(error)}`); + loadingFinished(`Create failed: ${error}`); }, ); });