generator share links; secure send copy message
This commit is contained in:
24
CHANGELOG.md
24
CHANGELOG.md
@@ -12,6 +12,19 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **A link that opens the code generator with the work already in it.** Copy
|
||||||
|
share link hands somebody a URL that reopens the page with the content, the
|
||||||
|
label, the copy count and every layout value the sender changed - so "here is
|
||||||
|
the label, print it" is a link rather than a screenshot and a list of
|
||||||
|
settings to retype. Everything rides in the fragment, which browsers never
|
||||||
|
transmit: it does not reach Flask, the IIS log or the proxy in between. Only
|
||||||
|
the values that differ from a first-time visitor's defaults travel, so the
|
||||||
|
link stays short enough to paste into a chat box, and an unknown key is
|
||||||
|
ignored rather than refused, so a link made by a newer version still opens.
|
||||||
|
The picture never travels - a data URL runs to megabytes and no chat client
|
||||||
|
would carry it. This is convenience, not secrecy: anyone holding the link can
|
||||||
|
read the content, which is what Secure Send is for.
|
||||||
|
|
||||||
- **Reading anything now needs a permission.** A sweep of the URL map found
|
- **Reading anything now needs a permission.** A sweep of the URL map found
|
||||||
**165 GET routes that answered anybody with no token at all**: the whole
|
**165 GET routes that answered anybody with no token at all**: the whole
|
||||||
asset inventory, every machine, PC, printer and network device, the USB
|
asset inventory, every machine, PC, printer and network device, the USB
|
||||||
@@ -115,6 +128,17 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Secure Send now hands you a message, not a bare URL.** A link on its own
|
||||||
|
invites being pasted onto text already sitting 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 beside it protects nothing. That happened in
|
||||||
|
production: six LAPS passwords sent as the caption of a link whose whole job
|
||||||
|
was to keep them out of the chat. Copy message puts a sentence on the
|
||||||
|
clipboard instead, which has nowhere to attach itself, and the success card
|
||||||
|
now says plainly to delete the password from the chat box before pasting.
|
||||||
|
Nothing about the link itself was wrong - the URL carries only the token, the
|
||||||
|
key rides in the fragment, and neither ever reached the message.
|
||||||
|
|
||||||
- **Ticking a section off crossed out its title and nothing else.** The strike
|
- **Ticking a section off crossed out its title and nothing else.** The strike
|
||||||
now covers everything under that heading, including its sub-sections, which
|
now covers everything under that heading, including its sub-sections, which
|
||||||
is what "this section is done" means to somebody who ticked the thing that
|
is what "this section is done" means to somebody who ticked the thing that
|
||||||
|
|||||||
116
plugins/tools/frontend/sharelink.js
Normal file
116
plugins/tools/frontend/sharelink.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* A link that opens the generator with somebody's work already in it.
|
||||||
|
*
|
||||||
|
* WHY THE FRAGMENT
|
||||||
|
*
|
||||||
|
* Everything rides after the `#`. A fragment is never transmitted: it does not
|
||||||
|
* reach Flask, it is not in the IIS log, and the proxy between the two never
|
||||||
|
* sees it. A query string would put the encoded content in all three, and the
|
||||||
|
* whole point of this feature is to send somebody a code they can print - which
|
||||||
|
* means the content is often an asset number, a serial, or a URL somebody would
|
||||||
|
* rather not scatter through logs.
|
||||||
|
*
|
||||||
|
* It is NOT secrecy. The link still sits in a chat message, in browser history
|
||||||
|
* and in whatever archives that chat. Anyone holding the link has the content.
|
||||||
|
* Secure Send exists for the case where that matters.
|
||||||
|
*
|
||||||
|
* WHAT TRAVELS
|
||||||
|
*
|
||||||
|
* What was typed (content, label, copies, the CSV) plus the settings that
|
||||||
|
* change how the label looks. Not the picture: a data URL runs to megabytes and
|
||||||
|
* would produce a link no chat client will carry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Short keys because the whole thing has to survive being pasted into a chat
|
||||||
|
// box. `c` is the content, `t` the code type, `p` the label preset.
|
||||||
|
const SHORT = {
|
||||||
|
c: 'content',
|
||||||
|
l: 'label',
|
||||||
|
n: 'copies',
|
||||||
|
b: 'back',
|
||||||
|
v: 'csv',
|
||||||
|
s: 'source',
|
||||||
|
t: 'codetype',
|
||||||
|
e: 'errorcorrection',
|
||||||
|
p: 'preset',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout values travel under their own names - there are enough of them that
|
||||||
|
// abbreviating would cost more in confusion than it saves in characters.
|
||||||
|
const PASSTHROUGH = [
|
||||||
|
'labelwidth', 'labelheight', 'padding', 'quiet', 'gap', 'codesize',
|
||||||
|
'barheight', 'labelfont', 'layout', 'align', 'valign',
|
||||||
|
'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi',
|
||||||
|
'backenabled', 'backsource', 'backcodetype', 'backtext', 'backlayout',
|
||||||
|
'backorder',
|
||||||
|
]
|
||||||
|
|
||||||
|
const LONG = Object.fromEntries(Object.entries(SHORT).map(([k, v]) => [v, k]))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the fragment for a state object. Returns '' when there is nothing to
|
||||||
|
* share, so a caller can disable its button rather than emit a bare '#'.
|
||||||
|
*/
|
||||||
|
export function buildFragment (state, defaults = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
for (const [key, name] of Object.entries(SHORT)) {
|
||||||
|
const value = state[name]
|
||||||
|
if (value === undefined || value === null || value === '') continue
|
||||||
|
// Copies of 1 is what everybody has; it does not need saying.
|
||||||
|
if (name === 'copies' && Number(value) === 1) continue
|
||||||
|
params.set(key, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the layout values that DIFFER from this browser's current settings.
|
||||||
|
// Sending all twenty would make a link four times longer for no gain, and the
|
||||||
|
// recipient's own saved settings are a reasonable starting point.
|
||||||
|
for (const name of PASSTHROUGH) {
|
||||||
|
const value = state[name]
|
||||||
|
if (value === undefined || value === null || value === '') continue
|
||||||
|
if (defaults[name] !== undefined && String(defaults[name]) === String(value)) continue
|
||||||
|
params.set(name, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = params.toString()
|
||||||
|
return encoded ? '#' + encoded : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a fragment back into a partial state object.
|
||||||
|
*
|
||||||
|
* Unknown keys are ignored rather than refused: a link made by a newer version
|
||||||
|
* must still open here, minus whatever this version does not understand.
|
||||||
|
*/
|
||||||
|
export function readFragment (hash) {
|
||||||
|
const raw = String(hash || '').replace(/^#/, '')
|
||||||
|
if (!raw) return {}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(raw)
|
||||||
|
const state = {}
|
||||||
|
|
||||||
|
for (const [key, value] of params.entries()) {
|
||||||
|
const name = SHORT[key] || (PASSTHROUGH.includes(key) ? key : null)
|
||||||
|
if (!name) continue
|
||||||
|
state[name] = value
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a fragment value to the type the ref already holds.
|
||||||
|
*
|
||||||
|
* Everything arrives as a string. Assigning '203' to a numeric input renders,
|
||||||
|
* but the moment it is used in arithmetic it concatenates - so a nudge of 0
|
||||||
|
* plus '2' becomes '02'. Match the current value's type instead.
|
||||||
|
*/
|
||||||
|
export function coerce (value, current) {
|
||||||
|
if (typeof current === 'number') {
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number : current
|
||||||
|
}
|
||||||
|
if (typeof current === 'boolean') {
|
||||||
|
return value === 'true' || value === '1'
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
107
plugins/tools/frontend/sharelink.spec.js
Normal file
107
plugins/tools/frontend/sharelink.spec.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// The share link has to survive a chat client, a recipient whose saved settings
|
||||||
|
// differ from the sender's, and a version of the page older than the link.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import { buildFragment, readFragment, coerce } from './sharelink'
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
codetype: 'qr', errorcorrection: 'M', preset: 'zebra1x05',
|
||||||
|
labelwidth: 1.0, labelheight: 0.5, dpi: 203, layout: 'side',
|
||||||
|
}
|
||||||
|
|
||||||
|
function state (extra = {}) {
|
||||||
|
return { source: 'single', content: 'WJ-3204', ...DEFAULTS, ...extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildFragment', () => {
|
||||||
|
it('carries what was typed', () => {
|
||||||
|
const params = new URLSearchParams(buildFragment(state(), DEFAULTS).slice(1))
|
||||||
|
expect(params.get('c')).toBe('WJ-3204')
|
||||||
|
expect(params.get('s')).toBe('single')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves out layout values the sender never changed', () => {
|
||||||
|
const fragment = buildFragment(state(), DEFAULTS)
|
||||||
|
expect(fragment).not.toContain('labelwidth')
|
||||||
|
expect(fragment).not.toContain('dpi')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries a layout value the sender did change', () => {
|
||||||
|
const fragment = buildFragment(state({ labelwidth: 2.0, dpi: 300 }), DEFAULTS)
|
||||||
|
const params = new URLSearchParams(fragment.slice(1))
|
||||||
|
expect(params.get('labelwidth')).toBe('2')
|
||||||
|
expect(params.get('dpi')).toBe('300')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing about one copy, which is what everybody has', () => {
|
||||||
|
expect(buildFragment(state({ copies: 1 }), DEFAULTS)).not.toContain('n=')
|
||||||
|
expect(buildFragment(state({ copies: 4 }), DEFAULTS)).toContain('n=4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty when there is nothing to share', () => {
|
||||||
|
// So the caller can disable its button rather than emit a bare '#'.
|
||||||
|
expect(buildFragment({}, DEFAULTS)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never carries the picture', () => {
|
||||||
|
// A data URL runs to megabytes; the link would not survive a chat box.
|
||||||
|
const fragment = buildFragment(state({ pictureUrl: 'data:image/png;base64,AAAA' }), DEFAULTS)
|
||||||
|
expect(fragment).not.toContain('data:image')
|
||||||
|
expect(fragment).not.toContain('pictureUrl')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readFragment', () => {
|
||||||
|
it('reads back what buildFragment wrote', () => {
|
||||||
|
const sent = state({ content: 'Bay 310 / press 2', label: 'Press', copies: 3,
|
||||||
|
labelwidth: 2.0, layout: 'stack' })
|
||||||
|
const back = readFragment(buildFragment(sent, DEFAULTS))
|
||||||
|
expect(back.content).toBe('Bay 310 / press 2')
|
||||||
|
expect(back.label).toBe('Press')
|
||||||
|
expect(back.copies).toBe('3')
|
||||||
|
expect(back.labelwidth).toBe('2')
|
||||||
|
expect(back.layout).toBe('stack')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives the characters people actually put in a label', () => {
|
||||||
|
const awkward = 'CNC #4 & 5 - 50% duty, "spare"'
|
||||||
|
const back = readFragment(buildFragment(state({ content: awkward }), DEFAULTS))
|
||||||
|
expect(back.content).toBe(awkward)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a key it does not know', () => {
|
||||||
|
// A link made by a newer version must still open here, minus the parts
|
||||||
|
// this version cannot use.
|
||||||
|
const back = readFragment('#c=X&somethingnew=1')
|
||||||
|
expect(back.content).toBe('X')
|
||||||
|
expect(back.somethingnew).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty for an empty or absent fragment', () => {
|
||||||
|
expect(readFragment('')).toEqual({})
|
||||||
|
expect(readFragment('#')).toEqual({})
|
||||||
|
expect(readFragment(undefined)).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('coerce', () => {
|
||||||
|
it('keeps a number a number', () => {
|
||||||
|
// '0' + '2' is '02'; 0 + 2 is 2. The nudge fields are arithmetic.
|
||||||
|
expect(coerce('2', 0)).toBe(2)
|
||||||
|
expect(coerce('0.4286', 0.5)).toBeCloseTo(0.4286)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the current value when the link is nonsense', () => {
|
||||||
|
expect(coerce('not-a-number', 203)).toBe(203)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads a boolean back as a boolean', () => {
|
||||||
|
expect(coerce('true', false)).toBe(true)
|
||||||
|
expect(coerce('false', true)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a string alone', () => {
|
||||||
|
expect(coerce('stack', 'side')).toBe('stack')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,25 @@
|
|||||||
<div class="code-generator">
|
<div class="code-generator">
|
||||||
<div class="page-header no-print">
|
<div class="page-header no-print">
|
||||||
<h1>Barcode / QR Generator</h1>
|
<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>
|
||||||
|
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
@@ -343,6 +361,8 @@ import { LABEL_PRESETS, qrModuleCount, qrFit, qrSvgDataUri, barcodeSvgDataUri }
|
|||||||
from '@/utils/codes'
|
from '@/utils/codes'
|
||||||
import { parseCsv, buildPages, MAX_LABELS } from '../labelPages'
|
import { parseCsv, buildPages, MAX_LABELS } from '../labelPages'
|
||||||
import { labelVars, LABEL_VAR_NAMES } from '../labelVars'
|
import { labelVars, LABEL_VAR_NAMES } from '../labelVars'
|
||||||
|
import { buildFragment, readFragment, coerce } from '../sharelink'
|
||||||
|
import { withBase } from '@/utils/basePath'
|
||||||
|
|
||||||
const PRESETS = LABEL_PRESETS
|
const PRESETS = LABEL_PRESETS
|
||||||
|
|
||||||
@@ -396,6 +416,11 @@ const images = ref([])
|
|||||||
const testMode = ref(false)
|
const testMode = ref(false)
|
||||||
const overlong = ref([])
|
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 = [
|
const SETTING_KEYS = [
|
||||||
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
|
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
|
||||||
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
|
'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign',
|
||||||
@@ -410,6 +435,7 @@ const settingRefs = {
|
|||||||
picturesize, pictureposition, pictureside, pictureopacity,
|
picturesize, pictureposition, pictureside, pictureopacity,
|
||||||
backenabled, backsource, backcodetype, backtext, backlayout, backorder,
|
backenabled, backsource, backcodetype, backtext, backlayout, backorder,
|
||||||
}
|
}
|
||||||
|
for (const key of SETTING_KEYS) DEFAULT_SETTINGS[key] = settingRefs[key].value
|
||||||
|
|
||||||
// --- back side --------------------------------------------------------------
|
// --- back side --------------------------------------------------------------
|
||||||
|
|
||||||
@@ -725,6 +751,52 @@ function saveSettings() {
|
|||||||
try { localStorage.setItem('toolsCodeGenerator', JSON.stringify(state)) } catch { /* private mode */ }
|
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(() => {
|
onMounted(() => {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem('toolsCodeGenerator') || '{}')
|
const saved = JSON.parse(localStorage.getItem('toolsCodeGenerator') || '{}')
|
||||||
@@ -732,6 +804,24 @@ onMounted(() => {
|
|||||||
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
|
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
|
||||||
}
|
}
|
||||||
} catch { /* ignore a corrupt entry */ }
|
} 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()
|
reconcileBackLayout()
|
||||||
applyVars()
|
applyVars()
|
||||||
renderAll()
|
renderAll()
|
||||||
@@ -754,6 +844,22 @@ watch([pages, codetype, backcodetype, errorcorrection], renderAll)
|
|||||||
|
|
||||||
.code-generator { width: 100%; }
|
.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
|
/* 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
|
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
|
column scrolls on its own so a 500-label run cannot push the controls off
|
||||||
|
|||||||
@@ -94,8 +94,30 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="link-row">
|
<div class="link-row">
|
||||||
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
|
<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>
|
</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">
|
<p class="control-hint">
|
||||||
This is the only time the link is shown - it is not stored anywhere you can
|
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.
|
look it up, and the key in it exists nowhere else.
|
||||||
@@ -142,6 +164,7 @@ const busy = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const link = ref('')
|
const link = ref('')
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
const messagecopied = ref(false)
|
||||||
const linkInput = ref(null)
|
const linkInput = ref(null)
|
||||||
|
|
||||||
// Only nag once there is something to nag about: an empty box is not a mistake.
|
// 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() {
|
function selectAll() {
|
||||||
linkInput.value?.select()
|
linkInput.value?.select()
|
||||||
}
|
}
|
||||||
@@ -245,6 +297,17 @@ function reset() {
|
|||||||
.counter.over { color: var(--warning); font-weight: 600; }
|
.counter.over { color: var(--warning); font-weight: 600; }
|
||||||
.control-note { color: var(--warning); font-size: 0.875rem; margin: 8px 0; }
|
.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; }
|
.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 { display: flex; gap: 8px; align-items: center; margin: 0.5rem 0; }
|
||||||
.link-row .form-control { flex: 1; }
|
.link-row .form-control { flex: 1; }
|
||||||
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }
|
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user