gdrive.js 12 KB

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