155 lines
5.0 KiB
Vue
155 lines
5.0 KiB
Vue
<template>
|
|
<div class="reveal-page">
|
|
<div class="reveal-card card">
|
|
<h1>A secret was shared with you</h1>
|
|
|
|
<!-- CHECKING, not revealing. The page asks whether the link is still live
|
|
before anyone clicks, so it can say what will happen rather than
|
|
spending the only view to find out. -->
|
|
<p v-if="state === 'checking'" class="muted">Checking the link...</p>
|
|
|
|
<template v-else-if="state === 'ready'">
|
|
<p>
|
|
Opening this destroys it. Make sure you can save or use the secret now -
|
|
it cannot be shown twice, and nobody can recover it for you.
|
|
</p>
|
|
<!-- An explicit click, never automatic. Mail and chat scanners fetch
|
|
links to check them; a page that revealed on load would burn the
|
|
secret before its recipient ever saw it. -->
|
|
<button class="btn btn-primary" :disabled="busy" @click="reveal">
|
|
{{ busy ? 'Opening...' : 'Show me the secret' }}
|
|
</button>
|
|
</template>
|
|
|
|
<template v-else-if="state === 'revealed'">
|
|
<p class="muted">Here it is. It is gone from the server now.</p>
|
|
<pre class="secret">{{ plaintext }}</pre>
|
|
<button class="btn btn-primary" @click="copySecret">
|
|
{{ copied ? 'Copied' : 'Copy' }}
|
|
</button>
|
|
</template>
|
|
|
|
<template v-else-if="state === 'nokey'">
|
|
<p class="problem">This link is missing its key.</p>
|
|
<p class="muted">
|
|
The part after the # is what decrypts the secret, and it is not here.
|
|
Some chat and mail systems trim it when they rewrite links. Ask the
|
|
sender to share the link again - by a route that keeps it whole, or
|
|
pasted as plain text - and to create a new secret if this one has since
|
|
expired.
|
|
</p>
|
|
</template>
|
|
|
|
<template v-else-if="state === 'undecryptable'">
|
|
<p class="problem">The secret could not be decrypted.</p>
|
|
<p class="muted">
|
|
The link opened, but the key in it does not match. That usually means
|
|
the link was altered in transit. The secret has now been used up, so
|
|
ask the sender for a new one.
|
|
</p>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<p class="problem">This link is not valid.</p>
|
|
<p class="muted">
|
|
It may have been opened already, or it may have expired. Secrets are
|
|
destroyed once read, so a link only works the number of times the
|
|
sender chose. Ask them for a new one.
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { toolsApi } from '../api'
|
|
import { openSecret, cryptoAvailable } from '../securesend'
|
|
|
|
const route = useRoute()
|
|
const state = ref('checking')
|
|
const plaintext = ref('')
|
|
const busy = ref(false)
|
|
const copied = ref(false)
|
|
|
|
const token = route.params.token
|
|
// The key lives in the fragment, which the router does not parse and the server
|
|
// never receives. Read it off the raw location.
|
|
const fragmentKey = (window.location.hash || '').replace(/^#/, '')
|
|
|
|
onMounted(async () => {
|
|
if (!cryptoAvailable()) {
|
|
state.value = 'undecryptable'
|
|
return
|
|
}
|
|
if (!fragmentKey) {
|
|
state.value = 'nokey'
|
|
return
|
|
}
|
|
try {
|
|
const response = await toolsApi.peekSecret(token)
|
|
state.value = response.data.data.available ? 'ready' : 'gone'
|
|
} catch (err) {
|
|
state.value = 'gone'
|
|
}
|
|
})
|
|
|
|
async function reveal() {
|
|
busy.value = true
|
|
try {
|
|
const response = await toolsApi.redeemSecret(token)
|
|
const { ciphertext, iv } = response.data.data
|
|
plaintext.value = await openSecret({ ciphertext, iv, fragmentKey })
|
|
state.value = 'revealed'
|
|
} catch (err) {
|
|
// A failed DECRYPT is different from a missing secret, and the difference
|
|
// matters to the person reading: one means the link was mangled, the other
|
|
// means it was already used. The secret is spent either way.
|
|
state.value = err.response ? 'gone' : 'undecryptable'
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function copySecret() {
|
|
try {
|
|
await navigator.clipboard.writeText(plaintext.value)
|
|
copied.value = true
|
|
setTimeout(() => { copied.value = false }, 2000)
|
|
} catch (err) {
|
|
copied.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Standalone: this page is opened by somebody who may have no account here, so
|
|
it carries no navigation and nothing to click into. */
|
|
.reveal-page {
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2rem 1rem;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.reveal-card { max-width: 32rem; width: 100%; padding: 2rem; }
|
|
.reveal-card h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
|
.muted { color: var(--text-light); font-size: 0.9rem; }
|
|
.problem { color: var(--warning); font-weight: 600; margin: 0 0 0.5rem; }
|
|
|
|
.secret {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 1rem;
|
|
margin: 1rem 0;
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
font-size: 0.95rem;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
</style>
|