"""Flask application factory.""" import os import logging from flask import Flask, send_from_directory from .config import config from .extensions import db, jwt, init_extensions from .plugins import plugin_manager # Platform contract version. See ADR-001 for the contract surface and # ADR-002 for the bump rules. Plugins declare a compatible range in # their manifest.json `core_version` field. Pre-1.0 (0.x) means the # contract is still settling; sister sites should pin tight ranges. # 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache, # model bases, core models, response + pagination helpers, employee_connection) # so plugins no longer import internal core paths. Additive, hence minor bump. # 0.4.0: removed the never-implemented get_searchable_fields hook (search is a # core concern over the asset model) and wired the get_dashboard_widgets hook to # a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction. # 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge # plugin report cards into the Reports hub. Additive optional hook, minor bump. # 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards, # get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the # GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump. # 0.9.0: added the dualpath single-machine collapse helpers to shopdb.api # (resolve_dualpath_pairs, dualpath_single_machine_enabled), consumed by the # machines plugin list/detail to collapse dual-bay pairs. Two additive names, # minor bump. # 0.10.0: added the get_permissions hook so a plugin declares the RBAC # permissions its own routes enforce, instead of core accumulating them in one # catalog. Consumed by full_permission_catalog() (core + enabled plugins), # which backs seeding, the role grid, and API-token scope validation. Additive # optional hook, minor bump. # 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's # unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped # managed service token without importing core token internals. Additive name # on the import surface, minor bump. # 0.16.0: added the get_settings_defaults hook so a plugin declares the Setting # rows it owns (key, value, type, category, description, public). The framework # seeds them at install, at enable, and on `flask plugin upgrade-all`, files a # first-time write under the declared category, and lets a plugin mark a key # readable without auth for pages that run logged out. Additive optional hook, # minor bump. # 0.18.0: added DISPLAY_ROLES, DISPLAY_ROLE_PATHS and normalize_display_role # beside DashboardDefault (which 0.17.0 imported but never listed in __all__). # The role vocabulary is now the kiosk's own - Dashboard / Lobby / 3DPrintRoom, # the literal values of C:\Enrollment\display-type.txt - so a plugin holding its # own copy of that map (geenforce did) can read core's instead of drifting from # it. Additive names on the import surface, minor bump. # 0.19.0: get_dashboard_widgets returns DATA AND SHAPE, not a component name. # The old shape ('name' + 'component' + 'size') named a Vue component per # widget, which cannot survive a lean build - a plugin's component may never be # staged into the frontend bundle (ADR-013) - and five plugins were declaring # widgets that pointed at components nobody had written. Core now owns the # renderers and a card declares id / title / endpoint / render / severity / # permission / empty / position. BREAKING for any plugin still using the old # shape, which is why it is recorded here: the change itself shipped earlier # without a bump, and a contract that changes silently is not a contract. __contract_version__ = '0.20.0' # Product release version (see ADR-007). The product version and the # plugin-contract version above are distinct series with independent # bump rules. Not part of the shopdb.api contract surface, so it is # not re-exported there. __version__ = '0.10.0' def create_app(config_name: str = None) -> Flask: """ Application factory. Args: config_name: Configuration name ('development', 'production', 'testing') Returns: Configured Flask application """ if config_name is None: config_name = os.environ.get('FLASK_ENV', 'development') app = Flask(__name__, instance_relative_config=True) config_class = config.get(config_name, config['default']) # Production must validate its env-driven config before boot. if config_name == 'production' and hasattr(config_class, 'validate'): config_class.validate() app.config.from_object(config_class) # Load instance config if exists app.config.from_pyfile('config.py', silent=True) # Per-plugin collector keys (ADR-006) are dynamic env-vars # (COLLECTOR_API_KEY_) that from_object cannot pick up because # they are not class attributes. Copy them in explicitly so per-plugin # credential isolation works in real deploys, not just tests. for envname, envvalue in os.environ.items(): if envname.startswith('COLLECTOR_API_KEY_') and envvalue: app.config[envname] = envvalue # Ensure instance folder exists os.makedirs(app.instance_path, exist_ok=True) # Configure logging configure_logging(app) # Initialize extensions init_extensions(app) # An optional unique column accepts any number of NULLs but exactly one # empty string, so a second blank code collided and became a 500. Normalise # blank to NULL once, at the mapper, rather than in each endpoint. from .utils.blankunique import register_blank_unique_normaliser register_blank_unique_normaliser() # Initialize plugin manager with app.app_context(): plugin_manager.init_app(app, db) # Register core blueprints register_blueprints(app) # Personal API token auth shim: recognize `Bearer shopdb_pat_...` before # any JWT decode and mint a request-scoped JWT for the token's owner. from .utils.apitoken_auth import install_apitoken_auth install_apitoken_auth(app) # Register CLI commands register_cli_commands(app) # Register error handlers register_error_handlers(app) # Serve Vue frontend register_frontend_routes(app) # JWT user loader (identity is a string in JWT, convert to int for DB lookup) @jwt.user_lookup_loader def user_lookup_callback(_jwt_header, jwt_data): from .core.models import User identity = jwt_data["sub"] return db.session.get(User, int(identity)) @app.after_request def apply_security_headers(response): """Baseline response headers. The app shipped with none of these. Deliberately the three that cost nothing and break nothing: nosniff a response whose bytes and declared type disagree is not re-typed by the browser into something executable. This is the general form of the upload problem that shopdb/utils/uploads.py addresses per-file. frame-ancestors same-origin only. The kiosks open routes directly rather than framing them, so this costs the fleet nothing and stops the UI being framed elsewhere and clicked through. Sent as X-Frame-Options too, because the display bays run browsers old enough to want it. Referrer-Policy an asset id or a hostname in a path is not handed to whatever a user clicks through to. NOT a full page CSP. This app serves an SPA with inline styles, so a real script-src policy is a change worth making on its own with the frontend in front of you - claiming one here by adding a permissive header would be worse than having none. """ response.headers.setdefault('X-Content-Type-Options', 'nosniff') response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN') response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin') # setdefault: an upload response has already declared its own, stricter # Content-Security-Policy, and this must not weaken it. response.headers.setdefault('Content-Security-Policy', "frame-ancestors 'self'") return response return app CORE_BLUEPRINT_NAMES = ( 'auth', 'assets', 'modeltypes', 'plugins', 'vendors', 'models', 'businessunits', 'locations', 'maplevels', 'mappositions', 'operatingsystems', 'dashboard', 'dashboarddefaults', 'applications', 'supportteams', 'search', 'reports', 'collector', 'settings', 'auditlogs', 'users', 'customfields', 'setup', 'pluginui', 'apitokens', 'docs', ) def register_blueprints(app: Flask): """Register core API blueprints from CORE_BLUEPRINT_NAMES. Each entry maps to an attribute `_bp` exported by `shopdb.core.api` and a URL prefix `/api/`. Adding a new core resource is one entry in CORE_BLUEPRINT_NAMES, not a 3-line edit in this function. """ from .core import api as api_module api_prefix = '/api' for name in CORE_BLUEPRINT_NAMES: attr_name = f'{name}_bp' if not hasattr(api_module, attr_name): raise RuntimeError( f'Core blueprint "{attr_name}" missing from shopdb.core.api. ' f'Either add it or remove "{name}" from CORE_BLUEPRINT_NAMES.' ) bp = getattr(api_module, attr_name) app.register_blueprint(bp, url_prefix=f'{api_prefix}/{name}') def register_cli_commands(app: Flask): """Register Flask CLI commands.""" from .plugins.cli import plugin_cli from .cli import db_cli, seed_cli, relationships_cli, csv_cli app.cli.add_command(plugin_cli) app.cli.add_command(db_cli) app.cli.add_command(seed_cli) app.cli.add_command(relationships_cli) app.cli.add_command(csv_cli) def register_error_handlers(app: Flask): """Register error handlers.""" from .utils.responses import error_response, ErrorCodes from .exceptions import ShopDBException # A uniqueness violation is the caller's problem, not a server fault. Without # this it surfaced as a bare 500 with a SQLAlchemy traceback in the log and # nothing usable on screen - the operator saw "internal server error" for # having reused a code that was already taken. from sqlalchemy.exc import IntegrityError @app.errorhandler(IntegrityError) def handle_integrity_error(error): from .extensions import db db.session.rollback() message = str(getattr(error, 'orig', error)) app.logger.warning('integrity error: %s', message) if 'Duplicate entry' in message or 'UNIQUE constraint' in message: return error_response( ErrorCodes.CONFLICT, 'That value is already in use. Codes and identifiers must be unique.', http_code=409) if 'foreign key constraint' in message.lower(): return error_response( ErrorCodes.VALIDATION_ERROR, 'That record refers to something that does not exist, or is still in use elsewhere.', http_code=400) return error_response(ErrorCodes.VALIDATION_ERROR, 'The database rejected that change.', http_code=400) @app.errorhandler(ShopDBException) def handle_shopdb_exception(error): http_codes = { 'NOT_FOUND': 404, 'UNAUTHORIZED': 401, 'FORBIDDEN': 403, 'CONFLICT': 409, 'VALIDATION_ERROR': 400, } http_code = http_codes.get(error.code, 400) return error_response( error.code, error.message, details=error.details, http_code=http_code ) @app.errorhandler(404) def not_found_error(error): return error_response( ErrorCodes.NOT_FOUND, 'Resource not found', http_code=404 ) @app.errorhandler(500) def internal_error(error): return error_response( ErrorCodes.INTERNAL_ERROR, 'An internal error occurred', http_code=500 ) @app.errorhandler(401) def unauthorized_error(error): return error_response( ErrorCodes.UNAUTHORIZED, 'Authentication required', http_code=401 ) def register_frontend_routes(app: Flask): """Serve Vue frontend static files.""" frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist') @app.route('/', defaults={'path': ''}) @app.route('/') def serve_frontend(path): # Don't serve API routes as frontend if path.startswith('api/'): from .utils.responses import error_response, ErrorCodes return error_response(ErrorCodes.NOT_FOUND, 'API endpoint not found', http_code=404) # Try to serve a static asset directly. send_from_directory handles # the safe-join + 404 itself; no explicit existence probe needed # (the probe was a path-traversal risk surface). if path: try: response = send_from_directory(frontend_dist, path) # Asset filenames carry a content hash, so a given URL never # changes - cache them hard. Everything else stays revalidated. if path.startswith('assets/'): response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' else: response.headers['Cache-Control'] = 'no-cache' return response except Exception: pass # index.html names the hashed chunks, so a stale copy points at files a # deploy has already deleted and the SPA stops navigating. Always # revalidate it. response = send_from_directory(frontend_dist, 'index.html') response.headers['Cache-Control'] = 'no-cache' return response def configure_logging(app: Flask): """Configure application logging.""" log_level = app.config.get('LOG_LEVEL', 'INFO') logging.basicConfig( level=getattr(logging, log_level), format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' )