Share a code by link instead of by instructions
Sending somebody a label meant sending a screenshot and a list of settings to retype, and they would get one of them wrong. Copy share link hands them a URL that reopens the generator with the content, the label, the copy count and every layout value that was actually changed. The whole payload rides in the fragment. A fragment is never transmitted, so it does not reach Flask, it is not in the IIS log, and the proxy between them never sees it - which matters because the content is usually an asset number or a serial, and those have no business scattered through logs. It is NOT secrecy: the link sits in a chat message and in browser history, and anyone holding it can read the content. Secure Send exists for the case where that matters, and the note under the box says so. Only values that differ from a first-time visitor's defaults are carried, so the link stays short enough to paste into a chat box; a shared link then wins over the recipient's own saved settings, because somebody following one is asking to see THAT code rather than their own last one. An unknown key is ignored rather than refused, so a link made by a newer version still opens on an older one. The picture is never carried - a data URL runs to megabytes. Values are coerced back to the type the field holds. Everything arrives from a URL as a string, and a nudge of 0 plus '2' is '02'. Proven end to end: a link made in one browser, opened in a clean context with no saved settings, rendering the same code.
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -12,6 +12,19 @@ ADR-007 and ADR-002.
|
||||
|
||||
### 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
|
||||
**165 GET routes that answered anybody with no token at all**: the whole
|
||||
asset inventory, every machine, PC, printer and network device, the USB
|
||||
|
||||
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="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
|
||||
|
||||
Reference in New Issue
Block a user