generator share links; secure send copy message

This commit is contained in:
2026-08-28 11:46:56 -04:00
parent 8e02235a71
commit e60945e56e
5 changed files with 418 additions and 2 deletions

View File

@@ -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

View 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
}

View 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')
})
})

View File

@@ -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

View File

@@ -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; }