Files
shopdb-flask/plugins/tools/frontend/views/SecureSendCreate.vue
cproudlock 92cf1d71f0
Some checks failed
CI / backend (push) Failing after 7m9s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled
Stop making people sign in to reach tools that need no account
/tools, /tools/codes and /tools/secure-send were all behind requiresAuth. None
of them needed to be, and one of them actively contradicted a setting added an
hour earlier: securesend_allow_guests lets somebody with no account create a
link, and the page they would create it on was behind a login wall.

The label generator runs entirely in the browser - no API, no data, nothing to
protect - so gating it only ever cost a guest the use of it.

THE SERVER STILL DECIDES who may create a secret. A signed-in user needs
tools.secrets; a guest needs the site to have opened guest creation. Making the
page reachable is not the same as making the action allowed, and the two were
conflated.

The page now reads the setting on load, so a guest at a site that has NOT opened
guest creation is told to sign in instead of being handed a form that was always
going to be refused - with a line saying that receiving a link never needs an
account, because somebody who lands there confused usually has a link they
should be opening instead.

A signed-in user always sees the form, even without the permission: the server's
"you do not have permission" is a different answer from "sign in", and
collapsing them would send somebody to a login page they are already past.
2026-08-21 16:00:18 -04:00

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>