Merge origin/main into align-wilm-main (align wilm with Cameron's latest)
Bring main's endpoint hardening, operator-report validation, launch-path policy, and theme-variable CSS into wilm. Channel model reconciled to this repo's shipping (stable=release tag, edge=main trunk) while keeping wilm's remote-BuildKit publish step and GHCR pull-based launcher.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -230,7 +230,36 @@
|
||||
v-model="form.azuredeviceid"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Entra/Azure deviceId GUID (set by Azure imports)"
|
||||
placeholder="Entra deviceId GUID (set by the Azure import)"
|
||||
/>
|
||||
<small class="muted">Opens the device in Intune from the PC page.</small>
|
||||
</div>
|
||||
|
||||
<!-- The OBJECT id, not the device id. Entra gives a device both and
|
||||
they address different places: deviceId opens Intune, objectId
|
||||
opens the Entra device record. The import populates both, so a
|
||||
page showing only one left the other uneditable when it was
|
||||
wrong. -->
|
||||
<div class="form-group">
|
||||
<label for="azureobjectid">Azure Object ID</label>
|
||||
<input
|
||||
id="azureobjectid"
|
||||
v-model="form.azureobjectid"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Entra objectId GUID (set by the Azure import)"
|
||||
/>
|
||||
<small class="muted">Opens the device in Entra from the PC page.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="azuredevicecategory">Azure Device Category</label>
|
||||
<input
|
||||
id="azuredevicecategory"
|
||||
v-model="form.azuredevicecategory"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Entra device category or group label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -395,6 +424,8 @@ const form = ref({
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
azuredeviceid: '',
|
||||
azureobjectid: '',
|
||||
azuredevicecategory: '',
|
||||
accessmethods: [],
|
||||
notes: '',
|
||||
mapx: null,
|
||||
@@ -529,6 +560,8 @@ onMounted(async () => {
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
azuredeviceid: ext.azuredeviceid || '',
|
||||
azureobjectid: ext.azureobjectid || '',
|
||||
azuredevicecategory: ext.azuredevicecategory || '',
|
||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||
protocolid: a.protocolid,
|
||||
portoverride: a.portoverride ?? null
|
||||
@@ -605,6 +638,8 @@ async function savePC() {
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
azuredeviceid: form.value.azuredeviceid || null,
|
||||
azureobjectid: form.value.azureobjectid || null,
|
||||
azuredevicecategory: form.value.azuredevicecategory || null,
|
||||
accessmethods: form.value.accessmethods,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
|
||||
@@ -21,16 +21,30 @@ from flask_jwt_extended import current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
require_role,
|
||||
require_role, Setting,
|
||||
)
|
||||
|
||||
from ..models import EndpointMonitor
|
||||
from .routes import endpoints_bp
|
||||
|
||||
|
||||
# Shipped monitors default to this PC type until they are authored as policies.
|
||||
# Which PC type the shipped monitors attach to, as a SETTING rather than a
|
||||
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
|
||||
# type is called "Shop Floor Workstation" got no shipped monitors at all when
|
||||
# this was hardcoded, and a plant that happens to use the name for something
|
||||
# else got all of them. The default matches the seeded type so nothing changes
|
||||
# for a site that uses it; a blank setting attaches the shipped bundle to
|
||||
# nothing, which is the right neutral behaviour for a site that does not want
|
||||
# it rather than a silent mismatch.
|
||||
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
|
||||
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
|
||||
|
||||
|
||||
def shipped_pctype():
|
||||
"""The PC type the shipped monitor bundle attaches to at this site."""
|
||||
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
|
||||
return (configured or '').strip()
|
||||
|
||||
_VALID_KINDS = ('process', 'service')
|
||||
|
||||
|
||||
@@ -93,9 +107,6 @@ def update_monitor(monitor_id):
|
||||
if monitor is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
|
||||
http_code=404)
|
||||
if monitor.source != 'local':
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'Shipped monitors cannot be edited', http_code=403)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
kind = (body.get('kind') or monitor.kind or '').strip().lower()
|
||||
@@ -110,8 +121,27 @@ def update_monitor(monitor_id):
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
|
||||
|
||||
monitor.kind = kind
|
||||
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
||||
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
|
||||
# one site's disk layout. It used to refuse every edit, which meant a plant
|
||||
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
|
||||
# the launch path nor switch the monitor off - and then got permanent false
|
||||
# drift warnings for it. ADR-015 allows seed data precisely because a site
|
||||
# can edit it afterwards.
|
||||
#
|
||||
# So: kind and displayname are the identity 0007's policy bundle matches on
|
||||
# and stay fixed on a shipped row; the paths and the active flag, which are
|
||||
# what actually differ between plants, are editable.
|
||||
if monitor.source != 'local':
|
||||
for field in ('kind', 'displayname'):
|
||||
if field in body and (body.get(field) or '').strip() != (
|
||||
getattr(monitor, field) or ''):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'A shipped monitor keeps its kind and display name; its '
|
||||
'paths and active flag can be changed', http_code=403)
|
||||
else:
|
||||
monitor.kind = kind
|
||||
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
|
||||
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
|
||||
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
|
||||
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
|
||||
@@ -142,13 +172,14 @@ def delete_monitor(monitor_id):
|
||||
def _resolve_pctype_monitors(pctype):
|
||||
"""Monitor definitions a PC type should watch: shipped defaults + policy.
|
||||
|
||||
Shipped monitors are attached to SHIPPED_DEFAULT_PCTYPE as a code rule;
|
||||
Shipped monitors attach to the PC type named by the shipped-pctype setting;
|
||||
policy-attached monitors come from the computers policy resolver by PC type.
|
||||
Returns a list of EndpointMonitor rows (active only), de-duped by id.
|
||||
"""
|
||||
selected = {}
|
||||
|
||||
if pctype == SHIPPED_DEFAULT_PCTYPE:
|
||||
shipped_for = shipped_pctype()
|
||||
if shipped_for and pctype == shipped_for:
|
||||
for monitor in EndpointMonitor.query.filter_by(
|
||||
source='shipped', isactive=True).all():
|
||||
selected[monitor.endpointmonitorid] = monitor
|
||||
|
||||
@@ -39,11 +39,12 @@ def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
# A watched service or process reports a STATE STRING, not a boolean, and the
|
||||
# vocabulary varies by source. Both the services panel and the fleet health
|
||||
# rollup have to agree on what counts as running, so the rule lives here once -
|
||||
# reading it wrong makes every service look stopped.
|
||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
# What counts as RUNNING is defined once, by the reconciler, and imported here.
|
||||
# The panel and the reconciler read the same agent snapshot, so a disagreement
|
||||
# would paint a service green and flag it as drift on the same page. Importing
|
||||
# api -> services is the direction the plugin layout already runs in; the
|
||||
# reconciler's own import of this module is deferred, so there is no cycle.
|
||||
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
|
||||
|
||||
|
||||
def _service_running(entry):
|
||||
@@ -1781,6 +1782,13 @@ OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
|
||||
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
|
||||
# malformed or hostile agent cannot fill the disk with one request.
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
# The multipart envelope carries the other form fields and the part boundaries
|
||||
# too, so content_length is legitimately larger than the image. Allow for that
|
||||
# rather than rejecting a screenshot that is just under the cap.
|
||||
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
|
||||
# PNG signature. The route stores and serves the file as image/png, so this is
|
||||
# the one thing it accepts.
|
||||
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
def _operator_report_dir():
|
||||
@@ -1837,13 +1845,26 @@ def upload_operator_report():
|
||||
holds only metadata plus the stored filename. One retention sweep runs
|
||||
opportunistically so the feed stays bounded.
|
||||
"""
|
||||
from werkzeug.utils import secure_filename
|
||||
import uuid
|
||||
|
||||
error = _collector_auth_or_error()
|
||||
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,
|
||||
@@ -1864,17 +1885,24 @@ def upload_operator_report():
|
||||
|
||||
upload = request.files.get('screenshot')
|
||||
if upload is not None and upload.filename:
|
||||
data = upload.read()
|
||||
# Read one byte past the cap: enough to know it was exceeded, without
|
||||
# taking the whole of a body that lied about its length.
|
||||
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
|
||||
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot exceeds the {} byte limit'.format(
|
||||
OPERATOR_REPORT_MAX_IMAGE_BYTES))
|
||||
if data and not data.startswith(_PNG_MAGIC):
|
||||
# The stored file is served back as image/png. Storing whatever
|
||||
# arrived under that name meant the extension and the bytes could
|
||||
# disagree; the upload headers make the document case inert, but
|
||||
# nothing is gained by keeping a file we already know is not what it
|
||||
# claims.
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'screenshot must be a PNG')
|
||||
if data:
|
||||
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
|
||||
if ext != '.png':
|
||||
ext = '.png'
|
||||
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
|
||||
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
|
||||
imagemimetype = 'image/png'
|
||||
imagebytesize = len(data)
|
||||
report_dir = _operator_report_dir()
|
||||
|
||||
@@ -20,17 +20,27 @@ CRUD API refuses to edit or delete them):
|
||||
trying to START the process when a launch path is mapped - never something
|
||||
the product ships switched on for every endpoint.
|
||||
|
||||
BusinessPolicy "RPCA Shopfloor App Monitors"
|
||||
BusinessPolicy "Shopfloor App Monitors"
|
||||
the site-level wrapper, carrying the Configuration Policy as its one item.
|
||||
It was named for the originating plant when this arrived. A shipped row is
|
||||
product-owned and every adopting site sees it, so the name cannot be one
|
||||
site's designator (ADR-015). Migration 0010 renames it on installs that
|
||||
already stamped this one.
|
||||
|
||||
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
|
||||
so the bundle survives id drift across installs.
|
||||
|
||||
Lives in the endpoints chain, after 0006, because the monitor rows it references
|
||||
are seeded there. The computers policy tables are created by the core baseline
|
||||
before per-plugin chains run; this migration still guards on their presence and
|
||||
no-ops cleanly if they are absent, and is idempotent on the policy/business-policy
|
||||
names so re-running never duplicates the bundle.
|
||||
are seeded there. The policy tables belong to the COMPUTERS plugin and are
|
||||
created by its own post-cutover chain, not by the core baseline; endpoints
|
||||
declares a hard dependency on computers, which is what orders the two.
|
||||
|
||||
It guards on those tables being present and no-ops if they are not, and it is
|
||||
idempotent on the policy names so re-running never duplicates the bundle. Be
|
||||
clear about what the guard costs, though: Alembic stamps the revision either
|
||||
way, so if it ever no-ops the bundle is simply never seeded and nothing comes
|
||||
back to finish the job. That is acceptable only because the dependency makes
|
||||
the absent case unreachable in a supported install.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
@@ -47,7 +57,7 @@ depends_on = None
|
||||
|
||||
|
||||
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
BUSINESS_POLICY_NAME = 'RPCA Shopfloor App Monitors'
|
||||
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
# The shipped monitors this bundle attaches, by identity (kind, displayname).
|
||||
_SHIPPED_MONITORS = (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Rename the shipped business policy off one site's designator.
|
||||
|
||||
0007 seeded it as "RPCA Shopfloor App Monitors". RPCA is the originating
|
||||
plant's facility designator, and the row is product-owned: source='shipped',
|
||||
seeded into every install, and the CRUD API refuses to rename it. So every
|
||||
adopting site would carry another plant's label at the top of its policy list
|
||||
with no way to change it, which is exactly what ADR-015 exists to prevent.
|
||||
|
||||
0007 now seeds the neutral name, which covers a fresh install. This migration
|
||||
covers the installs that already stamped 0007 with the old one - the
|
||||
originating site, and anyone who upgraded before this landed.
|
||||
|
||||
Renames only when the old name is present AND the new one is not, so it is
|
||||
idempotent and cannot collide with a site that already has a policy of its own
|
||||
under the new name. Downgrade is deliberately one-way: putting another site's
|
||||
designator back into a shipped row is not a state worth being able to reach.
|
||||
|
||||
Revision ID: endpoints0010renamepolicy
|
||||
Revises: endpoints0009opreports
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'endpoints0010renamepolicy'
|
||||
down_revision = 'endpoints0009opreports'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
OLD_NAME = 'RPCA Shopfloor App Monitors'
|
||||
NEW_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
|
||||
def _has_table(bind, name):
|
||||
return sa.inspect(bind).has_table(name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
# The policy tables belong to the computers plugin. Same guard as 0007: if
|
||||
# they are not there, there is nothing seeded to rename.
|
||||
if not _has_table(bind, 'businesspolicies'):
|
||||
return
|
||||
|
||||
exists = bind.execute(
|
||||
sa.text('SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
|
||||
{'name': NEW_NAME}).scalar()
|
||||
if exists:
|
||||
return
|
||||
|
||||
bind.execute(
|
||||
sa.text('UPDATE businesspolicies SET name = :new WHERE name = :old'),
|
||||
{'new': NEW_NAME, 'old': OLD_NAME})
|
||||
|
||||
|
||||
def downgrade():
|
||||
# One-way on purpose. See the module docstring.
|
||||
pass
|
||||
@@ -27,11 +27,16 @@ Two seams are kept deliberately narrow so this stays easy to change:
|
||||
can call it directly.
|
||||
"""
|
||||
|
||||
# The running-state vocabulary a watched process/service can report. Kept in
|
||||
# lockstep with plugins/endpoints/api/routes.py::_RUNNING_STATES so a service the
|
||||
# panel paints green is never flagged as drift here. Both read from the same
|
||||
# agent snapshot; disagreeing would show a running app as out-of-desired-state.
|
||||
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
# The running-state vocabulary a watched process/service can report. A watched
|
||||
# service reports a STATE STRING, not a boolean, and the vocabulary varies by
|
||||
# source, so what counts as running is a single rule rather than two.
|
||||
#
|
||||
# It lives here, and api/routes.py imports it, because the panel and this
|
||||
# reconciler MUST agree: a service the panel paints green that this flags as
|
||||
# drift is a contradiction shown to the same person on the same page. It was
|
||||
# two copies with a comment asking a human to keep them in lockstep, which is
|
||||
# not a mechanism.
|
||||
RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
|
||||
|
||||
|
||||
def monitor_expects_running(monitor):
|
||||
@@ -82,7 +87,7 @@ def _reported_running_keys(reported_services):
|
||||
else:
|
||||
name, raw, kind = str(entry), '', None
|
||||
name = name.strip().lower()
|
||||
if not name or raw not in _RUNNING_STATES:
|
||||
if not name or raw not in RUNNING_STATES:
|
||||
continue
|
||||
running.add((kind, name))
|
||||
return running
|
||||
|
||||
@@ -336,6 +336,44 @@
|
||||
|
||||
<!-- Notes Section -->
|
||||
<h3 class="form-section-title">Notes</h3>
|
||||
<!-- Maintenance. Shown on the detail page since it was added but never
|
||||
editable, so a wrong date needed database access to correct. These
|
||||
are the columns a measuring instrument's calibration dates map onto
|
||||
when metrology moves to machines, which is the other reason they
|
||||
have to be enterable. -->
|
||||
<div class="form-group">
|
||||
<label for="lastmaintenancedate">Last Maintenance</label>
|
||||
<input
|
||||
id="lastmaintenancedate"
|
||||
v-model="form.lastmaintenancedate"
|
||||
type="date"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nextmaintenancedate">Next Maintenance</label>
|
||||
<input
|
||||
id="nextmaintenancedate"
|
||||
v-model="form.nextmaintenancedate"
|
||||
type="date"
|
||||
class="form-control"
|
||||
/>
|
||||
<small class="muted">Set directly; it is not derived from the interval.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="maintenanceintervaldays">Maintenance Interval (days)</label>
|
||||
<input
|
||||
id="maintenanceintervaldays"
|
||||
v-model.number="form.maintenanceintervaldays"
|
||||
type="number"
|
||||
min="1"
|
||||
class="form-control"
|
||||
placeholder="e.g. 180"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<textarea
|
||||
id="notes"
|
||||
@@ -420,6 +458,9 @@ const form = ref({
|
||||
businessunitid: '',
|
||||
requiresmanualconfig: false,
|
||||
islocationonly: false,
|
||||
lastmaintenancedate: '',
|
||||
nextmaintenancedate: '',
|
||||
maintenanceintervaldays: null,
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
@@ -613,6 +654,9 @@ onMounted(async () => {
|
||||
businessunitid: data.businessunitid || '',
|
||||
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
|
||||
islocationonly: data.machine?.islocationonly || false,
|
||||
lastmaintenancedate: (data.machine?.lastmaintenancedate || '').slice(0, 10),
|
||||
nextmaintenancedate: (data.machine?.nextmaintenancedate || '').slice(0, 10),
|
||||
maintenanceintervaldays: data.machine?.maintenanceintervaldays ?? null,
|
||||
notes: data.notes || '',
|
||||
mapx: data.mapx ?? null,
|
||||
mapy: data.mapy ?? null,
|
||||
@@ -714,6 +758,9 @@ async function saveMachine() {
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
requiresmanualconfig: form.value.requiresmanualconfig,
|
||||
islocationonly: form.value.islocationonly,
|
||||
lastmaintenancedate: form.value.lastmaintenancedate || null,
|
||||
nextmaintenancedate: form.value.nextmaintenancedate || null,
|
||||
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
|
||||
notes: form.value.notes || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask import Blueprint, request, Response, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
@@ -380,6 +380,24 @@ def printer_install_list():
|
||||
UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'})
|
||||
|
||||
|
||||
# A hostname and nothing else: letters, digits, dot, hyphen, optional :port.
|
||||
# Anything outside this cannot reach the generated script, so the quote that
|
||||
# would end the PowerShell literal never gets the chance.
|
||||
_HOST_RE = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$')
|
||||
|
||||
|
||||
def _safe_host(value):
|
||||
"""A request-supplied host, or None when it is not one.
|
||||
|
||||
The value reaches a single-quoted PowerShell -Uri literal in the generated
|
||||
.bat, so it is validated rather than escaped: an allowlist of what a host
|
||||
may contain is a smaller thing to get right than enumerating what a shell
|
||||
does with the rest.
|
||||
"""
|
||||
host = (value or '').strip()
|
||||
return host if host and _HOST_RE.match(host) else None
|
||||
|
||||
|
||||
def _batch_base_url():
|
||||
"""Base URL the generated .bat downloads installers from. Prefer the
|
||||
configured site_base_url (it already includes scheme + the /shopdb mount).
|
||||
@@ -387,12 +405,34 @@ def _batch_base_url():
|
||||
Fallback matters: behind IIS the app sees http on a loopback port and its
|
||||
url_root drops the mount, so a naive request.url_root yields a broken
|
||||
http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the
|
||||
mount (script_root) and force https instead."""
|
||||
mount (script_root) and force https instead.
|
||||
|
||||
THE FALLBACK IS THE DEFAULT PATH, not an edge case: site_base_url ships
|
||||
blank. So the host it uses is caller-supplied on every fresh install, and
|
||||
it lands unescaped inside a single-quoted PowerShell literal. X-Forwarded-Host
|
||||
is only believed when a proxy is actually declared (TRUSTED_PROXY_COUNT) -
|
||||
the same rule shopdb/utils/clientip.py applies to X-Forwarded-For, which was
|
||||
centralised for this exact reason and did not cover the Host variant. Either
|
||||
way the value is validated as a hostname before it is interpolated, and a
|
||||
request that supplies neither a usable forwarded host nor a usable Host gets
|
||||
a relative base rather than a poisoned absolute one.
|
||||
"""
|
||||
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
|
||||
if base:
|
||||
return base
|
||||
host = request.headers.get('X-Forwarded-Host') or request.host
|
||||
|
||||
host = None
|
||||
if current_app.config.get('TRUSTED_PROXY_COUNT'):
|
||||
host = _safe_host(request.headers.get('X-Forwarded-Host'))
|
||||
if not host:
|
||||
host = _safe_host(request.host)
|
||||
|
||||
root = (request.script_root or '').rstrip('/')
|
||||
if not host:
|
||||
# Same-origin relative URLs still download correctly from the browser
|
||||
# that fetched the script. Better than emitting a host we cannot vouch
|
||||
# for into a file that runs commands.
|
||||
return root
|
||||
return 'https://%s%s' % (host, root)
|
||||
|
||||
|
||||
|
||||
@@ -223,6 +223,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capabilities. On the detail page since they were added but never
|
||||
editable, so a wrong one needed database access to correct.
|
||||
Nothing writes these automatically - the collector reports queues
|
||||
and supplies, not capabilities - so a person is the only source. -->
|
||||
<div class="form-group">
|
||||
<label>Capabilities</label>
|
||||
<div class="capability-list">
|
||||
<label class="capability-item">
|
||||
<input type="checkbox" v-model="form.iscolor" />
|
||||
<span>Colour</span>
|
||||
</label>
|
||||
<label class="capability-item">
|
||||
<input type="checkbox" v-model="form.isduplex" />
|
||||
<span>Duplex</span>
|
||||
</label>
|
||||
<label class="capability-item">
|
||||
<input type="checkbox" v-model="form.isnetwork" />
|
||||
<span>Network</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="installpath">Driver Install Path</label>
|
||||
<input
|
||||
@@ -361,7 +383,12 @@ const form = ref({
|
||||
ipaddress: '',
|
||||
csfname: '',
|
||||
installpath: '',
|
||||
pin: ''
|
||||
pin: '',
|
||||
iscolor: false,
|
||||
isduplex: false,
|
||||
// true to match the create default in the API; most printers here are
|
||||
// networked and a new record that claimed otherwise would be wrong.
|
||||
isnetwork: true
|
||||
})
|
||||
|
||||
const printerTypes = ref([])
|
||||
@@ -559,6 +586,9 @@ onMounted(async () => {
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: printer.locationid || '',
|
||||
notes: printer.notes || '',
|
||||
iscolor: !!printer.printer?.iscolor,
|
||||
isduplex: !!printer.printer?.isduplex,
|
||||
isnetwork: printer.printer?.isnetwork !== false,
|
||||
mapx: printer.mapx ?? null,
|
||||
mapy: printer.mapy ?? null,
|
||||
levelid: printer.levelid ?? null,
|
||||
@@ -652,6 +682,9 @@ async function savePrinter() {
|
||||
installpath: form.value.installpath || null,
|
||||
pin: form.value.pin || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
iscolor: !!form.value.iscolor,
|
||||
isduplex: !!form.value.isduplex,
|
||||
isnetwork: !!form.value.isnetwork,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
@@ -688,6 +721,9 @@ async function savePrinter() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.capability-list { display: flex; flex-wrap: wrap; gap: 1rem; }
|
||||
.capability-item { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: normal; }
|
||||
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user