Sfoglia il codice sorgente

add option "Destination > upload to Dropbox"
(see #1308)

Gildas 2 anni fa
parent
commit
5e69bc2e34

+ 4 - 0
_locales/de/messages.json

@@ -627,6 +627,10 @@
 		"message": "Hochladen auf Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "Hochladen auf Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "Hochladen auf GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/en/messages.json

@@ -627,6 +627,10 @@
 		"message": "upload to Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "upload to Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "upload to GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/es/messages.json

@@ -627,6 +627,10 @@
 		"message": "subir a Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "subir a Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "subir a GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/fr/messages.json

@@ -627,6 +627,10 @@
 		"message": "téléverser sur Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "téléverser sur Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "téléverser sur GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/it/messages.json

@@ -627,6 +627,10 @@
 		"message": "carica su Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "carica su Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "carica su GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/ja/messages.json

@@ -627,6 +627,10 @@
 		"message": "Google Drive に保存",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "Dropbox に保存",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "GitHub にアップロードする",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/pl/messages.json

@@ -627,6 +627,10 @@
 		"message": "przesyłaj na Dysk Google",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "przesyłaj na Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "przesyłaj do GitHuba",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/pt_PT/messages.json

@@ -627,6 +627,10 @@
 		"message": "carregar para o Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "carregar para o Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "carregar para o GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/pt_br/messages.json

@@ -627,6 +627,10 @@
 		"message": "enviar para o Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "enviar para o Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "enviar para o GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/ru/messages.json

@@ -627,6 +627,10 @@
 		"message": "загрузить на Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "загрузить на Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "загрузить на GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/tr/messages.json

@@ -627,6 +627,10 @@
 		"message": "Google Drive'a yükle",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "Dropbox'a yükle",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "GitHub'a yükle",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/uk/messages.json

@@ -627,6 +627,10 @@
 		"message": "завантажити на Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "завантажити на Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "завантажити на GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/zh_CN/messages.json

@@ -627,6 +627,10 @@
 		"message": "保存到 Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "保存到 Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "保存到 GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 4 - 0
_locales/zh_TW/messages.json

@@ -627,6 +627,10 @@
 		"message": "保存到 Google Drive",
 		"description": "Options page label: 'upload to Google Drive'"
 	},
+	"optionSaveToDropbox": {
+		"message": "保存到 Dropbox",
+		"description": "Options page label: 'upload to Dropbox'"
+	},
 	"optionSaveToGitHub": {
 		"message": "保存到 GitHub",
 		"description": "Options page label: 'upload to GitHub'"

+ 9 - 2
src/core/bg/autosave.js

@@ -151,7 +151,7 @@ async function saveContent(message, tab) {
 		options.incognito = tab.incognito;
 		options.tabId = tabId;
 		options.tabIndex = tab.index;
-		options.keepFilename = options.saveToGDrive || options.saveToGitHub || options.saveWithWebDAV;
+		options.keepFilename = options.saveToGDrive || options.saveToGitHub || options.saveWithWebDAV || options.saveToDropbox;
 		let pageData;
 		try {
 			if (options.autoSaveExternalSave) {
@@ -163,7 +163,7 @@ async function saveContent(message, tab) {
 				options.tabId = tabId;
 				pageData = await getPageData(options, null, null, { fetch });
 				let skipped;
-				if (!options.saveToGDrive && !options.saveWithWebDAV && !options.saveToGitHub && !options.saveWithCompanion) {
+				if (!options.saveToGDrive && !options.saveWithWebDAV && !options.saveToGitHub && !options.saveToDropbox && !options.saveWithCompanion) {
 					const testSkip = await downloads.testSkipSave(pageData.filename, options);
 					skipped = testSkip.skipped;
 					options.filenameConflictAction = testSkip.filenameConflictAction;
@@ -182,6 +182,13 @@ async function saveContent(message, tab) {
 						}, {
 							filenameConflictAction: options.filenameConflictAction
 						});
+					} if (options.saveToDropbox) {
+						if (!(content instanceof Blob)) {
+							content = new Blob([content], { type: "text/html" });
+						}
+						await downloads.saveToDropbox(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, {
+							filenameConflictAction: options.filenameConflictAction
+						});
 					} else if (options.saveWithWebDAV) {
 						await downloads.saveWithWebDAV(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, options.webDAVURL, options.webDAVUser, options.webDAVPassword, {
 							filenameConflictAction: options.filenameConflictAction

+ 22 - 1
src/core/bg/config.js

@@ -102,6 +102,7 @@ const DEFAULT_CONFIG = {
 	saveToClipboard: false,
 	addProof: false,
 	saveToGDrive: false,
+	saveToDropbox: false,
 	saveWithWebDAV: false,
 	webDAVURL: "",
 	webDAVUser: "",
@@ -221,8 +222,11 @@ export {
 	updateRule,
 	addRule,
 	getAuthInfo,
+	getDropboxAuthInfo,
 	setAuthInfo,
-	removeAuthInfo
+	setDropboxAuthInfo,
+	removeAuthInfo,
+	removeDropboxAuthInfo
 };
 
 async function upgrade() {
@@ -578,10 +582,18 @@ async function getAuthInfo() {
 	return (await configStorage.get()).authInfo;
 }
 
+async function getDropboxAuthInfo() {
+	return (await configStorage.get()).dropboxAuthInfo;
+}
+
 async function setAuthInfo(authInfo) {
 	await configStorage.set({ authInfo });
 }
 
+async function setDropboxAuthInfo(authInfo) {
+	await configStorage.set({ dropboxAuthInfo: authInfo });
+}
+
 async function removeAuthInfo() {
 	let authInfo = getAuthInfo();
 	if (authInfo.revokableAccessToken) {
@@ -591,6 +603,15 @@ async function removeAuthInfo() {
 	}
 }
 
+async function removeDropboxAuthInfo() {
+	let authInfo = getDropboxAuthInfo();
+	if (authInfo.revokableAccessToken) {
+		setDropboxAuthInfo({ revokableAccessToken: authInfo.revokableAccessToken });
+	} else {
+		await configStorage.remove(["dropboxAuthInfo"]);
+	}
+}
+
 async function resetProfiles() {
 	await pendingUpgradePromise;
 	const allTabsData = await tabsData.get();

+ 80 - 8
src/core/bg/downloads.js

@@ -32,6 +32,7 @@ import { launchWebAuthFlow, extractAuthCode } from "./tabs-util.js";
 import * as ui from "./../../ui/bg/index.js";
 import * as woleet from "./../../lib/woleet/woleet.js";
 import { GDrive } from "./../../lib/gdrive/gdrive.js";
+import { Dropbox } from "./../../lib/dropbox/dropbox.js";
 import { WebDAV } from "./../../lib/webdav/webdav.js";
 import { GitHub } from "./../../lib/github/github.js";
 import { download } from "./download-util.js";
@@ -46,14 +47,16 @@ const CONFLICT_ACTION_UNIQUIFY = "uniquify";
 const REGEXP_ESCAPE = /([{}()^$&.*?/+|[\\\\]|\]|-)/g;
 let GDRIVE_CLIENT_ID = "207618107333-h1220p1oasj3050kr5r416661adm091a.apps.googleusercontent.com";
 let GDRIVE_CLIENT_KEY = "VQJ8Gq8Vxx72QyxPyeLtWvUt";
+const DROPBOX_CLIENT_ID = "s50p6litdvuzrtb";
+const DROPBOX_CLIENT_KEY = "i1vzwllesr14fzd";
 
-let gDrive;
-const oauth2 = browser.runtime.getManifest().oauth2;
-if (oauth2) {
-	GDRIVE_CLIENT_ID = oauth2.client_id;
-	GDRIVE_CLIENT_KEY = oauth2.client_secret;
+const gDriveOauth2 = browser.runtime.getManifest().oauth2;
+if (gDriveOauth2) {
+	GDRIVE_CLIENT_ID = gDriveOauth2.client_id;
+	GDRIVE_CLIENT_KEY = gDriveOauth2.client_secret;
 }
-gDrive = new GDrive(GDRIVE_CLIENT_ID, GDRIVE_CLIENT_KEY, SCOPES);
+const gDrive = new GDrive(GDRIVE_CLIENT_ID, GDRIVE_CLIENT_KEY, SCOPES);
+const dropbox = new Dropbox(DROPBOX_CLIENT_ID, DROPBOX_CLIENT_KEY);
 
 export {
 	onMessage,
@@ -61,6 +64,7 @@ export {
 	testSkipSave,
 	saveToGDrive,
 	saveToGitHub,
+	saveToDropbox,
 	saveWithWebDAV,
 	encodeSharpCharacter
 };
@@ -75,6 +79,12 @@ async function onMessage(message, sender) {
 		await gDrive.revokeAuthToken(authInfo && (authInfo.accessToken || authInfo.revokableAccessToken));
 		return {};
 	}
+	if (message.method.endsWith(".disableDropbox")) {
+		const authInfo = await config.getDropboxAuthInfo();
+		config.removeDropboxAuthInfo();
+		await dropbox.revokeAuthToken(authInfo && (authInfo.accessToken || authInfo.revokableAccessToken));
+		return {};
+	}
 	if (message.method.endsWith(".end")) {
 		if (message.hash) {
 			try {
@@ -156,7 +166,7 @@ async function downloadContent(contents, tab, incognito, message) {
 	const tabId = tab.id;
 	try {
 		let skipped;
-		if (message.backgroundSave && !message.saveToGDrive && !message.saveWithWebDAV && !message.saveToGitHub) {
+		if (message.backgroundSave && !message.saveToGDrive && !message.saveToDropbox && !message.saveWithWebDAV && !message.saveToGitHub) {
 			const testSkip = await testSkipSave(message.filename, message);
 			message.filenameConflictAction = testSkip.filenameConflictAction;
 			skipped = testSkip.skipped;
@@ -182,6 +192,12 @@ async function downloadContent(contents, tab, incognito, message) {
 					filenameConflictAction: message.filenameConflictAction,
 					prompt
 				});
+			} else if (message.saveToDropbox) {
+				await saveToDropbox(message.taskId, encodeSharpCharacter(message.filename), new Blob(contents, { type: MIMETYPE_HTML }), {
+					onProgress: (offset, size) => ui.onUploadProgress(tabId, offset, size),
+					filenameConflictAction: message.filenameConflictAction,
+					prompt
+				});
 			} else if (message.saveToGitHub) {
 				response = await saveToGitHub(message.taskId, encodeSharpCharacter(message.filename), contents.join(""), message.githubToken, message.githubUser, message.githubRepository, message.githubBranch, {
 					filenameConflictAction: message.filenameConflictAction,
@@ -234,7 +250,7 @@ async function downloadCompressedContent(message, tab) {
 	const tabId = tab.id;
 	try {
 		let skipped;
-		if (message.backgroundSave && !message.saveToGDrive && !message.saveWithWebDAV && !message.saveToGitHub) {
+		if (message.backgroundSave && !message.saveToGDrive && !message.saveToDropbox && !message.saveWithWebDAV && !message.saveToGitHub) {
 			const testSkip = await testSkipSave(message.filename, message);
 			message.filenameConflictAction = testSkip.filenameConflictAction;
 			skipped = testSkip.skipped;
@@ -280,6 +296,12 @@ async function downloadCompressedContent(message, tab) {
 					filenameConflictAction: message.filenameConflictAction,
 					prompt
 				});
+			} else if (message.saveToDropbox) {
+				await saveToDropbox(message.taskId, encodeSharpCharacter(message.filename), blob, {
+					onProgress: (offset, size) => ui.onUploadProgress(tabId, offset, size),
+					filenameConflictAction: message.filenameConflictAction,
+					prompt
+				});
 			} else if (message.saveToGitHub) {
 				response = await saveToGitHub(message.taskId, encodeSharpCharacter(message.filename), blob, message.githubToken, message.githubUser, message.githubRepository, message.githubBranch, {
 					filenameConflictAction: message.filenameConflictAction,
@@ -354,6 +376,24 @@ async function getAuthInfo(authOptions, force) {
 	return authInfo;
 }
 
+async function getDropboxAuthInfo(force) {
+	let authInfo = await config.getDropboxAuthInfo();
+	const options = {
+		launchWebAuthFlow: options => launchWebAuthFlow(options),
+		extractAuthCode: authURL => extractAuthCode(authURL)
+	};
+	dropbox.setAuthInfo(authInfo);
+	if (!authInfo || !authInfo.accessToken || force) {
+		authInfo = await dropbox.auth(options);
+		if (authInfo) {
+			await config.setDropboxAuthInfo(authInfo);
+		} else {
+			await config.removeDropboxAuthInfo();
+		}
+	}
+	return authInfo;
+}
+
 async function saveToGitHub(taskId, filename, content, githubToken, githubUser, githubRepository, githubBranch, { filenameConflictAction, prompt }) {
 	try {
 		const taskInfo = business.getTaskInfo(taskId);
@@ -412,6 +452,38 @@ async function saveToGDrive(taskId, filename, blob, authOptions, uploadOptions)
 	}
 }
 
+async function saveToDropbox(taskId, filename, blob, uploadOptions) {
+	try {
+		await getDropboxAuthInfo();
+		const taskInfo = business.getTaskInfo(taskId);
+		if (!taskInfo || !taskInfo.cancelled) {
+			return await dropbox.upload(filename, blob, uploadOptions, callback => business.setCancelCallback(taskId, callback));
+		}
+	}
+	catch (error) {
+		if (error.message == "invalid_token") {
+			let authInfo;
+			try {
+				authInfo = await dropbox.refreshAuthToken();
+			} catch (error) {
+				if (error.message == "unknown_token") {
+					authInfo = await getDropboxAuthInfo(true);
+				} else {
+					throw new Error(error.message + " (Dropbox)");
+				}
+			}
+			if (authInfo) {
+				await config.setDropboxAuthInfo(authInfo);
+			} else {
+				await config.removeDropboxAuthInfo();
+			}
+			return await saveToDropbox(taskId, filename, blob, uploadOptions);
+		} else {
+			throw new Error(error.message + " (Dropbox)");
+		}
+	}
+}
+
 async function testSkipSave(filename, options) {
 	let skipped, filenameConflictAction = options.filenameConflictAction;
 	if (filenameConflictAction == CONFLICT_ACTION_SKIP) {

+ 2 - 1
src/core/common/download.js

@@ -44,6 +44,7 @@ async function downloadPage(pageData, options) {
 		filename: pageData.filename,
 		saveToClipboard: options.saveToClipboard,
 		saveToGDrive: options.saveToGDrive,
+		saveToDropbox: options.saveToDropbox,
 		saveWithWebDAV: options.saveWithWebDAV,
 		webDAVURL: options.webDAVURL,
 		webDAVUser: options.webDAVUser,
@@ -105,7 +106,7 @@ async function downloadPage(pageData, options) {
 			await browser.runtime.sendMessage({ method: "downloads.end", taskId: options.taskId });
 		}
 	} else {
-		if (options.backgroundSave || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV) {
+		if (options.backgroundSave || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox) {
 			const blobURL = URL.createObjectURL(new Blob([pageData.content], { type: "text/html" }));
 			message.blobURL = blobURL;
 			const result = await browser.runtime.sendMessage(message);

+ 2 - 2
src/core/content/content.js

@@ -119,7 +119,7 @@ async function savePage(message) {
 			try {
 				const pageData = await processPage(options);
 				if (pageData) {
-					if (((!options.backgroundSave && !options.saveToClipboard) || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV) && options.confirmFilename) {
+					if (((!options.backgroundSave && !options.saveToClipboard) || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox) && options.confirmFilename) {
 						pageData.filename = ui.prompt("Save as", pageData.filename) || pageData.filename;
 					}
 					await download.downloadPage(pageData, options);
@@ -145,7 +145,7 @@ async function savePage(message) {
 async function processPage(options) {
 	const frames = singlefile.processors.frameTree;
 	let framesSessionId;
-	options.keepFilename = options.saveToGDrive || options.saveToGitHub || options.saveWithWebDAV;
+	options.keepFilename = options.saveToGDrive || options.saveToGitHub || options.saveWithWebDAV || options.saveToDropbox;
 	singlefile.helper.initDoc(document);
 	ui.onStartPage(options);
 	processor = new singlefile.SingleFile(options);

+ 326 - 0
src/lib/dropbox/dropbox.js

@@ -0,0 +1,326 @@
+/*
+ * Copyright 2010-2020 Gildas Lormeau
+ * contact : gildas.lormeau <at> gmail.com
+ * 
+ * This file is part of SingleFile.
+ *
+ *   The code in this file is free software: you can redistribute it and/or 
+ *   modify it under the terms of the GNU Affero General Public License 
+ *   (GNU AGPL) as published by the Free Software Foundation, either version 3
+ *   of the License, or (at your option) any later version.
+ * 
+ *   The code in this file is distributed in the hope that it will be useful, 
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of 
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 
+ *   General Public License for more details.
+ *
+ *   As additional permission under GNU AGPL version 3 section 7, you may 
+ *   distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU 
+ *   AGPL normally required by section 4, provided you include this license 
+ *   notice and a URL through which recipients can access the Corresponding 
+ *   Source.
+ */
+
+/* global browser, fetch */
+
+const TOKEN_URL = "https://api.dropboxapi.com/oauth2/token";
+const AUTH_URL = "https://www.dropbox.com/oauth2/authorize";
+const REVOKE_ACCESS_URL = "https://api.dropboxapi.com/2/auth/token/revoke";
+const DROPBOX_SEARCH_URL = "https://api.dropboxapi.com/2/files/search_v2";
+const DROPBOX_UPLOAD_URL = "https://content.dropboxapi.com/2/files/upload_session/start";
+const DROPBOX_APPEND_URL = "https://content.dropboxapi.com/2/files/upload_session/append_v2";
+const DROPBOX_FINISH_URL = "https://content.dropboxapi.com/2/files/upload_session/finish";
+const CONFLICT_ACTION_UNIQUIFY = "uniquify";
+const CONFLICT_ACTION_OVERWRITE = "overwrite";
+const CONFLICT_ACTION_SKIP = "skip";
+const CONFLICT_ACTION_PROMPT = "prompt";
+const ENCODED_CHARS = /[\u007f-\uffff]/g;
+
+class Dropbox {
+	constructor(clientId, clientKey) {
+		this.clientId = clientId;
+		this.clientKey = clientKey;
+	}
+	async auth(options = { interactive: true }) {
+		this.authURL = AUTH_URL +
+			"?client_id=" + this.clientId +
+			"&response_type=code" +
+			"&token_access_type=offline" +
+			"&redirect_uri=" + browser.identity.getRedirectURL();
+		return options.code ? authFromCode(this, options) : initAuth(this, options);
+	}
+	setAuthInfo(authInfo) {
+		if (authInfo) {
+			this.accessToken = authInfo.accessToken;
+			this.refreshToken = authInfo.refreshToken;
+			this.expirationDate = authInfo.expirationDate;
+		} else {
+			delete this.accessToken;
+			delete this.refreshToken;
+			delete this.expirationDate;
+		}
+	}
+	async refreshAuthToken() {
+		if (this.refreshToken) {
+			const httpResponse = await fetch(TOKEN_URL, {
+				method: "POST",
+				headers: { "Content-Type": "application/x-www-form-urlencoded" },
+				body: "client_id=" + this.clientId +
+					"&refresh_token=" + this.refreshToken +
+					"&grant_type=refresh_token" +
+					"&client_secret=" + this.clientKey
+			});
+			if (httpResponse.status == 400) {
+				throw new Error("unknown_token");
+			}
+			const response = await getJSON(httpResponse);
+			this.accessToken = response.access_token;
+			if (response.refresh_token) {
+				this.refreshToken = response.refresh_token;
+			}
+			if (response.expires_in) {
+				this.expirationDate = Date.now() + (response.expires_in * 1000);
+			}
+			return { accessToken: this.accessToken, refreshToken: this.refreshToken, expirationDate: this.expirationDate };
+		} else {
+			delete this.accessToken;
+		}
+	}
+	async revokeAuthToken(accessToken) {
+		if (accessToken) {
+			const httpResponse = await fetch(REVOKE_ACCESS_URL, {
+				method: "POST",
+				headers: {
+					"Authorization": "Bearer " + accessToken
+				}
+			});
+			try {
+				await httpResponse.text();
+			}
+			catch (error) {
+				if (error.message != "invalid_token") {
+					throw error;
+				}
+			}
+			finally {
+				delete this.accessToken;
+				delete this.refreshToken;
+				delete this.expirationDate;
+			}
+		}
+	}
+	async upload(filename, blob, options, setCancelCallback) {
+		const uploader = new MediaUploader({
+			token: this.accessToken,
+			file: blob,
+			filename,
+			onProgress: options.onProgress,
+			filenameConflictAction: options.filenameConflictAction,
+			prompt: options.prompt
+		});
+		if (setCancelCallback) {
+			setCancelCallback(() => uploader.cancelled = true);
+		}
+		await uploader.upload();
+	}
+}
+
+class MediaUploader {
+	constructor(options) {
+		this.file = options.file;
+		this.onProgress = options.onProgress;
+		this.contentType = this.file.type || "application/octet-stream";
+		this.metadata = {
+			name: options.filename,
+			mimeType: this.contentType
+		};
+		this.token = options.token;
+		this.offset = 0;
+		this.chunkSize = options.chunkSize || 8 * 1024 * 1024;
+		this.filenameConflictAction = options.filenameConflictAction;
+		this.prompt = options.prompt;
+	}
+	async upload() {
+		const httpListResponse = getResponse(await fetch(DROPBOX_SEARCH_URL, {
+			method: "POST",
+			headers: {
+				"Authorization": "Bearer " + this.token,
+				"Content-Type": "application/json"
+			},
+			body: stringify({
+				query: this.metadata.name,
+				options: {
+					filename: true
+				}
+			})
+		}));
+		const response = await getJSON(httpListResponse);
+		if (response.matches.length) {
+			if (this.filenameConflictAction == CONFLICT_ACTION_PROMPT) {
+				if (this.prompt) {
+					const name = await this.prompt(this.metadata.name);
+					if (name) {
+						this.metadata.name = name;
+					} else {
+						return response;
+					}
+				} else {
+					this.filenameConflictAction = CONFLICT_ACTION_UNIQUIFY;
+				}
+			} else if (this.filenameConflictAction == CONFLICT_ACTION_SKIP) {
+				return response;
+			}
+		}
+		const httpResponse = getResponse(await fetch(DROPBOX_UPLOAD_URL, {
+			method: "POST",
+			headers: {
+				"Authorization": "Bearer " + this.token,
+				"Dropbox-API-Arg": stringify({
+					close: false
+				}),
+				"Content-Type": "application/octet-stream"
+			}
+		}));
+		const sessionId = (await getJSON(httpResponse)).session_id;
+		this.sessionId = sessionId;
+		if (!this.cancelled) {
+			if (this.onProgress) {
+				this.onProgress(0, this.file.size);
+			}
+			return sendFile(this);
+		}
+	}
+}
+
+export { Dropbox };
+
+async function authFromCode(dropbox, options) {
+	const httpResponse = await fetch(TOKEN_URL, {
+		method: "POST",
+		headers: { "Content-Type": "application/x-www-form-urlencoded" },
+		body: "client_id=" + dropbox.clientId +
+			"&client_secret=" + dropbox.clientKey +
+			"&grant_type=authorization_code" +
+			"&code=" + options.code +
+			"&redirect_uri=" + browser.identity.getRedirectURL()
+	});
+	const response = await getJSON(httpResponse);
+	dropbox.accessToken = response.access_token;
+	dropbox.refreshToken = response.refresh_token;
+	dropbox.expirationDate = Date.now() + (response.expires_in * 1000);
+	return { accessToken: dropbox.accessToken, refreshToken: dropbox.refreshToken, expirationDate: dropbox.expirationDate };
+}
+
+async function initAuth(dropbox, options) {
+	let code;
+	try {
+		options.extractAuthCode(browser.identity.getRedirectURL())
+			.then(authCode => code = authCode)
+			.catch(() => { /* ignored */ });
+		return await options.launchWebAuthFlow({ url: dropbox.authURL });
+	}
+	catch (error) {
+		if (error.message && (error.message == "code_required" || error.message.includes("access"))) {
+			if (code) {
+				options.code = code;
+				return await authFromCode(dropbox, options);
+			} else {
+				throw new Error("code_required");
+			}
+		} else {
+			throw error;
+		}
+	}
+}
+
+async function sendFile(mediaUploader) {
+	let content = mediaUploader.file, end = mediaUploader.file.size;
+	if (mediaUploader.offset || mediaUploader.chunkSize) {
+		if (mediaUploader.chunkSize) {
+			end = Math.min(mediaUploader.offset + mediaUploader.chunkSize, mediaUploader.file.size);
+		}
+		content = content.slice(mediaUploader.offset, end);
+	}
+	const httpAppendResponse = getResponse(await fetch(DROPBOX_APPEND_URL, {
+		method: "POST",
+		headers: {
+			"Authorization": "Bearer " + mediaUploader.token,
+			"Content-Type": "application/octet-stream",
+			"Dropbox-API-Arg": stringify({
+				cursor: {
+					session_id: mediaUploader.sessionId,
+					offset: mediaUploader.offset
+				},
+				close: end == mediaUploader.file.size
+			})
+		},
+		body: content
+	}));
+	if (mediaUploader.onProgress && !mediaUploader.cancelled) {
+		mediaUploader.onProgress(mediaUploader.offset + mediaUploader.chunkSize, mediaUploader.file.size);
+	}
+	if (httpAppendResponse.status == 200) {
+		mediaUploader.offset = end;
+		if (mediaUploader.offset < mediaUploader.file.size) {
+			return sendFile(mediaUploader);
+		}
+	}
+	let path = mediaUploader.metadata.name;
+	if (!path.startsWith("/")) {
+		path = "/" + path;
+	}
+	const httpFinishResponse = await fetch(DROPBOX_FINISH_URL, {
+		method: "POST",
+		headers: {
+			"Authorization": "Bearer " + mediaUploader.token,
+			"Content-Type": "application/octet-stream",
+			"Dropbox-API-Arg": stringify({
+				cursor: {
+					session_id: mediaUploader.sessionId,
+					offset: mediaUploader.offset
+				},
+				commit: {
+					path,
+					mode: mediaUploader.filenameConflictAction == CONFLICT_ACTION_OVERWRITE ? "overwrite" : "add",
+					autorename: mediaUploader.filenameConflictAction == CONFLICT_ACTION_UNIQUIFY
+				}
+			})
+		}
+	});
+	if (httpFinishResponse.status == 200) {
+		return getJSON(httpFinishResponse);
+	} else if (httpFinishResponse.status == 409 && mediaUploader.filenameConflictAction == CONFLICT_ACTION_PROMPT) {
+		mediaUploader.offset = 0;
+		return mediaUploader.upload();
+	} else {
+		throw new Error("unknown_error (" + httpFinishResponse.status + ")");
+	}
+}
+
+async function getJSON(httpResponse) {
+	httpResponse = getResponse(httpResponse);
+	const response = await httpResponse.json();
+	if (response.error) {
+		throw new Error(response.error);
+	} else {
+		return response;
+	}
+}
+
+function getResponse(httpResponse) {
+	if (httpResponse.status == 200) {
+		return httpResponse;
+	} else if (httpResponse.status == 401) {
+		throw new Error("invalid_token");
+	} else {
+		throw new Error("unknown_error (" + httpResponse.status + ")");
+	}
+}
+
+function stringify(value) {
+	return JSON.stringify(value).replace(ENCODED_CHARS,
+		function (c) {
+			return "\\u" + ("000" + c.charCodeAt(0).toString(16)).slice(-4);
+		}
+	);
+}

+ 11 - 2
src/ui/bg/ui-options.js

@@ -78,6 +78,7 @@ const saveToFilesystemLabel = document.getElementById("saveToFilesystemLabel");
 const addProofLabel = document.getElementById("addProofLabel");
 const woleetKeyLabel = document.getElementById("woleetKeyLabel");
 const saveToGDriveLabel = document.getElementById("saveToGDriveLabel");
+const saveToDropboxLabel = document.getElementById("saveToDropboxLabel");
 const saveWithWebDAVLabel = document.getElementById("saveWithWebDAVLabel");
 const webDAVURLLabel = document.getElementById("webDAVURLLabel");
 const webDAVUserLabel = document.getElementById("webDAVUserLabel");
@@ -215,6 +216,7 @@ const saveToClipboardInput = document.getElementById("saveToClipboardInput");
 const addProofInput = document.getElementById("addProofInput");
 const woleetKeyInput = document.getElementById("woleetKeyInput");
 const saveToGDriveInput = document.getElementById("saveToGDriveInput");
+const saveToDropboxInput = document.getElementById("saveToDropboxInput");
 const saveWithWebDAVInput = document.getElementById("saveWithWebDAVInput");
 const webDAVURLInput = document.getElementById("webDAVURLInput");
 const webDAVUserInput = document.getElementById("webDAVUserInput");
@@ -512,6 +514,7 @@ saveToFilesystemInput.addEventListener("click", () => disableDestinationPermissi
 saveToClipboardInput.addEventListener("click", () => disableDestinationPermissions(["nativeMessaging"]), false);
 saveWithCompanionInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite"]), false);
 saveToGDriveInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"], false), false);
+saveToDropboxInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"], true, false), false);
 saveWithWebDAVInput.addEventListener("click", () => disableDestinationPermissions(["clipboardWrite", "nativeMessaging"]), false);
 saveCreatedBookmarksInput.addEventListener("click", saveCreatedBookmarks, false);
 passReferrerOnErrorInput.addEventListener("click", passReferrerOnError, false);
@@ -600,6 +603,7 @@ saveToFilesystemLabel.textContent = browser.i18n.getMessage("optionSaveToFilesys
 addProofLabel.textContent = browser.i18n.getMessage("optionAddProof");
 woleetKeyLabel.textContent = browser.i18n.getMessage("optionWoleetKey");
 saveToGDriveLabel.textContent = browser.i18n.getMessage("optionSaveToGDrive");
+saveToDropboxLabel.textContent = browser.i18n.getMessage("optionSaveToDropbox");
 saveWithWebDAVLabel.textContent = browser.i18n.getMessage("optionSaveWithWebDAV");
 webDAVURLLabel.textContent = browser.i18n.getMessage("optionWebDAVURL");
 webDAVUserLabel.textContent = browser.i18n.getMessage("optionWebDAVUser");
@@ -885,6 +889,7 @@ async function refresh(profileName) {
 	woleetKeyInput.value = profileOptions.woleetKey;
 	woleetKeyInput.disabled = !profileOptions.addProof;
 	saveToGDriveInput.checked = profileOptions.saveToGDrive;
+	saveToDropboxInput.checked = profileOptions.saveToDropbox;
 	saveWithWebDAVInput.checked = profileOptions.saveWithWebDAV;
 	webDAVURLInput.value = profileOptions.webDAVURL;
 	webDAVURLInput.disabled = !profileOptions.saveWithWebDAV;
@@ -902,7 +907,7 @@ async function refresh(profileName) {
 	githubBranchInput.value = profileOptions.githubBranch;
 	githubBranchInput.disabled = !profileOptions.saveToGitHub;
 	saveWithCompanionInput.checked = profileOptions.saveWithCompanion;
-	saveToFilesystemInput.checked = !profileOptions.saveToGDrive && !profileOptions.saveToGitHub && !profileOptions.saveWithCompanion && !profileOptions.saveToClipboard && !profileOptions.saveWithWebDAV;
+	saveToFilesystemInput.checked = !profileOptions.saveToGDrive && !profileOptions.saveToGitHub && !profileOptions.saveWithCompanion && !profileOptions.saveToClipboard && !profileOptions.saveWithWebDAV && !profileOptions.saveToDropbox;
 	compressHTMLInput.checked = profileOptions.compressHTML;
 	compressCSSInput.checked = profileOptions.compressCSS;
 	moveStylesInHeadInput.checked = profileOptions.moveStylesInHead;
@@ -1024,6 +1029,7 @@ async function update() {
 			addProof: addProofInput.checked,
 			woleetKey: woleetKeyInput.value,
 			saveToGDrive: saveToGDriveInput.checked,
+			saveToDropbox: saveToDropboxInput.checked,
 			saveWithWebDAV: saveWithWebDAVInput.checked,
 			webDAVURL: webDAVURLInput.value,
 			webDAVUser: webDAVUserInput.value,
@@ -1190,10 +1196,13 @@ async function onClickSaveToGDrive() {
 	await refresh();
 }
 
-async function disableDestinationPermissions(permissions, disableGDrive = true) {
+async function disableDestinationPermissions(permissions, disableGDrive = true, disableDropbox = true) {
 	if (disableGDrive) {
 		await browser.runtime.sendMessage({ method: "downloads.disableGDrive" });
 	}
+	if (disableDropbox) {
+		await browser.runtime.sendMessage({ method: "downloads.disableDropbox" });
+	}
 	try {
 		await browser.permissions.remove({ permissions });
 	} catch (error) {

+ 9 - 3
src/ui/pages/help.html

@@ -488,12 +488,18 @@
 						<p>Enter your password.</p>
 					</li>
 					<li data-options-label="saveToGDriveLabel" id="saveToGDriveOption"> <span class="option">Option:
-							upload to Google
-							Drive</span>
+							upload to Google Drive</span>
 						<p>Check this option to save the page on Google Drive.</p>
 						<p>The permissions requested by SingleFile allow it to access only to the files and folders it
 							has created. When you uncheck this option, SingleFile revokes automatically its access to
-							your Google Drive account. </p>
+							your Google Drive account.</p>
+						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
+					</li>
+					<li data-options-label="saveToDropboxLabel" id="saveToDropboxOption"> <span class="option">Option:
+							upload to Dropbox</span>
+						<p>Check this option to save the page on Dropbox.</p>
+						<p>The permissions requested by SingleFile allow it to access only to the files and folders it
+							has created.</p>
 						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
 					</li>
 					<li data-options-label="saveWithCompanionLabel" id="saveWithCompanionOption"> <span

+ 4 - 0
src/ui/pages/options.html

@@ -279,6 +279,10 @@
 				<label for="saveToGDriveInput" id="saveToGDriveLabel"></label>
 				<input type="radio" id="saveToGDriveInput" name="destinationInput">
 			</div>
+			<div class="option" id="saveToDropboxOption">
+				<label for="saveToDropboxInput" id="saveToDropboxLabel"></label>
+				<input type="radio" id="saveToDropboxInput" name="destinationInput">
+			</div>
 			<div class="option" id="saveWithCompanionOption">
 				<label for="saveWithCompanionInput" id="saveWithCompanionLabel"></label>
 				<input type="radio" id="saveWithCompanionInput" name="destinationInput">