252 lines
9.5 KiB
Vue
252 lines
9.5 KiB
Vue
<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>
|
|
|
|
<!-- A guest at a site that has not opened guest creation. Told here rather
|
|
than after typing a password into a form that was always going to be
|
|
refused. -->
|
|
<div v-if="supported && !mayCreate" class="card">
|
|
<p>Signing in is needed to create a secret link at this site.</p>
|
|
<p class="muted">
|
|
Receiving one never needs an account - if somebody sent you a link, open
|
|
that instead.
|
|
</p>
|
|
<router-link to="/login" class="btn btn-primary">Sign in</router-link>
|
|
</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="mayCreate && !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>
|
|
<input
|
|
v-model.number="views"
|
|
type="number"
|
|
class="form-control"
|
|
min="1"
|
|
:max="MAX_VIEWS"
|
|
/>
|
|
<span class="field-hint">
|
|
{{ views === 1 ? 'Once, then destroyed' : views + ' times, then destroyed' }}
|
|
(1 to {{ MAX_VIEWS }})
|
|
</span>
|
|
</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, onMounted } from 'vue'
|
|
import { toolsApi } from '../api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import { getSiteBaseUrl, getSetting } from '@/utils/siteSettings'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import {
|
|
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
|
|
} from '../securesend'
|
|
|
|
const supported = cryptoAvailable()
|
|
const auth = useAuthStore()
|
|
|
|
// Whether THIS visitor can create one. A signed-in user always sees the form -
|
|
// the server still enforces tools.secrets and says so if they lack it, which is
|
|
// a different message from "sign in". A guest sees it only where the site has
|
|
// opened guest creation.
|
|
const mayCreate = ref(true)
|
|
|
|
onMounted(async () => {
|
|
if (auth.isAuthenticated) return
|
|
const allowed = await getSetting('securesend_allow_guests', 'false')
|
|
mayCreate.value = String(allowed).toLowerCase() === 'true'
|
|
})
|
|
|
|
// Matches the server's ceiling. A free number rather than a dropdown because
|
|
// "how many people need this password" is the sender's business - a shift
|
|
// handover is not the same as one colleague on a call.
|
|
const MAX_VIEWS = 100
|
|
|
|
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 SITE'S CONFIGURED URL, not the one this browser happens to be using.
|
|
// site_base_url exists for exactly this - "used for QR codes and any
|
|
// absolute link the app emits" - because somebody creating a secret may
|
|
// have reached the app by an IP, a short hostname, or a name that only
|
|
// resolves inside one VLAN, and the recipient has to open the link from
|
|
// wherever they are. It falls back to the browsing origin when a site has
|
|
// not set one.
|
|
//
|
|
// withBase for the path: a site served under /shopdb or /ops carries that
|
|
// in front of every route, and leaving it off produced a 404 from IIS for a
|
|
// secret that existed - with the sender shown a link that could never work.
|
|
// site_base_url is DOCUMENTED as scheme + host, but sites really do set the
|
|
// full base including the subpath - and appending the mount to one that
|
|
// already carries it produced /shopdb/shopdb/s/<token>. Tolerate both
|
|
// rather than trusting the description: add the mount only when it is not
|
|
// there already.
|
|
const configured = (await getSiteBaseUrl()).replace(/\/+$/, '')
|
|
const mount = withBase('/').replace(/\/+$/, '')
|
|
const needsMount = mount && !configured.endsWith(mount)
|
|
|
|
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
|
|
// recipient and nowhere else.
|
|
link.value =
|
|
`${configured}${needsMount ? mount : ''}/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; }
|
|
.field-hint { display: block; font-size: 0.8125rem; color: var(--text-light); margin-top: 4px; }
|
|
.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>
|