diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml index 1d04ec1b..947cd047 100644 --- a/docker-compose.pull.yml +++ b/docker-compose.pull.yml @@ -83,7 +83,10 @@ services: environment: <<: *app-env ports: - - "${API_PORT:-5001}:5001" + # Loopback only, same as the DB below. This stack generates its own + # dev secrets and may hold a real imported dataset; it has no business + # listening on every interface of a laptop on a shop-floor network. + - "127.0.0.1:${API_PORT:-5001}:5001" volumes: - instance_data:/app/instance diff --git a/docs/ShopDB-Planner-Tasks-v2.csv b/docs/ShopDB-Planner-Tasks-v2.csv deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 2b48ee28..b199931e 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -256,19 +256,38 @@ function formatDate(value) { return formatValue(value, { format: 'date' }) } +// Called from the template, so it runs on EVERY render. Without the seen-set it +// re-fired the GET for a report whose fetch was still in flight, and because +// each resolution reassigns reportImageUrls the whole grid re-rendered and +// re-fired the rest - N thumbnails cost up to N-squared requests on first load. +// Two blobs resolving for one key also orphaned the first object URL, which +// revokeReportImageUrls could not free because only the surviving one was in +// the map. A failure was never recorded either, so a broken image retried for +// the life of the page. +// +// requestedReportImages tracks "we have asked", separately from the map of +// resolved URLs, and is cleared alongside it. +const requestedReportImages = new Set() + function reportImageUrl(panel, report) { const key = report.operatorreportid if (reportImageUrls.value[key]) return reportImageUrls.value[key] + if (requestedReportImages.has(key)) return '' + requestedReportImages.add(key) const base = panel.imagebase || '/api/endpoints/operatorreports' const path = toApiPath(base + '/' + key + '/image', props.assetid) api.get(path, { responseType: 'blob' }) .then((response) => { - reportImageUrls.value = { - ...reportImageUrls.value, - [key]: URL.createObjectURL(response.data), + const url = URL.createObjectURL(response.data) + // Another render may have resolved first; keep one URL per key and free + // the loser rather than orphaning it. + if (reportImageUrls.value[key]) { + try { URL.revokeObjectURL(url) } catch { /* already revoked */ } + return } + reportImageUrls.value = { ...reportImageUrls.value, [key]: url } }) - .catch(() => {}) + .catch(() => { /* stays in requestedReportImages: do not retry every render */ }) return '' } @@ -279,6 +298,7 @@ function revokeReportImageUrls() { try { URL.revokeObjectURL(url) } catch { /* already revoked */ } }) reportImageUrls.value = {} + requestedReportImages.clear() } onUnmounted(revokeReportImageUrls) @@ -668,7 +688,7 @@ defineExpose({ refresh: load, populatedGroups }) display: flex; align-items: center; justify-content: center; - background: var(--bg-alt, #1b1b1b); + background: var(--bg); overflow: hidden; } .pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; } diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 907a8ddc..04a81235 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -365,7 +365,7 @@ onUnmounted(() => { margin-top: 0.35rem; padding: 0.1rem 0.5rem; border-radius: 3px; - background: #c62828; + background: var(--danger); color: #fff; font-size: 0.62rem; font-weight: 700; diff --git a/plugins/computers/frontend/views/ConfigurationPoliciesList.vue b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue index d836d40b..24c37ab4 100644 --- a/plugins/computers/frontend/views/ConfigurationPoliciesList.vue +++ b/plugins/computers/frontend/views/ConfigurationPoliciesList.vue @@ -310,9 +310,9 @@ async function remove(p) { margin: 2px 0 0; padding: 6px 8px; font-size: 0.85em; - color: var(--warning-text, #8a6d3b); - background: var(--warning-bg, rgba(240, 173, 78, 0.12)); - border: 1px solid var(--warning-border, rgba(240, 173, 78, 0.4)); + color: var(--warning-dark); + background: var(--bg); + border: 1px solid var(--warning); border-radius: 4px; } diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 6a2d1ee8..815340d7 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -1851,6 +1851,20 @@ def upload_operator_report(): if error is not None: return error + # Refuse an oversized body BEFORE anything parses it. content_length is a + # header, so it is readable without touching the body; request.form is not + # - the first access to it makes werkzeug parse the whole multipart and + # spool the file part to a temp file. An earlier version of this check sat + # below that line and so ran after the thing it exists to prevent. The + # value is caller-supplied, which is why this is an early out and not the + # enforcement; the bounded read below is. + declared = request.content_length or 0 + if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'screenshot exceeds the {} byte limit'.format( + OPERATOR_REPORT_MAX_IMAGE_BYTES)) + hostname = (request.form.get('hostname') or '').strip() if not hostname: return error_response(ErrorCodes.VALIDATION_ERROR, @@ -1869,18 +1883,6 @@ def upload_operator_report(): imagemimetype = None imagebytesize = None - # Refuse an oversized body BEFORE reading it. The cap used to be checked - # after upload.read(), which pulls the whole thing into memory first - the - # check could not prevent what it was there to prevent. content_length is - # caller-supplied so it is a cheap early out, not the enforcement; the read - # below is still bounded. - declared = request.content_length or 0 - if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK: - return error_response( - ErrorCodes.VALIDATION_ERROR, - 'screenshot exceeds the {} byte limit'.format( - OPERATOR_REPORT_MAX_IMAGE_BYTES)) - upload = request.files.get('screenshot') if upload is not None and upload.filename: # Read one byte past the cap: enough to know it was exceeded, without diff --git a/scripts/Start-ShopDBDev.ps1 b/scripts/Start-ShopDBDev.ps1 index d4deade9..4de89097 100644 --- a/scripts/Start-ShopDBDev.ps1 +++ b/scripts/Start-ShopDBDev.ps1 @@ -85,11 +85,27 @@ function Clear-InterruptedRun { } } - # A directory that exists but has no valid .git is a broken/partial clone; - # remove it so the clone below can start fresh instead of failing. + # A directory that exists but has no valid .git is a broken/partial clone - + # USUALLY. It is also what -TargetDir pointed at somebody's Documents folder + # looks like, and this ran Remove-Item -Recurse -Force on it without asking. + # For a script distributed as "save it anywhere and run it", a mistyped + # -TargetDir must not be able to delete a user's files. + # + # Only reclaim a directory this script can show it owns: empty, or carrying + # the marker written after a successful clone. Anything else stops. if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) { - Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan - Remove-Item -Path $Target -Recurse -Force + $ours = Test-Path (Join-Path $Target $script:CloneMarker) + $empty = -not (Get-ChildItem -LiteralPath $Target -Force | + Select-Object -First 1) + if ($ours -or $empty) { + Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan + Remove-Item -Path $Target -Recurse -Force + } + else { + throw ("$Target already exists, is not empty, and was not created " + + "by this launcher. Refusing to delete it. Pass -TargetDir " + + 'pointing somewhere else, or remove it yourself if you are sure.') + } } # A clone created by an elevated (Administrator) run is owned by @@ -104,8 +120,18 @@ function Clear-InterruptedRun { try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" } finally { $ErrorActionPreference = $previous } if ($probe -match 'dubious ownership') { - Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan - Remove-Item -Path $Target -Recurse -Force + # Same rule as above: a .git directory proves this is a clone, but + # not that it is OUR clone, and re-cloning discards whatever is in + # it. Only reclaim one this launcher created. + if (Test-Path (Join-Path $Target $script:CloneMarker)) { + Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan + Remove-Item -Path $Target -Recurse -Force + } + else { + throw ("The git clone at $Target has mismatched ownership and " + + 'was not created by this launcher, so it is not being ' + + 'deleted. Remove it yourself, or pass -TargetDir elsewhere.') + } } } } @@ -114,6 +140,9 @@ function Clear-InterruptedRun { # stored GitHub credential for github.com, so clone/fetch authenticate against # the private repo without anything sensitive touching this script or disk. $repoUrl = 'https://github.com/ge-aero/shopdb-flask.git' +# Written into a clone this launcher made, so a later run can tell its own +# working directory apart from one that happens to sit at the same path. +$script:CloneMarker = '.shopdb-launcher' # Channels follow how this repo ships: a release is a TAG, the trunk is main. # Upstream this read 'dev-be' for edge, which is that repo's trunk branch and # does not exist here - the launcher would have cloned a missing branch. diff --git a/scripts/build-channel-installer.ps1 b/scripts/build-channel-installer.ps1 index 18fda7c1..9af2fbef 100644 --- a/scripts/build-channel-installer.ps1 +++ b/scripts/build-channel-installer.ps1 @@ -80,14 +80,29 @@ param( $ErrorActionPreference = 'Stop' -# Channel -> (branch, badge label). The single source of channel truth, mirrored +# Channel -> (ref, badge label). The single source of channel truth, mirrored # from dev-bootstrap.ps1 so the two entry points never disagree about what a # channel means. +# +# stable has NO fixed ref: a release here is a tag, not a branch, so it is +# resolved below from the newest v* tag. Writing 'main' here would build the +# trunk and hand it to a site under a word that promises a release - the same +# mislabel this repo's channel mapping was corrected for, missed in this file +# the first time round. $channels = @{ - stable = @{ Ref = 'main'; EnvLabel = '' } + stable = @{ Ref = $null; EnvLabel = '' } edge = @{ Ref = 'main'; EnvLabel = 'DEV' } } +function Resolve-StableRef { + # Newest release tag by version sort. Falls back to main with a warning + # rather than silently building something and calling it stable. + $tag = (git tag --list 'v*' --sort='-v:refname' | Select-Object -First 1) + if ($tag) { return $tag } + Write-Warning 'No v* tag in this repo; building stable from main.' + return 'main' +} + function Assert-LastExit([string]$What) { if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" } } @@ -100,7 +115,11 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { } $config = $channels[$Channel] -$branch = if ($Ref) { $Ref } else { $config.Ref } +# An explicit -Ref always wins. Otherwise the channel decides: edge is the +# trunk, stable resolves the newest release tag. +$branch = if ($Ref) { $Ref } + elseif ($config.Ref) { $config.Ref } + else { Resolve-StableRef } if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" } $appImage = "shopdb-flask:$Version" @@ -271,8 +290,15 @@ Write-Host "==> Loading image bundle" -ForegroundColor Cyan docker load -i $bundle.FullName if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." } +# -p is NOT optional here. Without it compose names the project after the +# containing folder, which is shopdb-- - so installing the +# next version creates a NEW project with new empty named volumes, and the site +# comes up looking wiped. The old data is still there under the old project's +# volumes, but nothing carries it across. Pin the project to the channel so an +# upgrade lands on the same volumes. $composeArgs = @( 'compose', '--env-file', '.env', + '-p', 'shopdb-__CHANNEL__', '-f', 'docker-compose.airgap.yml', '-f', 'docker-compose.channel.yml' ) @@ -289,6 +315,11 @@ Write-Host "ShopDB is up: https://localhost:$apiPort" -ForegroundColor Green Write-Host "Seed the first admin (once):" Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin " '@ + # The here-string above is single-quoted so nothing in it interpolates - that + # is deliberate, the installer is full of $ that must survive verbatim. The + # compose project name is the one value that has to be baked per channel, so + # substitute it here rather than switching the whole block to interpolation. + $installer = $installer.Replace('__CHANNEL__', $Channel) Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII # --- 6. README ---------------------------------------------------------- diff --git a/scripts/dev-bootstrap.ps1 b/scripts/dev-bootstrap.ps1 index a3c01e3c..189c37ba 100644 --- a/scripts/dev-bootstrap.ps1 +++ b/scripts/dev-bootstrap.ps1 @@ -282,10 +282,20 @@ try { } else { # Dev-grade secrets: generated locally, never for production. A real # deploy fills .env from the wiki runbook, not from this script. - $secret = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 })) - $jwt = [Convert]::ToBase64String((1..48 | ForEach-Object { Get-Random -Maximum 256 })) - $dbPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x' - $rootPass = [Convert]::ToBase64String((1..18 | ForEach-Object { Get-Random -Maximum 256 })) -replace '[^A-Za-z0-9]', 'x' + # + # Get-Random is System.Random: a 32-bit seed, and all four values came + # off one stream, so recovering the seed from any one of them recovers + # the other three. These sign JWTs for a stack a pilot may point at a + # real imported dataset, so they come from the CSPRNG instead. "Dev + # grade" is about rotation and provenance, not about being guessable. + $secret = [Convert]::ToBase64String( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48)) + $jwt = [Convert]::ToBase64String( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48)) + $dbPass = ([Convert]::ToBase64String( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x' + $rootPass = ([Convert]::ToBase64String( + [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x' $apiPort = $config.ApiPort $mysqlPort = $config.MysqlPort $image = "$imageRepo`:$($config.ImageTag)"