gdproxy/index.js

462 lines
17 KiB
JavaScript

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#x27;");
}
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 `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHTML(title)}</title>
<style nonce="${nonce}">${CSS}</style>
</head>
<body>
<div class="error">${escapeHTML(message)}</div>
</body>
</html>`;
}
// 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}&nbsp;${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 = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHTML(GDPROXY_NAME)}</title>
<style nonce="${nonce}">${CSS}</style>
</head>
<body>
<p>Redirecting to <a href="${escapeHTML(url.href)}">${escapeHTML(url.href)}</a></p>
</body>
</html>`;
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 = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHTML(drive.name)} - ${escapeHTML(unescapeURL(path.replace(/.+\//, "")))}</title>
<style nonce="${nonce}">${CSS}</style>
</head>
<body>
<h3>/${parsed[1]}:/${escapeHTML(path)}</h3>
<table>
<tr>
<th>Name</th>
<th>Size</th>
</tr>`;
if (id !== baseId) {
let bpath = path.split("/");
bpath.pop();
page += `
<tr>
<td><a href="/${parsed[1]}:/${bpath.join("/")}">..</a></td>
<td>-</td>
</tr>`;
}
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 += `
<tr>
<td><a href="/${parsed[1]}:/${path}/${escapeURL(name)}">${escapeHTML(name)}</a></td>
<td>${size}</td>
</tr>`;
}
page += "\n </table>";
if (pagenum !== 1 || typeof files.nextPageToken !== "undefined") {
page += "\n <hr />";
if (pagenum !== 1) {
let prevURL = `/${parsed[1]}:/${path}?page=${pagenum - 1}`;
page += `\n <a href="${prevURL}">Back</a>`;
}
page += `\n <center>Page ${pagenum}</center>`;
if (typeof files.nextPageToken !== "undefined") {
let nextURL = `/${parsed[1]}:/${path}?page=${pagenum + 1}`;
page += `\n <div class="text-right"><a href="${nextURL}">Next</a></div>`;
}
}
page += `
</body>
</html>`;
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
});
}