057cee1f02bffd41374c83f573de9a208038ddd3
20 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dd3db50460 |
Secure Send hands over a message, not a bare URL
A link on its own invites being pasted onto text that is already in the chat box, and every chat client turns the selection into the link's caption. The secret then travels as the visible message with the secure link wrapped around it, protecting nothing. This is not hypothetical. Six LAPS passwords went out that way in production - machine names and passwords as the caption, the correct /s/<token>#<key> URL underneath, doing nothing at all. Copy message puts a whole sentence on the clipboard: what the link is, how many opens it has, when it expires, and then the URL. 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 exactly what would let a secret ride along as markup. The success card now says to delete the password from the chat box before pasting. The existing hint explains that the key cannot be recovered, which is true and does not stop anybody pasting the password itself. Nothing about the link was wrong and nothing about it changed: the URL carries the token, the key rides in the fragment the browser never sends, the plaintext is dropped the moment it is sealed, and redeeming stays a POST so a preview cannot spend it. |
||
|
|
5e7e39a24c |
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. |
||
|
|
03a1151a5a |
Tech Tools is reachable by the people it was built for
Every tool on that page is usable without an account, on purpose - the routes carry no requiresAuth and say so - and Secure Send is the one write this product offers a guest. The sidebar entry still never appeared for them: it named no permission, so the filter that decides what a signed-out visitor may see had nothing to go on and dropped it. The plugin had no read permission to name, only tools.secrets, so it gains tools.view: reaching the page at all. The tools themselves run in the browser and touch no data, which is why this is about whether the entry appears rather than about what anybody can read. The item names either right. Somebody granted only Secure Send needs the entry that leads to it, and a manager who holds neither still does not see it. |
||
|
|
151d7c4f44 |
Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point. |
||
|
|
2ad2bd1b90 |
Sidebar sections are DECLARED, not guessed from a position number
The section headings were derived from position ranges: 10-29 became Assets, 30-49 became Information. A number is for ORDER; it was never a statement about meaning, and using it as one meant a plugin landed in whichever bucket its number happened to fall in. That is how USB Devices - an asset - came to sit under Information beside GE-Enforce and Tech Tools, and 3D Parts with it. Nobody chose that. It also meant two plugins picking the same number silently reordered each other: Warranties and Guides both held 8, the tie broke on plugin load order, and Guides rendered under Warranties rather than under Slides where it was asked to go. Warranties moves to 9. Each nav item now names its section and the renderer groups by what was declared. An item with no section stays in the unlabelled group at the top, which is the safe default: it appears, it is simply not filed. Assets gains USB Devices and 3D Parts. GE-Enforce and Tech Tools move to a new Fleet section - they are neither assets nor reference material, and calling them Information was only ever an artefact of their position numbers. Also: sorting used `position || 99`, and 0 is falsy in JavaScript, so Dashboard - the only item that uses 0 - sorted to the end of its own group. |
||
|
|
b96637e1a1 |
Let a baseline meet a table that is already there
Reported: `flask plugin upgrade-all` failed the tools plugin with "table securesendsecrets already exists". Every post-cutover baseline emitted a plain op.create_table. That is correct only while a table's existence and its plugin's Alembic stamp can never disagree, and they can. Anything that builds tables from the models - db.create_all(), which `flask db-utils create-all` calls - creates them and stamps nothing. A run that creates a table and then fails before its stamp commits leaves the same split, and re-running is the obvious thing to try. In both cases upgrade-all meets existing tables with an unstamped chain and the create is fatal. Nothing caught it because the migration test DROPS the post-cutover tables db.create_all() built before running upgrade-all. That arranges the one state where an unguarded create works, and never the state the report came from. The new test is the same sequence with the drop removed for the plugin under test. create_table_if_missing and create_index_if_missing are in alembic_template.py next to create_plugin_tables, which had the idempotency rule right and turns out to be called by nobody - its docstring claimed every 0001 used it. Fixed the docstring rather than rewiring the baselines onto it, because the reason they hand-write their DDL still stands: the helper's per-plugin MetaData cannot resolve a foreign key to a core table at CreateTable-compile time. INDEXES ARE MATCHED ON COLUMNS, NOT NAME, and that distinction matters. The two creators disagree about names - db.create_all() takes SQLAlchemy's default convention (ix_securesendsecrets_token) and a baseline spells it the way this repo's naming rules require (ixsecuresendsecretstoken). A name-keyed guard would have called the index missing and added a second one over the same column, which MySQL permits with a warning, so every affected site would have quietly grown duplicates. The test asserts no table ends up with two indexes over the same columns. Applied to all nine table-creating plugin migrations, not just the tools baseline that was reported: the four later migrations that create tables have the same exposure, and geenforce, measuringtools, printedparts and backups are the same defect waiting for a different site. WHAT THIS DOES NOT FIX. It makes a baseline idempotent, not a whole chain. A longer chain can still meet an ALTER whose column is already present - printedparts adding gagelabtag to a table create_all built at head is the case that showed up while testing. Guarding every op one at a time is the wrong answer to that; a database built from the models is already AT head and its chains want stamping rather than replaying, and there is no `flask plugin stamp` today. Left alone deliberately rather than half-built, and the new test is scoped to single-baseline plugins so it does not pretend otherwise. |
||
|
|
81575e46ca |
Give the guest setting somewhere to be changed
Reported: no Tools section in Settings. The setting existed in the database and had no page, which from a person's side is indistinguishable from the feature not existing. get_settings_defaults SEEDS a setting. get_settings_cards (ADR-010) is what puts it in the rail. Declaring only the first is a silent half-feature: nothing fails, no test notices, and the switch is unreachable. The page states the cost where the DECISION is made rather than in a document nobody opens - turn it on and it says a guest-created link records no creator, so the audit trail can say when and from where but never who. Admin-only, like every other settings page, because this one decides whether an unauthenticated caller may write to the site. Also caught while wiring it: the card asked for a 'shield' icon, which is not in the settings iconMap, so it would have rendered the fallback puzzle piece - the glyph that reads as "nobody chose this". Uses wrench, matching Tech Tools. The row still comes from `flask plugin upgrade-all`; the card appears either way, and without the row the page loads empty. |
||
|
|
92cf1d71f0 |
Stop making people sign in to reach tools that need no account
/tools, /tools/codes and /tools/secure-send were all behind requiresAuth. None of them needed to be, and one of them actively contradicted a setting added an hour earlier: securesend_allow_guests lets somebody with no account create a link, and the page they would create it on was behind a login wall. The label generator runs entirely in the browser - no API, no data, nothing to protect - so gating it only ever cost a guest the use of it. THE SERVER STILL DECIDES who may create a secret. A signed-in user needs tools.secrets; a guest needs the site to have opened guest creation. Making the page reachable is not the same as making the action allowed, and the two were conflated. The page now reads the setting on load, so a guest at a site that has NOT opened guest creation is told to sign in instead of being handed a form that was always going to be refused - with a line saying that receiving a link never needs an account, because somebody who lands there confused usually has a link they should be opening instead. A signed-in user always sees the form, even without the permission: the server's "you do not have permission" is a different answer from "sign in", and collapsing them would send somebody to a login page they are already past. |
||
|
|
8750ba2e74 |
Let a site open secure send to guests, and choose how many times a link opens
Two asks, and the first is a real security decision so it is a SETTING that defaults to the safe answer rather than a code change somebody inherits. securesend_allow_guests, OFF by default. With it off the model is unchanged: a login plus tools.secrets. With it on, somebody with no account - at a shared bay terminal, a contractor - can create a link too. The cost is named in the setting's own description and pinned by a test: a guest secret has NO CREATOR, so the audit trail can say when and from where but never who. That is the whole reason it is a switch a person turns on deliberately. IT FAILS CLOSED. A missing row, a half-migrated site, an unhappy database - the answer is no. An error must never be the thing that opens an unauthenticated write endpoint. A SIGNED-IN USER STILL NEEDS THE PERMISSION even when guests are allowed, which is surprising enough that a test pins it: opening the door to guests is a decision about the door, not about revoking what a role holds. Guest creation is rate limited per address, same shape as the login limiter. Not a security boundary - a determined person has more addresses - but an open endpoint should not be an easy place to park data or fill a disk. VIEWS ARE NOW A NUMBER, 1 to 100, instead of a four-item dropdown. How many people need a password is the sender's business: a shift handover is not one colleague on a call. The floor is what matters, not the ceiling - unlimited views would just be a password on a web page. Also folded in, from the retention question: nothing removed a secret that EXPIRED WITHOUT BEING OPENED. The recipient goes on holiday, the link is never clicked, and the ciphertext sat there for good - not readable, but it made "nothing lingers" untrue and grew the table in exactly the case where the feature failed. Any create now sweeps expired rows and a late click tidies its own, so it stays bounded without a scheduler an air-gapped site would never add. Two gates caught mistakes on the way: current_user is a LocalProxy and is never None, so `current_user is None` silently took the signed-in branch for a guest; and cache was imported straight from shopdb.extensions when it is already on the shopdb.api contract surface. 1836 tests green. |
||
|
|
e8920b2cf3 |
Do not add the mount to a site URL that already has it
|
||
|
|
a3811740bb |
Build a secret's link from the site's URL, not the browser's
Reported from production: clicking a secret link gave an IIS 404, and the link read /s/<token> where the site lives at /shopdb/s/<token>. The link was assembled from window.location.origin, which is scheme and host only. site_base_url already exists for this and says so in its own description - "used for QR codes and any absolute link the app emits" - with getSiteBaseUrl() as the helper the QR pages use. This reached past all of it. Both halves were wrong, and the second is worse because it fails silently: MOUNT PATH. A site served under /shopdb or /ops carries that in front of every route. Leaving it off is the reported 404. HOST. Whoever creates a secret may have reached the app by an IP, a short hostname, or a name that resolves in one VLAN, and the recipient opens the link from somewhere else entirely. The configured site URL is the one address known to work for both of them. The shape of this failure is what makes it worth spelling out: the secret was created SUCCESSFULLY and the sender was handed a link that could never work, so the failure landed on the recipient, the sender had no reason to doubt it, and the secret expired unread. A 500 would have been kinder. Falls back to the browsing origin when a site has not set site_base_url, which is what every other absolute link here does. BASE_URL is injected at build time, so the subpath half of this cannot show up in dev and only appears on a subpath deployment. |
||
|
|
7fc109d91a |
Give secure send a card, a form, and a page for the person receiving it
The backend has been there since
|
||
|
|
d4b49b91b4 |
Move secure send into Tech Tools, where somebody would look for it
|
||
|
|
f09d14d28c |
Refuse a measurement that makes the label stop existing
Reported as a barcode rendering 176 by 0. A bar height of zero is what does it: the number went straight into a CSS length, height: 0in is perfectly valid, and an image sized height: 100% of a zero-height box is zero pixels tall. The label comes out full width with nothing on it, which reads as a rendering failure rather than as a field doing exactly what it was told. A cleared field was the same bug wearing a different hat. An emptied number input holds '', not 0, so the page emitted `NaNin`; the browser dropped the declaration and the box fell back to auto height. That one merely looked wrong instead of vanishing, which is arguably worse for finding it. Measurements now go through labelVars(), which will not emit NaN, an empty length, or a zero for anything whose zero means the box stops existing. Padding, the quiet zone and the gap still take zero, because there zero is a real answer, and offsets stay signed. It is a separate module because this is exactly the kind of arithmetic that needs tests rather than a preview: every case in labelVars.spec.js is a value a number input can actually hold. The stylesheet now carries fallbacks on its var() reads as well, but they are a net, not the fix: a custom property set to garbage is substituted and then dropped at computed value time, and the fallback does not apply - it only covers a property that is absent entirely. |
||
|
|
e9fcd1e598 |
Stop a caption from eating the barcode, and put the labels beside the settings
Reported as the CODE128 not appearing in the preview. It was appearing, in 44 of the 45 preset and layout combinations swept - as a 36 pixel stub. The code box and the caption were both allowed to grow, so a caption of any length took half the label and then kept taking, and on 1.00 x 0.50in stock a few words of caption left a barcode too narrow to scan. Present, useless, and indisinguishable from a failed render. The caption now takes the room it needs and no more, and a CODE128 holds at least 40% of the label, or all of it when stacked: past that there are too few narrow bars left to read, and a caption that wraps or clips is the better trade. So that a real failure can never again look like this one, an empty code box now says so in red in the preview. It is screen-only - the marker must never reach a printed label. The settings were a narrow column with the preview trailing below them, which put the labels being adjusted off the bottom of the screen. Settings on the left, labels on the right in a column that stays in view and scrolls on its own, folding back to one column on a narrow window. That scrolling column is a print hazard worth naming: an ancestor with its own overflow clips a multi-page print to whatever happened to be on screen. Print resets it, and the check is a six-page two-sided run coming out as six pages with the label as the only clipping box in the chain. |
||
|
|
1abb6430f5 |
Put a code on the back of a card, a picture on the front, and the page in the app
The back side landed last commit carrying text by default, and a back laid out as text-only renders no code box at all. So a two-sided run came out with a blank reverse, which reads as the code having failed rather than as a setting doing exactly what it said. The back now defaults to carrying a code, takes its own content per row, and chooses its own symbology - a QR on the front that opens the record, a CODE128 on the back that the bench scanner already reads. Text-only is refused while the back carries a code, and the contradiction is reconciled on load, so a saved setting from the broken combination repairs itself rather than printing another blank box of cards. A picture can go on a label: above the code, beside it, or behind everything as a watermark, on either side or both. The file is read in the browser and never sent anywhere, which is what makes it usable at an air-gapped site, and it is deliberately not saved between visits - a data URL runs to megabytes and would take every sizing value with it when the quota gave out. The watermark says plainly that it costs the scanner contrast. The generator was a full-screen route with no navigation and a narrow column of controls against the left edge. That shape exists so a print cannot put the sidebar on label stock, which is a real hazard and the reason core's label pages are built that way - but it is a hazard the print stylesheet can answer, and style.css now hides the shell at print time. A tool someone spends ten minutes in gets the navigation and the full width; the stock still gets nothing but labels, confirmed against a print PDF rather than a preview. Two smaller things found while checking the above. A test print paired each page with a rendered code by loop position, while the codes are keyed to position in the whole run, so a two-sided test card could carry the wrong code; the index now travels with the page. And the back's dotted preview outline outspecified the print reset, drawing a dashed rule down the edge of every second card. |
||
|
|
39ede87d55 |
Give a label its own gap, its own position, and a back side
Three things the code generator could not do, each of which had someone
working around it with a control that meant something else.
The gap between a code and its caption was the quiet zone. That is the
clear space a scanner needs around the code, so closing up a caption meant
shaving the thing that makes the code readable, and the only way to get a
tight label to scan was to push the caption further away than it looked
like it should be. Quiet zone now means the code's own margin and nothing
else; gap means the distance to the caption, and adds to the quiet zone
rather than eating into it. Each stock preset carries the gap that suits it.
Position had one pair of controls and they moved the label box on the
stock. That is the fix for a printer whose origin is off, and it is not the
fix for a code sitting in the wrong place on a label - but it was the only
lever there, so it got used for both. The label div is now the media and
holds still; everything printed sits in a content wrapper that Align,
Vertical and Content X/Y move within it. The old pair kept its behaviour
and is labelled Media X/Y.
A badge card has two sides and the generator only ever emitted one page per
card. The back can now carry the same code again, a fixed block of text,
its own per-row content, or nothing. Page order is a choice because the two
kinds of printer want opposite things: interleaved for a duplex card
printer, which takes pages two at a time, and grouped for a single-sided
one whose stack gets flipped and reloaded. A row that leaves its back
column empty still gets a blank back page - a page count that varies per
row shifts every later back onto the wrong front, which prints a box of
perfectly readable cards that are all wrong.
CSV parsing and page building moved to plugins/tools/frontend/labelPages.js
(committed in
|
||
|
|
875fde9f48 |
Fix what the last round of device fixes broke, and two dips it missed
A review of |
||
|
|
c648bdf560 |
labels: one module knows how to draw a code, seven views stop guessing
Three core pages and four plugin pages each imported qrcode and jsbarcode directly, and each carried its own answer to the same questions: what margin, what width, which error correction, how big a module must be before a scanner can read it. The answers had already drifted - margin 0 in one place and 2 in another, width 150 against 160 - and on a label that is the difference between a sticker that scans and one that does not. frontend/src/utils/codes.js owns it now: the label-stock presets, the quiet-zone and margin defaults, CODE128 with no printed value, and the printer-resolution arithmetic that only the Tech Tools generator had. A view passes what is specific to its own label and nothing else - MachineBadge still asks for CODE39, because the badge readers predate the shop-floor scanners and decode nothing else, and that is exactly the kind of thing a call site should say out loud. views/print/qrLogo.js is folded in rather than left as a second half-shared helper that only some of the pages reached into. The check script now fails a build that imports either library outside that module. Without it this re-forks within a month: the next label page starts by copying the nearest existing one, which is how it happened the first time. Tests cover the part no amount of looking at a screen verifies - a QR that looks fine at 96 dpi on a monitor can be unreadable at 203 dpi on half-inch stock. |
||
|
|
f66763e6df |
tools: a Tech Tools section, starting with codes on label stock
A place for the small utilities a technician reaches for at a bench. The plugin owns no API and no tables: every tool runs entirely in the browser, so an air-gapped site gets them for free and a bad network cannot break them. Adding the next tool is a view, a route, and one entry in tools.js. First tool is a barcode/QR generator. Content is typed text, a URL, or a CSV (content,label,copies - quoted fields and an optional header both handled), so a batch of a few hundred is one paste. Label stock is adjustable in inches with five presets, and the code renders to an SVG data URI rather than a PNG: a bitmap gets downscaled to label size and smears the module edges a scanner reads, where SVG rasterizes at the printer's resolution with hard edges. It also carries the dot-grid rule that is easy to get wrong by eye. A thermal head cannot render a fraction of a dot, so a code sized off the grid gets uneven modules; pick a DPI and the page says what the current size lands on and what to use instead. The quiet zone is blank label rather than white baked into the code, so it can be tuned - and it applies to CODE128 too, which needs clear space at each end and was letting bars run into the caption. Tech Tools is the first bundled plugin that owns no schema, which two guards did not model: it belongs in the universal installer profile, and upgrade-all reports it 'no-migrations' where every plugin was assumed to report 'ok'. The migration test now asserts that status explicitly for schema-less plugins, so a table-owning plugin whose chain went missing still fails. |