Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
186
shopdb/config.py
Normal file
186
shopdb/config.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Flask application configuration."""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised when required configuration is missing or unsafe."""
|
||||
|
||||
|
||||
def _required_env(varname):
|
||||
"""Read an env var; raise ConfigError if missing or empty."""
|
||||
value = os.environ.get(varname)
|
||||
if not value:
|
||||
raise ConfigError(
|
||||
f'{varname} is required in production. Set it in the environment '
|
||||
f'before starting the app. Insecure defaults are not permitted in '
|
||||
f'ProductionConfig.'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration."""
|
||||
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
'DATABASE_URL',
|
||||
'mysql+pymysql://root:password@localhost:3306/shopdb_flask',
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True,
|
||||
'pool_recycle': 300,
|
||||
# Pin the client connection to utf8mb4 so reads/writes match the
|
||||
# utf8mb4 schema regardless of the server default charset. TestingConfig
|
||||
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
|
||||
'connect_args': {'charset': 'utf8mb4'},
|
||||
}
|
||||
|
||||
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(
|
||||
seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 3600))
|
||||
)
|
||||
JWT_REFRESH_TOKEN_EXPIRES = timedelta(
|
||||
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
|
||||
)
|
||||
|
||||
CORS_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',')
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
||||
|
||||
# API key for the unattended PowerShell collector scripts
|
||||
COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '')
|
||||
|
||||
ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true'
|
||||
ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
|
||||
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')
|
||||
|
||||
# Read-only HR/employee directory database (separate from the app DB).
|
||||
# Credentials come from the environment; never hardcode them in source.
|
||||
# No safe default for the password: unset means empty, the connection
|
||||
# fails loud rather than silently trying a guessed credential.
|
||||
EMPLOYEE_DB_HOST = os.environ.get('EMPLOYEE_DB_HOST', 'localhost')
|
||||
EMPLOYEE_DB_USER = os.environ.get('EMPLOYEE_DB_USER', '')
|
||||
EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '')
|
||||
EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees')
|
||||
|
||||
# Read-write CMMC USB check-in/out database (separate from the app DB).
|
||||
# Credentials come from the environment; never hardcode them in source.
|
||||
# No safe default for the password: unset means empty, the connection
|
||||
# fails loud rather than silently trying a guessed credential.
|
||||
CMMC_USB_DB_HOST = os.environ.get('CMMC_USB_DB_HOST', 'localhost')
|
||||
CMMC_USB_DB_USER = os.environ.get('CMMC_USB_DB_USER', '')
|
||||
CMMC_USB_DB_PASSWORD = os.environ.get('CMMC_USB_DB_PASSWORD', '')
|
||||
CMMC_USB_DB_NAME = os.environ.get('CMMC_USB_DB_NAME', 'cmmc_usb')
|
||||
|
||||
CACHE_TYPE = 'SimpleCache'
|
||||
CACHE_DEFAULT_TIMEOUT = 600
|
||||
|
||||
# IP-based login rate limit (fixed window). Defense in depth atop the
|
||||
# per-account lockout. Backed by the existing cache extension.
|
||||
AUTH_RATELIMIT_ENABLED = os.environ.get(
|
||||
'AUTH_RATELIMIT_ENABLED', 'true').lower() == 'true'
|
||||
AUTH_RATELIMIT_MAX = int(os.environ.get('AUTH_RATELIMIT_MAX', 30))
|
||||
AUTH_RATELIMIT_WINDOW_SECONDS = int(
|
||||
os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300))
|
||||
|
||||
DEFAULT_PAGE_SIZE = 20
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration."""
|
||||
|
||||
DEBUG = True
|
||||
SQLALCHEMY_ECHO = True
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
'DATABASE_URL',
|
||||
'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask',
|
||||
)
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing configuration."""
|
||||
|
||||
TESTING = True
|
||||
# Off by default so login-heavy fixtures do not trip the limiter; tests
|
||||
# that exercise rate limiting flip it on via app.config override.
|
||||
AUTH_RATELIMIT_ENABLED = False
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'connect_args': {'check_same_thread': False},
|
||||
'poolclass': StaticPool,
|
||||
}
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(seconds=5)
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration.
|
||||
|
||||
Validation is deferred to validate() so that importing this class in a
|
||||
non-production environment (tests, dev, tooling) does not raise.
|
||||
create_app() invokes validate() when config_name == 'production' so
|
||||
a misconfigured production deploy still fails loud at boot.
|
||||
"""
|
||||
|
||||
DEBUG = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
JWT_COOKIE_SECURE = True
|
||||
JWT_COOKIE_CSRF_PROTECT = True
|
||||
|
||||
@classmethod
|
||||
def validate(cls):
|
||||
"""Verify production config is safe. Called from create_app."""
|
||||
secret_key = os.environ.get('SECRET_KEY', '')
|
||||
jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
|
||||
database_url = os.environ.get('DATABASE_URL', '')
|
||||
cors_raw = os.environ.get('CORS_ORIGINS', '').strip()
|
||||
|
||||
insecure_defaults = {
|
||||
'dev-secret-key-change-in-production',
|
||||
'jwt-secret-key-change-in-production',
|
||||
}
|
||||
|
||||
if not secret_key or secret_key in insecure_defaults:
|
||||
raise ConfigError(
|
||||
'SECRET_KEY is required in production and must not be the '
|
||||
'development default. Set a strong random value in the '
|
||||
'environment before starting the app.'
|
||||
)
|
||||
if not jwt_secret or jwt_secret in insecure_defaults:
|
||||
raise ConfigError(
|
||||
'JWT_SECRET_KEY is required in production and must not be '
|
||||
'the development default. Set a strong random value in the '
|
||||
'environment before starting the app.'
|
||||
)
|
||||
if not database_url:
|
||||
raise ConfigError(
|
||||
'DATABASE_URL is required in production. No fallback to a '
|
||||
'development localhost URL is permitted.'
|
||||
)
|
||||
if not cors_raw or cors_raw == '*':
|
||||
raise ConfigError(
|
||||
'CORS_ORIGINS must be a comma-separated allowlist of '
|
||||
'explicit origins in production. Wildcard "*" is not '
|
||||
'permitted. Example: '
|
||||
'CORS_ORIGINS=https://shopdb.example.com,https://shopdb-mirror.example.com'
|
||||
)
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'testing': TestingConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig,
|
||||
}
|
||||
Reference in New Issue
Block a user