Files
shopdb-flask/shopdb/__init__.py
cproudlock 9d6342b3ab Make a plugin toggle take effect without a manual restart
Enabling a plugin could not make its routes live. Flask refuses
register_blueprint once the app has served a request, and each gunicorn worker
holds its own app object, so registering in the worker that served the toggle
would leave the others answering 404 for the same URL.

Worse, the window before the restart was a window in which workers disagreed
about reality. Four defects, all of which answered 200 while going wrong:

- Enabling a not-yet-installed plugin ALWAYS 500'd on a running app:
  install_plugin ran the migrations, updated the registry, and only then called
  register_blueprint and hit Flask's assertion. The toggle half-applied and
  reported failure.
- Enabling one plugin could hang the entire site. @jwt_required does SELECT
  users, so the request's transaction sits open holding a shared metadata lock;
  the plugin's migration runs on its own Alembic connection and needs an
  EXCLUSIVE lock on users for a foreign key. Each waits on the other, and MySQL
  grants metadata locks in request order, so every later login queued behind the
  pending exclusive. Observed twice as a total outage from one plugin toggle.
- Each worker rewrote the WHOLE registry file from a snapshot taken at boot, so
  concurrent enables erased each other: seven enables persisted three, with
  HTTP 200 for all seven.
- Dependency checks read that same stale snapshot, refusing a plugin whose
  dependency another worker had just enabled.

Routes still require the process to restart; what changes is who initiates it.
Each worker fingerprints instance/plugins.json at boot and exits gracefully when
it changes, and the supervisor replaces it. That is the primitive gunicorn's own
--reload uses. Recycles are staggered per worker so the pool rolls rather than
dropping at once, and the exit is scoped to gunicorn: on Windows sys.exit() in a
daemon thread ends the thread rather than the process, so IIS keeps the manual
restart until that path is proven on the Windows VM.

The registry now merges under an exclusive lock and replaces atomically, saves
only the entry being changed, and re-reads before any decision is made from it.

ADR-020 records the decision; ADR-013 is amended where it said the UI must tell
the operator to restart. The restart requirement and the honesty requirement
both stand - only the manual step is removed, and only where it is proven.
2026-09-20 09:06:03 -04:00

636 lines
29 KiB
Python

"""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 (a fetch route a fleet agent polls, say) 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 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.
# 0.21.0: added require_any_permission beside require_permission and require_role
# on shopdb.api so a plugin can gate a route on holding ANY of several
# permissions (an OR check) instead of one. Part of the read-permission rework
# where nav items and GET routes declare the permission they need. Additive name
# on the authz surface, minor bump.
# 0.22.0: added register_import_hook to shopdb.api so a plugin can own a VIRTUAL
# COLUMN on an importable CSV table - a header the bulk importer accepts but
# never writes - and a post-write row hook that turns that value into a plugin
# concept. The computers plugin uses it for a machineassetnumber column on
# computers.csv that builds the PC->machine controls link, so a site writes the
# machine number in the PC row instead of hand-building a relationships sheet.
# Core stays ignorant of the column's meaning. Additive name on the import
# surface, minor bump.
# 0.23.0: added mint_service_token, list_service_tokens and revoke_service_tokens
# to shopdb.api so a plugin can provision, list, and revoke a single-scope
# managed service token (an endpoint agent key) without importing core token
# internals. The endpoints plugin uses them for a UI-managed, regenerable
# collector.ingest.endpoints key whose revocation is an instant kill switch.
# Additive names on the surface, minor bump.
# 0.24.0: added collector_authorized to shopdb.api so a plugin can gate an
# agent-facing route that lives OUTSIDE the generic collector blueprint (the
# endpoints command claim/result long-poll callbacks) on the same per-plugin
# collector key + managed-token check the ingest route uses, without importing
# core collector internals. Additive name on the surface, minor bump.
# 0.25.0: added caller_holds(permission) to shopdb.api so a route can ask
# whether THIS caller holds a permission without refusing the request. It is for
# a surface that serves everyone but should only OFFER what the caller can use:
# the endpoints services panel emitted Start/Stop to every reader because the
# emission never consulted the caller, so a read-only viewer was shown controls
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
# visitor holds whatever the guest role was granted. Additive name, minor bump.
# 0.26.0: dashboard cards may declare 'shortname'. The dashboard merged its
# per-source cards into one worst-first queue, and a card's 'title' is written
# as a heading - "Endpoints with crashes or shutdowns" - which as a row badge
# ran wider than the hostname it qualified. Optional, defaults to the title, so
# a plugin that does not declare one is unaffected. Additive field, minor bump.
# 0.27.0: added Script to shopdb.api so a plugin can read the ShopDB-hosted
# script library that backs endpoint run_script delivery without importing core
# model internals. The endpoints plugin resolves a delivered script by name and
# serves its body by digest to the agent. Additive name on the surface, minor
# bump.
# 0.28.0: added client_ip and strip_port to shopdb.api so a plugin does not
# hand-roll caller-IP extraction. Two plugins had, and both read the first
# X-Forwarded-For hop, which the caller sends - so a rate limit keyed on it was
# defeated by rotating the header. The helper resolves from remote_addr, which
# ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a
# proxy is really in front. Additive names on the surface, minor bump.
# 0.30.0: added ImportBatch, ImportJournalEntry, and the BATCH_*/OP_* constants
# to shopdb.api so a plugin's bulk-import UI can list applied import batches and
# reverse one through the shared reversible journal without importing core model
# internals. The journal records the primitive mutations a bulk import makes
# (update a cell, insert a row) tied to a batch id, and a generic undo replays
# them in reverse. Additive names on the surface, minor bump.
#
# 0.31.0 widens the asset-panel vocabulary a plugin may declare: two more
# tab groups (serviceprocess, reports), a datetime column format, a map body
# key, and a tabs section that renders as a table. All optional, so a plugin
# that declares none of them is unaffected. Additive, minor bump.
# 0.32.0 adds caller_is_role to the authorization surface: the body-answering
# counterpart to require_role, for a route that serves everyone but treats one
# class of caller differently (admin break-glass override). Scoped personal
# tokens are always refused by it, matching require_role, so a plugin cannot
# accidentally let a scoped token inherit role authority from its owner.
# Additive name on the surface, minor bump.
# 0.33.0 adds per-PC agent credentials to the collector contract. BasePlugin
# gains two optional hooks, resolve_agent_credential(secret) and
# agent_shared_key_refused(hostname), and shopdb.api exports
# resolve_agent_credential, agent_shared_key_refused and collector_ingest. A
# collector schema may declare "acceptsagentcredential": true; the generic
# collector then accepts X-Endpoint-Credential for it, refuses the shared key
# for an enrolled PC (401 SHARED_KEY_REFUSED), and maps a PermissionError from
# apply_collector_payload to 403 ASSET_MISMATCH. All optional, so a plugin that
# declares none of it is unaffected. Additive, minor bump.
# 0.34.0 adds BusinessUnit to the core model surface. It was the one thing an
# asset carries that a plugin could not name: the endpoints fleet-alert scope
# resolves a business unit per poll, and with no contract name for it the route
# had dropped to db.text('SELECT businessunit FROM businessunits ...') against a
# core table. That workaround respects the letter of ADR-002 and defeats its
# point - a raw query is hidden coupling to the core schema that no contract
# bump can catch, and it is the kind of thing that gets copied. Exposing the
# model closes the gap instead of leaving the workaround as the worked example.
# Additive name on the surface, minor bump.
# 0.35.0 adds ip_in_cidrs and request_ip_allowlisted to shopdb.api: network
# position as an admission check for routes that run without a login. The two
# kiosks needed the identical gate - a device token suits an unattended screen
# provisioned once, and suits nothing about somebody opening the same page on
# their own PC - and a CIDR parser copied into two plugins is exactly where a
# fail-open creeps in later. Both fail closed on an empty setting, an
# unparseable caller address and a malformed entry. Caller trustworthiness is a
# deployment property: they read remote_addr, never raw X-Forwarded-For, so the
# IIS rewrite rule is what makes them meaningful. Additive names on the surface,
# minor bump.
__contract_version__ = '0.35.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.12.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)
# The product and contract versions, in config so a PLUGIN can read them
# through current_app instead of importing core (ADR-002 forbids that, and
# the contract test enforces it). Core code may still use the module
# constants directly.
app.config['VERSION'] = __version__
app.config['CONTRACT_VERSION'] = __contract_version__
# 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_<PLUGINNAME>) 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
# Behind a reverse proxy (IIS at /shopdb, nginx, a load balancer), the app
# sees the PROXY's connection, not the browser's: scheme http even though
# the user is on https, the proxy's host, the proxy's IP. Anything Flask
# builds from that is wrong at the browser - an absolute redirect Location
# above all, which is how a trailing-slash redirect on the endpoints list
# sent an https page to an http URL the browser then refused to follow.
#
# OFF BY DEFAULT AND DELIBERATELY. ProxyFix makes the app TRUST
# X-Forwarded-* headers, and a client can send those itself. Enabled with no
# proxy in front, any caller can claim a different scheme or source IP -
# which matters here because the login rate limit and the lockout are keyed
# on the client address. Set TRUSTED_PROXY_COUNT to the number of proxies
# actually in front of this instance (1 for a single IIS or nginx), and only
# when one really is.
proxycount = app.config.get('TRUSTED_PROXY_COUNT', 0)
if proxycount:
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=proxycount, x_proto=proxycount,
x_host=proxycount, x_prefix=proxycount)
# 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():
# On first run (no users exist), tell the operator a setup token is
# active and how to read it. The token gates POST /api/setup/create-admin
# to prevent a network race during the install window (API starts
# serving before the admin exists in every documented deploy path). The
# secret itself is never logged: logs are shipped and retained far more
# widely than the .env it came from.
from shopdb.core.models import User
try:
if User.query.count() == 0:
token = app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN')
if token:
app.logger.warning('=' * 60)
app.logger.warning('FIRST RUN: No users exist.')
app.logger.warning('A setup token is active (SETUP_TOKEN). '
'Read it with: flask seed show-setup-token')
app.logger.warning('Create admin: POST /api/setup/create-admin')
app.logger.warning(' with header: X-Setup-Token: <token>')
app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin')
app.logger.warning('=' * 60)
else:
app.logger.warning('FIRST RUN: No users exist, no SETUP_TOKEN set.')
app.logger.warning('Use CLI: docker exec shopdb-api flask seed admin')
except Exception:
pass # DB not ready yet, skip
plugin_manager.init_app(app, db)
# Recycle this worker when the enabled-plugin set changes underneath it, so
# a toggle takes effect without the operator restarting anything by hand.
from shopdb.plugins.recycle import init_recycle
init_recycle(app)
# Register core blueprints
register_blueprints(app)
# Finish any restore that is waiting on its schema upgrade. The backup
# sidecar has no Flask, so it loads the dump and leaves a sentinel; this
# container owns the app context that can run the migrations. Started here
# rather than left to a browser poll, because a restore must complete
# whether or not anyone is still watching it.
#
# Skipped unless a backups store actually exists, which keeps it off every
# deployment that does not run the backup overlay - and out of the test
# suite, which builds this app thousands of times.
# NOTE: wilm's backup implementation doesn't require initialization here
# 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"]
user = db.session.get(User, int(identity))
# Deactivating an account has to end its sessions. There is no token
# denylist, so without this check the access token stays good until it
# expires and the refresh token keeps minting new ones for 30 days.
# /auth/refresh and the API-token path already check isactive.
if user is None or not user.isactive:
return None
return user
@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',
'scripts',
'supportteams',
'search',
'reports',
'collector',
'settings',
'auditlogs',
'employees',
'users',
'customfields',
'setup',
'pluginui',
'apitokens',
'docs',
'shopdbbackups',
)
def register_blueprints(app: Flask):
"""Register core API blueprints from CORE_BLUEPRINT_NAMES.
Each entry maps to an attribute `<name>_bp` exported by
`shopdb.core.api` and a URL prefix `/api/<name>`. 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,
catalog_cli, audit_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)
app.cli.add_command(catalog_cli)
app.cli.add_command(audit_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)
# STRICT_TRANS_TABLES (see config.SQLALCHEMY_ENGINE_OPTIONS) turns what
# MySQL used to silently adjust into an error: a value too long for its
# column, a date that is not a date, a number that is not a number. That is
# the behaviour we want - the alternative is a 201 for a row the caller did
# not send - but the error is the CALLER's, so it must not read as a server
# fault. Without this it surfaced as a 500 with a traceback in the log.
from sqlalchemy.exc import DataError
@app.errorhandler(DataError)
def handle_data_error(error):
from .extensions import db
db.session.rollback()
message = str(getattr(error, 'orig', error))
app.logger.warning('data error: %s', message)
column = _column_from_data_error(message)
if 'too long' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That value is too long for %s.' % (column or 'this field'),
http_code=400)
if 'incorrect date' in message.lower() or \
'incorrect datetime' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That is not a valid date for %s.' % (column or 'this field'),
http_code=400)
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The database rejected that value%s.'
% (' for %s' % column if column else ''),
http_code=400)
# A wrong query argument is the caller's mistake. Registered app-wide so a
# list route does not have to remember to catch it - forgetting was the
# whole defect, in six routes across five plugins.
from .utils.queryargs import QueryArgError
@app.errorhandler(QueryArgError)
def handle_query_arg_error(error):
return error_response(ErrorCodes.VALIDATION_ERROR, str(error),
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
)
# Werkzeug answers these three itself, in HTML, so a JSON client asking a
# question slightly wrong got a page of markup with no error code and no
# envelope - and on a route the caller cannot see, the 405 also confirmed
# that the path exists, which is how a gated kiosk route meant to look
# absent announced itself.
#
# Only under /api/. Everything else is the SPA, which needs the framework's
# own behaviour (and its own catch-all) left alone.
from flask import request as flask_request
def _api_request():
return flask_request.path.startswith('/api/')
@app.errorhandler(405)
def method_not_allowed_error(error):
if not _api_request():
return error
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That method is not allowed on this endpoint.',
http_code=405)
@app.errorhandler(415)
def unsupported_media_type_error(error):
if not _api_request():
return error
return error_response(
ErrorCodes.VALIDATION_ERROR,
'This endpoint expects a JSON body; send '
'Content-Type: application/json.',
http_code=415)
@app.errorhandler(400)
def bad_request_error(error):
if not _api_request():
return error
# Werkzeug raises this for malformed JSON before any route sees it.
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The request body could not be read as JSON.',
http_code=400)
@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 _column_from_data_error(message):
"""Pull the column name out of a MySQL data error, when it names one.
MySQL says "Data too long for column 'username' at row 1"; SQLite says
nothing of the sort. Best effort on purpose - a message that names the
field is much more useful than a generic one, and a message that names
nothing is still better than a 500.
"""
import re
found = re.search(r"column '([^']+)'", message or '')
return found.group(1) if found else None
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('/<path:path>')
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'
)