Merge remote-tracking branch 'origin/main' into wilm

This commit is contained in:
benjamin.baker
2026-08-25 16:48:23 -04:00
51 changed files with 2806 additions and 608 deletions

View File

@@ -12,6 +12,11 @@ time (NoReferencedTableError). Same shape autogenerate produces. Tables inherit
the connection's default charset, matching how the core chain creates its own.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -23,7 +28,7 @@ depends_on = None
def upgrade():
op.create_table(
create_table_if_missing(
'backuprevisions',
sa.Column('backuprevisionid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
@@ -42,15 +47,15 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('backuprevisionid'),
)
op.create_index('ixbackuprevisionsassetid', 'backuprevisions', ['assetid'])
op.create_index('ixbackuprevisionsbackupkind', 'backuprevisions',
['backupkind'])
op.create_index('ixbackuprevisionscontenthash', 'backuprevisions',
['contenthash'])
create_index_if_missing('ixbackuprevisionsassetid', 'backuprevisions', ['assetid'])
create_index_if_missing('ixbackuprevisionsbackupkind', 'backuprevisions',
['backupkind'])
create_index_if_missing('ixbackuprevisionscontenthash', 'backuprevisions',
['contenthash'])
# The dedup read path is "latest revision for this asset+kind", so the
# composite index is the one that actually gets used on every collector post.
op.create_index('ixbackuprevisionsassetkind', 'backuprevisions',
['assetid', 'backupkind'])
create_index_if_missing('ixbackuprevisionsassetkind', 'backuprevisions',
['assetid', 'backupkind'])
def downgrade():

View File

@@ -16,16 +16,14 @@ from .api import computers_bp
logger = logging.getLogger(__name__)
# Marker stamped on PC->printer links this collector creates. The stale-link
# archive only touches rows carrying this label, so manually-created printer
# relationships are never removed by a collector push. Stored in the
# assetrelationships.label column (no origin column exists; see BUILD notes).
PRINTER_LINK_ORIGIN = 'collector:printers'
# Marker stamped on PC->measuringtool "controls" links this collector creates
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived
# by a collector push, so hand-made tool links survive.
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Only rows carrying
# this label are archived by a collector push, so hand-made tool links survive.
# Stored in assetrelationships.label (no origin column exists; see BUILD notes).
#
# There is deliberately no printer equivalent any more. PRINTER_LINK_ORIGIN went
# with _sync_printer_links: printer ASSIGNMENT is never collected, so there are
# no collector-owned printer rows left to label or archive.
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
# Marker stamped on the PC->machine "controls" link built from the reported
@@ -363,26 +361,10 @@ class ComputersPlugin(BasePlugin):
'type': 'array',
'items': {'name': 'string', 'version': 'string'},
},
# Printer identifiers reported by the GE-Enforce side, which
# runs Get-CimInstance Win32_Printer and marks the default with
# the Default flag. An identifier is the printer's windows name,
# share name, hostname, or port/IP; the collector resolves it
# flexibly to a printer asset. Both optional. Presence of either
# key drives the PC->printer relationship sync (and stale-link
# archive); absence leaves existing printer links untouched.
'defaultprinter': {
'type': 'string',
'description': ('Default printer identifier (Win32_Printer '
'with Default=true): windows name, share '
'name, hostname, or port IP.'),
},
'printers': {
'type': 'array',
'items': {'type': 'string'},
'description': ('All installed network printer identifiers '
'(Win32_Printer): windows name / share / '
'hostname / IP. Unresolved -> warning.'),
},
# RETIRED: 'defaultprinter' and 'printers' used to live here and
# wrote PC->printer assignment rows from a report. That is the
# one thing this path must never do - see _RETIRED_PRINTER_KEYS.
# A client still sending them is warned, not rejected.
'accessprotocols': {
'type': 'array',
'items': {'type': 'string'},
@@ -647,8 +629,9 @@ class ComputersPlugin(BasePlugin):
else:
machinelinks = []
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
# Printer assignment is NOT collected. See _warn_retired_printer_keys.
self._warn_retired_printer_keys(payload, warnings)
printerlinks = []
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
# get an attached MeasuringTool asset auto-created and linked.
@@ -742,134 +725,38 @@ class ComputersPlugin(BasePlugin):
AccessProtocol.protocolid.in_(wanted)).all()] if wanted else []
return sorted(names)
def _sync_printer_links(self, pcasset, payload, warnings):
"""Idempotently sync PC->printer relationships from collector printer data.
def _warn_retired_printer_keys(self, payload, warnings):
"""Refuse to turn a report into an assignment, out loud.
Default printer -> 'defaultprinter' (directional). Other reported
printers -> 'connectedto' (symmetric). Resolves each identifier to a
printer asset by windows name / share / hostname / asset number / name
or a communications IP. Unresolved identifiers add a warning and never
fail the push.
RETIRED 2026-08-24. This used to be _sync_printer_links, which wrote
'defaultprinter' and 'connectedto' rows straight from a collector
payload. That contradicted the rule the whole printer feature rests on:
ASSIGNED and OBSERVED never mix. A bay that installed the wrong printer
would have made itself right simply by reporting it, and enforcement
would stop meaning anything.
Stale-link archive: on each push, collector-tagged links (label ==
PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported
desired set are set inactive. Only tagged rows are touched, so manual
links survive. Runs only when the payload carried a printer key
('defaultprinter' or 'printers'); a PC that reports without printer data
keeps its existing links. Returns the desired-link list (created + kept).
Worse than dormant, it was ADVERTISED: the collector schema listed both
keys and COLLECTOR-INTEGRATION.md's example client set defaultprinter,
while the same document promised on another page that nothing on this
path writes an assignment row. The document contradicted itself and the
code sided with the wrong half.
Observed queues have their own path (POST /api/collector/printers) and
become assignments only when a person asks, through
seed-from-observed after reviewing the comparison.
WARNS RATHER THAN REJECTS. Unknown keys are not validated here, so a
client written against the old docs keeps pushing successfully; only its
printer keys stop doing anything. Silently ignoring them would leave an
integrator believing assignments were being written.
"""
from shopdb.api import (
AssetRelationship, RelationshipType, Asset, Communication)
has_default = 'defaultprinter' in payload
has_list = 'printers' in payload
if not has_default and not has_list:
return []
try:
from plugins.printers.models import Printer
except ImportError:
warnings.append('printers plugin unavailable; printer links skipped')
return []
dp_type = RelationshipType.query.filter_by(
relationshiptype='defaultprinter').first()
ct_type = RelationshipType.query.filter_by(
relationshiptype='connectedto').first()
if not dp_type or not ct_type:
warnings.append('printer relationship types missing; '
'run flask seed reference-data')
return []
def resolve(identifier):
# first match wins: printer text identity, then a printer IP.
ident = (identifier or '').strip()
if not ident:
return None
printer = (
Printer.query.join(Asset, Asset.assetid == Printer.assetid)
.filter(Asset.isactive == True)
.filter(db.or_(
Printer.windowsname.ilike(ident),
Printer.hostname.ilike(ident),
Printer.sharename.ilike(ident),
Asset.assetnumber.ilike(ident),
Asset.name.ilike(ident),
)).first())
if printer:
return printer.asset
comm = (
db.session.query(Communication)
.join(Printer, Printer.assetid == Communication.assetid)
.filter(Communication.ipaddress == ident)
.first())
if comm:
return db.session.get(Asset, comm.assetid)
return None
pcid = pcasset.assetid
desired = set() # (targetassetid, relationshiptypeid) to keep
printerlinks = []
default_id = None
default_ident = (payload.get('defaultprinter') or '').strip()
if default_ident:
target = resolve(default_ident)
if target:
default_id = target.assetid
desired.add((default_id, dp_type.relationshiptypeid))
self._sync_one(pcid, default_id, dp_type)
printerlinks.append({'assetid': default_id,
'relationshiptype': 'defaultprinter'})
else:
warnings.append(f'unresolved default printer: {default_ident}')
for ident in payload.get('printers') or []:
target = resolve(ident)
if not target:
warnings.append(f'unresolved printer: {ident}')
continue
if target.assetid == default_id:
continue # already the default link
desired.add((target.assetid, ct_type.relationshiptypeid))
self._sync_one(pcid, target.assetid, ct_type)
printerlinks.append({'assetid': target.assetid,
'relationshiptype': 'connectedto'})
# Archive collector-tagged links no longer reported (manual links, with
# a NULL/other label, are never matched here).
collector_rows = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcid,
AssetRelationship.relationshiptypeid.in_(
[dp_type.relationshiptypeid, ct_type.relationshiptypeid]),
AssetRelationship.isactive == True,
AssetRelationship.label == PRINTER_LINK_ORIGIN,
).all()
for rel in collector_rows:
if (rel.targetassetid, rel.relationshiptypeid) not in desired:
rel.isactive = False
return printerlinks
def _sync_one(self, pcid, printerid, reltype):
"""Reactivate or create one collector PC->printer link (idempotent)."""
from shopdb.api import AssetRelationship
existing = AssetRelationship.query.filter_by(
sourceassetid=pcid, targetassetid=printerid,
relationshiptypeid=reltype.relationshiptypeid).first()
if existing:
# do not re-stamp label: a pre-existing manual row stays manual.
if not existing.isactive:
existing.isactive = True
return existing
rel = AssetRelationship(
sourceassetid=pcid, targetassetid=printerid,
relationshiptypeid=reltype.relationshiptypeid,
label=PRINTER_LINK_ORIGIN)
db.session.add(rel)
return rel
# -- measuring-tool sync -----------------------------------------------
sent = [key for key in ('defaultprinter', 'printers') if key in payload]
if sent:
warnings.append(
'%s ignored: a collector report never sets printer assignment. '
'Report queues to POST /api/collector/printers, then adopt them '
'with POST /api/printers/assignments/seed-from-observed.'
% ' and '.join(sent))
def _sync_machine_link(self, comp, machinenumber, warnings,
origin_label=MACHINE_LINK_ORIGIN, preview=False):

View File

@@ -8,6 +8,11 @@ tables are created parent-first. computertypeid / measuringtooltypeid /
publishedby are SOFT references (plain columns, no FK) to optional plugins/core.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -19,7 +24,7 @@ depends_on = None
def upgrade():
op.create_table(
create_table_if_missing(
'manifestscopes',
sa.Column('scopeid', sa.Integer(), nullable=False),
sa.Column('scopename', sa.String(length=64), nullable=False),
@@ -37,7 +42,7 @@ def upgrade():
sa.PrimaryKeyConstraint('scopeid'),
sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
)
op.create_table(
create_table_if_missing(
'manifestentries',
sa.Column('entryid', sa.Integer(), nullable=False),
sa.Column('scopeid', sa.Integer(), nullable=False),
@@ -80,10 +85,10 @@ def upgrade():
sa.PrimaryKeyConstraint('entryid'),
sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
)
op.create_index('idx_entry_scope', 'manifestentries', ['scopeid'])
op.create_index('idx_entry_scope_order', 'manifestentries',
['scopeid', 'sortorder'])
op.create_table(
create_index_if_missing('idx_entry_scope', 'manifestentries', ['scopeid'])
create_index_if_missing('idx_entry_scope_order', 'manifestentries',
['scopeid', 'sortorder'])
create_table_if_missing(
'manifestentrypctypes',
sa.Column('entrypctypeid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
@@ -93,8 +98,8 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entrypctypeid'),
)
op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
op.create_table(
create_index_if_missing('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
create_table_if_missing(
'manifestentryhostnames',
sa.Column('entryhostnameid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
@@ -104,8 +109,8 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entryhostnameid'),
)
op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
op.create_table(
create_index_if_missing('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
create_table_if_missing(
'manifestentrymachinenumbers',
sa.Column('entrymachinenumberid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
@@ -115,9 +120,9 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entrymachinenumberid'),
)
op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers',
['entryid'])
op.create_table(
create_index_if_missing('idx_machinenumber_entry', 'manifestentrymachinenumbers',
['entryid'])
create_table_if_missing(
'manifestinusechecks',
sa.Column('inusecheckid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
@@ -127,7 +132,7 @@ def upgrade():
sa.PrimaryKeyConstraint('inusecheckid'),
sa.UniqueConstraint('entryid'),
)
op.create_table(
create_table_if_missing(
'manifestinusecheckprocesses',
sa.Column('inusecheckprocessid', sa.Integer(), nullable=False),
sa.Column('inusecheckid', sa.Integer(), nullable=False),
@@ -140,9 +145,9 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('inusecheckprocessid'),
)
op.create_index('idx_process_check', 'manifestinusecheckprocesses',
['inusecheckid'])
op.create_table(
create_index_if_missing('idx_process_check', 'manifestinusecheckprocesses',
['inusecheckid'])
create_table_if_missing(
'manifestpublishedversions',
sa.Column('publishedversionid', sa.Integer(), nullable=False),
sa.Column('scopeid', sa.Integer(), nullable=False),
@@ -158,9 +163,9 @@ def upgrade():
sa.UniqueConstraint('scopeid', 'versionnumber',
name='uq_published_scope_version'),
)
op.create_index('idx_published_scope', 'manifestpublishedversions',
['scopeid'])
op.create_table(
create_index_if_missing('idx_published_scope', 'manifestpublishedversions',
['scopeid'])
create_table_if_missing(
'manifestpayloads',
sa.Column('payloadid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
@@ -173,8 +178,8 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('payloadid'),
)
op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid'])
op.create_table(
create_index_if_missing('idx_payload_entry', 'manifestpayloads', ['entryid'])
create_table_if_missing(
'manifestenforcementreports',
sa.Column('reportid', sa.Integer(), nullable=False),
sa.Column('hostname', sa.String(length=100), nullable=False),
@@ -192,13 +197,13 @@ def upgrade():
sa.Column('iscurrent', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('reportid'),
)
op.create_index('idx_report_host', 'manifestenforcementreports',
['hostname'])
op.create_index('idx_report_current', 'manifestenforcementreports',
['iscurrent'])
op.create_index('idx_report_host_scope', 'manifestenforcementreports',
['hostname', 'scopename', 'phase'])
op.create_table(
create_index_if_missing('idx_report_host', 'manifestenforcementreports',
['hostname'])
create_index_if_missing('idx_report_current', 'manifestenforcementreports',
['iscurrent'])
create_index_if_missing('idx_report_host_scope', 'manifestenforcementreports',
['hostname', 'scopename', 'phase'])
create_table_if_missing(
'manifestenforcementresults',
sa.Column('resultid', sa.Integer(), nullable=False),
sa.Column('reportid', sa.Integer(), nullable=False),
@@ -212,9 +217,9 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('resultid'),
)
op.create_index('idx_result_report', 'manifestenforcementresults',
['reportid'])
op.create_table(
create_index_if_missing('idx_result_report', 'manifestenforcementresults',
['reportid'])
create_table_if_missing(
'pctypealiases',
sa.Column('aliasid', sa.Integer(), nullable=False),
sa.Column('aliasgroup', sa.Integer(), nullable=False),
@@ -223,7 +228,7 @@ def upgrade():
sa.UniqueConstraint('aliasgroup', 'aliasname',
name='uq_alias_group_name'),
)
op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup'])
create_index_if_missing('idx_alias_group', 'pctypealiases', ['aliasgroup'])
def downgrade():

View File

@@ -10,6 +10,11 @@ Create Date: 2026-07-21
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -24,7 +29,7 @@ def upgrade():
insp = sa.inspect(bind)
if 'manifestblobs' in insp.get_table_names():
return
op.create_table(
create_table_if_missing(
'manifestblobs',
sa.Column('sha256', sa.String(length=64), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),

View File

@@ -379,6 +379,7 @@ import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { apiError } from '@/utils/apiError'
import { planControllingPcChange } from './controllingPc'
const { isEnabled } = useIdentifierFlags()
@@ -440,6 +441,9 @@ const relationshipTypes = ref([])
const controllingPcId = ref(null)
const relationshipTypeId = ref(null)
const existingRelationshipId = ref(null)
// Which PC the link named when the form loaded, so an unchanged one can be left
// alone. Editing a machine is mostly editing something else about it.
const loadedControllingPcId = ref(null)
const currentMachine = ref(null)
// Filter models by selected machine vendor
@@ -575,7 +579,16 @@ onMounted(async () => {
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
controllingPcId.value = rel.sourceasset.assetid
relationshipTypeId.value = rel.relationshiptypeid
existingRelationshipId.value = rel.assetrelationshipid
// relationshipid. `assetrelationshipid` is not a key the API has
// ever emitted - to_dict serialises the column names and the
// column is relationshipid - so this read undefined every time,
// and saveRelationship then took the "there is nothing to
// replace" path on a machine that had a controlling PC. Saving
// ANY change, a map position included, re-POSTed the link that
// was already there and the server correctly answered 409 "this
// relationship already exists".
existingRelationshipId.value = rel.relationshipid
loadedControllingPcId.value = rel.sourceasset.assetid
break
}
}
@@ -693,31 +706,40 @@ async function saveMachine() {
}
async function saveRelationship(assetId) {
// If no PC selected and no existing relationship, nothing to do
if (!controllingPcId.value && !existingRelationshipId.value) {
// The decision lives in controllingPc.js, where it is covered by tests.
// See that file for why "is there a link" and "do I know its id" are two
// different questions and what happened when they were treated as one.
const plan = planControllingPcChange({
selected: controllingPcId.value,
loaded: loadedControllingPcId.value,
existingRelationshipId: existingRelationshipId.value,
})
if (plan.action === 'none') {
return
}
// If clearing the relationship
if (!controllingPcId.value && existingRelationshipId.value) {
await assetsApi.deleteRelationship(existingRelationshipId.value)
if (plan.deleteId !== null) {
await assetsApi.deleteRelationship(plan.deleteId)
existingRelationshipId.value = null
loadedControllingPcId.value = null
}
if (plan.action === 'unlink') {
return
}
// If PC is selected
if (controllingPcId.value) {
// If there's an existing relationship, delete it first
if (existingRelationshipId.value) {
await assetsApi.deleteRelationship(existingRelationshipId.value)
}
// PC controls Machine, so the PC is the source and the machine the target.
const created = await assetsApi.createRelationship({
sourceassetid: controllingPcId.value,
targetassetid: assetId,
relationshiptypeid: relationshipTypeId.value
})
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
await assetsApi.createRelationship({
sourceassetid: controllingPcId.value,
targetassetid: assetId,
relationshiptypeid: relationshipTypeId.value
})
}
// Keep the form honest about what is now stored, so a second save without a
// reload does not repeat the create it just did.
existingRelationshipId.value = created?.data?.data?.relationshipid ?? null
loadedControllingPcId.value = controllingPcId.value
}
</script>

View File

@@ -0,0 +1,68 @@
// What saving the machine form should do about its controlling-PC link.
//
// Pulled out of MachineForm.vue because the component decided this inline and
// got it wrong in a way nothing could catch: it treated "I know the id of the
// existing link" as "there IS an existing link". The id was read from
// `rel.assetrelationshipid`, a key the API has never emitted - the column is
// `relationshipid` and to_dict serialises column names - so it was undefined on
// every load, and the two questions gave opposite answers on a machine that
// plainly had a controlling PC.
//
// Both failures followed from that single conflation:
// - Saving ANY change, a map position included, re-POSTed the link that was
// already there. The server answered 409 "this relationship already
// exists", correctly, and the form surfaced it as a failed save.
// - Clearing the controlling PC did nothing at all, because "no selection and
// no known id" reads as "nothing to do".
//
// So THE LOADED PC IS THE AUTHORITY on whether a link exists, and the
// relationship id is only ever used to delete a row. If the id goes missing
// again the worst case is a link that cannot be removed, never a duplicate
// create.
/**
* Decide what to do with the controlling-PC link on save.
*
* @param {object} state
* @param {number|string|null} state.selected - PC id chosen in the form now.
* @param {number|string|null} state.loaded - PC id the link named on load.
* @param {number|null} state.existingRelationshipId - row id to delete, if known.
* @returns {{action: 'none'|'unlink'|'link'|'relink', deleteId: number|null}}
*/
export function planControllingPcChange({ selected, loaded, existingRelationshipId } = {}) {
const now = normalize(selected)
const before = normalize(loaded)
const deleteId = existingRelationshipId ?? null
// Unchanged is not a change. Somebody moving a marker on the map is not
// asking to be re-linked to the same PC, and delete-then-create on an
// untouched link opens a window where the machine has no controlling PC -
// which resolve_asset_position walks across, so a failure between the two
// calls would lose the machine's inherited position along with the link.
if (now === before) {
return { action: 'none', deleteId: null }
}
if (now === null) {
// Cleared. Nothing to delete means nothing to do, rather than an error:
// the link is already absent, which is the state being asked for.
return deleteId === null
? { action: 'none', deleteId: null }
: { action: 'unlink', deleteId }
}
if (before === null) {
return { action: 'link', deleteId: null }
}
// Swapped to a different PC. The old row goes first: 'controls' is singular
// per source, not per target, so leaving it would leave the machine with two.
return { action: 'relink', deleteId }
}
// Ids arrive as numbers from the API and strings from a <select>, and '' means
// "nothing chosen". Comparing those raw is how an unchanged PC looks changed.
function normalize(value) {
if (value === null || value === undefined || value === '') return null
return String(value)
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest'
import { planControllingPcChange } from './controllingPc'
// Reported: moving machine 3016 on the map, then saving, answered "this
// relationship already exists" and refused the save. The machine had a
// controlling PC and the form re-created that link on every save, because it
// asked "do I know the existing link's id" instead of "is there an existing
// link" - and the id was read from a key the API does not emit, so it was
// always undefined.
//
// The first two specs are that bug. The rest pin the behaviour it was hiding.
describe('planControllingPcChange', () => {
it('does nothing when the controlling PC is unchanged', () => {
// The reported case: only the map position moved.
expect(planControllingPcChange({
selected: 42, loaded: 42, existingRelationshipId: 7,
})).toEqual({ action: 'none', deleteId: null })
})
it('still does nothing when the existing row id was never learned', () => {
// The exact shape of the bug: the link is there, its id is not known. The
// answer must be "leave it alone", never "create it again" - a create here
// is the 409.
expect(planControllingPcChange({
selected: 42, loaded: 42, existingRelationshipId: undefined,
})).toEqual({ action: 'none', deleteId: null })
})
it('removes the link when the PC is cleared', () => {
// This never fired before either: no selection and no known id read as
// "nothing to do", so clearing a controlling PC silently kept it.
expect(planControllingPcChange({
selected: null, loaded: 42, existingRelationshipId: 7,
})).toEqual({ action: 'unlink', deleteId: 7 })
})
it('creates a link when there was none', () => {
expect(planControllingPcChange({
selected: 42, loaded: null, existingRelationshipId: null,
})).toEqual({ action: 'link', deleteId: null })
})
it('replaces the old row when the PC is swapped', () => {
// controls is singular per SOURCE, not per target, so the old row has to go
// or the machine ends up with two controlling PCs.
expect(planControllingPcChange({
selected: 99, loaded: 42, existingRelationshipId: 7,
})).toEqual({ action: 'relink', deleteId: 7 })
})
it('treats a select string and an API number as the same PC', () => {
// A <select> hands back '42' while the relationship carried 42. Comparing
// those raw makes an untouched link look like a swap, which is the same
// duplicate POST by another route.
expect(planControllingPcChange({
selected: '42', loaded: 42, existingRelationshipId: 7,
})).toEqual({ action: 'none', deleteId: null })
})
it('treats an empty selection as cleared, not as a PC named ""', () => {
expect(planControllingPcChange({
selected: '', loaded: 42, existingRelationshipId: 7,
})).toEqual({ action: 'unlink', deleteId: 7 })
})
it('does nothing when there was no PC and none was chosen', () => {
expect(planControllingPcChange({
selected: null, loaded: null, existingRelationshipId: null,
})).toEqual({ action: 'none', deleteId: null })
})
it('does not attempt a delete it cannot perform', () => {
// Cleared, but the row id was never learned. Asking the API to delete
// `undefined` produced a request against /relationships/undefined.
expect(planControllingPcChange({
selected: null, loaded: 42, existingRelationshipId: undefined,
})).toEqual({ action: 'none', deleteId: null })
})
})

View File

@@ -17,6 +17,11 @@ connection's default charset, so on a utf8mb4 database they are utf8mb4, matchin
how the core chain creates its tables.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -28,7 +33,7 @@ depends_on = None
def upgrade():
op.create_table(
create_table_if_missing(
'measuringtooltypes',
sa.Column('measuringtooltypeid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
@@ -40,7 +45,7 @@ def upgrade():
sa.PrimaryKeyConstraint('measuringtooltypeid'),
sa.UniqueConstraint('name'),
)
op.create_table(
create_table_if_missing(
'measuringtools',
sa.Column('measuringtoolid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
@@ -59,8 +64,8 @@ def upgrade():
sa.PrimaryKeyConstraint('measuringtoolid'),
sa.UniqueConstraint('assetid'),
)
op.create_index('idx_measuringtool_type', 'measuringtools',
['measuringtooltypeid'])
create_index_if_missing('idx_measuringtool_type', 'measuringtools',
['measuringtooltypeid'])
def downgrade():

View File

@@ -11,6 +11,11 @@ ops are written out explicitly anyway to match the measuringtools exemplar
and keep the baseline reviewable.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -22,7 +27,7 @@ depends_on = None
def upgrade():
op.create_table(
create_table_if_missing(
'printeditems',
sa.Column('printeditemid', sa.Integer(), nullable=False),
sa.Column('itemcode', sa.String(length=20), nullable=True),
@@ -39,9 +44,9 @@ def upgrade():
sa.PrimaryKeyConstraint('printeditemid'),
sa.UniqueConstraint('itemcode'),
)
op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
create_index_if_missing('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
op.create_table(
create_table_if_missing(
'printeditemtransactions',
sa.Column('transactionid', sa.Integer(), nullable=False),
sa.Column('printeditemid', sa.Integer(), nullable=False),
@@ -58,12 +63,12 @@ def upgrade():
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('transactionid'),
)
op.create_index('ix_printeditemtransactions_printeditemid',
'printeditemtransactions', ['printeditemid'])
op.create_index('ix_printeditemtransactions_employeesso',
'printeditemtransactions', ['employeesso'])
op.create_index('ix_printeditemtransactions_transactiondate',
'printeditemtransactions', ['transactiondate'])
create_index_if_missing('ix_printeditemtransactions_printeditemid',
'printeditemtransactions', ['printeditemid'])
create_index_if_missing('ix_printeditemtransactions_employeesso',
'printeditemtransactions', ['employeesso'])
create_index_if_missing('ix_printeditemtransactions_transactiondate',
'printeditemtransactions', ['transactiondate'])
def downgrade():

View File

@@ -5,6 +5,11 @@ the ADR-008 payoff: the plugin evolves its own schema without touching
the core chain. Applied by `flask plugin upgrade-all`.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -16,7 +21,7 @@ depends_on = None
def upgrade():
op.create_table(
create_table_if_missing(
'printeditemfiles',
sa.Column('fileid', sa.Integer(), nullable=False),
sa.Column('printeditemid', sa.Integer(), nullable=False),
@@ -35,8 +40,8 @@ def upgrade():
sa.PrimaryKeyConstraint('fileid'),
sa.UniqueConstraint('storedfilename'),
)
op.create_index('ix_printeditemfiles_printeditemid',
'printeditemfiles', ['printeditemid'])
create_index_if_missing('ix_printeditemfiles_printeditemid',
'printeditemfiles', ['printeditemid'])
def downgrade():

View File

@@ -4,6 +4,11 @@ Stores the last tier alerted per (printer, supply) so the poller fires once
on a downward crossing and re-arms after a refill. Idempotent create.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -19,7 +24,7 @@ def upgrade():
inspector = sa.inspect(bind)
if 'printersupplyalerts' in inspector.get_table_names():
return
op.create_table(
create_table_if_missing(
'printersupplyalerts',
sa.Column('printersupplyalertid', sa.Integer(), primary_key=True),
sa.Column('printerid', sa.Integer(), nullable=False),
@@ -35,8 +40,8 @@ def upgrade():
sa.UniqueConstraint('printerid', 'supplykey',
name='uq_printersupplyalert_printer_key'),
)
op.create_index('ix_printersupplyalerts_printerid',
'printersupplyalerts', ['printerid'])
create_index_if_missing('ix_printersupplyalerts_printerid',
'printersupplyalerts', ['printerid'])
def downgrade():

View File

@@ -21,6 +21,11 @@ Revision ID: printers0005observedqueues
Revises: printers0004drivervendor
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -36,7 +41,7 @@ def upgrade():
inspector = sa.inspect(bind)
if 'printerobservedqueues' in inspector.get_table_names():
return
op.create_table(
create_table_if_missing(
'printerobservedqueues',
sa.Column('printerobservedqueueid', sa.Integer(), nullable=False),
# Nullable and no index of its own beyond the explicit one below: an
@@ -67,10 +72,10 @@ def upgrade():
)
# Port address is the primary match key from an observed queue back to a
# printer asset, so every comparison read hits it.
op.create_index('idx_printerobservedqueues_portaddress',
'printerobservedqueues', ['portaddress'])
op.create_index('idx_printerobservedqueues_assetid',
'printerobservedqueues', ['assetid'])
create_index_if_missing('idx_printerobservedqueues_portaddress',
'printerobservedqueues', ['portaddress'])
create_index_if_missing('idx_printerobservedqueues_assetid',
'printerobservedqueues', ['assetid'])
def downgrade():

View File

@@ -6,9 +6,15 @@ on first read needs somewhere to keep the ciphertext until then.
TWO DIFFERENT DOORS, on purpose.
CREATE needs a login and a permission. Anonymous creation would be an
untraceable way to move data out of a system that otherwise records who did
what, and the creator is the only person these rows can ever name.
CREATE needs a LOGIN and nothing more. Sharing a password is something anybody
at the site may legitimately need to do, and a permission somebody has to be
granted first turns a two-minute job into a ticket - which is how people end up
pasting credentials into chat instead.
The login stays, though, and is not a formality: anonymous creation would make
this an unattributable write endpoint that stores arbitrary ciphertext, and the
creator is the only person these rows can ever name. Everybody browsing this app
is already signed in, so requiring it costs nothing.
READ needs neither, because the recipient usually has no account here and the
link is normally handed over in a call or a chat. The unguessable token IS the
@@ -27,12 +33,12 @@ import secrets as pysecrets
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from flask import current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db,
db, cache, Setting,
success_response, error_response, ErrorCodes,
require_permission,
)
from ..models import SecureSecret
@@ -50,18 +56,113 @@ MAX_CIPHERTEXT = 4096
DEFAULT_EXPIRY_HOURS = 24
MAX_EXPIRY_HOURS = 168
MAX_VIEWS = 5
# Generous, because "how many people need this password" is the site's business
# and not a number worth arguing with. The floor matters more than the ceiling:
# a secret readable an unlimited number of times is a password on a web page.
MAX_VIEWS = 100
# Guest creation, when a site enables it, is an unauthenticated write. Bounded
# per address so it cannot be used to fill a table or park data: a person
# sharing passwords does it a handful of times an hour, not hundreds.
GUEST_CREATE_MAX = 10
GUEST_CREATE_WINDOW_SECONDS = 600
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _sweep_expired():
"""Delete secrets whose window has passed, whoever asked.
Redeeming removes a row, and so does touching an expired one - but a secret
nobody ever opens was removed by NOTHING. The recipient goes on holiday, the
link is never clicked, and the ciphertext sits there for good: not a
disclosure risk, since it cannot be read, but it makes "nothing lingers"
untrue and grows the table in exactly the case where the feature failed.
Swept opportunistically rather than on a schedule: creating a secret is
authenticated and infrequent, and an air-gapped site is not going to have a
cron job anybody remembers to add.
"""
stale = SecureSecret.query.filter(SecureSecret.expiresat < _utcnow()).all()
for secret in stale:
db.session.delete(secret)
return len(stale)
def _guests_allowed():
"""May somebody with no account create a secret? Default NO.
Read straight from the row rather than the settings cache: this decides
whether an unauthenticated caller may write, and being wrong on the
permissive side for five minutes is the wrong way round to be wrong.
"""
try:
row = Setting.query.filter_by(key='securesend_allow_guests').first()
except Exception:
# FAIL CLOSED. If the setting cannot be read - the row is missing, the
# table is not there yet on a half-migrated site, the database is
# unhappy - the answer is no. An error must never be the thing that
# opens an unauthenticated write endpoint.
current_app.logger.exception('could not read securesend_allow_guests')
return False
return bool(row and str(row.value).strip().lower() in ('true', '1', 'yes'))
def _guest_ratelimited():
"""Bounded guest creation, per address, in a fixed window.
Same shape as the login limiter. Not a security boundary - a determined
person has more addresses - but it stops an open endpoint being an easy
place to park data or fill a disk.
"""
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr or 'unknown')
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
key = f'securesendguest:{address}:{bucket}'
used = cache.get(key) or 0
if used >= GUEST_CREATE_MAX:
return True
cache.set(key, used + 1, timeout=GUEST_CREATE_WINDOW_SECONDS)
return False
@tools_bp.route('/secrets', methods=['POST'])
@jwt_required()
@require_permission('tools.secrets')
@jwt_required(optional=True)
def create_secret():
"""Store an already-encrypted secret and return its token."""
"""Store an already-encrypted secret and return its token.
OPTIONAL jwt so the SETTING decides rather than the decorator: a guest has
to reach the handler to be told whether this site lets them in.
"""
# WHO MAY CREATE. Signed in: the tools.secrets permission, with the usual
# admin bypass. Not signed in: only when the site has deliberately opened
# it, and then within a rate limit.
# get_jwt_identity(), NOT `current_user is None`: current_user is a
# LocalProxy that is never None itself, so that test silently took the
# signed-in branch for a guest and then blew up dereferencing it.
identity = get_jwt_identity()
if identity is None:
if not _guests_allowed():
return error_response(
ErrorCodes.UNAUTHORIZED,
'Sign in to create a secret link.',
http_code=401)
if _guest_ratelimited():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Too many links from this address. Try again shortly.',
http_code=429)
elif not current_user.haspermission('tools.secrets'):
return error_response(
ErrorCodes.FORBIDDEN,
'You do not have permission to create secret links.',
http_code=403)
# Tidy up on the way past. Cheap, and it keeps the table bounded without a
# scheduler existing anywhere.
_sweep_expired()
data = request.get_json(silent=True) or {}
ciphertext = (data.get('ciphertext') or '').strip()
iv = (data.get('iv') or '').strip()
@@ -173,4 +274,9 @@ def peek_secret(token):
"""
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
live = bool(secret and not secret.is_expired() and not secret.is_spent())
if secret is not None and not live:
# A late click tidies its own row, so a dead secret does not need
# somebody to create a new one before it is cleaned up.
db.session.delete(secret)
db.session.commit()
return success_response({'available': live})

View File

@@ -0,0 +1,23 @@
// Tech Tools API client.
//
// Only secure send needs one - every other tool runs entirely in the browser
// and talks to nothing.
import api from '@/api'
export const toolsApi = {
createSecret(payload) {
return api.post('/tools/secrets', payload)
},
// Is the link still live? Spends nothing, so it is safe for the read page to
// ask before anyone clicks.
peekSecret(token) {
return api.get(`/tools/secrets/${token}`)
},
// POST, not GET, and that is deliberate: chat and mail clients prefetch GET
// urls to build previews, and a preview must not consume a one-view secret.
redeemSecret(token) {
return api.post(`/tools/secrets/${token}`)
},
}

View File

@@ -15,12 +15,48 @@ export default [
path: 'tools',
name: 'tools',
component: () => import('./views/ToolsIndex.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
// NO requiresAuth. Every tool here is usable without an account: the label
// generator runs entirely in the browser, and secure send decides for
// itself (a site can let guests create, and receiving never needs a login).
// Gating the index turned a public tool into one nobody could reach.
meta: { plugin: 'tools' }
},
{
path: 'tools/codes',
name: 'tools-codes',
component: () => import('./views/CodeGenerator.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
// Browser-only: no API, no data, nothing to protect.
meta: { plugin: 'tools' }
},
{
path: 'settings/secure-send',
name: 'secure-send-settings',
component: () => import('./views/SecureSendSettings.vue'),
// Admin, like every other settings page: this decides whether an
// unauthenticated caller may write to the site.
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'tools' }
},
{
path: 'tools/secure-send',
name: 'tools-secure-send',
component: () => import('./views/SecureSendCreate.vue'),
// Reachable by anyone; the SERVER decides who may actually create. A signed
// in user needs tools.secrets, and a guest needs the site to have turned on
// securesend_allow_guests. The page reads that setting so a guest who
// cannot create is told so, instead of being handed a form that 401s.
meta: { plugin: 'tools' }
},
]
// PUBLIC and OUTSIDE AppLayout. The recipient of a secret usually has no account
// here, so this page carries no navigation and asks for no login - the
// unguessable token in the path, and the key in the fragment, are what let them
// read it. Short path because the whole link gets pasted into a chat message.
export const toplevel = [
{
path: '/s/:token',
name: 'secure-send-read',
component: () => import('./views/SecureSendRead.vue'),
meta: { plugin: 'tools' }
},
]

View File

@@ -0,0 +1,136 @@
// Client-side crypto for secure send.
//
// THE KEY NEVER LEAVES THIS FILE'S PROCESS. It is generated here, put in the
// URL fragment, and read back out of the fragment on the other side. Browsers
// do not transmit fragments, so it reaches the recipient's browser and nowhere
// else - not the server, not its logs, not a proxy in between. That is what
// makes the stored ciphertext useless to anyone who reads the database.
//
// AES-GCM because it authenticates as well as encrypts: a tampered ciphertext
// fails to decrypt rather than producing plausible rubbish.
//
// Web Crypto only, no dependency. It is available on every browser the fleet
// runs and requires a secure context, which is the right constraint anyway -
// the page and its scripts still cross the network even though the key does not.
const ALGORITHM = 'AES-GCM'
const KEY_BITS = 256
const IV_BYTES = 12
function toBase64(bytes) {
let binary = ''
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
return btoa(binary)
}
function fromBase64(text) {
const binary = atob(text)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return bytes
}
// base64url for the fragment: '+' and '/' are legal in a fragment but survive
// copy-paste and link rewriting far less reliably than the url-safe alphabet.
function toBase64Url(bytes) {
return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function fromBase64Url(text) {
const padded = text.replace(/-/g, '+').replace(/_/g, '/')
return fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4))
}
export function cryptoAvailable() {
return typeof crypto !== 'undefined' && !!crypto.subtle
}
/** Encrypt text. Returns the payload for the server AND the key for the link. */
export async function sealSecret(plaintext) {
const key = await crypto.subtle.generateKey(
{ name: ALGORITHM, length: KEY_BITS }, true, ['encrypt', 'decrypt'])
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES))
const ciphertext = await crypto.subtle.encrypt(
{ name: ALGORITHM, iv }, key, new TextEncoder().encode(plaintext))
const raw = await crypto.subtle.exportKey('raw', key)
return {
ciphertext: toBase64(ciphertext),
iv: toBase64(iv),
// Goes in the fragment. Never sent anywhere.
fragmentKey: toBase64Url(raw),
}
}
/** Decrypt what the server handed back, using the key from the fragment. */
export async function openSecret({ ciphertext, iv, fragmentKey }) {
const key = await crypto.subtle.importKey(
'raw', fromBase64Url(fragmentKey),
{ name: ALGORITHM, length: KEY_BITS }, false, ['decrypt'])
const plaintext = await crypto.subtle.decrypt(
{ name: ALGORITHM, iv: fromBase64(iv) }, key, fromBase64(ciphertext))
return new TextDecoder().decode(plaintext)
}
// -- Keeping this from becoming a document transfer ---------------------------
//
// The server can only bound SIZE, because it cannot read what it holds. These
// run here, where the plaintext is still visible, and are advisory: they guide
// somebody who is about to do the wrong thing without realising. A determined
// person can POST directly, which is why the size limit on the server is the
// control that actually enforces.
// Matches MAX_CIPHERTEXT server-side, minus room for base64 expansion and the
// GCM tag, so the browser refuses before a round trip rather than after one.
export const MAX_PLAINTEXT = 2800
const MAX_LINES = 20
// A pasted file, recognised by how it starts. Cheap, high signal.
const FILE_SIGNATURES = [
['%PDF', 'a PDF'],
['PK\x03\x04', 'a zip or Office file'],
['{\\rtf', 'an RTF document'],
['\x89PNG', 'a PNG image'],
// JPEG magic bytes as escapes rather than literals: the naming gate bans
// non-ASCII source, and these are bytes rather than text anyway.
['\xff\xd8\xff', 'a JPEG image'],
['data:', 'a data URI'],
]
/** Why this text should not be sent, or null when it is fine. */
export function refuseReason(text) {
const value = text || ''
if (!value.trim()) return 'Nothing to send yet.'
if (value.length > MAX_PLAINTEXT) {
return `That is ${value.length} characters. This is for passwords and short `
+ `text, not documents - the limit is ${MAX_PLAINTEXT}.`
}
for (const [signature, description] of FILE_SIGNATURES) {
if (value.startsWith(signature)) {
return `That looks like ${description} pasted as text. Secure send is for `
+ 'credentials, not file transfer.'
}
}
const lines = value.split('\n').length
if (lines > MAX_LINES) {
return `That is ${lines} lines. A password is one - this looks like a `
+ 'document, and secure send is not a file transfer.'
}
return null
}
/**
* Reject a paste that carries FILES rather than text.
*
* Removes the easiest wrong path - dragging a PDF in, or Ctrl+V on a copied
* file - without anyone having to read a rule about it.
*/
export function pasteCarriesFiles(event) {
const items = event?.clipboardData?.items
if (!items) return false
return Array.from(items).some(item => item.kind === 'file')
}

View File

@@ -24,11 +24,21 @@ export const TOOLS = [
standalone: true,
keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'],
},
{
id: 'secure-send',
name: 'Secure Send',
description: 'Hand somebody a password by a link that works once and then destroys it.',
category: 'security',
route: '/tools/secure-send',
keywords: ['password', 'secret', 'credential', 'share', 'link', 'expire',
'one time', 'burn', 'pusher'],
},
]
export const CATEGORY_LABELS = {
labels: 'Labels & Printing',
convert: 'Conversion',
security: 'Secrets & Access',
network: 'Network',
}

View File

@@ -0,0 +1,251 @@
<template>
<div>
<div class="page-header">
<h1>Secure Send</h1>
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
</div>
<div class="card intro">
<p>
Hand somebody a password without leaving it in a chat window. The text is
encrypted in this browser and the key travels in the link, never to the
server - so nobody here can read what you send, including an administrator.
</p>
<p class="muted">
The link works once, then the secret is destroyed. For passwords and short
text only, not documents.
</p>
</div>
<!-- A guest at a site that has not opened guest creation. Told here rather
than after typing a password into a form that was always going to be
refused. -->
<div v-if="supported && !mayCreate" class="card">
<p>Signing in is needed to create a secret link at this site.</p>
<p class="muted">
Receiving one never needs an account - if somebody sent you a link, open
that instead.
</p>
<router-link to="/login" class="btn btn-primary">Sign in</router-link>
</div>
<div v-if="!supported" class="card control-note">
This browser cannot encrypt in page (Web Crypto is unavailable, which
usually means the site is not being served over HTTPS). Secure send needs
it, because the whole point is that the server never sees your text.
</div>
<template v-else-if="mayCreate && !link">
<div class="card">
<label class="field">
<span class="field-label">Secret</span>
<textarea
v-model="plaintext"
class="form-control"
rows="4"
placeholder="Password, connection string, short note..."
@paste="onPaste"
></textarea>
</label>
<p class="counter" :class="{ over: plaintext.length > MAX_PLAINTEXT }">
{{ plaintext.length }} / {{ MAX_PLAINTEXT }} characters
</p>
<p v-if="refusal" class="control-note">{{ refusal }}</p>
<div class="options">
<label class="field">
<span class="field-label">Expires after</span>
<select v-model.number="expiryhours" class="form-control">
<option :value="1">1 hour</option>
<option :value="8">8 hours</option>
<option :value="24">1 day</option>
<option :value="72">3 days</option>
<option :value="168">7 days</option>
</select>
</label>
<label class="field">
<span class="field-label">Can be opened</span>
<input
v-model.number="views"
type="number"
class="form-control"
min="1"
:max="MAX_VIEWS"
/>
<span class="field-hint">
{{ views === 1 ? 'Once, then destroyed' : views + ' times, then destroyed' }}
(1 to {{ MAX_VIEWS }})
</span>
</label>
</div>
<button class="btn btn-primary" :disabled="busy || !!refusal" @click="createLink">
{{ busy ? 'Encrypting...' : 'Create link' }}
</button>
<p v-if="error" class="control-note">{{ error }}</p>
</div>
</template>
<div v-else class="card">
<h3>Your link</h3>
<p class="muted">
Send this to the person who needs it. It opens
{{ views === 1 ? 'once' : views + ' times' }} and expires {{ expiryLabel }}.
</p>
<div class="link-row">
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy' }}</button>
</div>
<p class="control-hint">
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.
</p>
<button class="btn btn-secondary" @click="reset">Send another</button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { toolsApi } from '../api'
import { withBase } from '@/utils/basePath'
import { getSiteBaseUrl, getSetting } from '@/utils/siteSettings'
import { useAuthStore } from '@/stores/auth'
import {
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
} from '../securesend'
const supported = cryptoAvailable()
const auth = useAuthStore()
// Whether THIS visitor can create one. A signed-in user always sees the form -
// the server still enforces tools.secrets and says so if they lack it, which is
// a different message from "sign in". A guest sees it only where the site has
// opened guest creation.
const mayCreate = ref(true)
onMounted(async () => {
if (auth.isAuthenticated) return
const allowed = await getSetting('securesend_allow_guests', 'false')
mayCreate.value = String(allowed).toLowerCase() === 'true'
})
// Matches the server's ceiling. A free number rather than a dropdown because
// "how many people need this password" is the sender's business - a shift
// handover is not the same as one colleague on a call.
const MAX_VIEWS = 100
const plaintext = ref('')
const expiryhours = ref(24)
const views = ref(1)
const busy = ref(false)
const error = ref('')
const link = ref('')
const copied = ref(false)
const linkInput = ref(null)
// Only nag once there is something to nag about: an empty box is not a mistake.
const refusal = computed(() => (plaintext.value ? refuseReason(plaintext.value) : null))
const expiryLabel = computed(() => {
const hours = expiryhours.value
if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`
const days = hours / 24
return `in ${days} day${days === 1 ? '' : 's'}`
})
function onPaste(event) {
// A pasted FILE is the easiest wrong path into a tool that is not a file
// transfer. Refuse it here rather than letting its bytes become "text".
if (pasteCarriesFiles(event)) {
event.preventDefault()
error.value = 'Secure send takes text, not files. Paste the password itself.'
}
}
async function createLink() {
error.value = ''
busy.value = true
try {
const sealed = await sealSecret(plaintext.value)
const response = await toolsApi.createSecret({
ciphertext: sealed.ciphertext,
iv: sealed.iv,
expiryhours: expiryhours.value,
views: views.value,
})
const token = response.data.data.token
// THE SITE'S CONFIGURED URL, not the one this browser happens to be using.
// site_base_url exists for exactly this - "used for QR codes and any
// absolute link the app emits" - because somebody creating a secret may
// have reached the app by an IP, a short hostname, or a name that only
// resolves inside one VLAN, and the recipient has to open the link from
// wherever they are. It falls back to the browsing origin when a site has
// not set one.
//
// withBase for the path: a site served under /shopdb or /ops carries that
// in front of every route, and leaving it off produced a 404 from IIS for a
// secret that existed - with the sender shown a link that could never work.
// site_base_url is DOCUMENTED as scheme + host, but sites really do set the
// full base including the subpath - and appending the mount to one that
// already carries it produced /shopdb/shopdb/s/<token>. Tolerate both
// rather than trusting the description: add the mount only when it is not
// there already.
const configured = (await getSiteBaseUrl()).replace(/\/+$/, '')
const mount = withBase('/').replace(/\/+$/, '')
const needsMount = mount && !configured.endsWith(mount)
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
// recipient and nowhere else.
link.value =
`${configured}${needsMount ? mount : ''}/s/${token}#${sealed.fragmentKey}`
// Drop the plaintext as soon as it is sealed: no reason to keep it in memory
// behind the link that replaced it.
plaintext.value = ''
} catch (err) {
error.value = err.response?.data?.data?.error?.message || 'Could not create the link.'
} finally {
busy.value = false
}
}
async function copyLink() {
try {
await navigator.clipboard.writeText(link.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch (err) {
// Clipboard access is refused in some hardened configurations. Select the
// text instead so Ctrl+C still works rather than leaving a dead button.
selectAll()
}
}
function selectAll() {
linkInput.value?.select()
}
function reset() {
link.value = ''
error.value = ''
copied.value = false
}
</script>
<style scoped>
.intro p { margin: 0 0 0.5rem; }
.muted { color: var(--text-light); font-size: 0.9rem; }
.field { display: block; margin-bottom: 1rem; }
.field-label { display: block; font-size: 0.8125rem; color: var(--text-light); margin-bottom: 4px; }
.options { display: flex; gap: 1rem; flex-wrap: wrap; }
.options .field { flex: 1 1 12rem; }
.counter { font-size: 0.8125rem; color: var(--text-light); margin: 0 0 0.5rem; }
.field-hint { display: block; font-size: 0.8125rem; color: var(--text-light); margin-top: 4px; }
.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; }
.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; }
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="reveal-page">
<div class="reveal-card card">
<h1>A secret was shared with you</h1>
<!-- CHECKING, not revealing. The page asks whether the link is still live
before anyone clicks, so it can say what will happen rather than
spending the only view to find out. -->
<p v-if="state === 'checking'" class="muted">Checking the link...</p>
<template v-else-if="state === 'ready'">
<p>
Opening this destroys it. Make sure you can save or use the secret now -
it cannot be shown twice, and nobody can recover it for you.
</p>
<!-- An explicit click, never automatic. Mail and chat scanners fetch
links to check them; a page that revealed on load would burn the
secret before its recipient ever saw it. -->
<button class="btn btn-primary" :disabled="busy" @click="reveal">
{{ busy ? 'Opening...' : 'Show me the secret' }}
</button>
</template>
<template v-else-if="state === 'revealed'">
<p class="muted">Here it is. It is gone from the server now.</p>
<pre class="secret">{{ plaintext }}</pre>
<button class="btn btn-primary" @click="copySecret">
{{ copied ? 'Copied' : 'Copy' }}
</button>
</template>
<template v-else-if="state === 'nokey'">
<p class="problem">This link is missing its key.</p>
<p class="muted">
The part after the # is what decrypts the secret, and it is not here.
Some chat and mail systems trim it when they rewrite links. Ask the
sender to share the link again - by a route that keeps it whole, or
pasted as plain text - and to create a new secret if this one has since
expired.
</p>
</template>
<template v-else-if="state === 'undecryptable'">
<p class="problem">The secret could not be decrypted.</p>
<p class="muted">
The link opened, but the key in it does not match. That usually means
the link was altered in transit. The secret has now been used up, so
ask the sender for a new one.
</p>
</template>
<template v-else>
<p class="problem">This link is not valid.</p>
<p class="muted">
It may have been opened already, or it may have expired. Secrets are
destroyed once read, so a link only works the number of times the
sender chose. Ask them for a new one.
</p>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { toolsApi } from '../api'
import { openSecret, cryptoAvailable } from '../securesend'
const route = useRoute()
const state = ref('checking')
const plaintext = ref('')
const busy = ref(false)
const copied = ref(false)
const token = route.params.token
// The key lives in the fragment, which the router does not parse and the server
// never receives. Read it off the raw location.
const fragmentKey = (window.location.hash || '').replace(/^#/, '')
onMounted(async () => {
if (!cryptoAvailable()) {
state.value = 'undecryptable'
return
}
if (!fragmentKey) {
state.value = 'nokey'
return
}
try {
const response = await toolsApi.peekSecret(token)
state.value = response.data.data.available ? 'ready' : 'gone'
} catch (err) {
state.value = 'gone'
}
})
async function reveal() {
busy.value = true
try {
const response = await toolsApi.redeemSecret(token)
const { ciphertext, iv } = response.data.data
plaintext.value = await openSecret({ ciphertext, iv, fragmentKey })
state.value = 'revealed'
} catch (err) {
// A failed DECRYPT is different from a missing secret, and the difference
// matters to the person reading: one means the link was mangled, the other
// means it was already used. The secret is spent either way.
state.value = err.response ? 'gone' : 'undecryptable'
} finally {
busy.value = false
}
}
async function copySecret() {
try {
await navigator.clipboard.writeText(plaintext.value)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
} catch (err) {
copied.value = false
}
}
</script>
<style scoped>
/* Standalone: this page is opened by somebody who may have no account here, so
it carries no navigation and nothing to click into. */
.reveal-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
background: var(--bg);
}
.reveal-card { max-width: 32rem; width: 100%; padding: 2rem; }
.reveal-card h1 { font-size: 1.25rem; margin: 0 0 1rem; }
.muted { color: var(--text-light); font-size: 0.9rem; }
.problem { color: var(--warning); font-weight: 600; margin: 0 0 0.5rem; }
.secret {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.95rem;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div>
<div class="page-header">
<h1>Secure Send</h1>
</div>
<div v-if="loading" class="card loading">Loading...</div>
<div v-else class="card">
<label class="switch-row">
<input v-model="allowGuests" type="checkbox" />
<span>
<strong>Let people without an account create links</strong>
<span class="hint">
Off by default. With it off, creating a link needs a login and the
<code>tools.secrets</code> permission. Receiving one never needs an
account either way.
</span>
</span>
</label>
<!-- The cost, stated where the decision is made rather than in a document
nobody opens. -->
<p class="control-note" v-if="allowGuests">
A link created by a guest has no creator recorded. The audit trail can
say when it was made and from where, but never who made it.
</p>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<span v-if="saved" class="saved">Saved</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
const KEY = 'securesend_allow_guests'
const allowGuests = ref(false)
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'tools' })
const rows = response.data.data || []
const row = rows.find(setting => setting.key === KEY)
allowGuests.value = String(row?.value).toLowerCase() === 'true'
} finally {
loading.value = false
}
})
async function save() {
saving.value = true
try {
await settingsApi.update(KEY, allowGuests.value ? 'true' : 'false')
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
} finally {
saving.value = false
}
}
</script>
<style scoped>
.switch-row { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 1rem; }
.switch-row span { display: block; }
.hint { display: block; color: var(--text-light); font-size: 0.875rem; margin-top: 4px; }
.control-note { color: var(--warning); font-size: 0.875rem; margin: 0 0 1rem; }
.saved { margin-left: 10px; color: var(--text-light); font-size: 0.875rem; }
</style>

View File

@@ -12,6 +12,11 @@ plugin's tables, so the foreign key to the core users table cannot resolve at
CreateTable-compile time.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
@@ -23,7 +28,7 @@ depends_on = None
def upgrade():
op.create_table(
create_table_if_missing(
'securesendsecrets',
sa.Column('secretid', sa.Integer(), nullable=False),
# 128 bits of randomness as hex. With no login on the read side this is
@@ -47,13 +52,13 @@ def upgrade():
)
# Unique, because the token is looked up on every read and two rows sharing
# one would make a link ambiguous.
op.create_index('ixsecuresendsecretstoken', 'securesendsecrets',
['token'], unique=True)
create_index_if_missing('ixsecuresendsecretstoken', 'securesendsecrets',
['token'], unique=True)
# Expiry sweeps scan on this.
op.create_index('ixsecuresendsecretsexpiresat', 'securesendsecrets',
['expiresat'])
op.create_index('ixsecuresendsecretscreatedby', 'securesendsecrets',
['createdbyuserid'])
create_index_if_missing('ixsecuresendsecretsexpiresat', 'securesendsecrets',
['expiresat'])
create_index_if_missing('ixsecuresendsecretscreatedby', 'securesendsecrets',
['createdbyuserid'])
def downgrade():

View File

@@ -54,21 +54,66 @@ class ToolsPlugin(BasePlugin):
return [SecureSecret]
def get_permissions(self) -> List:
"""One permission, on CREATING a secret only.
"""Who may CREATE a secret, when guests are not allowed.
There is deliberately no read permission: the recipient usually has no
account here, and the unguessable token is what authorises the read.
Gating that side would make the tool useless for the case it exists for -
handing somebody a credential while you are on a call with them.
The default access model: signed in, and holding this. Reading needs no
permission at all - the recipient usually has no account here and the
unguessable token is the authorisation.
No delete permission either. A secret is spent by being read or by
expiring, and both remove the row; a hand-delete route would only add a
way to destroy the record of a secret that had not been read yet.
A site that turns on securesend_allow_guests bypasses this for people
with no account; it still applies to anyone who IS signed in, so a
member without it cannot create one while a guest can. That is
deliberate: opening the door to guests is a decision about the door, not
about revoking what a role does or does not hold.
"""
return [
('tools.secrets', 'Create secure send links', 'tools'),
]
def get_settings_defaults(self) -> List[dict]:
"""The one switch this plugin owns.
Public so the create page can read it BEFORE login and show a guest the
form rather than a sign-in wall. It says nothing sensitive: whether the
door is open is visible to anyone who tries the door.
"""
return [
{
'key': 'securesend_allow_guests',
'value': 'false',
'valuetype': 'boolean',
'category': 'tools',
'public': True,
'description': ('Let people with no account create secure send '
'links. Off by default: a guest secret has no '
'creator, so the audit trail can say when and '
'from where but never who. Receiving a secret '
'never needs an account either way.'),
},
]
def get_settings_cards(self) -> List[dict]:
"""A page for the one switch this plugin owns (ADR-010).
get_settings_defaults SEEDS the row; this is what gives somebody a place
to change it. Declaring only the first left the setting in the database
with no way to reach it, which is indistinguishable from the feature not
existing.
"""
return [
{
'group': 'Tools',
'to': '/settings/secure-send',
# From the settings iconMap; an unknown key falls back to a
# puzzle piece, which reads as "nobody chose this".
'icon': 'wrench',
'title': 'Secure Send',
'description': 'Whether people without an account can create '
'one-view password links',
'position': 60,
},
]
def get_navigation_items(self) -> List[dict]:
return [
{

View File

@@ -24,7 +24,7 @@
<option value="unknown">Unknown</option>
</select>
</label>
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset..." />
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset number, machine..." />
<span class="result-count">{{ filteredItems.length }} of {{ items.length }}</span>
</div>
@@ -186,6 +186,7 @@ import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import MachineMapChip from '../components/MachineMapChip.vue'
import { matchesWarrantySearch } from './warrantySearch'
const toast = useToast()
const route = useRoute()
@@ -197,16 +198,11 @@ const search = ref('')
const page = ref(1)
const perPage = 25
// Client-side search across vendor / service level / tag / linked asset numbers.
const filteredItems = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return items.value
return items.value.filter(w =>
(w.vendor || '').toLowerCase().includes(term) ||
(w.servicelevel || '').toLowerCase().includes(term) ||
(w.servicetag || '').toLowerCase().includes(term) ||
(w.assets || []).some(a => (a.assetnumber || '').toLowerCase().includes(term)))
})
// Client-side search. The field list lives in warrantySearch.js, where it is
// covered by tests - the machine number was rendered in this table for a long
// time before anything let you search for it.
const filteredItems = computed(() =>
items.value.filter(w => matchesWarrantySearch(w, search.value)))
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / perPage)))
const paginatedItems = computed(() => filteredItems.value.slice((page.value - 1) * perPage, page.value * perPage))

View File

@@ -0,0 +1,44 @@
// What the warranty list's search box matches against.
//
// Pulled out of WarrantiesList.vue so the field list is somewhere a test can
// see it. It went wrong the way a field list usually does: silently, by a
// column being added to the table and not to the filter.
//
// THE MACHINE NUMBER IS THE POINT. _related_machine in the warranty API exists
// precisely because "a shopfloor PC is bought, warranted and replaced as a PC,
// but it is FOUND by the machine it drives - nobody walks the floor looking for
// an asset number". The payload carries it, the table renders it in a chip, and
// the filter did not look at it - so the one identifier the feature was built
// around was the one thing the search box could not find.
/**
* Does this warranty match the search term?
*
* @param {object} warranty - a row from GET /api/warranties
* @param {string} term - the raw search box contents
* @returns {boolean} true when the term is empty or any searched field contains it
*/
export function matchesWarrantySearch(warranty, term) {
const needle = (term || '').trim().toLowerCase()
if (!needle) return true
if (!warranty) return false
const has = (value) => String(value ?? '').toLowerCase().includes(needle)
if (has(warranty.vendor) || has(warranty.servicelevel) || has(warranty.servicetag)) {
return true
}
return (warranty.assets || []).some((asset) => {
if (has(asset?.assetnumber)) return true
// The related machine is optional: a warranty on something that drives no
// machine has none, and a machine that was never linked leaves it null.
const machine = asset?.machine
return !!machine && (has(machine.machinenumber) || has(machine.name))
})
}
/** The fields above, in the order the placeholder should name them. */
export const WARRANTY_SEARCH_FIELDS = [
'vendor', 'service level', 'tag', 'asset number', 'machine',
]

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import { matchesWarrantySearch } from './warrantySearch'
// Reported: the warranty page could not be searched by machine number.
//
// The number was in the payload (_related_machine puts it there) and rendered
// in the table as a chip, but the filter looked only at vendor, service level,
// service tag and asset number. So the identifier the feature was built around
// - a PC is warranted as a PC but FOUND by the machine it drives - was the one
// thing the box could not find.
function warranty(overrides = {}) {
return {
vendor: 'Dell',
servicelevel: 'ProSupport',
servicetag: 'ABC1234',
assets: [{
assetnumber: 'PC-00912',
name: 'Bay 3105 workstation',
machine: { machinenumber: '3016', name: 'Makino a61nx' },
}],
...overrides,
}
}
describe('matchesWarrantySearch', () => {
it('matches on the machine number', () => {
// The reported case.
expect(matchesWarrantySearch(warranty(), '3016')).toBe(true)
})
it('matches on the machine name', () => {
expect(matchesWarrantySearch(warranty(), 'makino')).toBe(true)
})
it('still matches the fields it always did', () => {
expect(matchesWarrantySearch(warranty(), 'dell')).toBe(true)
expect(matchesWarrantySearch(warranty(), 'prosupport')).toBe(true)
expect(matchesWarrantySearch(warranty(), 'abc1234')).toBe(true)
expect(matchesWarrantySearch(warranty(), 'pc-00912')).toBe(true)
})
it('is case insensitive and ignores surrounding space', () => {
expect(matchesWarrantySearch(warranty(), ' MAKINO ')).toBe(true)
})
it('does not match an unrelated term', () => {
expect(matchesWarrantySearch(warranty(), '9999')).toBe(false)
})
it('keeps every row when the term is empty', () => {
expect(matchesWarrantySearch(warranty(), '')).toBe(true)
expect(matchesWarrantySearch(warranty(), ' ')).toBe(true)
expect(matchesWarrantySearch(warranty(), undefined)).toBe(true)
})
it('handles a warranty whose asset drives no machine', () => {
// machine is null whenever the covered asset has no controls link - a
// printer, or a PC nobody linked yet. It must not throw.
const row = warranty({ assets: [{ assetnumber: 'PR-42', machine: null }] })
expect(matchesWarrantySearch(row, 'PR-42')).toBe(true)
expect(matchesWarrantySearch(row, '3016')).toBe(false)
})
it('handles a warranty with no linked assets at all', () => {
const row = warranty({ assets: [] })
expect(matchesWarrantySearch(row, 'dell')).toBe(true)
expect(matchesWarrantySearch(row, '3016')).toBe(false)
})
it('tolerates missing fields rather than matching everything', () => {
// String(undefined) is 'undefined', which would make a search for 'und'
// match every row with an empty field.
const row = { vendor: null, servicelevel: undefined, assets: null }
expect(matchesWarrantySearch(row, 'und')).toBe(false)
expect(matchesWarrantySearch(row, 'null')).toBe(false)
})
})