gdrive.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. /*
  2. * Copyright 2010-2019 Gildas Lormeau
  3. * contact : gildas.lormeau <at> gmail.com
  4. *
  5. * This file is part of SingleFile.
  6. *
  7. * The code in this file is free software: you can redistribute it and/or
  8. * modify it under the terms of the GNU Affero General Public License
  9. * (GNU AGPL) as published by the Free Software Foundation, either version 3
  10. * of the License, or (at your option) any later version.
  11. *
  12. * The code in this file is distributed in the hope that it will be useful,
  13. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  15. * General Public License for more details.
  16. *
  17. * As additional permission under GNU AGPL version 3 section 7, you may
  18. * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
  19. * AGPL normally required by section 4, provided you include this license
  20. * notice and a URL through which recipients can access the Corresponding
  21. * Source.
  22. */
  23. /* global browser, fetch, setInterval */
  24. this.GDrive = this.GDrive || (() => {
  25. "use strict";
  26. const TOKEN_URL = "https://oauth2.googleapis.com/token";
  27. const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
  28. const REVOKE_ACCESS_URL = "https://accounts.google.com/o/oauth2/revoke";
  29. const GDRIVE_URL = "https://www.googleapis.com/drive/v3/files";
  30. const GDRIVE_UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3/files";
  31. let requestPermissionIdentityNeeded = true;
  32. class GDrive {
  33. constructor(clientId, scopes) {
  34. this.clientId = clientId;
  35. this.scopes = scopes;
  36. this.folderIds = new Map();
  37. setInterval(() => this.folderIds.clear(), 60 * 1000);
  38. }
  39. async auth(options = { interactive: true, auto: true }) {
  40. if (this.managedToken(options)) {
  41. if (options.requestPermissionIdentity && requestPermissionIdentityNeeded) {
  42. try {
  43. await browser.permissions.request({ permissions: ["identity"] });
  44. requestPermissionIdentityNeeded = false;
  45. }
  46. catch (error) {
  47. // ignored;
  48. }
  49. }
  50. const token = await browser.identity.getAuthToken({ interactive: options.interactive });
  51. if (token) {
  52. this.accessToken = token;
  53. return { accessToken: this.accessToken };
  54. }
  55. } else {
  56. this.getAuthURL(options);
  57. return options.code ? authFromCode(this, options) : initAuth(this, options);
  58. }
  59. }
  60. managedToken(options) {
  61. return Boolean(browser.identity && browser.identity.getAuthToken) && !options.forceWebAuthFlow;
  62. }
  63. setAuthInfo(authInfo, options) {
  64. if (!this.managedToken(options)) {
  65. if (authInfo) {
  66. this.accessToken = authInfo.accessToken;
  67. this.refreshToken = authInfo.refreshToken;
  68. this.expirationDate = authInfo.expirationDate;
  69. } else {
  70. delete this.accessToken;
  71. delete this.refreshToken;
  72. delete this.expirationDate;
  73. }
  74. }
  75. }
  76. getAuthURL(options = {}) {
  77. this.redirectURI = encodeURIComponent("urn:ietf:wg:oauth:2.0:oob" + (options.auto ? ":auto" : ""));
  78. this.authURL = AUTH_URL +
  79. "?client_id=" + this.clientId +
  80. "&response_type=code" +
  81. "&access_type=offline" +
  82. "&redirect_uri=" + this.redirectURI +
  83. "&scope=" + this.scopes.join(" ");
  84. return this.authURL;
  85. }
  86. async refreshAuthToken() {
  87. if (this.clientId && this.refreshToken) {
  88. const httpResponse = await fetch(TOKEN_URL, {
  89. method: "POST",
  90. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  91. body: "client_id=" + this.clientId +
  92. "&refresh_token=" + this.refreshToken +
  93. "&grant_type=refresh_token"
  94. });
  95. if (httpResponse.status == 400) {
  96. throw new Error("unknown_token");
  97. }
  98. const response = await getJSON(httpResponse);
  99. this.accessToken = response.access_token;
  100. if (response.refresh_token) {
  101. this.refreshToken = response.refresh_token;
  102. }
  103. if (response.expires_in) {
  104. this.expirationDate = Date.now() + (response.expires_in * 1000);
  105. }
  106. return { accessToken: this.accessToken, refreshToken: this.refreshToken, expirationDate: this.expirationDate };
  107. }
  108. }
  109. async revokeAuthToken(accessToken) {
  110. if (accessToken) {
  111. if (browser.identity && browser.identity.removeCachedAuthToken) {
  112. try {
  113. await browser.identity.removeCachedAuthToken({ token: accessToken });
  114. } catch (error) {
  115. // ignored
  116. }
  117. }
  118. const httpResponse = await fetch(REVOKE_ACCESS_URL, {
  119. method: "POST",
  120. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  121. body: "token=" + accessToken
  122. });
  123. try {
  124. await getJSON(httpResponse);
  125. }
  126. catch (error) {
  127. if (error.message != "invalid_token") {
  128. throw error;
  129. }
  130. }
  131. finally {
  132. delete this.accessToken;
  133. delete this.refreshToken;
  134. delete this.expirationDate;
  135. }
  136. }
  137. }
  138. async upload(fullFilename, blob, retry = true) {
  139. const parentFolderId = await getParentFolderId(this, fullFilename);
  140. const fileParts = fullFilename.split("/");
  141. const filename = fileParts.pop();
  142. const uploader = new MediaUploader({
  143. token: this.accessToken,
  144. file: blob,
  145. parents: [parentFolderId],
  146. filename
  147. });
  148. try {
  149. return await uploader.upload();
  150. }
  151. catch (error) {
  152. if (error.message == "path_not_found" && retry) {
  153. this.folderIds.clear();
  154. return this.upload(fullFilename, blob, false);
  155. } else {
  156. throw error;
  157. }
  158. }
  159. }
  160. }
  161. class MediaUploader {
  162. constructor(options) {
  163. this.file = options.file;
  164. this.contentType = this.file.type || "application/octet-stream";
  165. this.metadata = {
  166. name: options.filename,
  167. mimeType: this.contentType,
  168. parents: options.parents || ["root"]
  169. };
  170. this.token = options.token;
  171. this.offset = 0;
  172. this.chunkSize = options.chunkSize || 5 * 1024 * 1024;
  173. }
  174. async upload() {
  175. const httpResponse = getResponse(await fetch(GDRIVE_UPLOAD_URL + "?uploadType=resumable", {
  176. method: "POST",
  177. headers: {
  178. "Authorization": "Bearer " + this.token,
  179. "Content-Type": "application/json",
  180. "X-Upload-Content-Length": this.file.size,
  181. "X-Upload-Content-Type": this.contentType
  182. },
  183. body: JSON.stringify(this.metadata)
  184. }));
  185. const location = httpResponse.headers.get("Location");
  186. this.url = location;
  187. return sendFile(this);
  188. }
  189. }
  190. return GDrive;
  191. async function authFromCode(gdrive, options) {
  192. const httpResponse = await fetch(TOKEN_URL, {
  193. method: "POST",
  194. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  195. body: "client_id=" + gdrive.clientId +
  196. "&grant_type=authorization_code" +
  197. "&code=" + options.code +
  198. "&redirect_uri=" + gdrive.redirectURI
  199. });
  200. const response = await getJSON(httpResponse);
  201. gdrive.accessToken = response.access_token;
  202. gdrive.refreshToken = response.refresh_token;
  203. gdrive.expirationDate = Date.now() + (response.expires_in * 1000);
  204. return { accessToken: gdrive.accessToken, refreshToken: gdrive.refreshToken, expirationDate: gdrive.expirationDate };
  205. }
  206. async function initAuth(gdrive, options) {
  207. let code, cancelled;
  208. if (options.extractAuthCode) {
  209. options.extractAuthCode()
  210. .then(authCode => code = authCode)
  211. .catch(() => { cancelled = true; });
  212. }
  213. try {
  214. if (browser.identity && browser.identity.launchWebAuthFlow && !options.forceWebAuthFlow) {
  215. return await browser.identity.launchWebAuthFlow({
  216. interactive: options.interactive,
  217. url: gdrive.authURL
  218. });
  219. } else if (gdrive.launchWebAuthFlow) {
  220. return await gdrive.launchWebAuthFlow({ url: gdrive.authURL });
  221. } else {
  222. throw new Error("auth_not_supported");
  223. }
  224. }
  225. catch (error) {
  226. if (error.message && (error.message == "code_required" || error.message.includes("access"))) {
  227. if (!options.auto && !cancelled && !code && options.promptAuthCode) {
  228. code = await options.promptAuthCode();
  229. }
  230. if (code) {
  231. options.code = code;
  232. return await authFromCode(gdrive, options);
  233. } else {
  234. throw new Error("code_required");
  235. }
  236. } else {
  237. throw error;
  238. }
  239. }
  240. }
  241. async function getParentFolderId(gdrive, filename, retry = true) {
  242. const fileParts = filename.split("/");
  243. fileParts.pop();
  244. const folderId = gdrive.folderIds.get(fileParts.join("/"));
  245. if (folderId) {
  246. return folderId;
  247. }
  248. let parentFolderId = "root";
  249. if (fileParts.length) {
  250. let fullFolderName = "";
  251. for (const folderName of fileParts) {
  252. if (fullFolderName) {
  253. fullFolderName += "/";
  254. }
  255. fullFolderName += folderName;
  256. const folderId = gdrive.folderIds.get(fullFolderName);
  257. if (folderId) {
  258. parentFolderId = folderId;
  259. } else {
  260. try {
  261. parentFolderId = await getOrCreateFolder(gdrive, folderName, parentFolderId);
  262. gdrive.folderIds.set(fullFolderName, parentFolderId);
  263. } catch (error) {
  264. if (error.message == "path_not_found" && retry) {
  265. gdrive.folderIds.clear();
  266. return getParentFolderId(gdrive, filename, false);
  267. } else {
  268. throw error;
  269. }
  270. }
  271. }
  272. }
  273. }
  274. return parentFolderId;
  275. }
  276. async function getOrCreateFolder(gdrive, folderName, parentFolderId) {
  277. const response = await getFolder(gdrive, folderName, parentFolderId);
  278. if (response.files.length) {
  279. return response.files[0].id;
  280. } else {
  281. const response = await createFolder(gdrive, folderName, parentFolderId);
  282. return response.id;
  283. }
  284. }
  285. async function getFolder(gdrive, folderName, parentFolderId) {
  286. const httpResponse = await fetch(GDRIVE_URL + "?q=mimeType = 'application/vnd.google-apps.folder' and name = '" + folderName + "' and trashed != true and '" + parentFolderId + "' in parents", {
  287. headers: {
  288. "Authorization": "Bearer " + gdrive.accessToken
  289. }
  290. });
  291. return getJSON(httpResponse);
  292. }
  293. async function createFolder(gdrive, folderName, parentFolderId) {
  294. const httpResponse = await fetch(GDRIVE_URL, {
  295. method: "POST",
  296. headers: {
  297. "Authorization": "Bearer " + gdrive.accessToken,
  298. "Content-Type": "application/json"
  299. },
  300. body: JSON.stringify({
  301. name: folderName,
  302. parents: [parentFolderId],
  303. mimeType: "application/vnd.google-apps.folder"
  304. })
  305. });
  306. return getJSON(httpResponse);
  307. }
  308. async function sendFile(mediaUploader) {
  309. let content = mediaUploader.file, end = mediaUploader.file.size;
  310. if (mediaUploader.offset || mediaUploader.chunkSize) {
  311. if (mediaUploader.chunkSize) {
  312. end = Math.min(mediaUploader.offset + mediaUploader.chunkSize, mediaUploader.file.size);
  313. }
  314. content = content.slice(mediaUploader.offset, end);
  315. }
  316. const httpResponse = await fetch(mediaUploader.url, {
  317. method: "PUT",
  318. headers: {
  319. "Authorization": "Bearer " + mediaUploader.token,
  320. "Content-Type": mediaUploader.contentType,
  321. "Content-Range": "bytes " + mediaUploader.offset + "-" + (end - 1) + "/" + mediaUploader.file.size,
  322. "X-Upload-Content-Type": mediaUploader.contentType
  323. },
  324. body: content
  325. });
  326. if (httpResponse.status == 200 || httpResponse.status == 201) {
  327. return httpResponse.json();
  328. } else if (httpResponse.status == 308) {
  329. const range = httpResponse.headers.get("Range");
  330. if (range) {
  331. mediaUploader.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1;
  332. }
  333. sendFile(mediaUploader);
  334. } else {
  335. getResponse(httpResponse);
  336. }
  337. }
  338. async function getJSON(httpResponse) {
  339. httpResponse = getResponse(httpResponse);
  340. const response = await httpResponse.json();
  341. if (response.error) {
  342. throw new Error(response.error);
  343. } else {
  344. return response;
  345. }
  346. }
  347. function getResponse(httpResponse) {
  348. if (httpResponse.status == 200) {
  349. return httpResponse;
  350. } else if (httpResponse.status == 404) {
  351. throw new Error("path_not_found");
  352. } else if (httpResponse.status == 401) {
  353. throw new Error("invalid_token");
  354. } else {
  355. throw new Error("unknown_error (" + httpResponse.status + ")");
  356. }
  357. }
  358. })();