From 5e7e39a24c8092471f176ecce70fab3e2215e403 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 28 Aug 2026 11:28:06 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 13 ++ plugins/tools/frontend/sharelink.js | 116 ++++++++++++++++++ plugins/tools/frontend/sharelink.spec.js | 107 ++++++++++++++++ .../tools/frontend/views/CodeGenerator.vue | 108 +++++++++++++++- 4 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 plugins/tools/frontend/sharelink.js create mode 100644 plugins/tools/frontend/sharelink.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 69208dc9..fadef7d3 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/plugins/tools/frontend/sharelink.js b/plugins/tools/frontend/sharelink.js new file mode 100644 index 00000000..c3210949 --- /dev/null +++ b/plugins/tools/frontend/sharelink.js @@ -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 +} diff --git a/plugins/tools/frontend/sharelink.spec.js b/plugins/tools/frontend/sharelink.spec.js new file mode 100644 index 00000000..4566524b --- /dev/null +++ b/plugins/tools/frontend/sharelink.spec.js @@ -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') + }) +}) diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 834d14b8..eb3266e9 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -2,7 +2,25 @@
+ +
@@ -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