Wave 2 of the remediation plan. Eight findings from the feature test run were the same defect twice: a write that reports success while storing something other than what was sent. The first half is a date parser that swallowed its own error. Two plugins carried an identical private _parse_date returning None on ValueError, so an unparseable date and a deliberately cleared one produced the same write. PUT /api/warranty/<id> with enddate '30-06-2027' answered 200 "Warranty updated" and stored NULL, dropping the record out of every expiry surface on the strength of a typo; the measuring-tool calibration date did the same and flipped the tool to 'unknown', out of the calibration report and the map overlay. shopdb/utils/datevalues.py now holds one strict parser on the contract surface: an explicitly empty value clears the field, anything that is not a date is refused with a 400 naming the field, and those are different intents with different answers. The vendor-lookup paths keep a lenient wrapper on purpose - a provider changing its date shape must not fail the refresh of an otherwise good record, and no person typed that value. The second half is why the truncation findings were possible at all. MySQL was running non-strict: sql_mode on the 5.6 server is NO_ENGINE_SUBSTITUTION with no STRICT_TRANS_TABLES, so it accepted writes it could not store and adjusted them. A 257-character username became its first 100 characters (and the next distinct one then collided with it); an over-long asset number was cut at 50; a malformed releasedate became '0000-00-00' and permanently broke every read of that application. All with a 201. The mode is now set on the CONNECTION rather than left to the server, so a site whose my.cnf disagrees still gets it. That trades silent corruption for a DataError, which uncaught is a 500 - and a 500 says the server is broken when the truth is that the value does not fit. An error handler maps it to a 400 naming the column where MySQL names one. Verified by hand against MySQL 5.6: POST /api/users with a 257-character username now answers 400 "That value is too long for username." and stores nothing, where it previously stored a truncated row and answered 201. Twelve tests. The date guard is verified by planting: restore the swallowing parser and four of them fail. The DataError tests raise the error directly because the suite runs on SQLite, which does not enforce VARCHAR length at all - there is no value that would trigger it there, which is precisely why this went unnoticed. Scope: product
503 lines
22 KiB
Python
503 lines
22 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 (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.
|
|
# 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.
|
|
__contract_version__ = '0.30.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():
|
|
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"]
|
|
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',
|
|
'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 `<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)
|
|
|
|
@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 _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'
|
|
)
|