Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

5.42 Download encrypted submissions #235

Merged
merged 9 commits into from
Jul 26, 2019
8 changes: 6 additions & 2 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ module.exports = function(config) {
frameworks: ['mocha'],
files: [
'test/index.js',
{ pattern: 'public/fonts/icomoon.ttf', included: false, served: true }
{ pattern: 'public/fonts/icomoon.ttf', served: true, included: false },
{ pattern: 'public/blank.html', served: true, included: false },
{ pattern: 'test/files/*', served: true, included: false }
],
proxies: {
'/fonts/': '/base/public/fonts/'
'/fonts/': '/base/public/fonts/',
'/blank.html': '/base/public/blank.html',
'/test/files/': '/base/test/files/'
},
preprocessors: {
'test/index.js': ['webpack', 'sourcemap']
Expand Down
19 changes: 19 additions & 0 deletions public/blank.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<!--
Copyright 2019 ODK Central Developers
See the NOTICE file at the top-level directory of this distribution and at
https://github.com/opendatakit/central-frontend/blob/master/NOTICE.

This file is part of ODK Central. It is subject to the license terms in
the LICENSE file found in the top-level directory of this distribution and at
https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
including this file, may be copied, modified, propagated, or distributed
except according to the terms contained in the LICENSE file.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>ODK Central</title>
</head>
<body></body>
</html>
193 changes: 193 additions & 0 deletions src/components/submission/decrypt.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
<!--
Copyright 2019 ODK Central Developers
See the NOTICE file at the top-level directory of this distribution and at
https://github.com/opendatakit/central-frontend/blob/master/NOTICE.

This file is part of ODK Central. It is subject to the license terms in
the LICENSE file found in the top-level directory of this distribution and at
https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
including this file, may be copied, modified, propagated, or distributed
except according to the terms contained in the LICENSE file.
-->
<template>
<modal :state="state" backdrop hideable @hide="$emit('hide')"
@shown="$refs.passphrase.focus()">
<template #title>Decrypt and Download</template>
<template #body>
<p class="modal-introduction">
In order to download this data, you will need to provide your
passphrase. Your passphrase will be used only to decrypt your data for
download, after which I will forget it again.
</p>
<form @submit.prevent="submit">
<label class="form-group">
<input ref="passphrase" v-model="passphrase" type="password"
class="form-control" placeholder="Passphrase *" required
autocomplete="off">
<span class="form-label">Passphrase *</span>
</label>
<p v-if="managedKey != null && managedKey.hint != null"
class="modal-introduction">
Hint: {{ managedKey.hint }}
</p>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">
Download
</button>
<button type="button" class="btn btn-link" @click="$emit('hide')">
Cancel
</button>
</div>
</form>
<!-- We specify a Frontend page for src so that any cookies are sent when
the iframe form is submitted. -->
<iframe v-show="false" ref="iframe" src="/blank.html"></iframe>
</template>
</modal>
</template>

<script>
import { requestData } from '../../store/modules/request';

export default {
name: 'SubmissionDecrypt',
props: {
state: {
type: Boolean,
default: false
},
managedKey: Object, // eslint-disable-line vue/require-default-prop
formAction: {
type: String,
required: true
},
delayBetweenChecks: {
type: Number,
default: 1000
}
},
data() {
return {
passphrase: '',
// The number of times the iframe will be checked for a Problem after the
// iframe form is submitted
problemChecks: 0,
timeoutId: null
};
},
computed: requestData(['session']),
watch: {
state() {
if (this.state) return;
this.passphrase = '';
this.problemChecks = 0;
if (this.timeoutId != null) clearTimeout(this.timeoutId);
this.timeoutId = null;
}
},
methods: {
/*
replaceIframeBody() empties the iframe body, then appends a form to it. We
place a form in an iframe for a few reasons:

- We want to have the browser handle everything about the download, which
means that we cannot use an AJAX request.
- Our two options are an <a> element and a <form> element. We use a form
so that we can send a POST request. If we wish to securely pass the
passphrase to Backend, then assuming that wire security is not an issue,
we still need to ensure that the passphrase is not stored in the user's
browser history. A POST request allows us to accomplish that.
- However, submitting a form outside an iframe would navigate away from
Frontend, at least if a Problem is returned. Thus, we place the form
inside an iframe. The iframe may change pages, but that won't affect the
rest of Frontend.

Note that because the iframe may change pages after the form is submitted
(if a Problem is returned), we recreate the form each time we submit it.
*/
replaceIframeBody() {
const doc = this.$refs.iframe.contentWindow.document;
doc.body.innerHTML = '';
const form = doc.createElement('form');
form.setAttribute('method', 'post');
form.setAttribute('action', this.formAction);
doc.body.appendChild(form);

const passphraseInput = doc.createElement('input');
// This might not be necessary (not sure).
passphraseInput.setAttribute('type', 'password');
passphraseInput.setAttribute('name', this.managedKey.id.toString());
passphraseInput.setAttribute('autocomplete', 'off');
form.appendChild(passphraseInput);

const csrf = doc.createElement('input');
csrf.setAttribute('type', 'password');
csrf.setAttribute('name', '__csrf');
csrf.setAttribute('autocomplete', 'off');
form.appendChild(csrf);

passphraseInput.value = this.passphrase;
csrf.value = this.session.csrf;

return form;
},
// scheduleProblemCheck() checks the iframe for a Problem after waiting. We
// check for a Problem in this way, because when the iframe form is
// submitted, it is not an AJAX request, so there is not another way to know
// whether a Problem was returned (I think).
scheduleProblemCheck() {
this.timeoutId = setTimeout(
() => {
// If there is a Problem, it seems to be wrapped in a <pre> element.
const pre = this.$refs.iframe.contentWindow.document
matthew-white marked this conversation as resolved.
Show resolved Hide resolved
.querySelector('pre');
if (pre != null) {
let problem;
try {
problem = JSON.parse(pre.textContent);
} catch (e) {
this.$logger.error('cannot parse Problem');
}
if (problem != null) {
this.$logger.error(problem);
if (problem.message != null)
this.$alert().danger(problem.message);
}
this.problemChecks = 0;
} else {
this.problemChecks -= 1;
if (this.problemChecks !== 0) this.scheduleProblemCheck();
matthew-white marked this conversation as resolved.
Show resolved Hide resolved
}
},
this.delayBetweenChecks
);
},
submit() {
// Return immediately if the iframe is still loading. It would probably be
// better to wait for the iframe to load, then continue the process then,
// but there would be edge cases to consider in implementing that. (For
// example, what if the user submits the form, but then closes the modal
// before the iframe finishes loading?)
const iframeDoc = this.$refs.iframe.contentWindow.document;
if (iframeDoc.readyState === 'loading') return;
matthew-white marked this conversation as resolved.
Show resolved Hide resolved

this.replaceIframeBody();
const form = iframeDoc.body.querySelector('form');
form.submit();
// Make sure that the passphrase is no longer in the DOM. (This might not
// be necessary -- not sure.)
form.querySelector('input').value = '';

// Because the form submission is not an AJAX request, we will only know
// the result of the request if a Problem is returned: if a Problem is
// returned, the iframe will change pages, but if the download is
// successful, the iframe seems not to change.
this.$alert().info('Your data download should begin soon. If you have been waiting and it has not started, please try again.');

if (this.timeoutId != null) clearTimeout(this.timeoutId);
matthew-white marked this conversation as resolved.
Show resolved Hide resolved
this.problemChecks = 300;
this.scheduleProblemCheck();
}
}
};
</script>
Loading