generator share links; secure send copy message
This commit is contained in:
@@ -2,7 +2,25 @@
|
||||
<div class="code-generator">
|
||||
<div class="page-header no-print">
|
||||
<h1>Barcode / QR Generator</h1>
|
||||
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||
<div class="header-actions">
|
||||
<!-- Sends somebody THIS code, already set up. The content rides in the
|
||||
fragment, so it never reaches the server or its logs. -->
|
||||
<button class="btn btn-secondary" :disabled="!shareable" @click="copyShareLink">
|
||||
{{ sharecopied ? 'Link copied' : 'Copy share link' }}
|
||||
</button>
|
||||
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sharelink" class="share-row no-print">
|
||||
<input class="form-control mono" :value="sharelink" readonly
|
||||
@focus="$event.target.select()">
|
||||
<p class="share-note">
|
||||
Opens this page with the content and layout already set. The part after
|
||||
the <code>#</code> never reaches the server, so it stays out of the logs
|
||||
- but anyone holding the link can read it, so send a password with
|
||||
Secure Send instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
@@ -343,6 +361,8 @@ import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri }
|
||||
from '@/utils/codes'
|
||||
import { parseCsv, buildPages, MAX_LABELS } from '../labelPages'
|
||||
import { labelVars, LABEL_VAR_NAMES } from '../labelVars'
|
||||
import { buildFragment, readFragment, coerce } from '../sharelink'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const PRESETS = LABEL_PRESETS
|
||||
|
||||
@@ -396,6 +416,11 @@ const images = ref([])
|
||||
const testMode = ref(false)
|
||||
const overlong = ref([])
|
||||
|
||||
// The values a first-time visitor starts with, captured before localStorage or
|
||||
// a shared link overwrites anything. A share link then carries only what the
|
||||
// sender actually changed.
|
||||
const DEFAULT_SETTINGS = {}
|
||||
|
||||
const SETTING_KEYS = [
|
||||
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
|
||||
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
|
||||
@@ -410,6 +435,7 @@ const settingRefs = {
|
||||
picturesize, pictureposition, pictureside, pictureopacity,
|
||||
backenabled, backsource, backcodetype, backtext, backlayout, backorder,
|
||||
}
|
||||
for (const key of SETTING_KEYS) DEFAULT_SETTINGS[key] = settingRefs[key].value
|
||||
|
||||
// --- back side --------------------------------------------------------------
|
||||
|
||||
@@ -725,6 +751,52 @@ function saveSettings() {
|
||||
try { localStorage.setItem('toolsCodeGenerator', JSON.stringify(state)) } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sharing ---
|
||||
//
|
||||
// A link that opens this page with the work already in it. Everything rides in
|
||||
// the fragment, which is never sent to the server - see ../sharelink.js.
|
||||
|
||||
const sharelink = ref('')
|
||||
const sharecopied = ref(false)
|
||||
|
||||
// What travels: the typed content and every layout value. The picture does not
|
||||
// - a data URL runs to megabytes and no chat client would carry the link.
|
||||
function shareState () {
|
||||
const state = {
|
||||
source: source.value,
|
||||
content: singleContent.value,
|
||||
label: singleLabel.value,
|
||||
copies: singleCopies.value,
|
||||
back: singleBack.value,
|
||||
csv: source.value === 'csv' ? csvText.value : '',
|
||||
}
|
||||
for (const key of SETTING_KEYS) state[key] = settingRefs[key].value
|
||||
return state
|
||||
}
|
||||
|
||||
const shareable = computed(() =>
|
||||
Boolean(source.value === 'csv' ? csvText.value.trim() : singleContent.value.trim()))
|
||||
|
||||
function makeShareLink () {
|
||||
// Defaults are what a first-time visitor starts with, so only the values
|
||||
// somebody actually changed end up in the link.
|
||||
const fragment = buildFragment(shareState(), DEFAULT_SETTINGS)
|
||||
const mount = withBase('/').replace(/\/+$/, '')
|
||||
sharelink.value = `${window.location.origin}${mount}/tools/codes${fragment}`
|
||||
}
|
||||
|
||||
async function copyShareLink () {
|
||||
makeShareLink()
|
||||
try {
|
||||
await navigator.clipboard.writeText(sharelink.value)
|
||||
sharecopied.value = true
|
||||
setTimeout(() => { sharecopied.value = false }, 2000)
|
||||
} catch {
|
||||
// Clipboard refused (http, or a locked-down browser). The box below still
|
||||
// holds the link to copy by hand, which is why it is rendered at all.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('toolsCodeGenerator') || '{}')
|
||||
@@ -732,6 +804,24 @@ onMounted(() => {
|
||||
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
|
||||
}
|
||||
} catch { /* ignore a corrupt entry */ }
|
||||
|
||||
// A shared link wins over this browser's saved settings: somebody following
|
||||
// one is asking to see THAT code, not their own last one.
|
||||
const shared = readFragment(window.location.hash)
|
||||
if (Object.keys(shared).length) {
|
||||
if (shared.source) source.value = shared.source === 'csv' ? 'csv' : 'single'
|
||||
if (shared.content !== undefined) singleContent.value = shared.content
|
||||
if (shared.label !== undefined) singleLabel.value = shared.label
|
||||
if (shared.copies !== undefined) singleCopies.value = coerce(shared.copies, singleCopies.value)
|
||||
if (shared.back !== undefined) singleBack.value = shared.back
|
||||
if (shared.csv) { csvText.value = shared.csv; source.value = 'csv' }
|
||||
for (const key of SETTING_KEYS) {
|
||||
if (shared[key] !== undefined) {
|
||||
settingRefs[key].value = coerce(shared[key], settingRefs[key].value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reconcileBackLayout()
|
||||
applyVars()
|
||||
renderAll()
|
||||
@@ -754,6 +844,22 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
|
||||
|
||||
.code-generator { width: 100%; }
|
||||
|
||||
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
|
||||
/* Full width and monospace: the link is long, and somebody copying it by hand
|
||||
needs to see where it ends. */
|
||||
.share-row { margin: 0 0 1rem; }
|
||||
.share-row .mono {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
.share-note {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Settings on the left, the labels themselves on the right, where they stay in
|
||||
view while the settings that change them are being worked. The preview
|
||||
column scrolls on its own so a 500-label run cannot push the controls off
|
||||
|
||||
@@ -94,8 +94,30 @@
|
||||
</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>
|
||||
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy link' }}</button>
|
||||
<!--
|
||||
Copies a whole sentence, not the bare URL. Pasted into an EMPTY chat
|
||||
box there is nothing selected for the link to attach itself to - which
|
||||
is the failure this button exists to prevent. See copyMessage().
|
||||
-->
|
||||
<button class="btn btn-secondary" @click="copyMessage">
|
||||
{{ messagecopied ? 'Copied' : 'Copy message' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The warning that matters, stated where the mistake gets made. The hint
|
||||
below is about the key being unrecoverable, which is a different point
|
||||
and does not stop anybody pasting the password itself.
|
||||
-->
|
||||
<div class="alert-send">
|
||||
<strong>Send the link, not the secret.</strong>
|
||||
If the password is already typed in a chat box, delete it before pasting
|
||||
this. Pasting a link over selected text turns that text into the link's
|
||||
caption, and the secret then travels in the message itself - the link
|
||||
beside it protects nothing.
|
||||
</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.
|
||||
@@ -142,6 +164,7 @@ const busy = ref(false)
|
||||
const error = ref('')
|
||||
const link = ref('')
|
||||
const copied = ref(false)
|
||||
const messagecopied = ref(false)
|
||||
const linkInput = ref(null)
|
||||
|
||||
// Only nag once there is something to nag about: an empty box is not a mistake.
|
||||
@@ -222,6 +245,35 @@ async function copyLink() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The link with a sentence around it.
|
||||
*
|
||||
* A bare URL invites being pasted onto text that is already in the chat box,
|
||||
* and a chat client turns the selection into the link's caption - so the secret
|
||||
* travels as the visible message and the link protects nothing. It has happened
|
||||
* in production: six LAPS passwords sent as the caption of a link whose whole
|
||||
* job was to keep them out of the chat.
|
||||
*
|
||||
* A sentence has nowhere to attach itself. Pasted into an empty box it is a
|
||||
* message; pasted over a selection it replaces it rather than decorating it.
|
||||
* Plain text on purpose - a rich-text flavour is what would let a secret ride
|
||||
* along as markup.
|
||||
*/
|
||||
async function copyMessage() {
|
||||
const opens = views.value === 1
|
||||
? 'It opens once and then destroys itself'
|
||||
: `It opens ${views.value} times and then destroys itself`
|
||||
const message =
|
||||
`Secure link - ${opens}, and expires ${expiryLabel.value}: ${link.value}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(message)
|
||||
messagecopied.value = true
|
||||
setTimeout(() => { messagecopied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
linkInput.value?.select()
|
||||
}
|
||||
@@ -245,6 +297,17 @@ function reset() {
|
||||
.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; }
|
||||
/* Loud enough to be read before the paste, quiet enough not to look like an
|
||||
error on a page where nothing went wrong. */
|
||||
.alert-send {
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--bg-card-solid);
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.alert-send strong { display: block; margin-bottom: 2px; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user