mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
eb7b1be05f
Register Português in LANGUAGES and normalize pt-BR/pt-PT (and similar) to pt in _normalize_locale so Accept-Language and stored preferences resolve to translations/pt/. Add translations/pt/LC_MESSAGES/messages.po seeded from English msgids; translators can fill msgstr incrementally. Extend i18n tests for pt presence and catalog file. Update translation docs (TRANSLATION_SYSTEM, CONTRIBUTING_TRANSLATIONS, implementation note).
1409 lines
60 KiB
Python
1409 lines
60 KiB
Python
import logging
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
from datetime import timedelta
|
|
from urllib.parse import urlparse
|
|
|
|
import sentry_sdk
|
|
from authlib.integrations.flask_client import OAuth
|
|
from dotenv import load_dotenv
|
|
from flask import Flask, flash, g, jsonify, redirect, request, session, url_for
|
|
from flask_babel import Babel, _
|
|
from flask_limiter import Limiter
|
|
from flask_limiter.util import get_remote_address
|
|
from flask_login import LoginManager
|
|
from flask_migrate import Migrate
|
|
from flask_socketio import SocketIO
|
|
from flask_sqlalchemy import SQLAlchemy
|
|
from flask_wtf.csrf import CSRFError, CSRFProtect
|
|
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
|
from pythonjsonlogger import jsonlogger
|
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
|
from sqlalchemy.pool import StaticPool
|
|
from werkzeug.http import parse_options_header
|
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
# Initialize extensions
|
|
db = SQLAlchemy()
|
|
migrate = Migrate()
|
|
login_manager = LoginManager()
|
|
socketio = SocketIO()
|
|
babel = Babel()
|
|
csrf = CSRFProtect()
|
|
limiter = Limiter(key_func=get_remote_address, default_limits=[])
|
|
oauth = OAuth()
|
|
|
|
# Initialize Mail (will be configured in create_app)
|
|
from flask_mail import Mail
|
|
|
|
mail = Mail()
|
|
|
|
# Initialize APScheduler for background tasks
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
|
|
scheduler = BackgroundScheduler()
|
|
|
|
# Initialize Prometheus metrics
|
|
REQUEST_COUNT = Counter("tt_requests_total", "Total requests", ["method", "endpoint", "http_status"])
|
|
REQUEST_LATENCY = Histogram("tt_request_latency_seconds", "Request latency seconds", ["endpoint"])
|
|
|
|
# Initialize JSON logger for structured logging
|
|
json_logger = logging.getLogger("timetracker")
|
|
json_logger.setLevel(logging.INFO)
|
|
|
|
|
|
def log_event(name: str, **kwargs):
|
|
"""Log an event with structured JSON format including request context"""
|
|
try:
|
|
extra = {"request_id": getattr(g, "request_id", None), "event": name, **kwargs}
|
|
try:
|
|
from app.telemetry.otel_setup import get_trace_context_for_logs, is_otel_tracing_active
|
|
|
|
if is_otel_tracing_active():
|
|
extra.update(get_trace_context_for_logs())
|
|
except Exception:
|
|
pass
|
|
json_logger.info(name, extra=extra)
|
|
except Exception as e:
|
|
logging.getLogger(__name__).debug("Structured log_event failed: %s", e)
|
|
|
|
|
|
def identify_user(user_id, properties=None):
|
|
"""
|
|
Identify a user in the analytics backend (consent-aware).
|
|
Delegates to telemetry service; only sent when detailed analytics is opted in.
|
|
"""
|
|
try:
|
|
from app.telemetry.service import identify_user as _identify
|
|
|
|
_identify(user_id, properties)
|
|
except Exception as e:
|
|
logging.getLogger(__name__).debug("Telemetry identify_user failed: %s", e)
|
|
|
|
|
|
def track_event(user_id, event_name, properties=None):
|
|
"""
|
|
Track a product analytics event (consent-aware).
|
|
Delegates to telemetry service; only sent when detailed analytics is opted in.
|
|
"""
|
|
try:
|
|
from app.telemetry.service import send_analytics_event
|
|
|
|
send_analytics_event(user_id, event_name, properties)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def track_page_view(page_name, user_id=None, properties=None):
|
|
"""
|
|
Track a page view event (consent-aware). Only sent when detailed analytics is opted in.
|
|
"""
|
|
try:
|
|
if user_id is None:
|
|
from flask_login import current_user
|
|
|
|
if current_user.is_authenticated:
|
|
user_id = current_user.id
|
|
else:
|
|
return
|
|
page_properties = {
|
|
"page_name": page_name,
|
|
"$pathname": request.path if request else None,
|
|
"$current_url": request.url if request else None,
|
|
}
|
|
if properties:
|
|
page_properties.update(properties)
|
|
track_event(user_id, "$pageview", page_properties)
|
|
except Exception as e:
|
|
logging.getLogger(__name__).debug("Telemetry track_page_view failed: %s", e)
|
|
|
|
|
|
def create_app(config=None):
|
|
"""Application factory pattern"""
|
|
app = Flask(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
bootstrap_mode = os.getenv("TT_BOOTSTRAP_MODE", "").strip().lower()
|
|
|
|
# Validate environment variables on startup (non-blocking warnings in dev, errors in prod)
|
|
try:
|
|
from app.utils.env_validation import validate_all
|
|
|
|
is_production = os.getenv("FLASK_ENV", "production") == "production"
|
|
is_valid, results = validate_all(raise_on_error=is_production)
|
|
|
|
if not is_valid:
|
|
if is_production:
|
|
app.logger.error("Environment validation failed - see details below")
|
|
else:
|
|
app.logger.warning("Environment validation warnings - see details below")
|
|
|
|
if results.get("warnings"):
|
|
for warning in results["warnings"]:
|
|
app.logger.warning(f" - {warning}")
|
|
|
|
if results.get("production", {}).get("issues"):
|
|
for issue in results["production"]["issues"]:
|
|
if is_production:
|
|
app.logger.error(f" - {issue}")
|
|
else:
|
|
app.logger.warning(f" - {issue}")
|
|
except Exception as e:
|
|
# Don't fail app startup if validation itself fails
|
|
app.logger.warning(f"Environment validation check failed: {e}")
|
|
|
|
# Make app aware of reverse proxy (scheme/host/port) for correct URL generation & cookies
|
|
# Trust a single proxy by default; adjust via env if needed
|
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
|
|
|
# Configuration
|
|
# Load env-specific config class
|
|
try:
|
|
env_name = os.getenv("FLASK_ENV", "production")
|
|
cfg_map = {
|
|
"development": "app.config.DevelopmentConfig",
|
|
"testing": "app.config.TestingConfig",
|
|
"production": "app.config.ProductionConfig",
|
|
}
|
|
app.config.from_object(cfg_map.get(env_name, "app.config.Config"))
|
|
except Exception:
|
|
app.config.from_object("app.config.Config")
|
|
if config:
|
|
app.config.update(config)
|
|
|
|
# Production safety: refuse to start with default SECRET_KEY
|
|
if (
|
|
app.config.get("FLASK_ENV") == "production"
|
|
and app.config.get("SECRET_KEY") == "dev-secret-key-change-in-production"
|
|
):
|
|
raise ValueError(
|
|
"SECRET_KEY must be set explicitly in production. "
|
|
"Set the SECRET_KEY environment variable to a secure random value."
|
|
)
|
|
|
|
# Special handling for SQLite in-memory DB during tests:
|
|
# ensure a single shared connection so objects don't disappear after commit.
|
|
try:
|
|
# In tests, proactively clear POSTGRES_* env hints to avoid accidental overrides
|
|
if app.config.get("TESTING"):
|
|
for var in ("POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_HOST", "DATABASE_URL"):
|
|
try:
|
|
os.environ.pop(var, None)
|
|
except Exception:
|
|
pass
|
|
db_uri = str(app.config.get("SQLALCHEMY_DATABASE_URI", "") or "")
|
|
if (
|
|
app.config.get("TESTING")
|
|
and isinstance(db_uri, str)
|
|
and db_uri.startswith("sqlite")
|
|
and ":memory:" in db_uri
|
|
):
|
|
# Use a file-based SQLite database during tests to ensure consistent behavior across contexts
|
|
db_file = os.path.join(tempfile.gettempdir(), f"timetracker_pytest_{os.getpid()}.sqlite")
|
|
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_file}"
|
|
# Also keep permissive engine options for SQLite
|
|
engine_opts = dict(app.config.get("SQLALCHEMY_ENGINE_OPTIONS") or {})
|
|
engine_opts.setdefault("connect_args", {"check_same_thread": False})
|
|
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = engine_opts
|
|
# Avoid attribute expiration on commit during tests to keep objects usable
|
|
if app.config.get("TESTING"):
|
|
session_opts = dict(app.config.get("SQLALCHEMY_SESSION_OPTIONS") or {})
|
|
session_opts.setdefault("expire_on_commit", False)
|
|
app.config["SQLALCHEMY_SESSION_OPTIONS"] = session_opts
|
|
except Exception:
|
|
# Do not fail app creation for engine option tweaks
|
|
pass
|
|
|
|
# All templates live in app/templates (legacy root templates/ was merged in)
|
|
|
|
# Prefer Postgres if POSTGRES_* env vars are present but URL points to SQLite
|
|
# BUT only if DATABASE_URL was not explicitly set to SQLite
|
|
current_url = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
|
explicit_database_url = os.getenv("DATABASE_URL", "")
|
|
|
|
# Only auto-switch to PostgreSQL if:
|
|
# 1. Not in testing mode
|
|
# 2. Current URL is SQLite
|
|
# 3. POSTGRES_* env vars are present
|
|
# 4. DATABASE_URL was NOT explicitly set to SQLite (respect user's explicit choice)
|
|
if (
|
|
not app.config.get("TESTING")
|
|
and isinstance(current_url, str)
|
|
and current_url.startswith("sqlite")
|
|
and (os.getenv("POSTGRES_DB") or os.getenv("POSTGRES_USER") or os.getenv("POSTGRES_PASSWORD"))
|
|
and not (explicit_database_url and explicit_database_url.startswith("sqlite"))
|
|
):
|
|
pg_user = os.getenv("POSTGRES_USER", "timetracker")
|
|
pg_pass = os.getenv("POSTGRES_PASSWORD", "timetracker")
|
|
pg_db = os.getenv("POSTGRES_DB", "timetracker")
|
|
pg_host = os.getenv("POSTGRES_HOST", "db")
|
|
app.config["SQLALCHEMY_DATABASE_URI"] = f"postgresql+psycopg2://{pg_user}:{pg_pass}@{pg_host}:5432/{pg_db}"
|
|
|
|
# Initialize extensions
|
|
db.init_app(app)
|
|
migrate.init_app(app, db)
|
|
login_manager.init_app(app)
|
|
socketio.init_app(app, cors_allowed_origins="*")
|
|
oauth.init_app(app)
|
|
|
|
# Fast-path for migration/bootstrap runs:
|
|
# we only need config + db/migrate + models loaded. Avoid registering routes,
|
|
# background jobs, integrations, etc. to keep migrations as fast and reliable
|
|
# as possible during container startup.
|
|
if bootstrap_mode == "migrate":
|
|
try:
|
|
# Ensure all model tables are registered in SQLAlchemy metadata
|
|
from . import models as _models # noqa: F401
|
|
except Exception:
|
|
pass
|
|
return app
|
|
|
|
# Initialize audit logging - register event listeners AFTER db.init_app()
|
|
# Flask-SQLAlchemy uses its own session class, so we need to register with it
|
|
from sqlalchemy import event
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.utils import audit
|
|
|
|
# Register with generic SQLAlchemy Session (catches all Session instances)
|
|
event.listen(Session, "before_flush", audit.receive_before_flush)
|
|
event.listen(Session, "after_flush", audit.receive_after_flush)
|
|
|
|
# Also register with Flask-SQLAlchemy's sessionmaker if available
|
|
# Flask-SQLAlchemy creates sessions from a sessionmaker, so we register with that
|
|
try:
|
|
# Get the sessionmaker from Flask-SQLAlchemy
|
|
if hasattr(db, "session") and hasattr(db.session, "registry"):
|
|
sessionmaker = db.session.registry()
|
|
if sessionmaker:
|
|
# Register with the session class that the sessionmaker creates
|
|
session_class = sessionmaker.class_
|
|
if session_class:
|
|
event.listen(session_class, "before_flush", audit.receive_before_flush)
|
|
event.listen(session_class, "after_flush", audit.receive_after_flush)
|
|
logger.info(
|
|
f"Registered audit logging with Flask-SQLAlchemy session class: {session_class.__name__}"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Could not register with Flask-SQLAlchemy sessionmaker: {e}")
|
|
|
|
# Register with SignallingSession (Flask-SQLAlchemy 2.x)
|
|
try:
|
|
from flask_sqlalchemy import SignallingSession
|
|
|
|
event.listen(SignallingSession, "before_flush", audit.receive_before_flush)
|
|
event.listen(SignallingSession, "after_flush", audit.receive_after_flush)
|
|
logger.info("Registered audit logging with Flask-SQLAlchemy SignallingSession")
|
|
except (ImportError, AttributeError):
|
|
pass
|
|
|
|
logger.info("Audit logging event listeners registered")
|
|
|
|
# OpenTelemetry (traces + OTLP metrics) — same OTLP credentials as manual log export
|
|
try:
|
|
from app.telemetry.otel_setup import init_opentelemetry
|
|
|
|
init_opentelemetry(app)
|
|
except Exception as e:
|
|
logger.warning("OpenTelemetry initialization skipped: %s", e)
|
|
|
|
# Initialize Settings from environment variables on startup.
|
|
# Skip during bootstrap/migration runs to avoid DB access before schema exists.
|
|
if bootstrap_mode != "migrate":
|
|
with app.app_context():
|
|
try:
|
|
from app.models import Settings
|
|
|
|
# This will create Settings if it doesn't exist and initialize from .env.
|
|
# The get_settings() method automatically initializes new Settings from .env.
|
|
Settings.get_settings()
|
|
except Exception as e:
|
|
# Don't fail app startup if Settings initialization fails
|
|
# (e.g., database not ready yet, migration not run)
|
|
app.logger.warning(f"Could not initialize Settings from environment: {e}")
|
|
|
|
# Initialize Flask-Mail
|
|
from app.utils.email import init_mail
|
|
|
|
init_mail(app)
|
|
|
|
# Initialize and start background scheduler (disabled in tests).
|
|
# Skip during bootstrap/migration runs to avoid background DB work during migrations.
|
|
if bootstrap_mode != "migrate":
|
|
if (not app.config.get("TESTING")) and (not scheduler.running):
|
|
from app.utils.scheduled_tasks import register_scheduled_tasks
|
|
|
|
scheduler.start()
|
|
# Register tasks after app context is available, passing app instance
|
|
with app.app_context():
|
|
register_scheduled_tasks(scheduler, app=app)
|
|
# Base telemetry: send first_seen once per install (idempotent)
|
|
try:
|
|
from app.telemetry.service import send_base_first_seen
|
|
|
|
send_base_first_seen()
|
|
except Exception:
|
|
pass
|
|
|
|
# Only initialize CSRF protection if enabled
|
|
if app.config.get("WTF_CSRF_ENABLED"):
|
|
csrf.init_app(app)
|
|
try:
|
|
# Configure limiter defaults from config if provided
|
|
default_limits = []
|
|
raw = app.config.get("RATELIMIT_DEFAULT")
|
|
if raw:
|
|
# support semicolon or comma separated limits
|
|
parts = [p.strip() for p in str(raw).replace(",", ";").split(";") if p.strip()]
|
|
if parts:
|
|
default_limits = parts
|
|
limiter._default_limits = default_limits # set after init
|
|
limiter.init_app(app)
|
|
except Exception:
|
|
limiter.init_app(app)
|
|
|
|
# Configure absolute translation directories before Babel init.
|
|
# Do NOT run subprocess-based compilation during app creation (slow, noisy).
|
|
try:
|
|
translations_dirs = (app.config.get("BABEL_TRANSLATION_DIRECTORIES") or "translations").split(",")
|
|
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
abs_dirs = []
|
|
for d in translations_dirs:
|
|
d = d.strip()
|
|
if not d:
|
|
continue
|
|
abs_dirs.append(d if os.path.isabs(d) else os.path.abspath(os.path.join(base_path, d)))
|
|
if abs_dirs:
|
|
app.config["BABEL_TRANSLATION_DIRECTORIES"] = os.pathsep.join(abs_dirs)
|
|
# Optional: best-effort compile missing/stale .mo files (Python-only, incremental).
|
|
# Disabled by default for faster startup; compile in Docker build instead.
|
|
if os.getenv("TT_COMPILE_TRANSLATIONS_ON_STARTUP", "false").strip().lower() in ("1", "true", "yes"):
|
|
from app.utils.i18n import ensure_translations_compiled
|
|
|
|
for d in abs_dirs:
|
|
ensure_translations_compiled(d)
|
|
except Exception:
|
|
pass
|
|
|
|
# Internationalization: locale selector compatible with Flask-Babel v4+
|
|
def _select_locale():
|
|
try:
|
|
# 1) User preference from DB
|
|
from flask_login import current_user
|
|
|
|
if current_user and getattr(current_user, "is_authenticated", False):
|
|
pref = getattr(current_user, "preferred_language", None)
|
|
if pref:
|
|
# Normalize locale code (e.g., 'no' -> 'nb' for Norwegian)
|
|
return _normalize_locale(pref)
|
|
# 2) Session override (set-language route)
|
|
if "preferred_language" in session:
|
|
return _normalize_locale(session.get("preferred_language"))
|
|
# 3) Best match with Accept-Language
|
|
supported = list(app.config.get("LANGUAGES", {}).keys()) or ["en"]
|
|
matched = request.accept_languages.best_match(supported) or app.config.get("BABEL_DEFAULT_LOCALE", "en")
|
|
return _normalize_locale(matched)
|
|
except Exception:
|
|
return app.config.get("BABEL_DEFAULT_LOCALE", "en")
|
|
|
|
def _normalize_locale(locale_code):
|
|
"""Normalize locale codes for Flask-Babel compatibility.
|
|
|
|
Some locale codes need to be normalized:
|
|
- 'no' -> 'nb' (Norwegian Bokmål catalog directory)
|
|
- 'pt-br', 'pt_pt', 'pt-pt', etc. -> 'pt' (single Portuguese catalog)
|
|
"""
|
|
if not locale_code:
|
|
return "en"
|
|
raw = locale_code.strip()
|
|
lower = raw.lower().replace("_", "-")
|
|
# Portuguese: fold regional variants to generic pt (one catalog under translations/pt/)
|
|
if lower == "pt" or lower.startswith("pt-"):
|
|
# pt-br, pt-pt, PT -> pt
|
|
return "pt"
|
|
locale_code = raw.lower().strip()
|
|
if locale_code == "no":
|
|
# Use 'nb' for Flask-Babel (standard Norwegian Bokmål locale)
|
|
return "nb"
|
|
return locale_code
|
|
|
|
babel.init_app(
|
|
app,
|
|
default_locale=app.config.get("BABEL_DEFAULT_LOCALE", "en"),
|
|
default_timezone=app.config.get("TZ", "Europe/Rome"),
|
|
locale_selector=_select_locale,
|
|
)
|
|
|
|
# Ensure gettext helpers available in Jinja
|
|
try:
|
|
from flask_babel import gettext as _gettext
|
|
from flask_babel import ngettext as _ngettext
|
|
|
|
app.jinja_env.globals.update(_=_gettext, ngettext=_ngettext)
|
|
except Exception:
|
|
pass
|
|
|
|
# Add Python built-ins that are useful in templates
|
|
app.jinja_env.globals.update(getattr=getattr)
|
|
|
|
# Log effective database URL (mask password)
|
|
db_url = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
|
try:
|
|
masked_db_url = re.sub(r"//([^:]+):[^@]+@", r"//\\1:***@", db_url)
|
|
except Exception:
|
|
masked_db_url = db_url
|
|
app.logger.info(f"Using database URL: {masked_db_url}")
|
|
|
|
# Configure login manager
|
|
login_manager.login_view = "auth.login"
|
|
login_manager.login_message = "Please log in to access this page."
|
|
login_manager.login_message_category = "info"
|
|
|
|
# Internationalization selector handled via babel.init_app(locale_selector=...)
|
|
|
|
# Ensure compatibility with tests and different Flask-Login versions:
|
|
# Some test suites set session['_user_id'] while Flask-Login (or vice versa)
|
|
# may read 'user_id'. Mirror both keys when one is present so that
|
|
# programmatic session login in tests works reliably.
|
|
@app.before_request
|
|
def _harmonize_login_session_keys():
|
|
try:
|
|
uid = session.get("_user_id") or session.get("user_id")
|
|
if uid:
|
|
# Normalize to strings as Flask-Login stores ids as strings
|
|
uid_str = str(uid)
|
|
if session.get("_user_id") != uid_str:
|
|
session["_user_id"] = uid_str
|
|
if session.get("user_id") != uid_str:
|
|
session["user_id"] = uid_str
|
|
except Exception:
|
|
# Do not block request processing on any session edge case
|
|
pass
|
|
|
|
# In testing, ensure that if a session user id is present but current_user
|
|
# isn't populated yet, we proactively authenticate the user for this request.
|
|
# This improves reliability of auth-dependent integration tests that set
|
|
# session keys directly or occasionally lose the session between redirects.
|
|
@app.before_request
|
|
def _ensure_user_authenticated_in_tests():
|
|
try:
|
|
if app.config.get("TESTING"):
|
|
from flask_login import current_user, login_user
|
|
|
|
from app.utils.db import safe_query
|
|
|
|
if not getattr(current_user, "is_authenticated", False):
|
|
uid = session.get("_user_id") or session.get("user_id")
|
|
if uid:
|
|
from app.models import User
|
|
|
|
try:
|
|
user_id_int = int(uid)
|
|
except (ValueError, TypeError):
|
|
user = None
|
|
else:
|
|
user = safe_query(lambda: User.query.get(user_id_int), default=None)
|
|
|
|
if user and getattr(user, "is_active", True):
|
|
login_user(user, remember=True)
|
|
except Exception:
|
|
# Never fail the request due to this helper
|
|
pass
|
|
|
|
# Register user loader
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
"""Load user for Flask-Login with proper transaction error handling"""
|
|
from app.models import User
|
|
from app.utils.db import safe_query
|
|
|
|
try:
|
|
user_id_int = int(user_id)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
return safe_query(lambda: User.query.get(user_id_int), default=None)
|
|
|
|
# Check if initial setup is required (skip for certain routes)
|
|
@app.before_request
|
|
def check_setup_required():
|
|
try:
|
|
# Skip setup check in testing mode
|
|
if app.config.get("TESTING"):
|
|
return
|
|
|
|
# Skip setup check for these routes
|
|
skip_routes = [
|
|
"setup.initial_setup",
|
|
"static",
|
|
"auth.login",
|
|
"auth.logout",
|
|
"main.health_check",
|
|
"main.readiness_check",
|
|
]
|
|
if request.endpoint in skip_routes:
|
|
return
|
|
|
|
# Skip for assets and health checks
|
|
if request.path.startswith("/static/") or request.path.startswith("/_"):
|
|
return
|
|
|
|
# API discovery and mobile login must stay JSON (not HTML redirect) during install
|
|
if request.path.startswith("/api/v1/info") or request.path.startswith("/api/v1/health"):
|
|
return
|
|
if request.path == "/api/v1/auth/login" and request.method == "POST":
|
|
return
|
|
|
|
# Check if setup is complete
|
|
from app.utils.installation import get_installation_config
|
|
|
|
installation_config = get_installation_config()
|
|
|
|
if not installation_config.is_setup_complete():
|
|
return redirect(url_for("setup.initial_setup"))
|
|
except Exception:
|
|
pass
|
|
|
|
# Attach request ID for tracing
|
|
@app.before_request
|
|
def attach_request_id():
|
|
try:
|
|
g.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
|
except Exception:
|
|
pass
|
|
|
|
@app.before_request
|
|
def handle_api_cors_preflight():
|
|
if request.method == "OPTIONS" and request.path.startswith("/api/v1/"):
|
|
response = app.response_class(status=204)
|
|
response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin") or "*"
|
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-Request-ID"
|
|
response.headers["Access-Control-Max-Age"] = "600"
|
|
return response
|
|
|
|
# Start timer for Prometheus metrics
|
|
@app.before_request
|
|
def prom_start_timer():
|
|
try:
|
|
g._start_time = time.time()
|
|
except Exception:
|
|
pass
|
|
|
|
# Request logging for /login to trace POSTs reaching the app
|
|
@app.before_request
|
|
def log_login_requests():
|
|
try:
|
|
if request.path == "/login":
|
|
app.logger.info(
|
|
"%s %s from %s UA=%s",
|
|
request.method,
|
|
request.path,
|
|
request.headers.get("X-Forwarded-For") or request.remote_addr,
|
|
request.headers.get("User-Agent"),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Record Prometheus metrics and log write operations
|
|
@app.after_request
|
|
def record_metrics_and_log(response):
|
|
latency = time.time() - getattr(g, "_start_time", time.time())
|
|
try:
|
|
# Record Prometheus metrics
|
|
endpoint = request.endpoint or "unknown"
|
|
REQUEST_LATENCY.labels(endpoint=endpoint).observe(latency)
|
|
REQUEST_COUNT.labels(method=request.method, endpoint=endpoint, http_status=response.status_code).inc()
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
from app.telemetry.otel_setup import inject_traceparent_headers, record_http_server_metrics
|
|
|
|
route = getattr(request.url_rule, "rule", None) or (request.endpoint or "unknown")
|
|
record_http_server_metrics(request.method, route, response.status_code, latency)
|
|
response = inject_traceparent_headers(response)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
# Log write operations
|
|
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
|
app.logger.info(
|
|
"%s %s -> %s from %s",
|
|
request.method,
|
|
request.path,
|
|
response.status_code,
|
|
request.headers.get("X-Forwarded-For") or request.remote_addr,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return response
|
|
|
|
# Configure session
|
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(seconds=int(os.getenv("PERMANENT_SESSION_LIFETIME", 86400)))
|
|
|
|
# Setup logging (including JSON logging)
|
|
from app.utils.setup_logging import setup_logging as _setup_logging
|
|
|
|
_setup_logging(app)
|
|
|
|
# Enable query logging in development mode
|
|
if app.config.get("FLASK_DEBUG") or app.config.get("TESTING"):
|
|
try:
|
|
from app.utils.query_logging import enable_query_counting, enable_query_logging
|
|
|
|
enable_query_logging(app, slow_query_threshold=0.1)
|
|
enable_query_counting(app)
|
|
app.logger.info("Query logging enabled (development mode)")
|
|
except Exception as e:
|
|
app.logger.warning(f"Could not enable query logging: {e}")
|
|
|
|
# Optional performance instrumentation (slow-request log, query-count when PERF_QUERY_PROFILE=1)
|
|
try:
|
|
from app.utils.performance import init_performance_logging
|
|
|
|
init_performance_logging(app)
|
|
except Exception as e:
|
|
app.logger.warning(f"Could not init performance logging: {e}")
|
|
|
|
# Load analytics configuration (embedded at build time)
|
|
from app.config.analytics_defaults import get_analytics_config, has_analytics_configured
|
|
|
|
analytics_config = get_analytics_config()
|
|
|
|
# Log analytics status (for transparency)
|
|
if has_analytics_configured():
|
|
app.logger.info("TimeTracker with analytics configured (telemetry opt-in via admin dashboard)")
|
|
else:
|
|
app.logger.info("TimeTracker build without analytics configuration")
|
|
|
|
# Initialize Sentry for error monitoring
|
|
# Priority: Env var > Built-in default > Disabled
|
|
sentry_dsn = analytics_config.get("sentry_dsn", "")
|
|
if sentry_dsn:
|
|
try:
|
|
sentry_sdk.init(
|
|
dsn=sentry_dsn,
|
|
integrations=[FlaskIntegration()],
|
|
traces_sample_rate=analytics_config.get("sentry_traces_rate", 0.0),
|
|
environment=os.getenv("FLASK_ENV", "production"),
|
|
release=analytics_config.get("app_version"),
|
|
)
|
|
app.logger.info("Sentry error monitoring initialized")
|
|
except Exception as e:
|
|
app.logger.warning(f"Failed to initialize Sentry: {e}")
|
|
|
|
# Fail-fast on weak/missing secret in production
|
|
# Skip validation in testing or debug mode
|
|
is_testing = app.config.get("TESTING", False)
|
|
# Check both config and environment variable for FLASK_ENV
|
|
flask_env_config = app.config.get("FLASK_ENV")
|
|
flask_env_env = os.getenv("FLASK_ENV", "production")
|
|
flask_env = flask_env_config if flask_env_config else flask_env_env
|
|
is_production_env = flask_env == "production" and not is_testing
|
|
|
|
if not app.debug and is_production_env:
|
|
secret = app.config.get("SECRET_KEY")
|
|
placeholder_values = {
|
|
"dev-secret-key-change-in-production",
|
|
"your-secret-key-change-this",
|
|
"your-secret-key-here",
|
|
}
|
|
if (not secret) or (secret in placeholder_values) or (isinstance(secret, str) and len(secret) < 32):
|
|
app.logger.error("Invalid SECRET_KEY configured in production; refusing to start")
|
|
raise RuntimeError("Invalid SECRET_KEY in production")
|
|
|
|
# Check for debug mode in production - this is a security risk
|
|
flask_debug = app.config.get("FLASK_DEBUG", False)
|
|
if flask_debug or app.debug:
|
|
app.logger.error("Debug mode is enabled in production; refusing to start")
|
|
app.logger.error("Debug mode can leak sensitive information and should never be enabled in production")
|
|
raise RuntimeError("Debug mode cannot be enabled in production")
|
|
|
|
# Apply security headers and a basic CSP
|
|
@app.after_request
|
|
def apply_security_headers(response):
|
|
try:
|
|
headers = app.config.get("SECURITY_HEADERS", {}) or {}
|
|
for k, v in headers.items():
|
|
# do not overwrite existing header if already present
|
|
if not response.headers.get(k):
|
|
response.headers[k] = v
|
|
# Minimal CSP allowing our own resources and common CDNs used in templates
|
|
if not response.headers.get("Content-Security-Policy"):
|
|
csp = (
|
|
"default-src 'self'; "
|
|
"img-src 'self' data: https:; "
|
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.datatables.net https://uicdn.toast.com; "
|
|
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com data:; "
|
|
"script-src 'self' 'unsafe-inline' https://code.jquery.com https://cdn.datatables.net https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://uicdn.toast.com; "
|
|
"connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; "
|
|
"frame-ancestors 'none'"
|
|
)
|
|
response.headers["Content-Security-Policy"] = csp
|
|
# Additional privacy headers
|
|
if not response.headers.get("Referrer-Policy"):
|
|
response.headers["Referrer-Policy"] = "no-referrer"
|
|
if not response.headers.get("Permissions-Policy"):
|
|
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
|
except Exception:
|
|
pass
|
|
|
|
# CSRF cookie/token handling
|
|
# If CSRF is enabled, ensure CSRF cookie exists for HTML GET responses
|
|
# If CSRF is disabled, explicitly clear any existing CSRF cookie to avoid confusion
|
|
try:
|
|
if request.path.startswith("/api/v1/"):
|
|
response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin") or "*"
|
|
response.headers["Vary"] = "Origin"
|
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
|
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-Request-ID"
|
|
except Exception:
|
|
pass
|
|
|
|
if app.config.get("WTF_CSRF_ENABLED"):
|
|
try:
|
|
# Only for safe, HTML page responses
|
|
if request.method == "GET":
|
|
content_type = response.headers.get("Content-Type", "")
|
|
if isinstance(content_type, str) and content_type.startswith("text/html"):
|
|
cookie_name = app.config.get("CSRF_COOKIE_NAME", "XSRF-TOKEN")
|
|
has_cookie = bool(request.cookies.get(cookie_name))
|
|
if not has_cookie:
|
|
# Generate a CSRF token and set cookie using same settings as /auth/csrf-token
|
|
try:
|
|
from flask_wtf.csrf import generate_csrf
|
|
|
|
token = generate_csrf()
|
|
except Exception:
|
|
token = ""
|
|
cookie_secure = bool(
|
|
app.config.get(
|
|
"CSRF_COOKIE_SECURE",
|
|
app.config.get("SESSION_COOKIE_SECURE", False),
|
|
)
|
|
)
|
|
cookie_httponly = bool(app.config.get("CSRF_COOKIE_HTTPONLY", False))
|
|
cookie_samesite = app.config.get("CSRF_COOKIE_SAMESITE", "Lax")
|
|
cookie_domain = app.config.get("CSRF_COOKIE_DOMAIN") or None
|
|
cookie_path = app.config.get("CSRF_COOKIE_PATH", "/")
|
|
try:
|
|
max_age = int(app.config.get("WTF_CSRF_TIME_LIMIT", 3600))
|
|
except Exception:
|
|
max_age = 3600
|
|
response.set_cookie(
|
|
cookie_name,
|
|
token or "",
|
|
max_age=max_age,
|
|
secure=cookie_secure,
|
|
httponly=cookie_httponly,
|
|
samesite=cookie_samesite,
|
|
domain=cookie_domain,
|
|
path=cookie_path,
|
|
)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
cookie_name = app.config.get("CSRF_COOKIE_NAME", "XSRF-TOKEN")
|
|
if request.cookies.get(cookie_name):
|
|
# Clear the cookie by setting it expired
|
|
response.set_cookie(
|
|
cookie_name,
|
|
"",
|
|
max_age=0,
|
|
expires=0,
|
|
path=app.config.get("CSRF_COOKIE_PATH", "/"),
|
|
domain=app.config.get("CSRF_COOKIE_DOMAIN") or None,
|
|
secure=bool(
|
|
app.config.get("CSRF_COOKIE_SECURE", app.config.get("SESSION_COOKIE_SECURE", False))
|
|
),
|
|
httponly=bool(app.config.get("CSRF_COOKIE_HTTPONLY", False)),
|
|
samesite=app.config.get("CSRF_COOKIE_SAMESITE", "Lax"),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return response
|
|
|
|
# CSRF error handler with HTML-friendly fallback
|
|
@app.errorhandler(CSRFError)
|
|
def handle_csrf_error(e):
|
|
# Prefer HTML flow for classic form posts regardless of Accept header quirks
|
|
try:
|
|
mimetype, _ = parse_options_header(request.headers.get("Content-Type", ""))
|
|
is_classic_form = mimetype in ("application/x-www-form-urlencoded", "multipart/form-data")
|
|
except Exception:
|
|
is_classic_form = False
|
|
|
|
# Log details for diagnostics
|
|
try:
|
|
try:
|
|
from flask_login import current_user as _cu
|
|
|
|
user_id = getattr(_cu, "id", None) if getattr(_cu, "is_authenticated", False) else None
|
|
except Exception:
|
|
user_id = None
|
|
app.logger.warning(
|
|
"CSRF failure: path=%s method=%s form=%s json=%s ref=%s user=%s reason=%s",
|
|
request.path,
|
|
request.method,
|
|
bool(request.form),
|
|
request.is_json,
|
|
request.referrer,
|
|
user_id,
|
|
getattr(e, "description", ""),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
if request.method == "POST" and (is_classic_form or (request.form and not request.is_json)):
|
|
try:
|
|
flash(_("Your session expired or the page was open too long. Please try again."), "warning")
|
|
except Exception:
|
|
flash("Your session expired or the page was open too long. Please try again.", "warning")
|
|
|
|
# Redirect back to a safe same-origin referrer if available, else to dashboard
|
|
dest = url_for("main.dashboard")
|
|
try:
|
|
ref = request.referrer
|
|
if ref:
|
|
ref_host = urlparse(ref).netloc
|
|
cur_host = urlparse(request.host_url).netloc
|
|
if ref_host and ref_host == cur_host:
|
|
dest = ref
|
|
except Exception:
|
|
pass
|
|
return redirect(dest)
|
|
|
|
# JSON/XHR fall-through
|
|
try:
|
|
wants_json = (
|
|
request.is_json
|
|
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
|
or request.accept_mimetypes["application/json"] >= request.accept_mimetypes["text/html"]
|
|
)
|
|
except Exception:
|
|
wants_json = False
|
|
|
|
if wants_json:
|
|
return jsonify(error="csrf_token_missing_or_invalid"), 400
|
|
|
|
# Default to HTML-friendly behavior
|
|
try:
|
|
flash(_("Your session expired or the page was open too long. Please try again."), "warning")
|
|
except Exception:
|
|
flash("Your session expired or the page was open too long. Please try again.", "warning")
|
|
dest = url_for("main.dashboard")
|
|
try:
|
|
ref = request.referrer
|
|
if ref:
|
|
ref_host = urlparse(ref).netloc
|
|
cur_host = urlparse(request.host_url).netloc
|
|
if ref_host and ref_host == cur_host:
|
|
dest = ref
|
|
except Exception:
|
|
pass
|
|
return redirect(dest)
|
|
|
|
# Expose csrf_token() in Jinja templates even without FlaskForm
|
|
# Always inject the function, but return empty string when CSRF is disabled
|
|
@app.context_processor
|
|
def inject_csrf_token():
|
|
def get_csrf_token():
|
|
# Return empty string if CSRF is disabled
|
|
if not app.config.get("WTF_CSRF_ENABLED"):
|
|
return ""
|
|
try:
|
|
from flask_wtf.csrf import generate_csrf
|
|
|
|
return generate_csrf()
|
|
except Exception:
|
|
return ""
|
|
|
|
return dict(csrf_token=get_csrf_token)
|
|
|
|
# CSRF token refresh endpoint (GET)
|
|
@app.route("/auth/csrf-token", methods=["GET"])
|
|
def get_csrf_token():
|
|
# If CSRF is disabled, return empty token
|
|
if not app.config.get("WTF_CSRF_ENABLED"):
|
|
resp = jsonify(csrf_token="", csrf_enabled=False)
|
|
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
return resp
|
|
|
|
try:
|
|
from flask_wtf.csrf import generate_csrf
|
|
|
|
token = generate_csrf()
|
|
except Exception:
|
|
token = ""
|
|
resp = jsonify(csrf_token=token, csrf_enabled=True)
|
|
try:
|
|
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
except Exception:
|
|
pass
|
|
# Also set/update a CSRF cookie for double-submit pattern and SPA helpers
|
|
try:
|
|
cookie_name = app.config.get("CSRF_COOKIE_NAME", "XSRF-TOKEN")
|
|
# Derive defaults from session cookie flags if not explicitly set
|
|
cookie_secure = bool(
|
|
app.config.get(
|
|
"CSRF_COOKIE_SECURE",
|
|
app.config.get("SESSION_COOKIE_SECURE", False),
|
|
)
|
|
)
|
|
cookie_httponly = bool(app.config.get("CSRF_COOKIE_HTTPONLY", False))
|
|
cookie_samesite = app.config.get("CSRF_COOKIE_SAMESITE", "Lax")
|
|
cookie_domain = app.config.get("CSRF_COOKIE_DOMAIN") or None
|
|
cookie_path = app.config.get("CSRF_COOKIE_PATH", "/")
|
|
try:
|
|
max_age = int(app.config.get("WTF_CSRF_TIME_LIMIT", 3600))
|
|
except Exception:
|
|
max_age = 3600
|
|
resp.set_cookie(
|
|
cookie_name,
|
|
token or "",
|
|
max_age=max_age,
|
|
secure=cookie_secure,
|
|
httponly=cookie_httponly,
|
|
samesite=cookie_samesite,
|
|
domain=cookie_domain,
|
|
path=cookie_path,
|
|
)
|
|
except Exception:
|
|
pass
|
|
return resp
|
|
|
|
# Register blueprints (centralized in blueprint_registry)
|
|
from app.blueprint_registry import register_all_blueprints
|
|
|
|
register_all_blueprints(app, logger)
|
|
|
|
# Register integration connectors
|
|
try:
|
|
from app.integrations import registry
|
|
|
|
logger.info("Integration connectors registered")
|
|
except Exception as e:
|
|
logger.warning(f"Could not register integration connectors: {e}")
|
|
|
|
# Exempt API blueprints from CSRF protection (requires api_bp, api_v1_bp, api_docs_bp)
|
|
from app.routes.api import api_bp
|
|
from app.routes.api_docs import api_docs_bp
|
|
from app.routes.api_v1 import api_v1_bp
|
|
|
|
# Only if CSRF is enabled (JSON API uses token authentication, not CSRF tokens)
|
|
if app.config.get("WTF_CSRF_ENABLED"):
|
|
csrf.exempt(api_bp)
|
|
csrf.exempt(api_v1_bp)
|
|
csrf.exempt(api_docs_bp)
|
|
|
|
# Initialize OIDC IP cache
|
|
from app.utils.oidc_metadata import initialize_ip_cache
|
|
|
|
ip_cache_ttl = int(app.config.get("OIDC_IP_CACHE_TTL", 300))
|
|
initialize_ip_cache(ip_cache_ttl)
|
|
|
|
# Register OAuth OIDC client if enabled
|
|
from app.utils.auth_method import auth_includes_oidc
|
|
|
|
try:
|
|
auth_method = (app.config.get("AUTH_METHOD") or "local").strip().lower()
|
|
except Exception:
|
|
auth_method = "local"
|
|
|
|
if auth_includes_oidc(auth_method):
|
|
issuer = app.config.get("OIDC_ISSUER")
|
|
client_id = app.config.get("OIDC_CLIENT_ID")
|
|
client_secret = app.config.get("OIDC_CLIENT_SECRET")
|
|
scopes = app.config.get("OIDC_SCOPES", "openid profile email")
|
|
if issuer and client_id and client_secret:
|
|
# Try to fetch metadata first using our utility with better DNS handling
|
|
from app.utils.oidc_metadata import fetch_oidc_metadata
|
|
|
|
# Get retry configuration from environment
|
|
max_retries = int(app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3))
|
|
retry_delay = int(app.config.get("OIDC_METADATA_RETRY_DELAY", 2))
|
|
timeout = int(app.config.get("OIDC_METADATA_FETCH_TIMEOUT", 10))
|
|
dns_strategy = app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
|
|
use_ip_directly = app.config.get("OIDC_USE_IP_DIRECTLY", True)
|
|
use_docker_internal = app.config.get("OIDC_USE_DOCKER_INTERNAL", True)
|
|
|
|
metadata, metadata_error, diagnostics = fetch_oidc_metadata(
|
|
issuer,
|
|
max_retries=max_retries,
|
|
retry_delay=retry_delay,
|
|
timeout=timeout,
|
|
use_dns_test=True,
|
|
dns_strategy=dns_strategy,
|
|
use_ip_directly=use_ip_directly,
|
|
use_docker_internal=use_docker_internal,
|
|
)
|
|
|
|
# Log diagnostics if available
|
|
if diagnostics:
|
|
app.logger.info(
|
|
"OIDC metadata fetch diagnostics: DNS strategy=%s, IP=%s, attempts=%d",
|
|
diagnostics.get("dns_resolution", {}).get("strategy", "unknown"),
|
|
diagnostics.get("dns_resolution", {}).get("ip_address", "none"),
|
|
len(diagnostics.get("strategies_tried", [])),
|
|
)
|
|
|
|
if metadata:
|
|
# Successfully fetched metadata - register with it
|
|
try:
|
|
oauth.register(
|
|
name="oidc",
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
server_metadata_url=f"{issuer.rstrip('/')}/.well-known/openid-configuration",
|
|
client_kwargs={
|
|
"scope": scopes,
|
|
"code_challenge_method": "S256",
|
|
},
|
|
)
|
|
app.logger.info("OIDC client registered with issuer %s", issuer)
|
|
except Exception as e:
|
|
app.logger.error("Failed to register OIDC client after metadata fetch: %s", e)
|
|
else:
|
|
# Metadata fetch failed - try to register anyway (Authlib will attempt fetch)
|
|
# If that also fails, we'll handle it gracefully and store config for lazy loading
|
|
app.logger.warning(
|
|
"Failed to fetch OIDC metadata at startup: %s. "
|
|
"Attempting to register client anyway - Authlib will retry metadata fetch.",
|
|
metadata_error,
|
|
)
|
|
try:
|
|
oauth.register(
|
|
name="oidc",
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
server_metadata_url=f"{issuer.rstrip('/')}/.well-known/openid-configuration",
|
|
client_kwargs={
|
|
"scope": scopes,
|
|
"code_challenge_method": "S256",
|
|
},
|
|
)
|
|
app.logger.info(
|
|
"OIDC client registered (Authlib will handle metadata fetch) for issuer %s",
|
|
issuer,
|
|
)
|
|
except Exception as e:
|
|
error_msg = str(e)
|
|
# Check if it's a DNS resolution error
|
|
if (
|
|
"NameResolutionError" in error_msg
|
|
or "Failed to resolve" in error_msg
|
|
or "[Errno -2]" in error_msg
|
|
):
|
|
# Store config for lazy loading in login route
|
|
app.config["OIDC_ISSUER_FOR_LAZY_LOAD"] = issuer
|
|
app.config["OIDC_CLIENT_ID_FOR_LAZY_LOAD"] = client_id
|
|
app.config["OIDC_CLIENT_SECRET_FOR_LAZY_LOAD"] = client_secret
|
|
app.config["OIDC_SCOPES_FOR_LAZY_LOAD"] = scopes
|
|
issuer_host = urlparse(issuer).netloc.split(":")[0] if issuer else "unknown"
|
|
app.logger.warning(
|
|
"OIDC client registration failed due to DNS resolution error: %s. "
|
|
"Client will be created lazily on first login attempt. "
|
|
"Troubleshooting:\n"
|
|
"1. Verify DNS resolution: docker exec -it <container> python -c \"import socket; print(socket.gethostbyname('%s'))\"\n"
|
|
"2. Configure DNS servers in Docker/Portainer stack (add 'dns: [8.8.8.8, 8.8.4.4]' to service)\n"
|
|
"3. If both containers are on same Docker network, use container name instead of external domain\n"
|
|
"4. See docs/TROUBLESHOOTING_OIDC_DNS.md for detailed solutions",
|
|
error_msg,
|
|
issuer_host,
|
|
)
|
|
else:
|
|
issuer_host = urlparse(issuer).netloc.split(":")[0] if issuer else "unknown"
|
|
app.logger.error(
|
|
"Failed to register OIDC client: %s\n"
|
|
"Troubleshooting:\n"
|
|
"1. Verify DNS resolution: docker exec -it <container> python -c \"import socket; print(socket.gethostbyname('%s'))\"\n"
|
|
"2. Configure DNS servers in Docker/Portainer stack (add 'dns: [8.8.8.8, 8.8.4.4]' to service)\n"
|
|
"3. If both containers are on same Docker network, use container name instead of external domain\n"
|
|
"4. See docs/TROUBLESHOOTING_OIDC_DNS.md for detailed solutions",
|
|
error_msg,
|
|
issuer_host,
|
|
)
|
|
else:
|
|
app.logger.warning(
|
|
"AUTH_METHOD is %s but OIDC envs are incomplete; OIDC login will not work",
|
|
auth_method,
|
|
)
|
|
|
|
# Schedule background metadata refresh if enabled
|
|
refresh_interval = int(app.config.get("OIDC_METADATA_REFRESH_INTERVAL", 3600))
|
|
if refresh_interval > 0 and issuer and client_id and client_secret:
|
|
|
|
def refresh_oidc_metadata():
|
|
"""Background task to refresh OIDC metadata"""
|
|
try:
|
|
from app.utils.oidc_metadata import fetch_oidc_metadata
|
|
|
|
max_retries = int(app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3))
|
|
retry_delay = int(app.config.get("OIDC_METADATA_RETRY_DELAY", 2))
|
|
timeout = int(app.config.get("OIDC_METADATA_FETCH_TIMEOUT", 10))
|
|
dns_strategy = app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
|
|
use_ip_directly = app.config.get("OIDC_USE_IP_DIRECTLY", True)
|
|
use_docker_internal = app.config.get("OIDC_USE_DOCKER_INTERNAL", True)
|
|
|
|
app.logger.info("Background OIDC metadata refresh started for issuer %s", issuer)
|
|
metadata, metadata_error, diagnostics = fetch_oidc_metadata(
|
|
issuer,
|
|
max_retries=max_retries,
|
|
retry_delay=retry_delay,
|
|
timeout=timeout,
|
|
use_dns_test=True,
|
|
dns_strategy=dns_strategy,
|
|
use_ip_directly=use_ip_directly,
|
|
use_docker_internal=use_docker_internal,
|
|
)
|
|
|
|
if metadata:
|
|
app.logger.info(
|
|
"Background OIDC metadata refresh successful (issuer: %s, strategy: %s)",
|
|
metadata.get("issuer"),
|
|
(
|
|
diagnostics.get("dns_resolution", {}).get("strategy", "unknown")
|
|
if diagnostics
|
|
else "unknown"
|
|
),
|
|
)
|
|
else:
|
|
app.logger.warning(
|
|
"Background OIDC metadata refresh failed: %s (existing connection will continue to work)",
|
|
metadata_error,
|
|
)
|
|
except Exception as e:
|
|
app.logger.error("Error in background OIDC metadata refresh: %s", str(e))
|
|
|
|
# Schedule the refresh task
|
|
try:
|
|
scheduler.add_job(
|
|
func=refresh_oidc_metadata,
|
|
trigger="interval",
|
|
seconds=refresh_interval,
|
|
id="oidc_metadata_refresh",
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
)
|
|
app.logger.info("Scheduled OIDC metadata refresh every %d seconds", refresh_interval)
|
|
except Exception as e:
|
|
app.logger.warning("Failed to schedule OIDC metadata refresh: %s", str(e))
|
|
|
|
# Prometheus metrics endpoint
|
|
@app.route("/metrics")
|
|
def metrics():
|
|
"""Expose Prometheus metrics"""
|
|
return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST}
|
|
|
|
# Register error handlers
|
|
from app.utils.error_handlers import register_error_handlers
|
|
|
|
register_error_handlers(app)
|
|
|
|
# Register context processors
|
|
from app.utils.context_processors import register_context_processors
|
|
|
|
register_context_processors(app)
|
|
|
|
# Register i18n template filters
|
|
from app.utils.i18n_helpers import register_i18n_filters
|
|
|
|
register_i18n_filters(app)
|
|
|
|
# (translations compiled and directories set before Babel init)
|
|
|
|
# Register template filters
|
|
from app.utils.template_filters import register_template_filters
|
|
|
|
register_template_filters(app)
|
|
|
|
# Initialize module registry and helpers
|
|
from app.utils.module_helpers import init_module_helpers
|
|
|
|
init_module_helpers(app)
|
|
|
|
# Register CLI commands
|
|
from app.utils.cli import register_cli_commands
|
|
|
|
register_cli_commands(app)
|
|
|
|
# Promote configured admin usernames automatically on each request (idempotent)
|
|
@app.before_request
|
|
def _promote_admin_users_on_request():
|
|
try:
|
|
from flask_login import current_user
|
|
|
|
if not current_user or not getattr(current_user, "is_authenticated", False):
|
|
return
|
|
admin_usernames = [u.strip().lower() for u in app.config.get("ADMIN_USERNAMES", ["admin"])]
|
|
if (
|
|
current_user.username
|
|
and current_user.username.lower() in admin_usernames
|
|
and current_user.role != "admin"
|
|
):
|
|
current_user.role = "admin"
|
|
db.session.commit()
|
|
except Exception:
|
|
# Non-fatal; avoid breaking requests if this fails
|
|
try:
|
|
db.session.rollback()
|
|
except Exception:
|
|
pass
|
|
|
|
# Initialize database on first request
|
|
def initialize_database():
|
|
try:
|
|
# Import models to ensure they are registered
|
|
from app.models import Comment, Issue, Project, Settings, Task, TaskActivity, TimeEntry, User
|
|
|
|
# Create database tables
|
|
db.create_all()
|
|
|
|
# Check and migrate Task Management tables if needed
|
|
from app.utils.legacy_migrations import migrate_issues_table, migrate_task_management_tables
|
|
|
|
migrate_task_management_tables()
|
|
migrate_issues_table()
|
|
|
|
# Create default admin user or demo user if it doesn't exist
|
|
if app.config.get("DEMO_MODE"):
|
|
demo_username = (app.config.get("DEMO_USERNAME") or "demo").strip().lower()
|
|
from app.models import Role
|
|
|
|
demo_user = User.query.filter_by(username=demo_username).first()
|
|
if not demo_user:
|
|
demo_user = User(username=demo_username, role="user")
|
|
demo_user.is_active = True
|
|
demo_user.set_password(app.config.get("DEMO_PASSWORD", "demo"))
|
|
|
|
user_role = Role.query.filter_by(name="user").first()
|
|
if user_role:
|
|
demo_user.roles.append(user_role)
|
|
|
|
db.session.add(demo_user)
|
|
db.session.commit()
|
|
print(f"Created demo user: {demo_username}")
|
|
else:
|
|
# One-time upgrade: older installs created the demo account as admin (SSTI risk).
|
|
user_role = Role.query.filter_by(name="user").first()
|
|
changed = False
|
|
if demo_user.role != "user":
|
|
demo_user.role = "user"
|
|
changed = True
|
|
for r in list(demo_user.roles):
|
|
if r.name in ("admin", "super_admin"):
|
|
demo_user.roles.remove(r)
|
|
changed = True
|
|
if user_role and user_role not in demo_user.roles:
|
|
demo_user.roles.append(user_role)
|
|
changed = True
|
|
if changed:
|
|
db.session.commit()
|
|
print(f"Updated demo user privileges: {demo_username}")
|
|
else:
|
|
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
|
|
if not User.query.filter_by(username=admin_username).first():
|
|
from app.models import Role
|
|
|
|
admin_user = User(username=admin_username, role="admin")
|
|
admin_user.is_active = True
|
|
|
|
admin_role = Role.query.filter_by(name="admin").first()
|
|
if admin_role:
|
|
admin_user.roles.append(admin_role)
|
|
|
|
db.session.add(admin_user)
|
|
db.session.commit()
|
|
print(f"Created default admin user: {admin_username}")
|
|
|
|
print("Database initialized successfully")
|
|
except Exception as e:
|
|
print(f"Error initializing database: {e}")
|
|
# Don't raise the exception, just log it
|
|
|
|
# Store the initialization function for later use
|
|
app.initialize_database = initialize_database
|
|
|
|
return app
|
|
|
|
|
|
def init_database(app):
|
|
"""Initialize database tables and create default admin user"""
|
|
with app.app_context():
|
|
try:
|
|
# Import models to ensure they are registered
|
|
from app.models import Comment, Issue, Project, Settings, Task, TaskActivity, TimeEntry, User
|
|
|
|
# Create database tables
|
|
db.create_all()
|
|
|
|
# Check and migrate Task Management tables if needed
|
|
from app.utils.legacy_migrations import migrate_issues_table, migrate_task_management_tables
|
|
|
|
migrate_task_management_tables()
|
|
migrate_issues_table()
|
|
|
|
# Create default admin user or demo user if it doesn't exist
|
|
if app.config.get("DEMO_MODE"):
|
|
demo_username = (app.config.get("DEMO_USERNAME") or "demo").strip().lower()
|
|
from app.models import Role
|
|
|
|
demo_user = User.query.filter_by(username=demo_username).first()
|
|
if not demo_user:
|
|
demo_user = User(username=demo_username, role="user")
|
|
demo_user.is_active = True
|
|
demo_user.set_password(app.config.get("DEMO_PASSWORD", "demo"))
|
|
|
|
user_role = Role.query.filter_by(name="user").first()
|
|
if user_role:
|
|
demo_user.roles.append(user_role)
|
|
|
|
db.session.add(demo_user)
|
|
db.session.commit()
|
|
print(f"Created demo user: {demo_username}")
|
|
else:
|
|
# One-time upgrade: older installs created the demo account as admin (SSTI risk).
|
|
user_role = Role.query.filter_by(name="user").first()
|
|
changed = False
|
|
if demo_user.role != "user":
|
|
demo_user.role = "user"
|
|
changed = True
|
|
for r in list(demo_user.roles):
|
|
if r.name in ("admin", "super_admin"):
|
|
demo_user.roles.remove(r)
|
|
changed = True
|
|
if user_role and user_role not in demo_user.roles:
|
|
demo_user.roles.append(user_role)
|
|
changed = True
|
|
if changed:
|
|
db.session.commit()
|
|
print(f"Updated demo user privileges: {demo_username}")
|
|
else:
|
|
admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0]
|
|
if not User.query.filter_by(username=admin_username).first():
|
|
from app.models import Role
|
|
|
|
admin_user = User(username=admin_username, role="admin")
|
|
admin_user.is_active = True
|
|
|
|
admin_role = Role.query.filter_by(name="admin").first()
|
|
if admin_role:
|
|
admin_user.roles.append(admin_role)
|
|
|
|
db.session.add(admin_user)
|
|
db.session.commit()
|
|
print(f"Created default admin user: {admin_username}")
|
|
|
|
print("Database initialized successfully")
|
|
except Exception as e:
|
|
print(f"Error initializing database: {e}")
|
|
raise
|