diff options
| -rw-r--r-- | ext/bg/js/backend.js | 12 | ||||
| -rw-r--r-- | ext/bg/js/settings/backup.js | 226 | ||||
| -rw-r--r-- | ext/bg/js/util.js | 9 | ||||
| -rw-r--r-- | ext/bg/settings.html | 49 | 
4 files changed, 296 insertions, 0 deletions
| diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 245e3de2..3c8a068b 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -129,6 +129,18 @@ class Backend {          return this.options;      } +    async setFullOptions(options) { +        if (this.isPreparedPromise !== null) { +            await this.isPreparedPromise; +        } +        try { +            this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options)); +        } catch (e) { +            // This shouldn't happen, but catch errors just in case of bugs +            logError(e); +        } +    } +      async getOptions(optionsContext) {          if (this.isPreparedPromise !== null) {              await this.isPreparedPromise; diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index 6494cd65..1e099288 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -115,8 +115,234 @@ async function _onSettingsExportClick() {  } +// Importing + +async function _settingsImportSetOptionsFull(optionsFull) { +    return utilIsolate(await utilBackend().setFullOptions( +        utilBackgroundIsolate(optionsFull) +    )); +} + +function _showSettingsImportError(error) { +    logError(error); +    document.querySelector('#settings-import-error-modal-message').textContent = `${error}`; +    $('#settings-import-error-modal').modal('show'); +} + +async function _showSettingsImportWarnings(warnings) { +    const modalNode = $('#settings-import-warning-modal'); +    const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button'); +    const messageContainer = document.querySelector('#settings-import-warning-modal-message'); +    if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) { +        return {result: false}; +    } + +    // Set message +    const fragment = document.createDocumentFragment(); +    for (const warning of warnings) { +        const node = document.createElement('li'); +        node.textContent = `${warning}`; +        fragment.appendChild(node); +    } +    messageContainer.textContent = ''; +    messageContainer.appendChild(fragment); + +    // Show modal +    modalNode.modal('show'); + +    // Wait for modal to close +    return new Promise((resolve) => { +        const onButtonClick = (e) => { +            e.preventDefault(); +            complete({ +                result: true, +                sanitize: e.currentTarget.dataset.importSanitize === 'true' +            }); +            modalNode.modal('hide'); + +        }; +        const onModalHide = () => { +            complete({result: false}); +        }; + +        let completed = false; +        const complete = (result) => { +            if (completed) { return; } +            completed = true; + +            modalNode.off('hide.bs.modal', onModalHide); +            for (const button of buttons) { +                button.removeEventListener('click', onButtonClick, false); +            } + +            resolve(result); +        }; + +        // Hook events +        modalNode.on('hide.bs.modal', onModalHide); +        for (const button of buttons) { +            button.addEventListener('click', onButtonClick, false); +        } +    }); +} + +function _isLocalhostUrl(urlString) { +    try { +        const url = new URL(urlString); +        switch (url.hostname.toLowerCase()) { +            case 'localhost': +            case '127.0.0.1': +            case '[::1]': +                switch (url.protocol.toLowerCase()) { +                    case 'http:': +                    case 'https:': +                        return true; +                } +                break; +        } +    } catch (e) { +        // NOP +    } +    return false; +} + +function _settingsImportSanitizeProfileOptions(options, dryRun) { +    const warnings = []; + +    const anki = options.anki; +    if (isObject(anki)) { +        const fieldTemplates = anki.fieldTemplates; +        if (typeof fieldTemplates === 'string') { +            warnings.push('anki.fieldTemplates contains a non-default value'); +            if (!dryRun) { +                delete anki.fieldTemplates; +            } +        } +        const server = anki.server; +        if (typeof server === 'string' && server.length > 0 && !_isLocalhostUrl(server)) { +            warnings.push('anki.server uses a non-localhost URL'); +            if (!dryRun) { +                delete anki.server; +            } +        } +    } + +    const audio = options.audio; +    if (isObject(audio)) { +        const customSourceUrl = audio.customSourceUrl; +        if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !_isLocalhostUrl(customSourceUrl)) { +            warnings.push('audio.customSourceUrl uses a non-localhost URL'); +            if (!dryRun) { +                delete audio.customSourceUrl; +            } +        } +    } + +    return warnings; +} + +function _settingsImportSanitizeOptions(optionsFull, dryRun) { +    const warnings = new Set(); + +    const profiles = optionsFull.profiles; +    if (Array.isArray(profiles)) { +        for (const profile of profiles) { +            if (!isObject(profile)) { continue; } +            const options = profile.options; +            if (!isObject(options)) { continue; } + +            const warnings2 = _settingsImportSanitizeProfileOptions(options, dryRun); +            for (const warning of warnings2) { +                warnings.add(warning); +            } +        } +    } + +    return warnings; +} + +function _utf8Decode(arrayBuffer) { +    try { +        return new TextDecoder('utf-8').decode(arrayBuffer); +    } catch (e) { +        const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); +        return decodeURIComponent(escape(binaryString)); +    } +} + +async function _importSettingsFile(file) { +    const dataString = _utf8Decode(await utilReadFileArrayBuffer(file)); +    const data = JSON.parse(dataString); + +    // Type check +    if (!isObject(data)) { +        throw new Error(`Invalid data type: ${typeof data}`); +    } + +    // Version check +    const version = data.version; +    if (!( +        typeof version === 'number' && +        Number.isFinite(version) && +        version === Math.floor(version) +    )) { +        throw new Error(`Invalid version: ${version}`); +    } + +    if (!( +        version >= 0 && +        version <= SETTINGS_EXPORT_CURRENT_VERSION +    )) { +        throw new Error(`Unsupported version: ${version}`); +    } + +    // Verify options exists +    let optionsFull = data.options; +    if (!isObject(optionsFull)) { +        throw new Error(`Invalid options type: ${typeof optionsFull}`); +    } + +    // Upgrade options +    optionsFull = optionsUpdateVersion(optionsFull, {}); + +    // Check for warnings +    const sanitizationWarnings = _settingsImportSanitizeOptions(optionsFull, true); + +    // Show sanitization warnings +    if (sanitizationWarnings.size > 0) { +        const {result, sanitize} = await _showSettingsImportWarnings(sanitizationWarnings); +        if (!result) { return; } + +        if (sanitize !== false) { +            _settingsImportSanitizeOptions(optionsFull, false); +        } +    } + +    // Assign options +    await _settingsImportSetOptionsFull(optionsFull); + +    // Reload settings page +    window.location.reload(); +} + +function _onSettingsImportClick() { +    document.querySelector('#settings-import-file').click(); +} + +function _onSettingsImportFileChange(e) { +    const files = e.target.files; +    if (files.length === 0) { return; } + +    const file = files[0]; +    e.target.value = null; +    _importSettingsFile(file).catch(_showSettingsImportError); +} + +  // Setup  window.addEventListener('DOMContentLoaded', () => {      document.querySelector('#settings-export').addEventListener('click', _onSettingsExportClick, false); +    document.querySelector('#settings-import').addEventListener('click', _onSettingsImportClick, false); +    document.querySelector('#settings-import-file').addEventListener('change', _onSettingsImportFileChange, false);  }, false); diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 0527dc0b..4c989642 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -155,3 +155,12 @@ function utilReadFile(file) {          reader.readAsBinaryString(file);      });  } + +function utilReadFileArrayBuffer(file) { +    return new Promise((resolve, reject) => { +        const reader = new FileReader(); +        reader.onload = () => resolve(reader.result); +        reader.onerror = () => reject(reader.error); +        reader.readAsArrayBuffer(file); +    }); +} diff --git a/ext/bg/settings.html b/ext/bg/settings.html index ec0e2939..56b5610e 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -866,6 +866,55 @@                  <div>                      <button class="btn btn-default" id="settings-export">Export Settings</button> +                    <button class="btn btn-default" id="settings-import">Import Settings</button> +                </div> + +                <div hidden><input type="file" id="settings-import-file" accept=".json,application/json"></div> + +                <div class="modal fade" tabindex="-1" role="dialog" id="settings-import-error-modal"> +                    <div class="modal-dialog modal-dialog-centered"> +                        <div class="modal-content"> +                            <div class="modal-header"> +                                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> +                                <h4 class="modal-title">Import Error</h4> +                            </div> +                            <div class="modal-body"> +                                <p> +                                    An error occurred while trying to import the settings file: +                                </p> +                                <p class="text-danger" id="settings-import-error-modal-message"></p> +                                <p> +                                    Additional info can be found in the developer console. +                                </p> +                            </div> +                            <div class="modal-footer"> +                                <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> +                            </div> +                        </div> +                    </div> +                </div> + +                <div class="modal fade" tabindex="-1" role="dialog" id="settings-import-warning-modal"> +                    <div class="modal-dialog modal-dialog-centered"> +                        <div class="modal-content"> +                            <div class="modal-header"> +                                <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> +                                <h4 class="modal-title">Import Security Warning</h4> +                            </div> +                            <div class="modal-body"> +                                <p> +                                    Settings file contains settings which may pose a security risk. +                                    Only import settings from sources you trust. +                                </p> +                                <ul class="text-danger" id="settings-import-warning-modal-message"></ul> +                            </div> +                            <div class="modal-footer"> +                                <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> +                                <button type="button" class="btn btn-danger settings-import-warning-modal-import-button">Import</button> +                                <button type="button" class="btn btn-primary settings-import-warning-modal-import-button" data-import-sanitize="true">Sanitize and Import</button> +                            </div> +                        </div> +                    </div>                  </div>              </div> |