diff options
Diffstat (limited to 'background/google-drive-folders.js')
| -rw-r--r-- | background/google-drive-folders.js | 396 |
1 files changed, 396 insertions, 0 deletions
diff --git a/background/google-drive-folders.js b/background/google-drive-folders.js new file mode 100644 index 0000000..4e6a9c6 --- /dev/null +++ b/background/google-drive-folders.js @@ -0,0 +1,396 @@ +/** + * SPDX-License-Identifier: CC0-1.0 + * + * Copyright (C) 2021, 2026 Woj. Kosior <koszko@koszko.org> + */ + +(() => { + +blockJsOnUrl("https://drive.google.com/drive/folders/*"); + +/* Define how to handle various mime types used by Google. */ + +const defaultLinkProducer = id => ({ + view: `https://drive.google.com/file/d/${id}`, + download: `https://drive.google.com/uc?export=download&id=${id}` +}); + +const knownMimes = { + "application/vnd.google-apps.folder": { + links: id => ({ + view: `https://drive.google.com/drive/folders/${id}` + }), + type: "folder", + is_folder: true + }, + "application/vnd.google-apps.shortcut": { + links: id => ({ + view: `https://drive.google.com/drive/folders/${id}` + }), + type: "shortcut", + is_folder: true + }, + "application/vnd.google-apps.document": { + links: id => ({ + view: `https://docs.google.com/document/d/${id}`, + download: `https://docs.google.com/document/d/${id}/export?format=odt`, + }), + type: "Google text document", + newMime: "application/vnd.oasis.opendocument.text" + }, + "application/vnd.google-apps.spreadsheet": { + links: id => ({ + view: `https://docs.google.com/spreadsheets/d/${id}`, + download: `https://docs.google.com/spreadsheets/d/${id}/export?format=ods` + }), + type: "Google spreadsheet", + newMime: "application/vnd.oasis.opendocument.spreadsheet" + }, + "application/vnd.google-apps.presentation": { + links: id => ({ + view: `https://docs.google.com/presentation/d/${id}`, + download: `https://docs.google.com/presentation/d/${id}/export/pptx` + }), + type: "Google presentation", + newMime: "application/vnd.openxmlformats-officedocument.presentationml.presentation" + }, + "application/vnd.google-apps.drawing": { + links: id => ({ + view: `https://docs.google.com/drawings/d/${id}`, + download: `https://docs.google.com/drawings/d/${id}/export/jpeg` + }), + type: "Google drawing", + newMime: "image/jpeg" + }, + "application/vnd.google-apps.script": { + links: id => ({ + download: `https://script.google.com/feeds/download/export?format=json&id=${id}` + }), + type: "Google script", + newMime: "application/vnd.google-apps.script+json" + }, + "application/vnd.google-apps.jam": { + links: id => ({ + download: `https://jamboard.google.com/export?id=${id}` + }), + type: "Google jam", + newMime: "application/pdf" + }, + /* + * Human-friendly names defined below will be displayed to the user instead + * of raw mime types. Please add more here. + */ + ...[ + [ "image/jpeg", "JPEG image"], + ["application/octet-stream", "binary data"], + [ "application/pdf", "PDF document"], + [ "application/rar", "RAR archive"], + [ "application/zip", "ZIP archive"] + ].map(([mime, displayName]) => [ + mime, + { + links: id => defaultLinkProducer, + type: displayName, + newMime: mime + } + ]) +}; + +function getMimeInfo(mime) { + return knownMimes[mime] || { + links: defaultLinkProducer, + type: mime || "", + newMime: mime || "application/octet-stream" + }; +} + +function transformDocument(origDoc) { + /* + * Prepare folder contents data as well as data regarding the folder itself. + */ + + const contents = new Map(); + + function add_contents_item(item) + { + const old_item = contents.get(item.id) || {}; + Object.assign(old_item, item); + contents.set(item.id, old_item); + } + + const this_folder = {}; + + function replace_string_escape(match, group) + { + return String.fromCharCode(`0x${group}`); + } + + function try_parse(data, replace_x_escapes) + { + if (!data) + return null; + + if (replace_x_escapes) + data = data.replaceAll(/\\x([0-9a-f]{2})/g, replace_string_escape); + + try { + return JSON.parse(data); + } catch (e) { + console.log(e); + } + + return null; + } + + function process_file_data(file_data, callback) + { + if (!Array.isArray(file_data) || !typeof file_data[0] === "string") { + console.log("cannot process the following file data object:", + file_data); + return; + } + + const result = {id: file_data[0], folders: []}; + + if (Array.isArray(file_data[1])) { + for (const item of file_data[1]) { + if (typeof item === "string" && item !== this_folder.id) + result.folders.push(item); + } + } + if (typeof file_data[2] === "string") + result.filename = file_data[2]; + if (typeof file_data[3] === "string" && file_data[3].search("/") >= 0) + result.mime = file_data[3]; + if (typeof file_data[9] === "number") + result.date1 = new Date(file_data[9]); + if (typeof file_data[10] === "number") + result.date2 = new Date(file_data[10]); + + callback(result); + } + + /* + * By searching for scripts with calls to AF_initDataCallback we get about 7 + * matches. All arguments to this function seem to be arrays parseable as + * JSON, but their contents are very different and only two of those ~7 + * arrays actually hold some data useful to us. Here we try to filter out + * the other cases and then extract the useful data. + */ + function process_af_init_data(data) + { + if (!Array.isArray(data) || !/^driveweb/.test(data[0]) || + !Array.isArray(data[1])) + return; + + /* + * First useful "kind" of object we can encounter is this folder's data. + */ + if (typeof data[1][0] === "string") { + process_file_data(data[1], item => Object.assign(this_folder, item)); + return; + } + + /* + * Second "kind" of object holds data about all items in the folder. + * Folders and Files are stored in separate lists (doesn't matter to us, + * since we distinguish them based on mime type anyway). + */ + for (const data_sub of data[1]) { + if (!Array.isArray(data_sub) || !/^driveweb/.test(data_sub[0]) || + !Array.isArray(data_sub[1])) + continue; + for (const item of data_sub[1]) + process_file_data(item, add_contents_item); + } + } + + /* + * Folder items data actually exists in both of the 2 kinds of scripts we + * search for. In case of both of the regexes below we call + * `process_file_data' in the end. As a result, we process the same file + * multiple time as it appears in 2 scripts. This is, however, a good thing, + * because it makes a change less likely to break our fix. + */ + + const ivd_data_regex = + /\s*window\s*\['_DRIVE_ivd'\]\s*=\s*'(\\x5b[^']+)'(.*)$/; + const af_init_data_regex = + /AF_initDataCallback\s*\(.+data\s*:\s*(\[.*\])[^\]]+$/; + + for (const script of origDoc.scripts) { + const ivd_data_match = ivd_data_regex.exec(script.textContent); + if (ivd_data_match) { + const ivd_data = try_parse(ivd_data_match[1], true); + if (ivd_data && Array.isArray(ivd_data) && + Array.isArray(ivd_data[0])) { + for (const item of ivd_data[0]) + process_file_data(item, add_contents_item); + } + } + + const af_init_data_match = af_init_data_regex.exec(script.textContent); + if (af_init_data_match) { + const af_init_data = try_parse(af_init_data_match[1], false); + if (af_init_data) + process_af_init_data(af_init_data); + } + } + + /* Construct our own user interface. */ + + const newDoc = new DOMParser().parseFromString('<html>', 'text/html'); + + const body = newDoc.createElement("body"); + const folders = newDoc.createElement("div"); + const files = newDoc.createElement("div"); + const folders_heading = newDoc.createElement("h2"); + const files_heading = newDoc.createElement("h2"); + + folders_heading.textContent = "Folders"; + files_heading.textContent = "Files"; + + let has_folders = false; + let has_files = false; + + folders.appendChild(folders_heading); + files.appendChild(files_heading); + + body.setAttribute("style", "width: 100vw; height: 100vh; overflow: scroll; color: #555; margin: 15px; -webkit-user-select: initial;"); + + const drive_folder_regex = /application\/vnd.google-apps.(folder|shortcut)/; + + function add_item_to_view(item_data) + { + const item_div = newDoc.createElement("div"); + const item_heading = newDoc.createElement("h4"); + + item_div.setAttribute("style", "border: 2px solid #999; border-radius: 8px; padding: 10px; display: inline-block; margin: 2px;"); + + let item_heading_style = "margin: 8px 4px;"; + + if (item_data.filename) { + item_heading.textContent = item_data.filename; + } else { + item_heading.textContent = "(no name)"; + item_heading_style += " font-style:italic;"; + } + item_heading.setAttribute("style", item_heading_style); + item_div.appendChild(item_heading); + + const mime_info = getMimeInfo(item_data.mime); + + if (mime_info.type) { + const type_div = newDoc.createElement("div"); + type_div.setAttribute("style", "margin-bottom: 5px;"); + type_div.textContent = mime_info.type; + + item_div.appendChild(type_div); + } + + if (mime_info.is_folder) + has_folders = true; + else + has_files = true; + + const links = {}; + if (item_data.id) + Object.assign(links, mime_info.links(item_data.id)); + + if (links.view) { + const view_button = newDoc.createElement("a"); + view_button.setAttribute("style", "border-radius: 5px; padding: 10px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block; margin: 5px;"); + view_button.textContent = "view"; + view_button.href = links.view; + + item_div.appendChild(view_button); + } + + if (links.download) { + const download_button = newDoc.createElement("a"); + download_button.setAttribute("style", "border-radius: 5px; padding: 10px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block; margin: 5px;"); + download_button.textContent = "download"; + download_button.href = links.download; + download_button.type = mime_info.newMime; + + item_div.appendChild(download_button); + } + + (mime_info.is_folder ? folders : files).appendChild(item_div); + } + + for (const item of contents.values()) + add_item_to_view(item); + + if (this_folder.filename) { + const heading = newDoc.createElement("h1"); + heading.textContent = this_folder.filename; + body.appendChild(heading); + } + if (this_folder.folders && this_folder.folders.length > 0) { + for (const parent_id of this_folder.folders) { + const up_button = newDoc.createElement("a"); + let text = "go up"; + + up_button.setAttribute("style", "border-radius: 5px; padding: 10px; color: #333; background-color: lightgreen; text-decoration: none; box-shadow: -4px 8px 8px #888; display: inline-block; margin: 5px;"); + up_button.href = `https://drive.google.com/drive/folders/${parent_id}`; + if (this_folder.folders.length > 1) + text = `${text} (${parent_id})`; + up_button.textContent = text; + + body.appendChild(up_button); + } + } + if (has_folders) + body.appendChild(folders); + if (has_files) + body.appendChild(files); + if (!(has_files || has_folders)) { + const no_files_message = newDoc.createElement("h3"); + no_files_message.textContent = "No files found."; + body.appendChild(no_files_message); + } + + newDoc.documentElement.replaceChild(body, newDoc.body); + + return '<!DOCTYPE html>\n' + newDoc.documentElement.outerHTML; +} + +function alterGoogleDriveFolderResponse(details) { + if (String(details.statusCode)[0] !== '2') + return; + + for (const header of details.responseHeaders) { + if (/^content-type$/i.test(header.name) && + !/^text[/]html; *charset *= *utf-8/i.test(header.value)) + return; + } + + const filter = browser.webRequest.filterResponseData(details.requestId); + + const decoder = new TextDecoder("utf-8"); + + const pieces = []; + + filter.ondata = event => pieces.push( + decoder.decode(event.data, { stream: true }) + ); + + filter.onstop = event => { + const origDoc = new DOMParser() + .parseFromString(pieces.join(''), 'text/html'); + filter.write(new TextEncoder().encode(transformDocument(origDoc))); + filter.close(); + }; +} + +browser.webRequest.onHeadersReceived.addListener( + alterGoogleDriveFolderResponse, + { + urls: ["https://drive.google.com/drive/folders/*"], + types: ["main_frame", "sub_frame"] + }, + ["blocking", "responseHeaders"] +); + +})(); |
