commit d10cec7aba52633f4d64b37b0016f195f8cdd1b9 Author: blank X Date: Tue Aug 17 17:56:54 2021 +0700 Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..211185a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 blank X + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..56b1056 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# gdproxy + +A google drive index but no client-side javascript because I hate it diff --git a/index.js b/index.js new file mode 100644 index 0000000..d852697 --- /dev/null +++ b/index.js @@ -0,0 +1,461 @@ +const GDPROXY_NAME = "a gdproxy instance"; +const ACCENT_COLOR = "#DD65E1"; +const SERVICE_ACCOUNTS = [ + // Enter service accounts' key herd + { + "type": "service_account", + "project_id": "no", + "private_key_id": "noo", + "private_key": "hell no", + "client_email": "you get the point now i hope", + "client_id": "107802968794798221608", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/no" + } +]; +const DRIVES = [ + { + // Enter folder id id you want to narrow access to a folder or null + "folderId": "1QIrFMjC1ITraZ2xyHjocY9c5LUlFZ-I0", + // If null, folderId must not be null + "teamDriveId": null, + "name": GDPROXY_NAME, + "useCache": true, + // username:password pairs, empty to disable authentication + "auth": [] + }, + { + // Enter folder id if you want to narrow access to a folder or null + "folderId": null, + // If null, folderId must not be null + "teamDriveId": "0AOmq9TyURQtXUk9PVA", + "name": "tdroot", + "useCache": true, + // username:password pairs, empty to disable authentication + "auth": [ + "username:password", + "horny:yes" + ] + }, + { + // Enter folder id if you want to narrow access to a folder or null + "folderId": "1GrPj3xvy503vAEsuhs5NgogmeKLLMGoU", + // If null, folderId must not be null + "teamDriveId": "0AOmq9TyURQtXUk9PVA", + "name": "tdnotroot", + "useCache": false, + // username:password pairs, empty to disable authentication + "auth": [] + } +]; + +// This is past the config part + +const CSS = `body { + background-color: black; + color: white; + font-family: sans-serif; + padding: 1em; +} +a { + color: ${ACCENT_COLOR}; +} +div.error { + border: 1px solid #fcc; + background: #fee; + padding: 0.5em 1em 0.5em 1em; + color: black; + text-align: center; +} +.text-right { + text-align: right; +} +th { + text-align: left; +} +th:nth-child(1) { + width: 100%; +}`; +const PATH_RE = /^\/(\d+):\/(?:(.+)\/?)?$/; +for (let i=0; i < DRIVES.length; i++) { + if (DRIVES[i].useCache) { + DRIVES[i].pathCache = {}; + DRIVES[i].listCache = {}; + } +} +let pageTokenCache = {}; +let sa = SERVICE_ACCOUNTS[Math.floor(Math.random() * SERVICE_ACCOUNTS.length)]; +let saBearer = null; +let saBearerExpiry = -1; +let saKey = sa.private_key.substring("-----BEGIN PRIVATE KEY-----".length, sa.private_key.length - "-----END PRIVATE KEY-----".length); + +// mmm yes no document.createElement +function escapeHTML(text) { + return text.replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} +let escapeURL = encodeURIComponent; +let unescapeURL = decodeURIComponent; +Object.prototype.exists = Object.prototype.hasOwnProperty; +function btoaurl(i) { + return btoa(i).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} +// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#pkcs_8_import +function str2ab(str) { + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + return buf; +} +function ab2str(ab) { + let str = ""; + ab = new Uint8Array(ab); + for (let i=0; i < ab.byteLength; i++) { + str += String.fromCharCode(ab[i]); + } + return str; +} +function buildErrorPage(nonce, title, message) { + return ` + + + + ${escapeHTML(title)} + + + +
${escapeHTML(message)}
+ +`; +} +// https://stackoverflow.com/a/49361727 +// https://stackoverflow.com/a/11832950 +function format_bytes(size) { + const power = 1000n; + let n = 0; + const power_labels = ["", "K", "M", "G", "T", "P"]; + while (size >= power && power_labels.length - 1 > n) { + size /= power; + n += 1; + } + return `${Math.round(Number(size) * 100) / 100} ${power_labels[n]}B`; +} + +async function pathToId(path, drive) { + let paths = path.split("/"); + let id = drive.folderId; + let mimeType = null; + for (let i=0; i < paths.length; i++) { + path = paths[i]; + if (path === "") { + continue; + } + path = unescapeURL(path).replaceAll("\\", "\\\\").replaceAll("'", "\\'"); + if (drive.useCache && drive.pathCache.exists(path)) { + id = drive.pathCache[path].id; + mimeType = drive.pathCache[path].mimeType; + continue; + } + let query = `driveId=${drive.teamDriveId}&q=${escapeURL("name = '" + path + "' and '" + id + "' in parents")}&corpora=drive`; + if (drive.teamDriveId !== null) { + if (id === null) { + query = `driveId=${drive.teamDriveId}&q=${escapeURL("name = '" + path + "' and '" + drive.teamDriveId + "' in parents")}&corpora=drive`; + } + } else { + query = `q=${escapeURL("name = '" + path + "' and '" + id + "' in parents")}&corpora=user`; + } + let url = `https://www.googleapis.com/drive/v3/files?${query}&includeItemsFromAllDrives=true&pageSize=1&supportsAllDrives=true&fields=files(id,mimeType)`; + let resp = await (await fetch(url, {"headers": {"Authorization": "Bearer " + saBearer}})).json(); + if (resp.files.length < 1) { + return null, null; + } + id = resp.files[0].id; + mimeType = resp.files[0].mimeType; + if (drive.useCache) { + drive.pathCache[path] = {"id": id, "mimeType": mimeType}; + } + } + return {"id": id, "mimeType": mimeType}; +} +async function listFiles(id, drive, pageToken) { + if (drive.useCache && drive.listCache.exists(`${id},${pageToken || ""}`)) { + return drive.listCache[`${id},${pageToken || ""}`]; + } + let url = `https://www.googleapis.com/drive/v3/files?q=${escapeURL("'" + id + "' in parents")}&includeItemsFromAllDrives=true&supportsAllDrives=true&fields=nextPageToken,files(name,size)&orderBy=folder,name_natural`; + if (typeof pageToken !== "undefined") { + url += `&pageToken=${escapeURL(pageToken)}`; + } + url += "&corpora="; + if (drive.teamDriveId !== null) { + url += `drive&driveId=${drive.teamDriveId}`; + } else { + url += "user"; + } + let resp = await (await fetch(url, {"headers": {"Authorization": "Bearer " + saBearer}})).json(); + if (drive.useCache) { + drive.listCache[`${id},${pageToken || ""}`] = resp; + } + return resp; +} + +addEventListener("fetch", event => { + event.respondWith(handleRequest(event.request)); +}); + +async function handleRequest(request) { + let nonceArray = crypto.getRandomValues(new Uint8Array(16)); + let nonce = ""; + for (let i=0; i < nonceArray.length; i++) { + nonce += nonceArray[i].toString(16).padStart(2, "0"); + } + if (request.method !== "GET") { + return new Response(buildErrorPage(nonce, GDPROXY_NAME, "501 Not Implemented"), { + status: 501, + statusText: "Not Implemented", + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'`, + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000" + } + }); + } + let url = new URL(request.url); + if (url.protocol !== "https:" || url.pathname === "/") { + url.protocol = "https:"; + if (url.pathname === "/") { + url.pathname = "/0:/"; + } + let content = ` + + + + ${escapeHTML(GDPROXY_NAME)} + + + +

Redirecting to ${escapeHTML(url.href)}

+ +`; + return new Response(content, { + status: 301, + statusText: "Moved Permanently", + headers: { + "Location": url.href, + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'`, + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000" + } + }); + } + let parsed = url.pathname.match(PATH_RE); + if (parsed === null || Number(parsed[1]) >= DRIVES.length) { + return new Response(buildErrorPage(nonce, GDPROXY_NAME + " - Not Found", "404 Not Found"), { + status: 404, + statusText: "Not Found", + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'`, + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000" + } + }); + } + let drive = DRIVES[Number(parsed[1])]; + if (drive.auth.length !== 0) { + let authHeader = request.headers.get("Authorization"); + if (!request.headers.has("Authorization") || !authHeader.startsWith("Basic ")) { + return new Response(buildErrorPage(nonce, drive.name + " - Unauthorised", "Unauthorised"), { + status: 401, + statusText: "Unauthorised", + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'`, + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000", + "WWW-Authenticate": `Basic realm="${drive.name}"` + } + }); + } + authHeader = atob(authHeader.replace("Basic ", "")); + let allowed = false; + for (let i=0; i < drive.auth.length; i++) { + if (authHeader === drive.auth[i]) { + allowed = true; + break; + } + } + if (!allowed) { + return new Response(buildErrorPage(nonce, drive.name + " - Unauthorised", "Unauthorised"), { + status: 401, + statusText: "Unauthorised", + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'`, + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000", + "WWW-Authenticate": `Basic realm="${drive.name}"` + } + }); + } + } + if (Date.now() >= saBearerExpiry) { + if (typeof saKey === "string") { + saKey = await crypto.subtle.importKey( + "pkcs8", + str2ab(atob(saKey.replace("-", ""))), + { + "name": "RSASSA-PKCS1-v1_5", + "hash": "SHA-256" + }, + false, + ["sign"] + ); + } + let iat = Math.floor(Date.now() / 1000); + let exp = iat + 60 * 60; + let token = btoaurl(JSON.stringify({"alg": "RS256", "typ": "JWT"})); + token += "." + btoaurl(JSON.stringify({ + "iss": sa.client_email, + "aud": sa.token_uri, + "scope": "https://www.googleapis.com/auth/drive.readonly", + "exp": exp, + "iat": iat + })); + let sig = btoaurl(ab2str(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", saKey, str2ab(token)))); + token += "." + sig; + let params = new URLSearchParams(); + params.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"); + params.append("assertion", token); + let resp = await (await fetch(sa.token_uri, {"method": "POST", "body": params})).json(); + saBearer = resp.access_token; + saBearerExpiry = (exp - 60) * 1000; + } + let path = parsed[2]; + if (typeof path === "undefined") { + path = ""; + } else { + path = path.replaceAll(/^\/+|\/+$/g, ""); + } + let baseId = drive.folderId || drive.teamDriveId; + let id = baseId; + let mimeType = "application/vnd.google-apps.folder"; + if (path !== "") { + let resp = await pathToId(path, drive); + if (resp === null) { + return new Response(buildErrorPage(nonce, drive.name + " - Not Found", "404 Not Found"), { + status: 404, + statusText: "Not Found", + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'`, + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000" + } + }); + } + id = resp.id; + mimeType = resp.mimeType; + } + if (mimeType === "application/vnd.google-apps.folder") { + let query = new URLSearchParams(url.search); + let pagenum = Number(query.get("page") || "1"); + let files = await listFiles(id, drive, pageTokenCache[`${id},${pagenum - 1}`]); + if (typeof pageTokenCache[`${id},${pagenum - 1}`] === "undefined") { + pagenum = 1; + } + if (typeof files.nextPageToken !== "undefined") { + pageTokenCache[`${id},${pagenum}`] = files.nextPageToken; + } + let page = ` + + + + ${escapeHTML(drive.name)} - ${escapeHTML(unescapeURL(path.replace(/.+\//, "")))} + + + +

/${parsed[1]}:/${escapeHTML(path)}

+ + + + + `; + if (id !== baseId) { + let bpath = path.split("/"); + bpath.pop(); + page += ` + + + + `; + } + for (let i=0; i < files.files.length; i++) { + let file = files.files[i]; + let name = file.name; + let size = file.size; + if (typeof size === "undefined") { + size = "-"; + } else { + size = format_bytes(BigInt(size)); + } + page += ` + + + + `; + } + page += "\n
NameSize
..-
${escapeHTML(name)}${size}
"; + if (pagenum !== 1 || typeof files.nextPageToken !== "undefined") { + page += "\n
"; + if (pagenum !== 1) { + let prevURL = `/${parsed[1]}:/${path}?page=${pagenum - 1}`; + page += `\n Back`; + } + page += `\n
Page ${pagenum}
`; + if (typeof files.nextPageToken !== "undefined") { + let nextURL = `/${parsed[1]}:/${path}?page=${pagenum + 1}`; + page += `\n
Next
`; + } + } + page += ` + +`; + return new Response(page, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; style-src 'nonce-${nonce}'`, + "Referrer-Policy": "no-referrer", + "Strict-Transport-Security": "max-age=31536000" + } + }); + } + let reqHeaders = {"Authorization": "Bearer " + saBearer}; + if (request.headers.has("Range")) { + reqHeaders.Range = request.headers.get("Range"); + } + let resp = await fetch(`https://www.googleapis.com/drive/v3/files/${id}?alt=media&supportsAllDrives=true`, {"headers": reqHeaders}); + let headers = new Headers({"Content-Type": resp.headers.get("Content-Type")}); + if (resp.headers.has("Content-Range")) { + headers.append("Content-Range", resp.headers.get("Content-Range")); + } + if (resp.headers.has("Content-Length")) { + headers.append("Content-Length", resp.headers.get("Content-Length")); + } + if (resp.headers.has("Accept-Ranges")) { + headers.append("Accept-Ranges", resp.headers.get("Accept-Ranges")); + } + return new Response(resp.body, { + status: resp.status, + statusText: resp.statusText, + headers: headers + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f32b01e --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "private": false, + "name": "gdproxy", + "version": "1.0.0", + "description": "Exposes Google Drive folders and files", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "format": "prettier --write '**/*.{js,css,json,md}'" + }, + "author": "blank X ", + "license": "MIT", + "devDependencies": { + "prettier": "^1.18.2" + } +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..2bceaf2 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,7 @@ +name = "gdproxy" +type = "javascript" + +account_id = "" +workers_dev = true +route = "" +zone_id = ""