Secure send: the pages a person actually touches
This commit is contained in:
190
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
190
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Secure Send</h1>
|
||||
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card intro">
|
||||
<p>
|
||||
Hand somebody a password without leaving it in a chat window. The text is
|
||||
encrypted in this browser and the key travels in the link, never to the
|
||||
server - so nobody here can read what you send, including an administrator.
|
||||
</p>
|
||||
<p class="muted">
|
||||
The link works once, then the secret is destroyed. For passwords and short
|
||||
text only, not documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="card control-note">
|
||||
This browser cannot encrypt in page (Web Crypto is unavailable, which
|
||||
usually means the site is not being served over HTTPS). Secure send needs
|
||||
it, because the whole point is that the server never sees your text.
|
||||
</div>
|
||||
|
||||
<template v-else-if="!link">
|
||||
<div class="card">
|
||||
<label class="field">
|
||||
<span class="field-label">Secret</span>
|
||||
<textarea
|
||||
v-model="plaintext"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="Password, connection string, short note..."
|
||||
@paste="onPaste"
|
||||
></textarea>
|
||||
</label>
|
||||
<p class="counter" :class="{ over: plaintext.length > MAX_PLAINTEXT }">
|
||||
{{ plaintext.length }} / {{ MAX_PLAINTEXT }} characters
|
||||
</p>
|
||||
<p v-if="refusal" class="control-note">{{ refusal }}</p>
|
||||
|
||||
<div class="options">
|
||||
<label class="field">
|
||||
<span class="field-label">Expires after</span>
|
||||
<select v-model.number="expiryhours" class="form-control">
|
||||
<option :value="1">1 hour</option>
|
||||
<option :value="8">8 hours</option>
|
||||
<option :value="24">1 day</option>
|
||||
<option :value="72">3 days</option>
|
||||
<option :value="168">7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Can be opened</span>
|
||||
<select v-model.number="views" class="form-control">
|
||||
<option :value="1">Once</option>
|
||||
<option :value="2">Twice</option>
|
||||
<option :value="3">3 times</option>
|
||||
<option :value="5">5 times</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="busy || !!refusal" @click="createLink">
|
||||
{{ busy ? 'Encrypting...' : 'Create link' }}
|
||||
</button>
|
||||
<p v-if="error" class="control-note">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<h3>Your link</h3>
|
||||
<p class="muted">
|
||||
Send this to the person who needs it. It opens
|
||||
{{ views === 1 ? 'once' : views + ' times' }} and expires {{ expiryLabel }}.
|
||||
</p>
|
||||
<div class="link-row">
|
||||
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
|
||||
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="control-hint">
|
||||
This is the only time the link is shown - it is not stored anywhere you can
|
||||
look it up, and the key in it exists nowhere else.
|
||||
</p>
|
||||
<button class="btn btn-secondary" @click="reset">Send another</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { toolsApi } from '../api'
|
||||
import {
|
||||
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
|
||||
} from '../securesend'
|
||||
|
||||
const supported = cryptoAvailable()
|
||||
|
||||
const plaintext = ref('')
|
||||
const expiryhours = ref(24)
|
||||
const views = ref(1)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const link = ref('')
|
||||
const copied = ref(false)
|
||||
const linkInput = ref(null)
|
||||
|
||||
// Only nag once there is something to nag about: an empty box is not a mistake.
|
||||
const refusal = computed(() => (plaintext.value ? refuseReason(plaintext.value) : null))
|
||||
|
||||
const expiryLabel = computed(() => {
|
||||
const hours = expiryhours.value
|
||||
if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
||||
const days = hours / 24
|
||||
return `in ${days} day${days === 1 ? '' : 's'}`
|
||||
})
|
||||
|
||||
function onPaste(event) {
|
||||
// A pasted FILE is the easiest wrong path into a tool that is not a file
|
||||
// transfer. Refuse it here rather than letting its bytes become "text".
|
||||
if (pasteCarriesFiles(event)) {
|
||||
event.preventDefault()
|
||||
error.value = 'Secure send takes text, not files. Paste the password itself.'
|
||||
}
|
||||
}
|
||||
|
||||
async function createLink() {
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const sealed = await sealSecret(plaintext.value)
|
||||
const response = await toolsApi.createSecret({
|
||||
ciphertext: sealed.ciphertext,
|
||||
iv: sealed.iv,
|
||||
expiryhours: expiryhours.value,
|
||||
views: views.value,
|
||||
})
|
||||
const token = response.data.data.token
|
||||
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
|
||||
// recipient and nowhere else.
|
||||
link.value = `${window.location.origin}/s/${token}#${sealed.fragmentKey}`
|
||||
// Drop the plaintext as soon as it is sealed: no reason to keep it in memory
|
||||
// behind the link that replaced it.
|
||||
plaintext.value = ''
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.data?.error?.message || 'Could not create the link.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
// Clipboard access is refused in some hardened configurations. Select the
|
||||
// text instead so Ctrl+C still works rather than leaving a dead button.
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
linkInput.value?.select()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
link.value = ''
|
||||
error.value = ''
|
||||
copied.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intro p { margin: 0 0 0.5rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field-label { display: block; font-size: 0.8125rem; color: var(--text-light); margin-bottom: 4px; }
|
||||
.options { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.options .field { flex: 1 1 12rem; }
|
||||
.counter { font-size: 0.8125rem; color: var(--text-light); margin: 0 0 0.5rem; }
|
||||
.counter.over { color: var(--warning); font-weight: 600; }
|
||||
.control-note { color: var(--warning); font-size: 0.875rem; margin: 8px 0; }
|
||||
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 12px; }
|
||||
.link-row { display: flex; gap: 8px; align-items: center; margin: 0.5rem 0; }
|
||||
.link-row .form-control { flex: 1; }
|
||||
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user