diff --git a/CHANGELOG.md b/CHANGELOG.md index 30191b88..b41042f8 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 @@ -115,6 +128,17 @@ ADR-007 and ADR-002. ### 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 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 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 @@
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; }