Security hardening, multi-site portability, and a documentation and API-surface sweep

This commit is contained in:
2026-08-14 16:28:34 -04:00
parent 72f8da0e58
commit 265ca2eb01
76 changed files with 3684 additions and 811 deletions

View File

@@ -3,7 +3,7 @@
import glob
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
@@ -112,6 +112,7 @@ def _require_computer_models():
from shopdb.utils.authz import require_permission
from shopdb.utils.uploads import send_upload
applications_bp = Blueprint('applications', __name__)
@@ -550,7 +551,7 @@ def upload_application_image(app_id: int):
@applications_bp.route('/image/<path:filename>', methods=['GET'])
def serve_application_image(filename):
"""Serve an uploaded application image (public - lists and tiles read it)."""
return send_from_directory(_appimage_dir(), filename)
return send_upload(_appimage_dir(), filename)
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
@@ -635,7 +636,7 @@ def serve_application_package(filename):
open URL would publish it to anything that can reach the site. Always sent
as an attachment so a browser saves it rather than trying to render it.
"""
return send_from_directory(_apppackage_dir(), filename, as_attachment=True)
return send_upload(_apppackage_dir(), filename, as_attachment=True)
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])

View File

@@ -768,14 +768,23 @@ def create_asset_relationship():
'An asset cannot be related to itself'
)
# Check for duplicate relationship
# Check for duplicate relationship.
#
# Deleting a relationship is SOFT (isactive = False, see
# delete_asset_relationship). The row therefore survives, and this lookup
# found it - so deleting a link and adding it back answered 409 "this
# relationship already exists" about a link the page no longer shows, with
# no way forward from the UI at all. The row cannot simply be inserted
# again either: assetrelationships is unique on (source, target, type).
#
# Reactivating IS the create, for an inactive row.
existing = AssetRelationship.query.filter_by(
sourceassetid=source_id,
targetassetid=target_id,
relationshiptypeid=type_id
).first()
if existing:
if existing and existing.isactive:
return error_response(
ErrorCodes.CONFLICT,
'This relationship already exists',
@@ -798,7 +807,11 @@ def create_asset_relationship():
targetassetid=source_id,
relationshiptypeid=type_id
).first()
if inverse is not None:
# isactive, for the same reason as the duplicate check above: a soft
# deleted inverse is a link somebody already removed, and blocking on it
# made "remove the existing one first" - the instruction in this very
# message - fail to unblock anything.
if inverse is not None and inverse.isactive:
return error_response(
ErrorCodes.CONFLICT,
f"The reverse of this relationship already exists "
@@ -808,14 +821,20 @@ def create_asset_relationship():
http_code=409
)
rel = AssetRelationship(
sourceassetid=source_id,
targetassetid=target_id,
relationshiptypeid=type_id,
notes=data.get('notes')
)
db.session.add(rel)
if existing is not None:
# The soft-deleted row from the duplicate check. Reuse it: the unique
# index on (source, target, type) means there is no second row to add.
rel = existing
rel.isactive = True
rel.notes = data.get('notes')
else:
rel = AssetRelationship(
sourceassetid=source_id,
targetassetid=target_id,
relationshiptypeid=type_id,
notes=data.get('notes')
)
db.session.add(rel)
apply_import_timestamps(rel, data)
db.session.flush()

View File

@@ -3,7 +3,7 @@
import glob
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
@@ -19,6 +19,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.uploads import send_upload
models_bp = Blueprint('models', __name__)
@@ -228,7 +229,7 @@ def upload_model_image(model_id: int):
@models_bp.route('/image/<path:filename>', methods=['GET'])
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_from_directory(_modelimage_dir(), filename)
return send_upload(_modelimage_dir(), filename)
@models_bp.route('/<int:model_id>/image', methods=['DELETE'])

View File

@@ -2,7 +2,7 @@
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from werkzeug.utils import secure_filename
@@ -11,6 +11,7 @@ from shopdb.core.models import Setting, AuditLog
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission, require_role
from shopdb.utils.uploads import send_upload
settings_bp = Blueprint('settings', __name__)
@@ -189,7 +190,7 @@ def upload_map_blueprint():
@settings_bp.route('/map-blueprint/<path:filename>', methods=['GET'])
def serve_map_blueprint(filename):
"""Serve an uploaded blueprint image (public - kiosks read it)."""
return send_from_directory(_map_dir(), filename)
return send_upload(_map_dir(), filename)
@settings_bp.route('/branding-logo', methods=['POST'])
@@ -234,7 +235,7 @@ def upload_branding_logo():
@settings_bp.route('/branding/<path:filename>', methods=['GET'])
def serve_branding_logo(filename):
"""Serve an uploaded branding logo (public - kiosks/print pages read it)."""
return send_from_directory(_branding_dir(), filename)
return send_upload(_branding_dir(), filename)
@settings_bp.route('', methods=['GET'])
@@ -520,10 +521,10 @@ def build_default_settings():
},
{
'key': 'pc_access_domain',
'value': 'device.geaerospace.net',
'value': 'device.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
'valuetype': 'string',
'category': 'site',
'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.'
'description': 'Domain appended to a PC hostname to build remote-access links (host.device.geaerospace.net). Blank = use the hostname as-is.' # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
},
{
'key': 'employeeid_pattern',
@@ -541,7 +542,7 @@ def build_default_settings():
},
{
'key': 'printer_hostname_template',
'value': 'Printer-{ip}.printer.geaerospace.net',
'value': 'Printer-{ip}.printer.geaerospace.net', # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
'valuetype': 'string',
'category': 'site',
'description': 'Template for a printer hostname built from its IP. {ip} is the dash-separated IP address.'