Parcourir la source

implement `upload to S3` (fix #1487)

Gildas il y a 1 an
Parent
commit
9966233468

+ 24 - 0
_locales/de/messages.json

@@ -667,6 +667,30 @@
 		"message": "Zweigname",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "Hochladen auf Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "Domain",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "Region",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "Bucket-Name",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "Zugangsschlüssel",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "Geheimschlüssel",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "Hochladen auf einen WebDAV-Server",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/en/messages.json

@@ -667,6 +667,30 @@
 		"message": "branch name",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "upload to Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "domain",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "region",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "bucket name",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "access key",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "secret key",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "upload to a WebDAV server",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/es/messages.json

@@ -667,6 +667,30 @@
 		"message": "nombre de la rama",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "subir a Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "dominio",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "región",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "bucket nombre",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "clave de acceso",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "clave secreta",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "subir a un servidor WebDAV",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/fr/messages.json

@@ -667,6 +667,30 @@
 		"message": "nom de la branche",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "téléverser sur Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "domaine",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "région",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "nom du bucket",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "clé d'accès",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "clé secrète",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "téléverser sur un serveur WebDAV",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/it/messages.json

@@ -667,6 +667,30 @@
 		"message": "nome branch",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "carica su Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "dominio",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "regione",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "nome bucket",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "chiave di accesso",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "chiave segreta",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "carica su un server WebDAV",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/ja/messages.json

@@ -667,6 +667,30 @@
 		"message": "ブランチ名",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "Amazon S3 にアップロードする",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "ドメイン",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "リージョン",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "バケット名",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "アクセスキー",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "シークレットキー",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "WebDAVサーバーにアップロードする",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/nl_NL/messages.json

@@ -667,6 +667,30 @@
 		"message": "naam van de branch",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "upload to Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "domain",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "region",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "bucket name",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "access key",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "secret key",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "upload naar een WebDAV-server",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/pl/messages.json

@@ -667,6 +667,30 @@
 		"message": "nazwa gałęzi",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "upload to Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "domain",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "region",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "bucket name",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "access key",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "secret key",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "przesyłaj na serwer WebDAV",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/pt_PT/messages.json

@@ -667,6 +667,30 @@
 		"message": "nome do branch (ramo)",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "carregar para a Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "domínio",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "região",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "nome do bucket",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "chave de acesso",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "chave secreta",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "carregar para um servidor WebDAV",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/pt_br/messages.json

@@ -667,6 +667,30 @@
 		"message": "nome do branch (ramo)",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "enviar para o Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "domínio",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "região",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "nome do bucket",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "chave de acesso",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "chave secreta",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "enviar para um servidor WebDAV",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/ru/messages.json

@@ -667,6 +667,30 @@
 		"message": "имя ветки",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "upload to Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "domain",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "region",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "bucket name",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "access key",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "secret key",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "загрузить на WebDAV-сервер",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/tr/messages.json

@@ -667,6 +667,30 @@
 		"message": "şube adı",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "Amazon S3'e yükleme",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "alan adı",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "bölge",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "kova adı",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "erişim anahtarı",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "gizli anahtar",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "bir WebDAV sunucusuna yükle",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/uk/messages.json

@@ -667,6 +667,30 @@
 		"message": "назва філії",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "завантажити на Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "домен",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "регіон",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "назва відра",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "ключ доступу",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "секретний ключ",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "вивантаження на WebDAV-сервер",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/zh_CN/messages.json

@@ -667,6 +667,30 @@
 		"message": "分支名称",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "保存到 Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "域名",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "区域",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "存储桶名称",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "访问密钥",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "密钥",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "保存到 WebDAV 服务器",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 24 - 0
_locales/zh_TW/messages.json

@@ -667,6 +667,30 @@
 		"message": "分支名稱",
 		"description": "Options page label: 'branch name'"
 	},
+	"optionSaveToS3": {
+		"message": "保存到 Amazon S3",
+		"description": "Options page label: 'upload to Amazon S3'"
+	},
+	"optionS3Domain": {
+		"message": "域名",
+		"description": "Options page label: 'domain'"
+	},
+	"optionS3Region": {
+		"message": "區域",
+		"description": "Options page label: 'region'"
+	},
+	"optionS3Bucket": {
+		"message": "存儲桶名稱",
+		"description": "Options page label: 'bucket name'"
+	},
+	"optionS3AccessKey": {
+		"message": "訪問密鑰",
+		"description": "Options page label: 'access key'"
+	},
+	"optionS3SecretKey": {
+		"message": "秘密密鑰",
+		"description": "Options page label: 'secret key'"
+	},
 	"optionSaveWithWebDAV": {
 		"message": "保存到 WebDAV 服務器",
 		"description": "Options page label: 'upload to a WebDAV server'"

+ 17 - 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.saveToDropbox || options.saveToRestFormApi;
+		options.keepFilename = options.saveToGDrive || options.saveToGitHub || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3;
 		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.saveToDropbox && !options.saveWithCompanion && !options.saveToRestFormApi) {
+				if (!options.saveToGDrive && !options.saveWithWebDAV && !options.saveToGitHub && !options.saveToDropbox && !options.saveWithCompanion && !options.saveToRestFormApi && !options.saveToS3) {
 					const testSkip = await downloads.testSkipSave(pageData.filename, options);
 					skipped = testSkip.skipped;
 					options.filenameConflictAction = testSkip.filenameConflictAction;
@@ -213,6 +213,21 @@ async function saveContent(message, tab) {
 							options.saveToRestFormApiFileFieldName,
 							options.saveToRestFormApiUrlFieldName
 						);
+					} else if (options.saveToS3) {
+						if (!(content instanceof Blob)) {
+							content = new Blob([content], { type });
+						}
+						await downloads.saveToS3(
+							message.taskId,
+							content,
+							options.url,
+							options.S3Domain,
+							options.S3Region,
+							options.S3Bucket,
+							options.S3AccessKey,
+							options.S3SecretKey,
+							options.S3KeyPrefix
+						);
 					} else {
 						if (!(content instanceof Blob)) {
 							content = new Blob([content], { type });

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

@@ -110,6 +110,7 @@ const DEFAULT_CONFIG = {
 	webDAVPassword: "",
 	saveToGitHub: false,
 	saveToRestFormApi: false,
+	saveToS3: false,
 	githubToken: "",
 	githubUser: "",
 	githubRepository: "SingleFile-Archives",
@@ -171,7 +172,12 @@ const DEFAULT_CONFIG = {
 	saveToRestFormApiUrl: "",
 	saveToRestFormApiFileFieldName: "",
 	saveToRestFormApiUrlFieldName: "",
-	saveToRestFormApiToken: ""
+	saveToRestFormApiToken: "",
+	S3Domain: "s3.amazonaws.com",
+	S3Region: "",
+	S3Bucket: "",
+	S3AccessKey: "",
+	S3SecretKey: ""
 };
 
 const DEFAULT_RULES = [{

+ 26 - 1
src/core/bg/downloads.js

@@ -35,6 +35,7 @@ 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 { S3 } from "./../../lib/s3/s3.js";
 import { download } from "./download-util.js";
 import * as yabson from "./../../lib/yabson/yabson.js";
 import { RestFormApi } from "../../lib/../lib/rest-form-api/index.js";
@@ -67,6 +68,7 @@ export {
 	saveToDropbox,
 	saveWithWebDAV,
 	saveToRestFormApi,
+	saveToS3,
 	encodeSharpCharacter
 };
 
@@ -172,7 +174,7 @@ async function downloadContent(contents, tab, incognito, message) {
 	const tabId = tab.id;
 	try {
 		let skipped;
-		if (message.backgroundSave && !message.saveToGDrive && !message.saveToDropbox && !message.saveWithWebDAV && !message.saveToGitHub && !message.saveToRestFormApi) {
+		if (message.backgroundSave && !message.saveToGDrive && !message.saveToDropbox && !message.saveWithWebDAV && !message.saveToGitHub && !message.saveToRestFormApi && !message.saveToS3) {
 			const testSkip = await testSkipSave(message.filename, message);
 			message.filenameConflictAction = testSkip.filenameConflictAction;
 			skipped = testSkip.skipped;
@@ -226,6 +228,11 @@ async function downloadContent(contents, tab, incognito, message) {
 					message.saveToRestFormApiFileFieldName,
 					message.saveToRestFormApiUrlFieldName
 				);
+			} else if (message.saveToS3) {
+				response = await saveToS3(message.taskId, encodeSharpCharacter(message.filename), new Blob(contents, { type: message.mimeType }), message.S3Domain, message.S3Region, message.S3Bucket, message.S3AccessKey, message.S3SecretKey, {
+					filenameConflictAction: message.filenameConflictAction,
+					prompt
+				});
 			} else {
 				message.url = URL.createObjectURL(new Blob(contents, { type: message.mimeType }));
 				response = await downloadPage(message, {
@@ -347,6 +354,11 @@ async function downloadCompressedContent(message, tab) {
 					message.saveToRestFormApiFileFieldName,
 					message.saveToRestFormApiUrlFieldName
 				);
+			} else if (message.saveToS3) {
+				response = await saveToS3(message.taskId, encodeSharpCharacter(message.filename), blob, message.S3Domain, message.S3Region, message.S3Bucket, message.S3AccessKey, message.S3SecretKey, {
+					filenameConflictAction: message.filenameConflictAction,
+					prompt
+				});
 			} else {
 				message.url = URL.createObjectURL(blob);
 				response = await downloadPage(message, {
@@ -442,6 +454,19 @@ async function saveToGitHub(taskId, filename, content, githubToken, githubUser,
 	}
 }
 
+async function saveToS3(taskId, filename, blob, domain, region, bucket, accessKey, secretKey, { filenameConflictAction, prompt }) {
+	try {
+		const taskInfo = business.getTaskInfo(taskId);
+		if (!taskInfo || !taskInfo.cancelled) {
+			const client = new S3(region, bucket, accessKey, secretKey, domain);
+			business.setCancelCallback(taskId, () => client.abort());
+			return await client.upload(filename, blob, { filenameConflictAction, prompt });
+		}
+	} catch (error) {
+		throw new Error(error.message + " (S3)");
+	}
+}
+
 async function saveWithWebDAV(taskId, filename, content, url, username, password, { filenameConflictAction, prompt }) {
 	try {
 		const taskInfo = business.getTaskInfo(taskId);

+ 9 - 3
src/core/common/download.js

@@ -107,7 +107,13 @@ async function downloadPage(pageData, options) {
 		saveToRestFormApiUrl: options.saveToRestFormApiUrl,
 		saveToRestFormApiFileFieldName: options.saveToRestFormApiFileFieldName,
 		saveToRestFormApiUrlFieldName: options.saveToRestFormApiUrlFieldName,
-		saveToRestFormApiToken: options.saveToRestFormApiToken
+		saveToRestFormApiToken: options.saveToRestFormApiToken,
+		saveToS3: options.saveToS3,
+		S3Domain: options.S3Domain,
+		S3Region: options.S3Region,
+		S3Bucket: options.S3Bucket,
+		S3AccessKey: options.S3AccessKey,
+		S3SecretKey: options.S3SecretKey
 	};
 	const pingInterval = setInterval(() => {
 		browser.runtime.sendMessage({ method: "ping" }).then(() => { });
@@ -140,9 +146,9 @@ async function downloadPage(pageData, options) {
 			await browser.runtime.sendMessage({ method: "downloads.end", taskId: options.taskId });
 		}
 	} else {
-		if ((options.backgroundSave && !options.sharePage) || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi) {
+		if ((options.backgroundSave && !options.sharePage) || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) {
 			let filename = pageData.filename;
-			if ((options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi) && options.confirmFilename && !options.openEditor) {
+			if ((options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) && options.confirmFilename && !options.openEditor) {
 				filename = ui.prompt("Save as", pageData.filename);
 			}
 			if (filename) {

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

@@ -171,7 +171,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.saveToDropbox || options.saveToRestFormApi) && options.confirmFilename && !options.openEditor) {
+					if (((!options.backgroundSave && !options.saveToClipboard) || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3) && options.confirmFilename && !options.openEditor) {
 						const filename = ui.prompt("Save as", pageData.filename);
 						if (filename) {
 							pageData.filename = filename;
@@ -206,7 +206,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.saveToDropbox || options.saveToRestFormApi;
+	options.keepFilename = options.saveToGDrive || options.saveToGitHub || options.saveWithWebDAV || options.saveToDropbox || options.saveToRestFormApi || options.saveToS3;
 	singlefile.helper.initDoc(document);
 	ui.onStartPage(options);
 	processor = new singlefile.SingleFile(options);

+ 321 - 0
src/lib/s3/s3.js

@@ -0,0 +1,321 @@
+/*
+ * 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 crypto, TextEncoder, fetch, URLSearchParams, AbortController, Response */
+
+const EMPTY_STRING = "";
+const CONFLICT_ACTION_UNIQUIFY = "uniquify";
+const CONFLICT_ACTION_OVERWRITE = "overwrite";
+const CONFLICT_ACTION_PROMPT = "prompt";
+const EXTENSION_SEPARATOR = ".";
+const INDEX_FILENAME_PREFIX = " (";
+const INDEX_FILENAME_SUFFIX = ")";
+const INDEX_FILENAME_REGEXP = /\s\((\d+)\)$/;
+const ABORT_ERROR_NAME = "AbortError";
+const S3_SERVICE = "s3";
+const S3_DOMAIN = S3_SERVICE + ".amazonaws.com";
+
+export { S3 };
+
+class S3 {
+	constructor(region, bucket, accessKey, secretKey, domain = S3_DOMAIN) {
+		this.api = new API({ domain, region, bucket, accessKey, secretKey });
+		this.headObjectSupported = true;
+		this.listObjectsSupported = true;
+	}
+
+	async upload(path, blob, options) {
+		const { filenameConflictAction, prompt } = options;
+		this.controller = new AbortController();
+		options.signal = this.controller.signal;
+		try {
+			if (filenameConflictAction == CONFLICT_ACTION_OVERWRITE) {
+				return this.api.putObject({ path }, { body: await getUint8Array(blob) });
+			} else {
+				let response;
+				if (this.headObjectSupported) {
+					response = await this.api.headObject({ path }, options);
+				}
+				if (!this.headObjectSupported || response.status == 403) {
+					this.headObjectSupported = false;
+					if (this.listObjectsSupported) {
+						response = await this.api.listObjects({ path }, options);
+					}
+					if (!this.listObjectsSupported || response.status == 403) {
+						this.listObjectsSupported = false;
+						response = await this.api.getObject({ path }, options);
+					}
+				}
+				if (response.status == 200) {
+					if (filenameConflictAction == CONFLICT_ACTION_PROMPT) {
+						if (prompt) {
+							path = await prompt(path);
+							if (path) {
+								return this.upload(path, blob, options);
+							} else {
+								return response;
+							}
+						} else {
+							options.filenameConflictAction = CONFLICT_ACTION_UNIQUIFY;
+							return this.upload(path, blob, options);
+						}
+					} else if (filenameConflictAction == CONFLICT_ACTION_UNIQUIFY) {
+						const { filenameWithoutExtension, extension, indexFilename } = splitFilename(path);
+						options.indexFilename = indexFilename + 1;
+						path = getFilename(filenameWithoutExtension, options.indexFilename, extension);
+						return this.upload(path, blob, options);
+					}
+				} else if (response.status == 404) {
+					blob = new Uint8Array(await blob.arrayBuffer());
+					return this.api.putObject({ path }, { body: await getUint8Array(blob) });
+				} else {
+					throw new Error(response.statusText || "Error " + response.status);
+				}
+			}
+		} catch (error) {
+			if (error.name != ABORT_ERROR_NAME) {
+				throw error;
+			}
+		}
+	}
+
+	abort() {
+		if (this.controller) {
+			this.controller.abort();
+		}
+	}
+}
+
+async function getUint8Array(blob) {
+	return new Uint8Array(await new Response(blob).arrayBuffer());
+}
+
+function splitFilename(filename) {
+	let filenameWithoutExtension = filename;
+	let extension = EMPTY_STRING;
+	const indexExtensionSeparator = filename.lastIndexOf(EXTENSION_SEPARATOR);
+	if (indexExtensionSeparator > -1) {
+		filenameWithoutExtension = filename.substring(0, indexExtensionSeparator);
+		extension = filename.substring(indexExtensionSeparator + 1);
+	}
+	let indexFilename;
+	({ filenameWithoutExtension, indexFilename } = extractIndexFilename(filenameWithoutExtension));
+	return { filenameWithoutExtension, extension, indexFilename };
+}
+
+function extractIndexFilename(filenameWithoutExtension) {
+	const indexFilenameMatch = filenameWithoutExtension.match(INDEX_FILENAME_REGEXP);
+	let indexFilename = 0;
+	if (indexFilenameMatch && indexFilenameMatch.length > 1) {
+		const parsedIndexFilename = Number(indexFilenameMatch[indexFilenameMatch.length - 1]);
+		if (!Number.isNaN(parsedIndexFilename)) {
+			indexFilename = parsedIndexFilename;
+			filenameWithoutExtension = filenameWithoutExtension.replace(INDEX_FILENAME_REGEXP, EMPTY_STRING);
+		}
+	}
+	return { filenameWithoutExtension, indexFilename };
+}
+
+function getFilename(filenameWithoutExtension, indexFilename, extension) {
+	return filenameWithoutExtension +
+		INDEX_FILENAME_PREFIX + indexFilename + INDEX_FILENAME_SUFFIX +
+		(extension ? EXTENSION_SEPARATOR + extension : EMPTY_STRING);
+}
+
+const AWS4 = "AWS4";
+const AWS4_ALGORITHM = AWS4 + "-HMAC-SHA256";
+const AWS4_REQUEST = "aws4_request";
+
+const GET_METHOD = "GET";
+const PUT_METHOD = "PUT";
+const HEAD_METHOD = "HEAD";
+
+class API {
+	constructor({ domain, region, bucket, accessKey, secretKey }) {
+		this.domain = domain;
+		this.region = region;
+		this.bucket = bucket;
+		this.accessKey = accessKey;
+		this.secretKey = secretKey;
+	}
+
+	async putObject({ path }, { headers = {}, body }) {
+		return fetchS3(this, { path }, { method: PUT_METHOD, headers, body });
+	}
+
+	async getObject({ path }, { headers = {} } = {}) {
+		return fetchS3(this, { path }, { method: GET_METHOD, headers });
+	}
+
+	async headObject({ path }, { headers = {} } = {}) {
+		return fetchS3(this, { path }, { method: HEAD_METHOD, headers });
+	}
+
+	async listObjects({ path }, { headers = {}, continuationToken, delimiter, encodingType, prefix, maxKeys } = {}) {
+		const searchParams = new URLSearchParams();
+		searchParams.set("list-type", "2");
+		if (continuationToken) {
+			searchParams.set("continuation-token", continuationToken);
+		}
+		if (delimiter) {
+			searchParams.set("delimiter", delimiter);
+		}
+		if (encodingType) {
+			searchParams.set("encoding-type", encodingType);
+		}
+		if (prefix) {
+			searchParams.set("prefix", prefix);
+		}
+		if (maxKeys) {
+			searchParams.set("max-keys", maxKeys);
+		}
+		return fetchS3(this, { path, searchParams }, { method: "GET", headers });
+	}
+}
+
+async function fetchS3({ region, bucket, accessKey, secretKey, domain }, { path = "/", searchParams = new URLSearchParams() }, { method, headers = {}, body }) {
+	const date = new Date();
+	const isoDate = getISODate(date);
+	const service = S3_SERVICE;
+	if (!path.startsWith("/")) {
+		path = "/" + path;
+	}
+	headers.host = bucket + "." + domain;
+	if (body) {
+		headers["content-length"] = body.byteLength;
+	}
+	headers["x-amz-content-sha256"] = await getHexHash(body);
+	headers["x-amz-date"] = isoDate;
+	headers.authorization = AWS4_ALGORITHM + " " +
+		"Credential=" + accessKey + "/" + getCredentialScope(isoDate, region, service) + "," +
+		"SignedHeaders=" + getSignedHeaders(headers) + "," +
+		"Signature=" + await getSignature({ region, secretKey, service }, { path, searchParams }, { method, headers, body, isoDate });
+	const url = "https://" + bucket + "." + domain + path + (searchParams.size ? "?" + searchParams : "");
+	const options = { method, headers };
+	if (body) {
+		options.body = body;
+	}
+	return fetch(url, options);
+}
+
+async function getSignature({ region, secretKey, service }, { path, searchParams }, { method, headers, body, isoDate }) {
+	const stringToSign = await getStringToSign(path, searchParams, headers, body, isoDate, region, method, service);
+	const signingKey = await getSigningKey(secretKey, isoDate, region, service);
+	const signature = await getHMAC(signingKey, getEncodedText(stringToSign));
+	return getHexadecimal(signature);
+}
+
+async function getStringToSign(path, searchParams, headers, body, isoDate, region, method, service) {
+	const canonicalRequest = await getCanonicalRequest(path, searchParams, headers, method, body);
+	return AWS4_ALGORITHM + "\n" +
+		isoDate + "\n" +
+		getCredentialScope(isoDate, region, service) + "\n" +
+		getHexadecimal(await getHashSHA256(getEncodedText(canonicalRequest)));
+}
+
+async function getCanonicalRequest(path, searchParams, headers, method, body = new Uint8Array(0)) {
+	return method + "\n" +
+		getCanonicalURI(path) + "\n" +
+		getCanonicalQuery(searchParams) + "\n" +
+		getCanonicalHeaders(headers) + "\n" +
+		getSignedHeaders(headers) + "\n" +
+		await getHexHash(body);
+}
+
+function getCanonicalURI(path) {
+	return decodeURIComponent(path).replace(/[^A-Za-z0-9-._~/]/g, encodeHexadecimal);
+}
+
+function encodeHexadecimal(character) {
+	let result = encodeURIComponent(character);
+	if (result.startsWith("%")) {
+		result = result.toUpperCase();
+	} else {
+		result = "%" + result.charCodeAt(0).toString(16).toUpperCase();
+	}
+	return result;
+}
+
+function getCanonicalQuery(searchParams) {
+	if (searchParams) {
+		let result = "";
+		searchParams.sort();
+		for (const [key, value] of searchParams) {
+			result += encodeURIComponent(key) + "=" + encodeURIComponent(value) + "&";
+		}
+		return result.slice(0, -1);
+	} else {
+		return "";
+	}
+}
+
+function getCanonicalHeaders(headers) {
+	let result = "";
+	const sortedHeaders = Object.keys(headers).sort();
+	for (const header of sortedHeaders) {
+		result += header + ":" + headers[header] + "\n";
+	}
+	return result;
+}
+
+function getSignedHeaders(headers) {
+	return Object.keys(headers).map(header => header.toLowerCase()).sort().join(";");
+}
+
+async function getHexHash(body = new Uint8Array(0)) {
+	return getHexadecimal(await getHashSHA256(body));
+}
+
+function getCredentialScope(isoDate, region, service) {
+	return isoDate.substring(0, 8) + "/" + region + "/" + service + "/" + AWS4_REQUEST;
+}
+
+async function getSigningKey(secretKey, isoDate, region, service) {
+	const dateKey = await getHMAC(getEncodedText(AWS4 + secretKey), getEncodedText(isoDate.substring(0, 8)));
+	const dateRegionKey = await getHMAC(dateKey, getEncodedText(region));
+	const dateRegionServiceKey = await getHMAC(dateRegionKey, getEncodedText(service));
+	return getHMAC(dateRegionServiceKey, getEncodedText(AWS4_REQUEST));
+}
+
+async function getHashSHA256(value) {
+	return crypto.subtle.digest("SHA-256", value);
+}
+
+async function getHMAC(key, value) {
+	const algorithm = { name: "HMAC", hash: { name: "SHA-256" } };
+	const importedKey = await crypto.subtle.importKey("raw", key, algorithm, false, ["sign"]);
+	return crypto.subtle.sign(algorithm, importedKey, value);
+}
+
+function getEncodedText(text) {
+	return new TextEncoder().encode(text);
+}
+
+function getHexadecimal(hash) {
+	return Array.from(new Uint8Array(hash)).map(byte => byte.toString(16).padStart(2, "0")).join("");
+}
+
+function getISODate(date) {
+	const result = new Date(date);
+	return result.toISOString().replace(/[:-]|\.\d{3}/g, "");
+}

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

@@ -21,6 +21,8 @@
  *   Source.
  */
 
+import { save } from "../../core/bg/companion.js";
+
 /* global browser, window, document, localStorage, FileReader, location, fetch, TextDecoder, DOMParser, HTMLElement, MouseEvent */
 
 const HELP_ICON_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAABIUlEQVQ4y+2TsarCMBSGvxTBRdqiUZAWOrhJB9EXcPKFfCvfQYfulUKHDqXg4CYUJSioYO4mSDX3ttzt3n87fMlHTpIjlsulxpDZbEYYhgghSNOUOI5Ny2mZYBAELBYLer0eAJ7ncTweKYri4x7LJJRS0u12n7XrukgpjSc0CpVSXK/XZ32/31FKNW85z3PW6zXT6RSAJEnIsqy5UGvNZrNhu90CcDqd+C6tT6J+v//2Th+PB2VZ1hN2Oh3G4zGTyQTbtl/YbrdjtVpxu91+Ljyfz0RRhG3bzOfzF+Y4TvNXvlwuaK2pE4tfzr/wzwsty0IIURlL0998KxRCMBqN8H2/wlzXJQxD2u12vVkeDoeUZUkURRU+GAw4HA7s9/sK+wK6CWHasQ/S/wAAAABJRU5ErkJggg==";
@@ -92,6 +94,12 @@ const githubUserLabel = document.getElementById("githubUserLabel");
 const githubRepositoryLabel = document.getElementById("githubRepositoryLabel");
 const githubBranchLabel = document.getElementById("githubBranchLabel");
 const saveWithCompanionLabel = document.getElementById("saveWithCompanionLabel");
+const saveToS3Label = document.getElementById("saveToS3Label");
+const S3DomainLabel = document.getElementById("S3DomainLabel");
+const S3RegionLabel = document.getElementById("S3RegionLabel");
+const S3BucketLabel = document.getElementById("S3BucketLabel");
+const S3AccessKeyLabel = document.getElementById("S3AccessKeyLabel");
+const S3SecretKeyLabel = document.getElementById("S3SecretKeyLabel");
 const compressHTMLLabel = document.getElementById("compressHTMLLabel");
 const insertTextBodyLabel = document.getElementById("insertTextBodyLabel");
 const insertEmbeddedImageLabel = document.getElementById("insertEmbeddedImageLabel");
@@ -228,6 +236,7 @@ const webDAVURLInput = document.getElementById("webDAVURLInput");
 const webDAVUserInput = document.getElementById("webDAVUserInput");
 const webDAVPasswordInput = document.getElementById("webDAVPasswordInput");
 const saveToGitHubInput = document.getElementById("saveToGitHubInput");
+const saveToS3Input = document.getElementById("saveToS3Input");
 const githubTokenInput = document.getElementById("githubTokenInput");
 const githubUserInput = document.getElementById("githubUserInput");
 const githubRepositoryInput = document.getElementById("githubRepositoryInput");
@@ -341,7 +350,11 @@ const saveToRestFormApiUrlInput = document.getElementById("saveToRestFormApiUrlI
 const saveToRestFormApiFileFieldNameInput = document.getElementById("saveToRestFormApiFileFieldNameInput");
 const saveToRestFormApiUrlFieldNameInput = document.getElementById("saveToRestFormApiUrlFieldNameInput");
 const saveToRestFormApiTokenInput = document.getElementById("saveToRestFormApiTokenInput");
-
+const S3DomainInput = document.getElementById("S3DomainInput");
+const S3RegionInput = document.getElementById("S3RegionInput");
+const S3BucketInput = document.getElementById("S3BucketInput");
+const S3AccessKeyInput = document.getElementById("S3AccessKeyInput");
+const S3SecretKeyInput = document.getElementById("S3SecretKeyInput");
 
 let sidePanelDisplay;
 if (location.href.endsWith("#side-panel")) {
@@ -661,6 +674,12 @@ githubTokenLabel.textContent = browser.i18n.getMessage("optionGitHubToken");
 githubUserLabel.textContent = browser.i18n.getMessage("optionGitHubUser");
 githubRepositoryLabel.textContent = browser.i18n.getMessage("optionGitHubRepository");
 githubBranchLabel.textContent = browser.i18n.getMessage("optionGitHubBranch");
+saveToS3Label.textContent = browser.i18n.getMessage("optionSaveToS3");
+S3DomainLabel.textContent = browser.i18n.getMessage("optionS3Domain");
+S3RegionLabel.textContent = browser.i18n.getMessage("optionS3Region");
+S3BucketLabel.textContent = browser.i18n.getMessage("optionS3Bucket");
+S3AccessKeyLabel.textContent = browser.i18n.getMessage("optionS3AccessKey");
+S3SecretKeyLabel.textContent = browser.i18n.getMessage("optionS3SecretKey");
 saveWithCompanionLabel.textContent = browser.i18n.getMessage("optionSaveWithCompanion");
 compressHTMLLabel.textContent = browser.i18n.getMessage("optionCompressHTML");
 insertTextBodyLabel.textContent = browser.i18n.getMessage("optionInsertTextBody");
@@ -977,8 +996,19 @@ async function refresh(profileName) {
 	saveToRestFormApiFileFieldNameInput.disabled = !profileOptions.saveToRestFormApi;
 	saveToRestFormApiUrlFieldNameInput.value = profileOptions.saveToRestFormApiUrlFieldName;
 	saveToRestFormApiUrlFieldNameInput.disabled = !profileOptions.saveToRestFormApi;
+	saveToS3Input.checked = profileOptions.saveToS3;
+	S3DomainInput.value = profileOptions.S3Domain;
+	S3DomainInput.disabled = !profileOptions.saveToS3;
+	S3RegionInput.value = profileOptions.S3Region;
+	S3RegionInput.disabled = !profileOptions.saveToS3;
+	S3BucketInput.value = profileOptions.S3Bucket;
+	S3BucketInput.disabled = !profileOptions.saveToS3;
+	S3AccessKeyInput.value = profileOptions.S3AccessKey;
+	S3AccessKeyInput.disabled = !profileOptions.saveToS3;
+	S3SecretKeyInput.value = profileOptions.S3SecretKey;
+	S3SecretKeyInput.disabled = !profileOptions.saveToS3;
 	sharePageInput.checked = profileOptions.sharePage;
-	saveToFilesystemInput.checked = !profileOptions.saveToGDrive && !profileOptions.saveToGitHub && !profileOptions.saveWithCompanion && !profileOptions.saveToClipboard && !profileOptions.saveWithWebDAV && !profileOptions.saveToDropbox && !profileOptions.saveToRestFormApi && !profileOptions.sharePage;
+	saveToFilesystemInput.checked = !profileOptions.saveToGDrive && !profileOptions.saveToGitHub && !profileOptions.saveToS3 && !profileOptions.saveWithCompanion && !profileOptions.saveToClipboard && !profileOptions.saveWithWebDAV && !profileOptions.saveToDropbox && !profileOptions.saveToRestFormApi && !profileOptions.sharePage;
 	compressHTMLInput.checked = profileOptions.compressHTML;
 	compressCSSInput.checked = profileOptions.compressCSS;
 	moveStylesInHeadInput.checked = profileOptions.moveStylesInHead;
@@ -1188,6 +1218,12 @@ async function update() {
 			saveToRestFormApiToken: saveToRestFormApiTokenInput.value,
 			saveToRestFormApiFileFieldName: saveToRestFormApiFileFieldNameInput.value,
 			saveToRestFormApiUrlFieldName: saveToRestFormApiUrlFieldNameInput.value,
+			saveToS3: saveToS3Input.checked,
+			S3Domain: S3DomainInput.value,
+			S3Region: S3RegionInput.value,
+			S3Bucket: S3BucketInput.value,
+			S3AccessKey: S3AccessKeyInput.value,
+			S3SecretKey: S3SecretKeyInput.value
 		}
 	});
 	try {

+ 25 - 0
src/ui/pages/help.html

@@ -509,6 +509,31 @@
 							your Google Drive account.</p>
 						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
 					</li>
+					<li data-options-label="saveToS3Label" id="saveToS3Option"> <span class="option">Option: upload to
+							Amazon S3</span>
+						<p>Check this option to save the page on Amazon S3. You must configure the bucket policy to
+							include the permission <code>s3:PutObject</code>, and <code>s3:HeadObject</code> or
+							<code>s3:ListObjects</code>, or <code>s3:GetObject</code> if you do not want to overwrite or
+							skip existing files in the event of a filename conflict.
+						</p>
+						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
+					</li>
+					<li data-options-label="S3DomainLabel"> <span class="option">Option: domain</span>
+						<p>Enter the domain of the Amazon S3 server (e.g. <code>s3.amazonaws.com</code>).</p>
+					</li>
+					<li data-options-label="S3RegionLabel"> <span class="option">Option: region</span>
+						<p>Enter the region of the Amazon S3 server (e.g. <code>us-east-1</code>).</p>
+					</li>
+					<li data-options-label="S3BucketLabel"> <span class="option">Option: bucket name</span>
+						<p>Enter the name of the bucket on Amazon S3 where the saved pages will be archived. Pages will
+							be uploaded to <code>&lt;bucket name&gt;.&lt;domain&gt;</code></p>.
+					</li>
+					<li data-options-label="S3AccessKeyLabel"> <span class="option">Option: access key</span>
+						<p>Enter your access key on Amazon S3 (e.g. <code>AKDO525K84LJTH4SFAKE</code>).</p>
+					</li>
+					<li data-options-label="S3SecretKeyLabel"> <span class="option">Option: secret key</span>
+						<p>Enter your secret key on Amazon S3.</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>

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

@@ -296,6 +296,30 @@
 				<label for="saveToGDriveInput" id="saveToGDriveLabel"></label>
 				<input type="radio" id="saveToGDriveInput" name="destinationInput">
 			</div>
+			<div class="option" id="saveToS3Option">
+				<label for="saveToS3Input" id="saveToS3Label"></label>
+				<input type="radio" id="saveToS3Input" name="destinationInput">
+			</div>
+			<div class="option second-level">
+				<label for="S3DomainInput" id="S3DomainLabel"></label>
+				<input type="text" id="S3DomainInput" class="medium-input">
+			</div>
+			<div class="option second-level">
+				<label for="S3RegionInput" id="S3RegionLabel"></label>
+				<input type="text" id="S3RegionInput" class="medium-input">
+			</div>
+			<div class="option second-level">
+				<label for="S3BucketInput" id="S3BucketLabel"></label>
+				<input type="text" id="S3BucketInput" class="medium-input">
+			</div>
+			<div class="option second-level">
+				<label for="S3AccessKeyInput" id="S3AccessKeyLabel"></label>
+				<input type="text" id="S3AccessKeyInput" class="medium-input">
+			</div>
+			<div class="option second-level">
+				<label for="S3SecretKeyInput" id="S3SecretKeyLabel"></label>
+				<input type="password" id="S3SecretKeyInput" class="medium-input">
+			</div>
 			<div class="option" id="saveToDropboxOption">
 				<label for="saveToDropboxInput" id="saveToDropboxLabel"></label>
 				<input type="radio" id="saveToDropboxInput" name="destinationInput">