From 07683145830459e565ba8f22dc7314def685f45d Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 22 Dec 2021 11:18:29 +0100 Subject: [PATCH 01/78] Use harbor to avoid docker rate limiting --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e48029f..f248e1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8-slim as base +FROM harbor.maxiv.lu.se/dockerhub/library/python:3.8-slim as base # Install Python dependencies in an intermediate image # as some requires a compiler (psycopg2) From 4f22a5da9dcb6f9c079342518003ae87533da402 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 24 Nov 2021 23:18:52 +0100 Subject: [PATCH 02/78] Add web ui basic structure --- app/cookie_auth.py | 56 ++++++++++++++++++++++ app/main.py | 28 +++++++---- app/static/css/bootstrap.min.css | 7 +++ app/static/css/notify.css | 5 ++ app/static/js/bootstrap.bundle.min.js | 7 +++ app/templates/base.html | 40 ++++++++++++++++ app/templates/bootstrap.html | 30 ++++++++++++ app/templates/index.html | 10 ++++ app/templates/login.html | 29 ++++++++++++ app/views/__init__.py | 5 ++ app/views/home.py | 67 +++++++++++++++++++++++++++ requirements.txt | 2 +- tests/conftest.py | 6 +-- 13 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 app/cookie_auth.py create mode 100644 app/static/css/bootstrap.min.css create mode 100644 app/static/css/notify.css create mode 100644 app/static/js/bootstrap.bundle.min.js create mode 100644 app/templates/base.html create mode 100644 app/templates/bootstrap.html create mode 100644 app/templates/index.html create mode 100644 app/templates/login.html create mode 100644 app/views/__init__.py create mode 100644 app/views/home.py diff --git a/app/cookie_auth.py b/app/cookie_auth.py new file mode 100644 index 0000000..48a8692 --- /dev/null +++ b/app/cookie_auth.py @@ -0,0 +1,56 @@ +import hashlib +import hmac +from typing import Optional +from fastapi.logger import logger +from fastapi.requests import Request +from fastapi.responses import Response +from .settings import SECRET_KEY, ACCESS_TOKEN_EXPIRE_MINUTES + +AUTH_COOKIE_NAME = "notify_token" +AUTH_SIZE = 16 + + +def sign(cookie: str) -> str: + h = hashlib.blake2b(digest_size=AUTH_SIZE, key=SECRET_KEY) + h.update(cookie.encode("utf-8")) + return h.hexdigest() + + +def verify(cookie: str, sig: str) -> bool: + good_sig = sign(cookie) + return hmac.compare_digest(good_sig, sig) + + +def set_auth(response: Response, user_id: int): + sig = sign(str(user_id)) + val = f"{user_id}:{sig}" + response.set_cookie( + AUTH_COOKIE_NAME, + val, + secure=False, + expires=ACCESS_TOKEN_EXPIRE_MINUTES * 60, + httponly=True, + samesite="Lax", + ) + + +def get_user_id_via_auth_cookie(request: Request) -> Optional[int]: + if AUTH_COOKIE_NAME not in request.cookies: + return None + val = request.cookies[AUTH_COOKIE_NAME] + parts = val.split(":") + if len(parts) != 2: + return None + user_id, sig = parts + if not verify(str(user_id), sig): + logger.warning("Hash mismatch, invalid cookie value") + return None + try: + return int(user_id) + except ValueError: + logger.warning(f"Invalid user_id {user_id} in cookie") + return None + + +def logout(response: Response): + response.delete_cookie(AUTH_COOKIE_NAME) diff --git a/app/main.py b/app/main.py index f857348..95fc2ba 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,14 @@ import logging import sentry_sdk +from pathlib import Path from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from fastapi import FastAPI from fastapi_versioning import VersionedFastAPI from fastapi.logger import logger +from fastapi.staticfiles import StaticFiles from . import monitoring from .api import login, users, services +from .views import home from .settings import SENTRY_DSN, ESS_NOTIFY_SERVER_ENVIRONMENT @@ -16,24 +19,33 @@ logger.handlers = gunicorn_error_logger.handlers logger.setLevel(gunicorn_error_logger.level) -original_app = FastAPI() +# Main application to serve HTML +app = FastAPI() +app.include_router(home.router, tags=["home"]) +# Serve static files +app_dir = Path(__file__).parent.resolve() +app.mount("/static", StaticFiles(directory=str(app_dir / "static")), name="static") -original_app.include_router(monitoring.router, prefix="/-", tags=["monitoring"]) -original_app.include_router(login.router, tags=["login"]) -original_app.include_router(users.router, prefix="/users", tags=["users"]) -original_app.include_router( +# API mounted under /api +original_api = FastAPI() +original_api.include_router(monitoring.router, prefix="/-", tags=["monitoring"]) +original_api.include_router(login.router, tags=["login"]) +original_api.include_router(users.router, prefix="/users", tags=["users"]) +original_api.include_router( services.router, prefix="/services", tags=["services"], ) -app = VersionedFastAPI( - original_app, +versioned_api = VersionedFastAPI( + original_api, version_format="{major}", - prefix_format="/api/v{major}", + prefix_format="/v{major}", ) +app.mount("/api", versioned_api) + if SENTRY_DSN: sentry_sdk.init(dsn=SENTRY_DSN, environment=ESS_NOTIFY_SERVER_ENVIRONMENT) app = SentryAsgiMiddleware(app) diff --git a/app/static/css/bootstrap.min.css b/app/static/css/bootstrap.min.css new file mode 100644 index 0000000..1472dec --- /dev/null +++ b/app/static/css/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.1.3 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors + * Copyright 2011-2021 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:first-child){border-top:2px solid currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.2rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.3rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/app/static/css/notify.css b/app/static/css/notify.css new file mode 100644 index 0000000..93f8025 --- /dev/null +++ b/app/static/css/notify.css @@ -0,0 +1,5 @@ +/* Show it is fixed to the top */ +body { + min-height: 75rem; + padding-top: 4.5rem; +} diff --git a/app/static/js/bootstrap.bundle.min.js b/app/static/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..cc0a255 --- /dev/null +++ b/app/static/js/bootstrap.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.1.3 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=(t,e,i)=>{Object.keys(i).forEach((n=>{const s=i[n],r=e[n],a=r&&o(r)?"element":null==(l=r)?`${l}`:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(a))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)}))},l=t=>!(!o(t)||0===t.getClientRects().length)&&"visible"===getComputedStyle(t).getPropertyValue("visibility"),c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",(()=>{p.forEach((t=>t()))})),p.push(e)):e()},_=t=>{"function"==typeof t&&t()},b=(e,i,n=!0)=>{if(!n)return void _(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),_(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},v=(t,e,i,n)=>{let s=t.indexOf(e);if(-1===s)return t[!i&&n?t.length-1:0];const o=t.length;return s+=i?1:-1,n&&(s=(s+o)%o),t[Math.max(0,Math.min(s,o-1))]},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,E=/::\d+$/,A={};let T=1;const O={mouseenter:"mouseover",mouseleave:"mouseout"},C=/^(mouseenter|mouseleave)/i,k=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function x(t){const e=L(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function D(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;sfunction(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=S(e,i,n),l=x(t),c=l[a]||(l[a]={}),h=D(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=L(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&j.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&j.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function I(t,e,i,n,s){const o=D(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function P(t){return t=t.replace(w,""),O[t]||t}const j={on(t,e,i,n){N(t,e,i,n,!1)},one(t,e,i,n){N(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=x(t),c=e.startsWith(".");if(void 0!==o){if(!l||!l[r])return;return void I(t,l,r,o,s?i:null)}c&&Object.keys(l).forEach((i=>{!function(t,e,i,n){const s=e[i]||{};Object.keys(s).forEach((o=>{if(o.includes(n)){const n=s[o];I(t,e,i,n.originalHandler,n.delegationSelector)}}))}(t,l,i,e.slice(1))}));const h=l[r]||{};Object.keys(h).forEach((i=>{const n=i.replace(E,"");if(!a||e.includes(n)){const e=h[i];I(t,l,r,e.originalHandler,e.delegationSelector)}}))},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=f(),s=P(e),o=e!==s,r=k.has(s);let a,l=!0,c=!0,h=!1,d=null;return o&&n&&(a=n.Event(e,i),n(t).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent("HTMLEvents"),d.initEvent(s,l,!0)):d=new CustomEvent(e,{bubbles:l,cancelable:!0}),void 0!==i&&Object.keys(i).forEach((t=>{Object.defineProperty(d,t,{get:()=>i[t]})})),h&&d.preventDefault(),c&&t.dispatchEvent(d),d.defaultPrevented&&void 0!==a&&a.preventDefault(),d}},M=new Map,H={set(t,e,i){M.has(t)||M.set(t,new Map);const n=M.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>M.has(t)&&M.get(t).get(e)||null,remove(t,e){if(!M.has(t))return;const i=M.get(t);i.delete(e),0===i.size&&M.delete(t)}};class B{constructor(t){(t=r(t))&&(this._element=t,H.set(this._element,this.constructor.DATA_KEY,this))}dispose(){H.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY),Object.getOwnPropertyNames(this).forEach((t=>{this[t]=null}))}_queueCallback(t,e,i=!0){b(t,e,i)}static getInstance(t){return H.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.1.3"}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}}const R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),c(this))return;const o=n(this)||this.closest(`.${s}`);t.getOrCreateInstance(o)[e]()}))};class W extends B{static get NAME(){return"alert"}close(){if(j.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=W.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(W,"close"),g(W);const $='[data-bs-toggle="button"]';class z extends B{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=z.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}function q(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function F(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}j.on(document,"click.bs.button.data-api",$,(t=>{t.preventDefault();const e=t.target.closest($);z.getOrCreateInstance(e).toggle()})),g(z);const U={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={};return Object.keys(t.dataset).filter((t=>t.startsWith("bs"))).forEach((i=>{let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=q(t.dataset[i])})),e},getDataAttribute:(t,e)=>q(t.getAttribute(`data-bs-${F(e)}`)),offset(t){const e=t.getBoundingClientRect();return{top:e.top+window.pageYOffset,left:e.left+window.pageXOffset}},position:t=>({top:t.offsetTop,left:t.offsetLeft})},V={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode;for(;n&&n.nodeType===Node.ELEMENT_NODE&&3!==n.nodeType;)n.matches(e)&&i.push(n),n=n.parentNode;return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(", ");return this.find(e,t).filter((t=>!c(t)&&l(t)))}},K="carousel",X={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},Y={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},Q="next",G="prev",Z="left",J="right",tt={ArrowLeft:J,ArrowRight:Z},et="slid.bs.carousel",it="active",nt=".active.carousel-item";class st extends B{constructor(t,e){super(t),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(e),this._indicatorsElement=V.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent),this._addEventListeners()}static get Default(){return X}static get NAME(){return K}next(){this._slide(Q)}nextWhenVisible(){!document.hidden&&l(this._element)&&this.next()}prev(){this._slide(G)}pause(t){t||(this._isPaused=!0),V.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(s(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(t){this._activeElement=V.findOne(nt,this._element);const e=this._getItemIndex(this._activeElement);if(t>this._items.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,et,(()=>this.to(t)));if(e===t)return this.pause(),void this.cycle();const i=t>e?Q:G;this._slide(i,this._items[t])}_getConfig(t){return t={...X,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(K,t,Y),t}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=40)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?J:Z)}_addEventListeners(){this._config.keyboard&&j.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,"mouseenter.bs.carousel",(t=>this.pause(t))),j.on(this._element,"mouseleave.bs.carousel",(t=>this.cycle(t)))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()}_addTouchEventListeners(){const t=t=>this._pointerEvent&&("pen"===t.pointerType||"touch"===t.pointerType),e=e=>{t(e)?this.touchStartX=e.clientX:this._pointerEvent||(this.touchStartX=e.touches[0].clientX)},i=t=>{this.touchDeltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this.touchStartX},n=e=>{t(e)&&(this.touchDeltaX=e.clientX-this.touchStartX),this._handleSwipe(),"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((t=>this.cycle(t)),500+this._config.interval))};V.find(".carousel-item img",this._element).forEach((t=>{j.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()))})),this._pointerEvent?(j.on(this._element,"pointerdown.bs.carousel",(t=>e(t))),j.on(this._element,"pointerup.bs.carousel",(t=>n(t))),this._element.classList.add("pointer-event")):(j.on(this._element,"touchstart.bs.carousel",(t=>e(t))),j.on(this._element,"touchmove.bs.carousel",(t=>i(t))),j.on(this._element,"touchend.bs.carousel",(t=>n(t))))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=tt[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(t){return this._items=t&&t.parentNode?V.find(".carousel-item",t.parentNode):[],this._items.indexOf(t)}_getItemByOrder(t,e){const i=t===Q;return v(this._items,e,i,this._config.wrap)}_triggerSlideEvent(t,e){const i=this._getItemIndex(t),n=this._getItemIndex(V.findOne(nt,this._element));return j.trigger(this._element,"slide.bs.carousel",{relatedTarget:t,direction:e,from:n,to:i})}_setActiveIndicatorElement(t){if(this._indicatorsElement){const e=V.findOne(".active",this._indicatorsElement);e.classList.remove(it),e.removeAttribute("aria-current");const i=V.find("[data-bs-target]",this._indicatorsElement);for(let e=0;e{j.trigger(this._element,et,{relatedTarget:o,direction:d,from:s,to:r})};if(this._element.classList.contains("slide")){o.classList.add(h),u(o),n.classList.add(c),o.classList.add(c);const t=()=>{o.classList.remove(c,h),o.classList.add(it),n.classList.remove(it,h,c),this._isSliding=!1,setTimeout(f,0)};this._queueCallback(t,n,!0)}else n.classList.remove(it),o.classList.add(it),this._isSliding=!1,f();a&&this.cycle()}_directionToOrder(t){return[J,Z].includes(t)?m()?t===Z?G:Q:t===Z?Q:G:t}_orderToDirection(t){return[Q,G].includes(t)?m()?t===G?Z:J:t===G?J:Z:t}static carouselInterface(t,e){const i=st.getOrCreateInstance(t,e);let{_config:n}=i;"object"==typeof e&&(n={...n,...e});const s="string"==typeof e?e:n.slide;if("number"==typeof e)i.to(e);else if("string"==typeof s){if(void 0===i[s])throw new TypeError(`No method named "${s}"`);i[s]()}else n.interval&&n.ride&&(i.pause(),i.cycle())}static jQueryInterface(t){return this.each((function(){st.carouselInterface(this,t)}))}static dataApiClickHandler(t){const e=n(this);if(!e||!e.classList.contains("carousel"))return;const i={...U.getDataAttributes(e),...U.getDataAttributes(this)},s=this.getAttribute("data-bs-slide-to");s&&(i.interval=!1),st.carouselInterface(e,i),s&&st.getInstance(e).to(s),t.preventDefault()}}j.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",st.dataApiClickHandler),j.on(window,"load.bs.carousel.data-api",(()=>{const t=V.find('[data-bs-ride="carousel"]');for(let e=0,i=t.length;et===this._element));null!==s&&o.length&&(this._selector=s,this._triggerArray.push(e))}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return rt}static get NAME(){return ot}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t,e=[];if(this._config.parent){const t=V.find(ut,this._config.parent);e=V.find(".collapse.show, .collapse.collapsing",this._config.parent).filter((e=>!t.includes(e)))}const i=V.findOne(this._selector);if(e.length){const n=e.find((t=>i!==t));if(t=n?pt.getInstance(n):null,t&&t._isTransitioning)return}if(j.trigger(this._element,"show.bs.collapse").defaultPrevented)return;e.forEach((e=>{i!==e&&pt.getOrCreateInstance(e,{toggle:!1}).hide(),t||H.set(e,"bs.collapse",null)}));const n=this._getDimension();this._element.classList.remove(ct),this._element.classList.add(ht),this._element.style[n]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const s=`scroll${n[0].toUpperCase()+n.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct,lt),this._element.style[n]="",j.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[n]=`${this._element[s]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,u(this._element),this._element.classList.add(ht),this._element.classList.remove(ct,lt);const e=this._triggerArray.length;for(let t=0;t{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct),j.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(lt)}_getConfig(t){return(t={...rt,...U.getDataAttributes(this._element),...t}).toggle=Boolean(t.toggle),t.parent=r(t.parent),a(ot,t,at),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=V.find(ut,this._config.parent);V.find(ft,this._config.parent).filter((e=>!t.includes(e))).forEach((t=>{const e=n(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}))}_addAriaAndCollapsedClass(t,e){t.length&&t.forEach((t=>{e?t.classList.remove(dt):t.classList.add(dt),t.setAttribute("aria-expanded",e)}))}static jQueryInterface(t){return this.each((function(){const e={};"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1);const i=pt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,"click.bs.collapse.data-api",ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();const e=i(this);V.find(e).forEach((t=>{pt.getOrCreateInstance(t,{toggle:!1}).toggle()}))})),g(pt);var mt="top",gt="bottom",_t="right",bt="left",vt="auto",yt=[mt,gt,_t,bt],wt="start",Et="end",At="clippingParents",Tt="viewport",Ot="popper",Ct="reference",kt=yt.reduce((function(t,e){return t.concat([e+"-"+wt,e+"-"+Et])}),[]),Lt=[].concat(yt,[vt]).reduce((function(t,e){return t.concat([e,e+"-"+wt,e+"-"+Et])}),[]),xt="beforeRead",Dt="read",St="afterRead",Nt="beforeMain",It="main",Pt="afterMain",jt="beforeWrite",Mt="write",Ht="afterWrite",Bt=[xt,Dt,St,Nt,It,Pt,jt,Mt,Ht];function Rt(t){return t?(t.nodeName||"").toLowerCase():null}function Wt(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function $t(t){return t instanceof Wt(t).Element||t instanceof Element}function zt(t){return t instanceof Wt(t).HTMLElement||t instanceof HTMLElement}function qt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof Wt(t).ShadowRoot||t instanceof ShadowRoot)}const Ft={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];zt(s)&&Rt(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});zt(n)&&Rt(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function Ut(t){return t.split("-")[0]}function Vt(t,e){var i=t.getBoundingClientRect();return{width:i.width/1,height:i.height/1,top:i.top/1,right:i.right/1,bottom:i.bottom/1,left:i.left/1,x:i.left/1,y:i.top/1}}function Kt(t){var e=Vt(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Xt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&qt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Yt(t){return Wt(t).getComputedStyle(t)}function Qt(t){return["table","td","th"].indexOf(Rt(t))>=0}function Gt(t){return(($t(t)?t.ownerDocument:t.document)||window.document).documentElement}function Zt(t){return"html"===Rt(t)?t:t.assignedSlot||t.parentNode||(qt(t)?t.host:null)||Gt(t)}function Jt(t){return zt(t)&&"fixed"!==Yt(t).position?t.offsetParent:null}function te(t){for(var e=Wt(t),i=Jt(t);i&&Qt(i)&&"static"===Yt(i).position;)i=Jt(i);return i&&("html"===Rt(i)||"body"===Rt(i)&&"static"===Yt(i).position)?e:i||function(t){var e=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&zt(t)&&"fixed"===Yt(t).position)return null;for(var i=Zt(t);zt(i)&&["html","body"].indexOf(Rt(i))<0;){var n=Yt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function ee(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}var ie=Math.max,ne=Math.min,se=Math.round;function oe(t,e,i){return ie(t,ne(e,i))}function re(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function ae(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const le={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=Ut(i.placement),l=ee(a),c=[bt,_t].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return re("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:ae(t,yt))}(s.padding,i),d=Kt(o),u="y"===l?mt:bt,f="y"===l?gt:_t,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=te(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,E=oe(v,w,y),A=l;i.modifiersData[n]=((e={})[A]=E,e.centerOffset=E-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Xt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function ce(t){return t.split("-")[1]}var he={top:"auto",right:"auto",bottom:"auto",left:"auto"};function de(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=!0===h?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:se(se(e*n)/n)||0,y:se(se(i*n)/n)||0}}(r):"function"==typeof h?h(r):r,u=d.x,f=void 0===u?0:u,p=d.y,m=void 0===p?0:p,g=r.hasOwnProperty("x"),_=r.hasOwnProperty("y"),b=bt,v=mt,y=window;if(c){var w=te(i),E="clientHeight",A="clientWidth";w===Wt(i)&&"static"!==Yt(w=Gt(i)).position&&"absolute"===a&&(E="scrollHeight",A="scrollWidth"),w=w,s!==mt&&(s!==bt&&s!==_t||o!==Et)||(v=gt,m-=w[E]-n.height,m*=l?1:-1),s!==bt&&(s!==mt&&s!==gt||o!==Et)||(b=_t,f-=w[A]-n.width,f*=l?1:-1)}var T,O=Object.assign({position:a},c&&he);return l?Object.assign({},O,((T={})[v]=_?"0":"",T[b]=g?"0":"",T.transform=(y.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",T)):Object.assign({},O,((e={})[v]=_?m+"px":"",e[b]=g?f+"px":"",e.transform="",e))}const ue={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:Ut(e.placement),variation:ce(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,de(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,de(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var fe={passive:!0};const pe={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=Wt(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,fe)})),a&&l.addEventListener("resize",i.update,fe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,fe)})),a&&l.removeEventListener("resize",i.update,fe)}},data:{}};var me={left:"right",right:"left",bottom:"top",top:"bottom"};function ge(t){return t.replace(/left|right|bottom|top/g,(function(t){return me[t]}))}var _e={start:"end",end:"start"};function be(t){return t.replace(/start|end/g,(function(t){return _e[t]}))}function ve(t){var e=Wt(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function ye(t){return Vt(Gt(t)).left+ve(t).scrollLeft}function we(t){var e=Yt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ee(t){return["html","body","#document"].indexOf(Rt(t))>=0?t.ownerDocument.body:zt(t)&&we(t)?t:Ee(Zt(t))}function Ae(t,e){var i;void 0===e&&(e=[]);var n=Ee(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=Wt(n),r=s?[o].concat(o.visualViewport||[],we(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Ae(Zt(r)))}function Te(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Oe(t,e){return e===Tt?Te(function(t){var e=Wt(t),i=Gt(t),n=e.visualViewport,s=i.clientWidth,o=i.clientHeight,r=0,a=0;return n&&(s=n.width,o=n.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(r=n.offsetLeft,a=n.offsetTop)),{width:s,height:o,x:r+ye(t),y:a}}(t)):zt(e)?function(t){var e=Vt(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Te(function(t){var e,i=Gt(t),n=ve(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ie(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ie(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+ye(t),l=-n.scrollTop;return"rtl"===Yt(s||i).direction&&(a+=ie(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Gt(t)))}function Ce(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?Ut(s):null,r=s?ce(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case mt:e={x:a,y:i.y-n.height};break;case gt:e={x:a,y:i.y+i.height};break;case _t:e={x:i.x+i.width,y:l};break;case bt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?ee(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case wt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Et:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ke(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.boundary,r=void 0===o?At:o,a=i.rootBoundary,l=void 0===a?Tt:a,c=i.elementContext,h=void 0===c?Ot:c,d=i.altBoundary,u=void 0!==d&&d,f=i.padding,p=void 0===f?0:f,m=re("number"!=typeof p?p:ae(p,yt)),g=h===Ot?Ct:Ot,_=t.rects.popper,b=t.elements[u?g:h],v=function(t,e,i){var n="clippingParents"===e?function(t){var e=Ae(Zt(t)),i=["absolute","fixed"].indexOf(Yt(t).position)>=0&&zt(t)?te(t):t;return $t(i)?e.filter((function(t){return $t(t)&&Xt(t,i)&&"body"!==Rt(t)})):[]}(t):[].concat(e),s=[].concat(n,[i]),o=s[0],r=s.reduce((function(e,i){var n=Oe(t,i);return e.top=ie(n.top,e.top),e.right=ne(n.right,e.right),e.bottom=ne(n.bottom,e.bottom),e.left=ie(n.left,e.left),e}),Oe(t,o));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}($t(b)?b:b.contextElement||Gt(t.elements.popper),r,l),y=Vt(t.elements.reference),w=Ce({reference:y,element:_,strategy:"absolute",placement:s}),E=Te(Object.assign({},_,w)),A=h===Ot?E:y,T={top:v.top-A.top+m.top,bottom:A.bottom-v.bottom+m.bottom,left:v.left-A.left+m.left,right:A.right-v.right+m.right},O=t.modifiersData.offset;if(h===Ot&&O){var C=O[s];Object.keys(T).forEach((function(t){var e=[_t,gt].indexOf(t)>=0?1:-1,i=[mt,gt].indexOf(t)>=0?"y":"x";T[t]+=C[i]*e}))}return T}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?Lt:l,h=ce(n),d=h?a?kt:kt.filter((function(t){return ce(t)===h})):yt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ke(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[Ut(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const xe={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=Ut(g),b=l||(_!==g&&p?function(t){if(Ut(t)===vt)return[];var e=ge(t);return[be(t),e,be(e)]}(g):[ge(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(Ut(i)===vt?Le(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,A=!0,T=v[0],O=0;O=0,D=x?"width":"height",S=ke(e,{placement:C,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),N=x?L?_t:bt:L?gt:mt;y[D]>w[D]&&(N=ge(N));var I=ge(N),P=[];if(o&&P.push(S[k]<=0),a&&P.push(S[N]<=0,S[I]<=0),P.every((function(t){return t}))){T=C,A=!1;break}E.set(C,P)}if(A)for(var j=function(t){var e=v.find((function(e){var i=E.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==j(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function De(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Se(t){return[mt,_t,gt,bt].some((function(e){return t[e]>=0}))}const Ne={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=De(r,n),c=De(a,s,o),h=Se(l),d=Se(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},Ie={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=Lt.reduce((function(t,i){return t[i]=function(t,e,i){var n=Ut(t),s=[bt,mt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[bt,_t].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},Pe={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=Ce({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},je={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=Ut(e.placement),b=ce(e.placement),v=!b,y=ee(_),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,A=e.rects.reference,T=e.rects.popper,O="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,C={x:0,y:0};if(E){if(o||a){var k="y"===y?mt:bt,L="y"===y?gt:_t,x="y"===y?"height":"width",D=E[y],S=E[y]+g[k],N=E[y]-g[L],I=f?-T[x]/2:0,P=b===wt?A[x]:T[x],j=b===wt?-T[x]:-A[x],M=e.elements.arrow,H=f&&M?Kt(M):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},R=B[k],W=B[L],$=oe(0,A[x],H[x]),z=v?A[x]/2-I-$-R-O:P-$-R-O,q=v?-A[x]/2+I+$+W+O:j+$+W+O,F=e.elements.arrow&&te(e.elements.arrow),U=F?"y"===y?F.clientTop||0:F.clientLeft||0:0,V=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,K=E[y]+z-V-U,X=E[y]+q-V;if(o){var Y=oe(f?ne(S,K):S,D,f?ie(N,X):N);E[y]=Y,C[y]=Y-D}if(a){var Q="x"===y?mt:bt,G="x"===y?gt:_t,Z=E[w],J=Z+g[Q],tt=Z-g[G],et=oe(f?ne(J,K):J,Z,f?ie(tt,X):tt);E[w]=et,C[w]=et-Z}}e.modifiersData[n]=C}},requiresIfExists:["offset"]};function Me(t,e,i){void 0===i&&(i=!1);var n=zt(e);zt(e)&&function(t){var e=t.getBoundingClientRect();e.width,t.offsetWidth,e.height,t.offsetHeight}(e);var s,o,r=Gt(e),a=Vt(t),l={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(n||!n&&!i)&&(("body"!==Rt(e)||we(r))&&(l=(s=e)!==Wt(s)&&zt(s)?{scrollLeft:(o=s).scrollLeft,scrollTop:o.scrollTop}:ve(s)),zt(e)?((c=Vt(e)).x+=e.clientLeft,c.y+=e.clientTop):r&&(c.x=ye(r))),{x:a.left+l.scrollLeft-c.x,y:a.top+l.scrollTop-c.y,width:a.width,height:a.height}}function He(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var Be={placement:"bottom",modifiers:[],strategy:"absolute"};function Re(){for(var t=arguments.length,e=new Array(t),i=0;ij.on(t,"mouseover",d))),this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(Je),this._element.classList.add(Je),j.trigger(this._element,"shown.bs.dropdown",t)}hide(){if(c(this._element)||!this._isShown(this._menu))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){j.trigger(this._element,"hide.bs.dropdown",t).defaultPrevented||("ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._popper&&this._popper.destroy(),this._menu.classList.remove(Je),this._element.classList.remove(Je),this._element.setAttribute("aria-expanded","false"),U.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,"hidden.bs.dropdown",t))}_getConfig(t){if(t={...this.constructor.Default,...U.getDataAttributes(this._element),...t},a(Ue,t,this.constructor.DefaultType),"object"==typeof t.reference&&!o(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Ue.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(t){if(void 0===Fe)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let e=this._element;"parent"===this._config.reference?e=t:o(this._config.reference)?e=r(this._config.reference):"object"==typeof this._config.reference&&(e=this._config.reference);const i=this._getPopperConfig(),n=i.modifiers.find((t=>"applyStyles"===t.name&&!1===t.enabled));this._popper=qe(e,this._menu,i),n&&U.setDataAttribute(this._menu,"popper","static")}_isShown(t=this._element){return t.classList.contains(Je)}_getMenuElement(){return V.next(this._element,ei)[0]}_getPlacement(){const t=this._element.parentNode;if(t.classList.contains("dropend"))return ri;if(t.classList.contains("dropstart"))return ai;const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?ni:ii:e?oi:si}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return"static"===this._config.display&&(t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:t,target:e}){const i=V.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(l);i.length&&v(i,e,t===Ye,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(t&&(2===t.button||"keyup"===t.type&&"Tab"!==t.key))return;const e=V.find(ti);for(let i=0,n=e.length;ie+t)),this._setElementAttributes(di,"paddingRight",(e=>e+t)),this._setElementAttributes(ui,"marginRight",(e=>e-t))}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t)[e];t.style[e]=`${i(Number.parseFloat(s))}px`}))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,"paddingRight"),this._resetElementAttributes(di,"paddingRight"),this._resetElementAttributes(ui,"marginRight")}_saveInitialAttribute(t,e){const i=t.style[e];i&&U.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=U.getDataAttribute(t,e);void 0===i?t.style.removeProperty(e):(U.removeDataAttribute(t,e),t.style[e]=i)}))}_applyManipulationCallback(t,e){o(t)?e(t):V.find(t,this._element).forEach(e)}isOverflowing(){return this.getWidth()>0}}const pi={className:"modal-backdrop",isVisible:!0,isAnimated:!1,rootElement:"body",clickCallback:null},mi={className:"string",isVisible:"boolean",isAnimated:"boolean",rootElement:"(element|string)",clickCallback:"(function|null)"},gi="show",_i="mousedown.bs.backdrop";class bi{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){this._config.isVisible?(this._append(),this._config.isAnimated&&u(this._getElement()),this._getElement().classList.add(gi),this._emulateAnimation((()=>{_(t)}))):_(t)}hide(t){this._config.isVisible?(this._getElement().classList.remove(gi),this._emulateAnimation((()=>{this.dispose(),_(t)}))):_(t)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_getConfig(t){return(t={...pi,..."object"==typeof t?t:{}}).rootElement=r(t.rootElement),a("backdrop",t,mi),t}_append(){this._isAppended||(this._config.rootElement.append(this._getElement()),j.on(this._getElement(),_i,(()=>{_(this._config.clickCallback)})),this._isAppended=!0)}dispose(){this._isAppended&&(j.off(this._element,_i),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){b(t,this._getElement(),this._config.isAnimated)}}const vi={trapElement:null,autofocus:!0},yi={trapElement:"element",autofocus:"boolean"},wi=".bs.focustrap",Ei="backward";class Ai{constructor(t){this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}activate(){const{trapElement:t,autofocus:e}=this._config;this._isActive||(e&&t.focus(),j.off(document,wi),j.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),j.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,wi))}_handleFocusin(t){const{target:e}=t,{trapElement:i}=this._config;if(e===document||e===i||i.contains(e))return;const n=V.focusableChildren(i);0===n.length?i.focus():this._lastTabNavDirection===Ei?n[n.length-1].focus():n[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Ei:"forward")}_getConfig(t){return t={...vi,..."object"==typeof t?t:{}},a("focustrap",t,yi),t}}const Ti="modal",Oi="Escape",Ci={backdrop:!0,keyboard:!0,focus:!0},ki={backdrop:"(boolean|string)",keyboard:"boolean",focus:"boolean"},Li="hidden.bs.modal",xi="show.bs.modal",Di="resize.bs.modal",Si="click.dismiss.bs.modal",Ni="keydown.dismiss.bs.modal",Ii="mousedown.dismiss.bs.modal",Pi="modal-open",ji="show",Mi="modal-static";class Hi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._dialog=V.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new fi}static get Default(){return Ci}static get NAME(){return Ti}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,xi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),this._scrollBar.hide(),document.body.classList.add(Pi),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),j.on(this._dialog,Ii,(()=>{j.one(this._element,"mouseup.dismiss.bs.modal",(t=>{t.target===this._element&&(this._ignoreBackdropClick=!0)}))})),this._showBackdrop((()=>this._showElement(t))))}hide(){if(!this._isShown||this._isTransitioning)return;if(j.trigger(this._element,"hide.bs.modal").defaultPrevented)return;this._isShown=!1;const t=this._isAnimated();t&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),this._focustrap.deactivate(),this._element.classList.remove(ji),j.off(this._element,Si),j.off(this._dialog,Ii),this._queueCallback((()=>this._hideModal()),this._element,t)}dispose(){[window,this._dialog].forEach((t=>j.off(t,".bs.modal"))),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new bi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_getConfig(t){return t={...Ci,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Ti,t,ki),t}_showElement(t){const e=this._isAnimated(),i=V.findOne(".modal-body",this._dialog);this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0,i&&(i.scrollTop=0),e&&u(this._element),this._element.classList.add(ji),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,e)}_setEscapeEvent(){this._isShown?j.on(this._element,Ni,(t=>{this._config.keyboard&&t.key===Oi?(t.preventDefault(),this.hide()):this._config.keyboard||t.key!==Oi||this._triggerBackdropTransition()})):j.off(this._element,Ni)}_setResizeEvent(){this._isShown?j.on(window,Di,(()=>this._adjustDialog())):j.off(window,Di)}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Pi),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,Li)}))}_showBackdrop(t){j.on(this._element,Si,(t=>{this._ignoreBackdropClick?this._ignoreBackdropClick=!1:t.target===t.currentTarget&&(!0===this._config.backdrop?this.hide():"static"===this._config.backdrop&&this._triggerBackdropTransition())})),this._backdrop.show(t)}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const{classList:t,scrollHeight:e,style:i}=this._element,n=e>document.documentElement.clientHeight;!n&&"hidden"===i.overflowY||t.contains(Mi)||(n||(i.overflowY="hidden"),t.add(Mi),this._queueCallback((()=>{t.remove(Mi),n||this._queueCallback((()=>{i.overflowY=""}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!m()||i&&!t&&m())&&(this._element.style.paddingLeft=`${e}px`),(i&&!t&&!m()||!i&&t&&m())&&(this._element.style.paddingRight=`${e}px`)}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=n(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,xi,(t=>{t.defaultPrevented||j.one(e,Li,(()=>{l(this)&&this.focus()}))}));const i=V.findOne(".modal.show");i&&Hi.getInstance(i).hide(),Hi.getOrCreateInstance(e).toggle(this)})),R(Hi),g(Hi);const Bi="offcanvas",Ri={backdrop:!0,keyboard:!0,scroll:!1},Wi={backdrop:"boolean",keyboard:"boolean",scroll:"boolean"},$i="show",zi=".offcanvas.show",qi="hidden.bs.offcanvas";class Fi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get NAME(){return Bi}static get Default(){return Ri}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._element.style.visibility="visible",this._backdrop.show(),this._config.scroll||(new fi).hide(),this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add($i),this._queueCallback((()=>{this._config.scroll||this._focustrap.activate(),j.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.remove($i),this._backdrop.hide(),this._queueCallback((()=>{this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._element.style.visibility="hidden",this._config.scroll||(new fi).reset(),j.trigger(this._element,qi)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_getConfig(t){return t={...Ri,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Bi,t,Wi),t}_initializeBackDrop(){return new bi({className:"offcanvas-backdrop",isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_addEventListeners(){j.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{this._config.keyboard&&"Escape"===t.key&&this.hide()}))}static jQueryInterface(t){return this.each((function(){const e=Fi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=n(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this))return;j.one(e,qi,(()=>{l(this)&&this.focus()}));const i=V.findOne(zi);i&&i!==e&&Fi.getInstance(i).hide(),Fi.getOrCreateInstance(e).toggle(this)})),j.on(window,"load.bs.offcanvas.data-api",(()=>V.find(zi).forEach((t=>Fi.getOrCreateInstance(t).show())))),R(Fi),g(Fi);const Ui=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Vi=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,Ki=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,Xi=(t,e)=>{const i=t.nodeName.toLowerCase();if(e.includes(i))return!Ui.has(i)||Boolean(Vi.test(t.nodeValue)||Ki.test(t.nodeValue));const n=e.filter((t=>t instanceof RegExp));for(let t=0,e=n.length;t{Xi(t,r)||i.removeAttribute(t.nodeName)}))}return n.body.innerHTML}const Qi="tooltip",Gi=new Set(["sanitize","allowList","sanitizeFn"]),Zi={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(array|string|function)",container:"(string|element|boolean)",fallbackPlacements:"array",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",allowList:"object",popperConfig:"(null|object|function)"},Ji={AUTO:"auto",TOP:"top",RIGHT:m()?"left":"right",BOTTOM:"bottom",LEFT:m()?"right":"left"},tn={animation:!0,template:'',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,fallbackPlacements:["top","right","bottom","left"],boundary:"clippingParents",customClass:"",sanitize:!0,sanitizeFn:null,allowList:{"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},popperConfig:null},en={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"},nn="fade",sn="show",on="show",rn="out",an=".tooltip-inner",ln=".modal",cn="hide.bs.modal",hn="hover",dn="focus";class un extends B{constructor(t,e){if(void 0===Fe)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return tn}static get NAME(){return Qi}static get Event(){return en}static get DefaultType(){return Zi}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains(sn))return void this._leave(null,this);this._enter(null,this)}}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ln),cn,this._hideModalHandler),this.tip&&this.tip.remove(),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this.isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.Event.SHOW),e=h(this._element),i=null===e?this._element.ownerDocument.documentElement.contains(this._element):e.contains(this._element);if(t.defaultPrevented||!i)return;"tooltip"===this.constructor.NAME&&this.tip&&this.getTitle()!==this.tip.querySelector(an).innerHTML&&(this._disposePopper(),this.tip.remove(),this.tip=null);const n=this.getTipElement(),s=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME);n.setAttribute("id",s),this._element.setAttribute("aria-describedby",s),this._config.animation&&n.classList.add(nn);const o="function"==typeof this._config.placement?this._config.placement.call(this,n,this._element):this._config.placement,r=this._getAttachment(o);this._addAttachmentClass(r);const{container:a}=this._config;H.set(n,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(a.append(n),j.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=qe(this._element,n,this._getPopperConfig(r)),n.classList.add(sn);const l=this._resolvePossibleFunction(this._config.customClass);l&&n.classList.add(...l.split(" ")),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>{j.on(t,"mouseover",d)}));const c=this.tip.classList.contains(nn);this._queueCallback((()=>{const t=this._hoverState;this._hoverState=null,j.trigger(this._element,this.constructor.Event.SHOWN),t===rn&&this._leave(null,this)}),this.tip,c)}hide(){if(!this._popper)return;const t=this.getTipElement();if(j.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.remove(sn),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1;const e=this.tip.classList.contains(nn);this._queueCallback((()=>{this._isWithActiveTrigger()||(this._hoverState!==on&&t.remove(),this._cleanTipClass(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.Event.HIDDEN),this._disposePopper())}),this.tip,e),this._hoverState=""}update(){null!==this._popper&&this._popper.update()}isWithContent(){return Boolean(this.getTitle())}getTipElement(){if(this.tip)return this.tip;const t=document.createElement("div");t.innerHTML=this._config.template;const e=t.children[0];return this.setContent(e),e.classList.remove(nn,sn),this.tip=e,this.tip}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),an)}_sanitizeAndSetContent(t,e,i){const n=V.findOne(i,t);e||!n?this.setElementContent(n,e):n.remove()}setElementContent(t,e){if(null!==t)return o(e)?(e=r(e),void(this._config.html?e.parentNode!==t&&(t.innerHTML="",t.append(e)):t.textContent=e.textContent)):void(this._config.html?(this._config.sanitize&&(e=Yi(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e)}getTitle(){const t=this._element.getAttribute("data-bs-original-title")||this._config.title;return this._resolvePossibleFunction(t)}updateAttachment(t){return"right"===t?"end":"left"===t?"start":t}_initializeOnDelegatedTarget(t,e){return e||this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return"function"==typeof t?t.call(this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"onChange",enabled:!0,phase:"afterWrite",fn:t=>this._handlePopperPlacementChange(t)}],onFirstUpdate:t=>{t.options.placement!==t.placement&&this._handlePopperPlacementChange(t)}};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(t)}`)}_getAttachment(t){return Ji[t.toUpperCase()]}_setListeners(){this._config.trigger.split(" ").forEach((t=>{if("click"===t)j.on(this._element,this.constructor.Event.CLICK,this._config.selector,(t=>this.toggle(t)));else if("manual"!==t){const e=t===hn?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,i=t===hn?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;j.on(this._element,e,this._config.selector,(t=>this._enter(t))),j.on(this._element,i,this._config.selector,(t=>this._leave(t)))}})),this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ln),cn,this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:"manual",selector:""}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute("title"),e=typeof this._element.getAttribute("data-bs-original-title");(t||"string"!==e)&&(this._element.setAttribute("data-bs-original-title",t||""),!t||this._element.getAttribute("aria-label")||this._element.textContent||this._element.setAttribute("aria-label",t),this._element.setAttribute("title",""))}_enter(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusin"===t.type?dn:hn]=!0),e.getTipElement().classList.contains(sn)||e._hoverState===on?e._hoverState=on:(clearTimeout(e._timeout),e._hoverState=on,e._config.delay&&e._config.delay.show?e._timeout=setTimeout((()=>{e._hoverState===on&&e.show()}),e._config.delay.show):e.show())}_leave(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusout"===t.type?dn:hn]=e._element.contains(t.relatedTarget)),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=rn,e._config.delay&&e._config.delay.hide?e._timeout=setTimeout((()=>{e._hoverState===rn&&e.hide()}),e._config.delay.hide):e.hide())}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=U.getDataAttributes(this._element);return Object.keys(e).forEach((t=>{Gi.has(t)&&delete e[t]})),(t={...this.constructor.Default,...e,..."object"==typeof t&&t?t:{}}).container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),a(Qi,t,this.constructor.DefaultType),t.sanitize&&(t.template=Yi(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=new RegExp(`(^|\\s)${this._getBasicClassPrefix()}\\S+`,"g"),i=t.getAttribute("class").match(e);null!==i&&i.length>0&&i.map((t=>t.trim())).forEach((e=>t.classList.remove(e)))}_getBasicClassPrefix(){return"bs-tooltip"}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null)}static jQueryInterface(t){return this.each((function(){const e=un.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(un);const fn={...un.Default,placement:"right",offset:[0,8],trigger:"click",content:"",template:''},pn={...un.DefaultType,content:"(string|element|function)"},mn={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"};class gn extends un{static get Default(){return fn}static get NAME(){return"popover"}static get Event(){return mn}static get DefaultType(){return pn}isWithContent(){return this.getTitle()||this._getContent()}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),".popover-header"),this._sanitizeAndSetContent(t,this._getContent(),".popover-body")}_getContent(){return this._resolvePossibleFunction(this._config.content)}_getBasicClassPrefix(){return"bs-popover"}static jQueryInterface(t){return this.each((function(){const e=gn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(gn);const _n="scrollspy",bn={offset:10,method:"auto",target:""},vn={offset:"number",method:"string",target:"(string|element)"},yn="active",wn=".nav-link, .list-group-item, .dropdown-item",En="position";class An extends B{constructor(t,e){super(t),this._scrollElement="BODY"===this._element.tagName?window:this._element,this._config=this._getConfig(e),this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,j.on(this._scrollElement,"scroll.bs.scrollspy",(()=>this._process())),this.refresh(),this._process()}static get Default(){return bn}static get NAME(){return _n}refresh(){const t=this._scrollElement===this._scrollElement.window?"offset":En,e="auto"===this._config.method?t:this._config.method,n=e===En?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),V.find(wn,this._config.target).map((t=>{const s=i(t),o=s?V.findOne(s):null;if(o){const t=o.getBoundingClientRect();if(t.width||t.height)return[U[e](o).top+n,s]}return null})).filter((t=>t)).sort(((t,e)=>t[0]-e[0])).forEach((t=>{this._offsets.push(t[0]),this._targets.push(t[1])}))}dispose(){j.off(this._scrollElement,".bs.scrollspy"),super.dispose()}_getConfig(t){return(t={...bn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}}).target=r(t.target)||document.documentElement,a(_n,t,vn),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const t=this._targets[this._targets.length-1];this._activeTarget!==t&&this._activate(t)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(let e=this._offsets.length;e--;)this._activeTarget!==this._targets[e]&&t>=this._offsets[e]&&(void 0===this._offsets[e+1]||t`${e}[data-bs-target="${t}"],${e}[href="${t}"]`)),i=V.findOne(e.join(","),this._config.target);i.classList.add(yn),i.classList.contains("dropdown-item")?V.findOne(".dropdown-toggle",i.closest(".dropdown")).classList.add(yn):V.parents(i,".nav, .list-group").forEach((t=>{V.prev(t,".nav-link, .list-group-item").forEach((t=>t.classList.add(yn))),V.prev(t,".nav-item").forEach((t=>{V.children(t,".nav-link").forEach((t=>t.classList.add(yn)))}))})),j.trigger(this._scrollElement,"activate.bs.scrollspy",{relatedTarget:t})}_clear(){V.find(wn,this._config.target).filter((t=>t.classList.contains(yn))).forEach((t=>t.classList.remove(yn)))}static jQueryInterface(t){return this.each((function(){const e=An.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,"load.bs.scrollspy.data-api",(()=>{V.find('[data-bs-spy="scroll"]').forEach((t=>new An(t)))})),g(An);const Tn="active",On="fade",Cn="show",kn=".active",Ln=":scope > li > .active";class xn extends B{static get NAME(){return"tab"}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains(Tn))return;let t;const e=n(this._element),i=this._element.closest(".nav, .list-group");if(i){const e="UL"===i.nodeName||"OL"===i.nodeName?Ln:kn;t=V.find(e,i),t=t[t.length-1]}const s=t?j.trigger(t,"hide.bs.tab",{relatedTarget:this._element}):null;if(j.trigger(this._element,"show.bs.tab",{relatedTarget:t}).defaultPrevented||null!==s&&s.defaultPrevented)return;this._activate(this._element,i);const o=()=>{j.trigger(t,"hidden.bs.tab",{relatedTarget:this._element}),j.trigger(this._element,"shown.bs.tab",{relatedTarget:t})};e?this._activate(e,e.parentNode,o):o()}_activate(t,e,i){const n=(!e||"UL"!==e.nodeName&&"OL"!==e.nodeName?V.children(e,kn):V.find(Ln,e))[0],s=i&&n&&n.classList.contains(On),o=()=>this._transitionComplete(t,n,i);n&&s?(n.classList.remove(Cn),this._queueCallback(o,t,!0)):o()}_transitionComplete(t,e,i){if(e){e.classList.remove(Tn);const t=V.findOne(":scope > .dropdown-menu .active",e.parentNode);t&&t.classList.remove(Tn),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!1)}t.classList.add(Tn),"tab"===t.getAttribute("role")&&t.setAttribute("aria-selected",!0),u(t),t.classList.contains(On)&&t.classList.add(Cn);let n=t.parentNode;if(n&&"LI"===n.nodeName&&(n=n.parentNode),n&&n.classList.contains("dropdown-menu")){const e=t.closest(".dropdown");e&&V.find(".dropdown-toggle",e).forEach((t=>t.classList.add(Tn))),t.setAttribute("aria-expanded",!0)}i&&i()}static jQueryInterface(t){return this.each((function(){const e=xn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,"click.bs.tab.data-api",'[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this)||xn.getOrCreateInstance(this).show()})),g(xn);const Dn="toast",Sn="hide",Nn="show",In="showing",Pn={animation:"boolean",autohide:"boolean",delay:"number"},jn={animation:!0,autohide:!0,delay:5e3};class Mn extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get DefaultType(){return Pn}static get Default(){return jn}static get NAME(){return Dn}show(){j.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(Sn),u(this._element),this._element.classList.add(Nn),this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.remove(In),j.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this._element.classList.contains(Nn)&&(j.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.add(Sn),this._element.classList.remove(In),this._element.classList.remove(Nn),j.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this._element.classList.contains(Nn)&&this._element.classList.remove(Nn),super.dispose()}_getConfig(t){return t={...jn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}},a(Dn,t,this.constructor.DefaultType),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),j.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Mn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(Mn),g(Mn),{Alert:W,Button:z,Carousel:st,Collapse:pt,Dropdown:hi,Modal:Hi,Offcanvas:Fi,Popover:gn,ScrollSpy:An,Tab:xn,Toast:Mn,Tooltip:un}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..8701d3a --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,40 @@ +{%- extends "bootstrap.html" %} + +{% block styles %} +{{super()}} + +{% endblock %} + +{% block title %}Notify{% endblock %} + +{% block navbar %} + +{% endblock %} + +{% block content %} +
+ {% block main %}{% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/bootstrap.html b/app/templates/bootstrap.html new file mode 100644 index 0000000..e550fac --- /dev/null +++ b/app/templates/bootstrap.html @@ -0,0 +1,30 @@ + + + + + + + + + + {% block title %}{% endblock %} + + {%- block styles %} + + {%- endblock styles %} + + + + {% block body -%} + {% block navbar %} + {%- endblock navbar %} + {% block content -%} + {%- endblock content %} + + {% block scripts %} + + {%- endblock scripts %} + {%- endblock body %} + + + \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..eb35fd1 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,10 @@ +{%- extends "base.html" %} + +{% block main %} +
+
+

Notify Server

+

Access notifications and settings. Please login to continue.

+
+
+{%- endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..a018882 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,29 @@ +{%- extends "base.html" %} + +{% block main %} +
+ +
+{%- endblock %} diff --git a/app/views/__init__.py b/app/views/__init__.py new file mode 100644 index 0000000..27df5b5 --- /dev/null +++ b/app/views/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path +from starlette.templating import Jinja2Templates + +app_dir = Path(__file__).parents[1].resolve() +templates = Jinja2Templates(directory=str(app_dir / "templates")) diff --git a/app/views/home.py b/app/views/home.py new file mode 100644 index 0000000..50aa839 --- /dev/null +++ b/app/views/home.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends, status +from fastapi.logger import logger +from starlette.responses import HTMLResponse, RedirectResponse +from starlette.requests import Request +from sqlalchemy.orm import Session +from . import templates +from .. import cookie_auth, crud, auth +from ..api import deps + +router = APIRouter() + + +@router.get("/", response_class=HTMLResponse) +async def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + + +@router.get("/login", response_class=HTMLResponse) +async def login_get(request: Request): + return templates.TemplateResponse( + "login.html", + { + "request": request, + "username": "", + "password": "", + "error": "", + }, + ) + + +@router.post("/login", response_class=HTMLResponse) +async def login_post( + request: Request, + db: Session = Depends(deps.get_db), +): + form = await request.form() + username = form.get("username", "").lower().strip() + password = form.get("password", "").strip() + result = { + "request": request, + "username": username, + "password": password, + "error": "", + } + + if not username or not password: + result["error"] = "You must specify a username and password" + return templates.TemplateResponse("login.html", result) + if not auth.authenticate_user(username, password): + logger.warning(f"Authentication failed for {username}") + result["error"] = "Invalid Username/Password" + return templates.TemplateResponse("login.html", result) + logger.info(f"User {username} successfully logged in") + db_user = crud.get_user_by_username(db, username.lower()) + if db_user is None: + db_user = crud.create_user(db, username.lower()) + + resp = RedirectResponse("/", status_code=status.HTTP_302_FOUND) + cookie_auth.set_auth(resp, db_user.id) + return resp + + +@router.get("/logout") +def logout(): + response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) + cookie_auth.logout(response) + return response diff --git a/requirements.txt b/requirements.txt index 022ef19..ae25f61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ chardet==4.0.0 click==7.1.2 cryptography==3.2.1 fastapi==0.61.2 -fastapi-versioning==0.8.0 +fastapi-versioning==0.10.0 google-auth==1.28.0 gunicorn==20.0.4 h11==0.11.0 diff --git a/tests/conftest.py b/tests/conftest.py index ce10edd..fafd03a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,9 +28,9 @@ environ["FIREBASE_PROJECT_ID"] = "my-project" environ["GOOGLE_APPLICATION_CREDENTIALS"] = "test-key.json" -from app.main import original_app, app # noqa E402 +from app.main import original_api, app # noqa E402 from app.database import Base, engine # noqa E402 -from app.api import deps # noqa E402 +from app import deps # noqa E402 from app.utils import create_access_token # noqa E402 from . import factories # noqa E402 @@ -55,7 +55,7 @@ def db(connection) -> Generator: factories.UserFactory._meta.sqlalchemy_session = session factories.ServiceFactory._meta.sqlalchemy_session = session factories.NotificationFactory._meta.sqlalchemy_session = session - original_app.dependency_overrides[deps.get_db] = lambda: session + original_api.dependency_overrides[deps.get_db] = lambda: session yield session session.close() transaction.rollback() From 48d289812602f2a986ccf1b505b3ae44019128e6 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Fri, 10 Dec 2021 23:29:17 +0100 Subject: [PATCH 03/78] Get user from cookie --- app/api/login.py | 3 +-- app/api/services.py | 3 +-- app/api/users.py | 3 +-- app/cookie_auth.py | 26 ++----------------------- app/{api => }/deps.py | 42 ++++++++++++++++++++++++++++++++++++++--- app/main.py | 4 ++-- app/settings.py | 2 ++ app/templates/403.html | 8 ++++++++ app/templates/404.html | 7 +++++++ app/templates/500.html | 7 +++++++ app/templates/base.html | 6 ++++-- app/views/exceptions.py | 32 +++++++++++++++++++++++++++++++ app/views/home.py | 13 +++++++++---- 13 files changed, 115 insertions(+), 41 deletions(-) rename app/{api => }/deps.py (60%) create mode 100644 app/templates/403.html create mode 100644 app/templates/404.html create mode 100644 app/templates/500.html create mode 100644 app/views/exceptions.py diff --git a/app/api/login.py b/app/api/login.py index 31b2675..216de6d 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -2,8 +2,7 @@ from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger from sqlalchemy.orm import Session -from . import deps -from .. import crud, utils, auth +from .. import deps, crud, utils, auth router = APIRouter() diff --git a/app/api/services.py b/app/api/services.py index 9a3e57a..8a2565f 100644 --- a/app/api/services.py +++ b/app/api/services.py @@ -11,8 +11,7 @@ from fastapi.logger import logger from sqlalchemy.orm import Session from typing import List, Optional -from . import deps -from .. import crud, models, schemas, utils +from .. import deps, crud, models, schemas, utils router = APIRouter() diff --git a/app/api/users.py b/app/api/users.py index 75c381c..f638ca7 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -2,8 +2,7 @@ from fastapi_versioning import version from sqlalchemy.orm import Session from typing import List -from . import deps -from .. import crud, models, schemas +from .. import deps, crud, models, schemas router = APIRouter() diff --git a/app/cookie_auth.py b/app/cookie_auth.py index 48a8692..4c039e4 100644 --- a/app/cookie_auth.py +++ b/app/cookie_auth.py @@ -1,17 +1,13 @@ import hashlib import hmac -from typing import Optional -from fastapi.logger import logger -from fastapi.requests import Request from fastapi.responses import Response -from .settings import SECRET_KEY, ACCESS_TOKEN_EXPIRE_MINUTES +from .settings import SECRET_KEY, ACCESS_TOKEN_EXPIRE_MINUTES, AUTH_COOKIE_NAME -AUTH_COOKIE_NAME = "notify_token" AUTH_SIZE = 16 def sign(cookie: str) -> str: - h = hashlib.blake2b(digest_size=AUTH_SIZE, key=SECRET_KEY) + h = hashlib.blake2b(digest_size=AUTH_SIZE, key=str(SECRET_KEY).encode("utf-8")) h.update(cookie.encode("utf-8")) return h.hexdigest() @@ -34,23 +30,5 @@ def set_auth(response: Response, user_id: int): ) -def get_user_id_via_auth_cookie(request: Request) -> Optional[int]: - if AUTH_COOKIE_NAME not in request.cookies: - return None - val = request.cookies[AUTH_COOKIE_NAME] - parts = val.split(":") - if len(parts) != 2: - return None - user_id, sig = parts - if not verify(str(user_id), sig): - logger.warning("Hash mismatch, invalid cookie value") - return None - try: - return int(user_id) - except ValueError: - logger.warning(f"Invalid user_id {user_id} in cookie") - return None - - def logout(response: Response): response.delete_cookie(AUTH_COOKIE_NAME) diff --git a/app/api/deps.py b/app/deps.py similarity index 60% rename from app/api/deps.py rename to app/deps.py index 3c2ffed..da134af 100644 --- a/app/api/deps.py +++ b/app/deps.py @@ -1,12 +1,15 @@ from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from starlette.requests import Request +from fastapi.security import OAuth2PasswordBearer, APIKeyCookie from fastapi.logger import logger from sqlalchemy.orm import Session from jwt import PyJWTError, ExpiredSignatureError -from .. import crud, models, utils -from ..database import SessionLocal +from . import crud, models, utils, cookie_auth +from .database import SessionLocal +from .settings import AUTH_COOKIE_NAME oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") +cookie_sec = APIKeyCookie(name=AUTH_COOKIE_NAME) def get_db(): @@ -67,3 +70,36 @@ def get_current_admin_user( detail="The user doesn't have enough privileges", ) return current_user + + +def get_current_user_from_cookie( + request: Request, db: Session = Depends(get_db) +) -> models.User: + unauthorized_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication" + ) + if AUTH_COOKIE_NAME not in request.cookies: + raise unauthorized_exception + cookie = request.cookies[AUTH_COOKIE_NAME] + parts = cookie.split(":") + if len(parts) != 2: + raise unauthorized_exception + user_id_s, sig = parts + if not cookie_auth.verify(user_id_s, sig): + logger.warning("Hash mismatch, invalid cookie value") + raise unauthorized_exception + try: + user_id = int(user_id_s) + except ValueError: + logger.warning(f"Invalid user_id {user_id_s} in cookie") + raise unauthorized_exception + user = crud.get_user(db, user_id) + if user is None: + logger.warning(f"Unknown user id {user_id}") + raise unauthorized_exception + if not user.is_active: + logger.warning(f"User {user.username} is inactive") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + return user diff --git a/app/main.py b/app/main.py index 95fc2ba..1a6c47e 100644 --- a/app/main.py +++ b/app/main.py @@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles from . import monitoring from .api import login, users, services -from .views import home +from .views import home, exceptions from .settings import SENTRY_DSN, ESS_NOTIFY_SERVER_ENVIRONMENT @@ -20,7 +20,7 @@ logger.setLevel(gunicorn_error_logger.level) # Main application to serve HTML -app = FastAPI() +app = FastAPI(exception_handlers=exceptions.exception_handlers) app.include_router(home.router, tags=["home"]) # Serve static files diff --git a/app/settings.py b/app/settings.py index 31c8717..a0fc419 100644 --- a/app/settings.py +++ b/app/settings.py @@ -60,6 +60,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES = config( "ACCESS_TOKEN_EXPIRE_MINUTES", cast=int, default=43200 ) +# Cookie name +AUTH_COOKIE_NAME = config("AUTH_COOKIE_NAME", cast=str, default="notify_token") # Number of push notifications sent in parallel NB_PARALLEL_PUSH = config("NB_PARALLEL_PUSH", cast=int, default=50) diff --git a/app/templates/403.html b/app/templates/403.html new file mode 100644 index 0000000..90a60ec --- /dev/null +++ b/app/templates/403.html @@ -0,0 +1,8 @@ +{%- extends "base.html" %} + +{% block title %}Forbidden{% endblock %} + +{% block main %} +

Forbidden

+

You don't have the permission to access the requested resource

+{%- endblock %} diff --git a/app/templates/404.html b/app/templates/404.html new file mode 100644 index 0000000..2a026a0 --- /dev/null +++ b/app/templates/404.html @@ -0,0 +1,7 @@ +{%- extends "base.html" %} + +{% block title %}Page Not Found{% endblock %} + +{% block main %} +

Page Not Found

+{%- endblock %} diff --git a/app/templates/500.html b/app/templates/500.html new file mode 100644 index 0000000..2c15fae --- /dev/null +++ b/app/templates/500.html @@ -0,0 +1,7 @@ +{%- extends "base.html" %} + +{% block title %}Internal Server Error{% endblock %} + +{% block main %} +

Internal Server Error

+{%- endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 8701d3a..8a61c2a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -25,8 +25,10 @@ diff --git a/app/views/exceptions.py b/app/views/exceptions.py new file mode 100644 index 0000000..18f8819 --- /dev/null +++ b/app/views/exceptions.py @@ -0,0 +1,32 @@ +from fastapi import status +from starlette.exceptions import HTTPException +from starlette.responses import RedirectResponse +from starlette.requests import Request +from . import templates + + +async def not_authenticated(request: Request, exc: HTTPException): + return RedirectResponse(url="/login") + + +async def forbidden(request: Request, exc: HTTPException): + return templates.TemplateResponse( + "403.html", {"request": request}, status_code=exc.status_code + ) + + +async def not_found(request: Request, exc: HTTPException): + return templates.TemplateResponse( + "404.html", {"request": request}, status_code=exc.status_code + ) + + +async def server_error(request: Request, exc: HTTPException): + return templates.TemplateResponse( + "500.html", + {"request": request}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +exception_handlers = {401: not_authenticated, 404: not_found, 500: server_error} diff --git a/app/views/home.py b/app/views/home.py index 50aa839..d89a715 100644 --- a/app/views/home.py +++ b/app/views/home.py @@ -4,15 +4,20 @@ from starlette.requests import Request from sqlalchemy.orm import Session from . import templates -from .. import cookie_auth, crud, auth -from ..api import deps +from .. import cookie_auth, crud, auth, models +from .. import deps router = APIRouter() @router.get("/", response_class=HTMLResponse) -async def index(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) +async def index( + request: Request, + current_user: models.User = Depends(deps.get_current_user_from_cookie), +): + return templates.TemplateResponse( + "index.html", {"request": request, "current_user": current_user} + ) @router.get("/login", response_class=HTMLResponse) From c81eb404c7f2d7e824692946746aecda49496bec Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Sat, 11 Dec 2021 18:04:02 +0100 Subject: [PATCH 04/78] Add web settings --- app/main.py | 6 ++- app/templates/_helpers.html | 3 ++ app/templates/base.html | 11 +++-- app/templates/login.html | 2 + .../{index.html => notifications.html} | 6 ++- app/templates/settings.html | 22 +++++++++ app/views/{home.py => account.py} | 9 ++-- app/views/notifications.py | 17 +++++++ app/views/settings.py | 48 +++++++++++++++++++ 9 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 app/templates/_helpers.html rename app/templates/{index.html => notifications.html} (61%) create mode 100644 app/templates/settings.html rename app/views/{home.py => account.py} (89%) create mode 100644 app/views/notifications.py create mode 100644 app/views/settings.py diff --git a/app/main.py b/app/main.py index 1a6c47e..6041c30 100644 --- a/app/main.py +++ b/app/main.py @@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles from . import monitoring from .api import login, users, services -from .views import home, exceptions +from .views import exceptions, account, notifications, settings from .settings import SENTRY_DSN, ESS_NOTIFY_SERVER_ENVIRONMENT @@ -21,7 +21,9 @@ # Main application to serve HTML app = FastAPI(exception_handlers=exceptions.exception_handlers) -app.include_router(home.router, tags=["home"]) +app.include_router(account.router) +app.include_router(notifications.router, prefix="/notifications") +app.include_router(settings.router, prefix="/settings") # Serve static files app_dir = Path(__file__).parent.resolve() diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html new file mode 100644 index 0000000..5950eb0 --- /dev/null +++ b/app/templates/_helpers.html @@ -0,0 +1,3 @@ +{% macro is_active(request, path) -%} +{% if request.url.path.startswith(path) %}active{% endif %} +{%- endmacro %} diff --git a/app/templates/base.html b/app/templates/base.html index 8a61c2a..94d8c60 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,4 +1,5 @@ {%- extends "bootstrap.html" %} +{% from "_helpers.html" import is_active %} {% block styles %} {{super()}} @@ -10,7 +11,7 @@ {% block navbar %} diff --git a/app/templates/login.html b/app/templates/login.html index a018882..3e66252 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,5 +1,7 @@ {%- extends "base.html" %} +{% block title %}Login{% endblock %} + {% block main %}
-{%- endblock %} \ No newline at end of file +{%- endblock %} From 029c2d4a5b0b92582687eb3181b71e189aabbb3c Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Mon, 10 Jan 2022 09:11:50 +0100 Subject: [PATCH 15/78] Add aiofiles requirement Required by starlette to serve static files --- requirements.txt | 1 + setup.py | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 1d109e8..bb8ca0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +aiofiles==0.8.0 alembic==1.5.7 cachetools==4.2.1 certifi==2020.11.8 diff --git a/setup.py b/setup.py index 56a54ec..4cebd96 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,7 @@ postgres_requires = ["psycopg2"] requirements = [ "alembic==1.5.7", + "aiofiles", "fastapi==0.61.2", "fastapi-versioning", "google-auth", From 10dbc69761f27e66315888ea1fc20359bf397f58 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Mon, 10 Jan 2022 12:14:07 +0100 Subject: [PATCH 16/78] Clean dependencies Do not pin dependencies in setup.py (use requirements.txt for that) --- requirements.txt | 85 +++++++++++++++++++++++++----------------------- setup.py | 29 +++++++---------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/requirements.txt b/requirements.txt index bb8ca0e..478724b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,48 +1,53 @@ aiofiles==0.8.0 -alembic==1.5.7 -cachetools==4.2.1 -certifi==2020.11.8 -cffi==1.14.3 -chardet==4.0.0 -click==7.1.2 -cryptography==3.2.1 -fastapi==0.61.2 +alembic==1.7.5 +anyio==3.4.0 +asgiref==3.4.1 +cachetools==4.2.4 +certifi==2021.10.8 +cffi==1.15.0 +charset-normalizer==2.0.10 +click==8.0.3 +cryptography==36.0.1 +fastapi==0.71.0 fastapi-versioning==0.10.0 -google-auth==1.28.0 -gunicorn==20.0.4 -h11==0.11.0 -h2==4.0.0 +google-auth==2.3.3 +gunicorn==20.1.0 +h11==0.12.0 +h2==4.1.0 hpack==4.0.0 -httpcore==0.12.1 -httptools==0.1.1 -httpx==0.16.1 -hyperframe==6.0.0 -idna==2.10 +httpcore==0.14.4 +httptools==0.3.0 +httpx==0.21.3 +hyperframe==6.0.1 +idna==3.3 +importlib-metadata==4.10.0 +importlib-resources==5.4.0 itsdangerous==2.0.1 -ldap3==2.8.1 -Mako==1.1.3 -MarkupSafe==1.1.1 +Jinja2==3.0.3 +ldap3==2.9.1 +Mako==1.1.6 +MarkupSafe==2.0.1 pyasn1==0.4.8 pyasn1-modules==0.2.8 -pycparser==2.20 -pydantic==1.7.2 -PyJWT==2.0.0 -python-dateutil==2.8.1 -python-dotenv==0.15.0 -python-editor==1.0.4 +pycparser==2.21 +pydantic==1.9.0 +PyJWT==2.3.0 +python-dotenv==0.19.2 python-multipart==0.0.5 -PyYAML==5.3.1 -requests==2.25.1 -rfc3986==1.4.0 -rsa==4.7.2 -sentry-sdk==0.19.3 -six==1.15.0 +PyYAML==6.0 +requests==2.27.1 +rfc3986==1.5.0 +rsa==4.8 +sentry-sdk==1.5.1 +six==1.16.0 sniffio==1.2.0 -SQLAlchemy==1.3.20 -starlette==0.13.6 -typer==0.3.2 -urllib3==1.26.4 -uvicorn==0.12.2 -uvloop==0.14.0 -watchgod==0.6 -websockets==8.1 +SQLAlchemy==1.3.24 +starlette==0.17.1 +typer==0.4.0 +typing-extensions==4.0.1 +urllib3==1.26.8 +uvicorn==0.16.0 +uvloop==0.16.0 +watchgod==0.7 +websockets==10.1 +zipp==3.7.0 diff --git a/setup.py b/setup.py index 4cebd96..7d49879 100644 --- a/setup.py +++ b/setup.py @@ -6,28 +6,24 @@ postgres_requires = ["psycopg2"] requirements = [ - "alembic==1.5.7", + "alembic", "aiofiles", - "fastapi==0.61.2", + "cryptography", + "fastapi", "fastapi-versioning", "google-auth", "requests", - "python-multipart==0.0.5", - "h11==0.11.0", - "h2==4.0.0", - "hpack==4.0.0", - "httpcore==0.12.1", - "httptools==0.1.1", - "httpx==0.16.1", - "hyperframe==6.0.0", + "h2", "itsdangerous", - "PyJWT==2.0.0", - "cryptography==3.2.1", - "ldap3==2.8.1", - "SQLAlchemy==1.3.20", + "jinja2", + "python-multipart", + "httpx", + "PyJWT", + "ldap3", + "SQLAlchemy<1.4", "uvicorn[standard]", - "gunicorn==20.0.4", - "sentry-sdk==0.19.3", + "gunicorn", + "sentry-sdk", "typer", ] tests_requires = [ @@ -36,7 +32,6 @@ "pytest-asyncio", "pytest-mock", "pytest-factoryboy", - "requests", "respx", "Faker", ] From 31837a18af0c632a50259bf045d2363451c83ef8 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 5 Dec 2023 13:56:33 +0100 Subject: [PATCH 17/78] Update MAX IV gitlab-ci --- .gitlab-ci.maxiv.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 33c78d6..4effc12 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -38,7 +38,7 @@ variables: HELM_SET_TEST_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" GITLAB_ENVIRONMENT_NAME: notify -test-python310: +test-python311: stage: test tags: - kubernetes From 159d71fafafcc2c07eb6e2c68e42705e28d0d668 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 12 Dec 2023 16:40:12 +0100 Subject: [PATCH 18/78] Fix tests for pydantic >= 2.3.0 --- setup.py | 3 ++- tests/api/test_services.py | 18 +++++++++++++----- tests/api/test_users.py | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/setup.py b/setup.py index 019d64b..27f194a 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ "aiofiles", "cryptography", "fastapi", - "pydantic>=2.0", + "pydantic>=2.3", "fastapi-versioning", "google-auth", "requests", @@ -28,6 +28,7 @@ "typer", ] tests_requires = [ + "packaging", "pytest", "pytest-cov", "pytest-asyncio", diff --git a/tests/api/test_services.py b/tests/api/test_services.py index 544a9b1..7f6f88d 100644 --- a/tests/api/test_services.py +++ b/tests/api/test_services.py @@ -1,6 +1,8 @@ import json import uuid import pytest +import importlib.metadata +import packaging.version from fastapi.testclient import TestClient from app import models, schemas @@ -102,7 +104,7 @@ def test_update_service_invalid_color( "input": color, "msg": "Value error, Color should match [0-9a-fA-F]{6}", "type": "value_error", - "url": "https://errors.pydantic.dev/2.1/v/value_error", + "url": f"{pydantic_errors_url()}/v/value_error", } ], } @@ -153,7 +155,7 @@ def test_read_service_notifications( "id": notification1.id, "service_id": str(notification1.service_id), "subtitle": notification1.subtitle, - "timestamp": notification1.timestamp.isoformat().rstrip("0"), + "timestamp": notification1.timestamp.isoformat(), "title": notification1.title, "url": notification1.url, }, @@ -161,7 +163,7 @@ def test_read_service_notifications( "id": notification2.id, "service_id": str(notification2.service_id), "subtitle": notification2.subtitle, - "timestamp": notification2.timestamp.isoformat().rstrip("0"), + "timestamp": notification2.timestamp.isoformat(), "title": notification2.title, "url": notification2.url, }, @@ -187,7 +189,7 @@ def test_read_service_notifications_invalid_service_id( "msg": "Input should be a valid UUID, invalid length: expected " "length 32 for simple format, found 4", "type": "uuid_parsing", - "url": "https://errors.pydantic.dev/2.1/v/uuid_parsing", + "url": f"{pydantic_errors_url()}/v/uuid_parsing", } ], } @@ -260,7 +262,13 @@ def test_create_notification_for_service( "id": db_notification.id, "service_id": str(service.id), "subtitle": sample_notification["subtitle"], - "timestamp": db_notification.timestamp.isoformat().rstrip("0"), + "timestamp": db_notification.timestamp.isoformat(), "title": sample_notification["title"], "url": sample_notification["url"], } + + +def pydantic_errors_url(): + version_str = importlib.metadata.version("pydantic") + version = packaging.version.parse(version_str) + return f"https://errors.pydantic.dev/{version.major}.{version.minor}" diff --git a/tests/api/test_users.py b/tests/api/test_users.py index ced2351..392d6a6 100644 --- a/tests/api/test_users.py +++ b/tests/api/test_users.py @@ -257,7 +257,7 @@ def test_read_current_user_notifications( "is_read": False, "service_id": str(notification1.service_id), "subtitle": notification1.subtitle, - "timestamp": notification1.timestamp.isoformat().rstrip("0"), + "timestamp": notification1.timestamp.isoformat(), "title": notification1.title, "url": notification1.url, }, @@ -266,7 +266,7 @@ def test_read_current_user_notifications( "is_read": False, "service_id": str(notification2.service_id), "subtitle": notification2.subtitle, - "timestamp": notification2.timestamp.isoformat().rstrip("0"), + "timestamp": notification2.timestamp.isoformat(), "title": notification2.title, "url": notification2.url, }, From ac0e75561b37d7bb45ebcf790a2323883f14b777 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 12 Dec 2023 16:49:28 +0100 Subject: [PATCH 19/78] Add docker image test to MAX IV pipeline --- .gitlab-ci.maxiv.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 4effc12..03ccdfc 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -38,6 +38,7 @@ variables: HELM_SET_TEST_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" GITLAB_ENVIRONMENT_NAME: notify +# Test with latest versions of requirements test-python311: stage: test tags: @@ -53,3 +54,14 @@ test-python311: - coverage.xml reports: junit: junit.xml + +# Test with frozen requirements +test-docker-image: + stage: test + tags: + - kubernetes + image: "$REGISTRY_IMAGE_NAME:$REGISTRY_IMAGE_TAG" + before_script: + - pip install --no-cache-dir .[tests] + script: + - pytest -v From d4d02aa8423201a88ee7ad80664eca454983f185 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 13 Dec 2023 08:34:04 +0100 Subject: [PATCH 20/78] Update requirements --- requirements.txt | 55 ++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/requirements.txt b/requirements.txt index df15dce..61c0935 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,51 +1,50 @@ -aiofiles==23.1.0 -alembic==1.11.1 -annotated-types==0.5.0 +aiofiles==23.2.1 +alembic==1.13.0 +annotated-types==0.6.0 anyio==3.7.1 -cachetools==5.3.1 -certifi==2023.7.22 -cffi==1.15.1 -charset-normalizer==3.2.0 -click==8.1.6 -cryptography==41.0.2 -fastapi==0.100.1 +cachetools==5.3.2 +certifi==2023.11.17 +cffi==1.16.0 +charset-normalizer==3.3.2 +click==8.1.7 +cryptography==41.0.7 +fastapi==0.105.0 fastapi-versioning==0.10.0 -google-auth==2.22.0 +google-auth==2.25.2 gunicorn==21.2.0 h11==0.14.0 h2==4.1.0 hpack==4.0.0 -httpcore==0.17.3 -httptools==0.6.0 -httpx==0.24.1 +httpcore==1.0.2 +httptools==0.6.1 +httpx==0.25.2 hyperframe==6.0.1 -idna==3.4 +idna==3.6 itsdangerous==2.1.2 Jinja2==3.1.2 ldap3==2.9.1 -Mako==1.2.4 +Mako==1.3.0 MarkupSafe==2.1.3 -packaging==23.1 -pyasn1==0.5.0 +packaging==23.2 +pyasn1==0.5.1 pyasn1-modules==0.3.0 pycparser==2.21 -pydantic==2.1.1 -pydantic_core==2.4.0 +pydantic==2.5.2 +pydantic_core==2.14.5 PyJWT==2.8.0 python-dotenv==1.0.0 python-multipart==0.0.6 PyYAML==6.0.1 requests==2.31.0 rsa==4.9 -sentry-sdk==1.29.0 -six==1.16.0 +sentry-sdk==1.39.0 sniffio==1.3.0 SQLAlchemy==1.3.24 starlette==0.27.0 typer==0.9.0 -typing_extensions==4.7.1 -urllib3==1.26.16 -uvicorn==0.23.2 -uvloop==0.17.0 -watchfiles==0.19.0 -websockets==11.0.3 +typing_extensions==4.9.0 +urllib3==2.1.0 +uvicorn==0.24.0.post1 +uvloop==0.19.0 +watchfiles==0.21.0 +websockets==12.0 From 57eea9ef074570b810a9708ee1d4da1511813795 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 13 Dec 2023 09:08:04 +0100 Subject: [PATCH 21/78] Update helm chart repo and template pipeline --- .gitlab-ci.maxiv.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 03ccdfc..313c095 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -4,7 +4,7 @@ include: file: "/PreCommit.gitlab-ci.yml" - project: kits-maxiv/kubernetes/k8s-gitlab-ci file: "/Docker-Helm-deploy.gitlab-ci.yml" - ref: "0.4" + ref: "0.6" # Override workflow rules to deploy on any branch workflow: @@ -26,8 +26,11 @@ stages: variables: DOCKER_REGISTRY_URL: "harbor.maxiv.lu.se/notify-server" - HELM_CHART_REPO: https://harbor.maxiv.lu.se/chartrepo/notify-server + HELM_CHART_REPO: oci://harbor.maxiv.lu.se/notify-server/charts HELM_CHART_NAME: notify-server + HELM_RELEASE_NAMESPACE_TEST: notify + HELM_RELEASE_NAMESPACE_PROD: notify + PIPELINES_FF_TOKENIZER: "true" PRODUCTION_BRANCH_NAME: "master" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" From 644f828a56f303624e3fbc1f22e7c8c1cd9a957e Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 13 Dec 2023 18:33:46 +0100 Subject: [PATCH 22/78] Change branch to main --- .gitlab-ci.maxiv.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 313c095..9fc11e9 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -31,7 +31,7 @@ variables: HELM_RELEASE_NAMESPACE_TEST: notify HELM_RELEASE_NAMESPACE_PROD: notify PIPELINES_FF_TOKENIZER: "true" - PRODUCTION_BRANCH_NAME: "master" + PRODUCTION_BRANCH_NAME: "main" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" HELM_SET_TEST_ingress_host: "notify-test-${CI_COMMIT_BRANCH}.apps.okdev.maxiv.lu.se" From 4c2c9fe454d2df548b7e34a248cbc6a9d3681966 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 6 Feb 2024 14:41:14 +0100 Subject: [PATCH 23/78] Enable buildah cache --- .gitlab-ci.maxiv.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 9fc11e9..6b7bc08 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -4,7 +4,7 @@ include: file: "/PreCommit.gitlab-ci.yml" - project: kits-maxiv/kubernetes/k8s-gitlab-ci file: "/Docker-Helm-deploy.gitlab-ci.yml" - ref: "0.6" + ref: "0.7" # Override workflow rules to deploy on any branch workflow: @@ -25,8 +25,7 @@ stages: - .post variables: - DOCKER_REGISTRY_URL: "harbor.maxiv.lu.se/notify-server" - HELM_CHART_REPO: oci://harbor.maxiv.lu.se/notify-server/charts + BUILDAH_CACHE: "true" HELM_CHART_NAME: notify-server HELM_RELEASE_NAMESPACE_TEST: notify HELM_RELEASE_NAMESPACE_PROD: notify From 609628f6087bd1c6a3f3cdbc3ab8f6b2d106eff6 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 6 Feb 2024 15:04:58 +0100 Subject: [PATCH 24/78] Update requirements --- requirements.txt | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/requirements.txt b/requirements.txt index 61c0935..dfeba81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,50 +1,50 @@ aiofiles==23.2.1 -alembic==1.13.0 +alembic==1.13.1 annotated-types==0.6.0 -anyio==3.7.1 +anyio==4.2.0 cachetools==5.3.2 -certifi==2023.11.17 +certifi==2024.2.2 cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 -cryptography==41.0.7 -fastapi==0.105.0 +cryptography==42.0.2 +fastapi==0.109.2 fastapi-versioning==0.10.0 -google-auth==2.25.2 +google-auth==2.27.0 gunicorn==21.2.0 h11==0.14.0 h2==4.1.0 hpack==4.0.0 httpcore==1.0.2 httptools==0.6.1 -httpx==0.25.2 +httpx==0.26.0 hyperframe==6.0.1 idna==3.6 itsdangerous==2.1.2 -Jinja2==3.1.2 +Jinja2==3.1.3 ldap3==2.9.1 -Mako==1.3.0 -MarkupSafe==2.1.3 +Mako==1.3.2 +MarkupSafe==2.1.5 packaging==23.2 pyasn1==0.5.1 pyasn1-modules==0.3.0 pycparser==2.21 -pydantic==2.5.2 -pydantic_core==2.14.5 +pydantic==2.6.1 +pydantic_core==2.16.2 PyJWT==2.8.0 -python-dotenv==1.0.0 -python-multipart==0.0.6 +python-dotenv==1.0.1 +python-multipart==0.0.7 PyYAML==6.0.1 requests==2.31.0 rsa==4.9 -sentry-sdk==1.39.0 +sentry-sdk==1.40.1 sniffio==1.3.0 SQLAlchemy==1.3.24 -starlette==0.27.0 +starlette==0.36.3 typer==0.9.0 typing_extensions==4.9.0 -urllib3==2.1.0 -uvicorn==0.24.0.post1 +urllib3==2.2.0 +uvicorn==0.27.0.post1 uvloop==0.19.0 watchfiles==0.21.0 websockets==12.0 From 4c0ceb562e64855a87a5a319362322434c6f61fa Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 6 Feb 2024 15:06:15 +0100 Subject: [PATCH 25/78] Fix FileNotFoundError when .env doesn't exist Config(".env") used to fail silently but now raises FileNotFoundError. .env is only used during development. For prod, we rely on env variables. --- app/settings.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/settings.py b/app/settings.py index c68e669..63d73a3 100644 --- a/app/settings.py +++ b/app/settings.py @@ -9,7 +9,10 @@ """ # Config will be read from environment variables and/or ".env" files. -config = Config(".env") +try: + config = Config(".env") +except FileNotFoundError: + config = Config() # Should be set to "ldap" or "url" AUTHENTICATION_METHOD = config("AUTHENTICATION_METHOD", cast=str, default="ldap") From 630dc9c4007a1b1f769dd092f6662e50e6a8c775 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 6 Feb 2024 15:08:48 +0100 Subject: [PATCH 26/78] Update pre-commit --- .pre-commit-config.yaml | 6 +++--- .../06fd850a57c0_rename_apn_token_to_device_token.py | 1 + alembic/versions/0881dca000d8_remove_token_from_database.py | 1 + .../versions/43f8a46a10f4_add_login_token_expire_date.py | 1 + alembic/versions/fcd46eadd970_initial_database_creation.py | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6169b5..1d471ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,14 @@ repos: - repo: https://github.com/ambv/black - rev: 23.7.0 + rev: 24.1.1 hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.4.1 + rev: v1.8.0 hooks: - id: mypy additional_dependencies: diff --git a/alembic/versions/06fd850a57c0_rename_apn_token_to_device_token.py b/alembic/versions/06fd850a57c0_rename_apn_token_to_device_token.py index 6b12521..71831e8 100644 --- a/alembic/versions/06fd850a57c0_rename_apn_token_to_device_token.py +++ b/alembic/versions/06fd850a57c0_rename_apn_token_to_device_token.py @@ -5,6 +5,7 @@ Create Date: 2021-03-27 13:32:58.393608 """ + from alembic import op diff --git a/alembic/versions/0881dca000d8_remove_token_from_database.py b/alembic/versions/0881dca000d8_remove_token_from_database.py index d135a34..b8133f9 100644 --- a/alembic/versions/0881dca000d8_remove_token_from_database.py +++ b/alembic/versions/0881dca000d8_remove_token_from_database.py @@ -5,6 +5,7 @@ Create Date: 2020-11-23 15:25:06.572358 """ + from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py b/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py index 9e70e44..e3a4862 100644 --- a/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py +++ b/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py @@ -5,6 +5,7 @@ Create Date: 2022-10-16 07:42:13.437968 """ + from datetime import datetime, timedelta from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/fcd46eadd970_initial_database_creation.py b/alembic/versions/fcd46eadd970_initial_database_creation.py index c32fb3d..a0f9ecd 100644 --- a/alembic/versions/fcd46eadd970_initial_database_creation.py +++ b/alembic/versions/fcd46eadd970_initial_database_creation.py @@ -5,6 +5,7 @@ Create Date: 2020-11-16 16:27:09.423308 """ + from alembic import op import sqlalchemy as sa from app.models import GUID From f50e458a52c605b7f05fd7bc3b48bc7393b9f4f9 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Fri, 9 Feb 2024 16:43:28 +0100 Subject: [PATCH 27/78] Use timezone aware datetime objects datetime.utcnow() is deprecated in 3.12 as it returns a naive datetime. Use timezone aware datetime objects internally. Pydantic serialization adds "Z" to timestamp in UTC timezone. To not brake current mobile clients, we should continue returning timestamp without timezone info. We use a custon PlainSerializer. Note that python isoformat() doesn't return "Z" but "+00:00". --- ...3f8a46a10f4_add_login_token_expire_date.py | 4 +- app/api/login.py | 4 +- app/crud.py | 4 +- app/models.py | 38 +++++++++++++++++-- app/schemas.py | 13 ++++++- app/utils.py | 4 +- tests/api/test_services.py | 7 ++-- tests/api/test_users.py | 11 +++--- tests/conftest.py | 10 +++-- tests/test_crud.py | 4 +- tests/test_models.py | 4 +- tests/test_utils.py | 8 ++-- tests/utils.py | 12 ++++++ 13 files changed, 92 insertions(+), 31 deletions(-) create mode 100644 tests/utils.py diff --git a/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py b/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py index e3a4862..983479c 100644 --- a/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py +++ b/alembic/versions/43f8a46a10f4_add_login_token_expire_date.py @@ -6,7 +6,7 @@ """ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from alembic import op import sqlalchemy as sa @@ -26,7 +26,7 @@ def upgrade(): # To avoid having to reset all tokens, we set the default value to the current date + 30 days # when running this migration. This ensures users will continue to receive notifications with # their current login token. - expire = datetime.utcnow() + timedelta(days=30) + expire = datetime.now(timezone.utc) + timedelta(days=30) users = sa.sql.table("users", sa.sql.column("login_token_expire_date")) op.execute(users.update().values(login_token_expire_date=expire)) op.alter_column("users", "login_token_expire_date", nullable=False) diff --git a/app/api/login.py b/app/api/login.py index cf6def6..4d9e510 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger @@ -26,7 +26,7 @@ def login( if db_user is None: db_user = crud.create_user(db, form_data.username.lower()) response.status_code = status.HTTP_201_CREATED - expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token = utils.create_access_token(db_user.username, expire=expire) crud.update_user_login_token_expire_date(db, db_user, expire) return {"access_token": access_token, "token_type": "bearer"} diff --git a/app/crud.py b/app/crud.py index bcbd29b..5f0418e 100644 --- a/app/crud.py +++ b/app/crud.py @@ -240,7 +240,9 @@ def update_user_notifications( def delete_notifications(db: Session, keep_days: int) -> None: """Delete notifications older than X days""" - date_limit = datetime.datetime.utcnow() - datetime.timedelta(days=keep_days) + date_limit = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + days=keep_days + ) # First retrieve all notifications id to delete old_notification_ids = db.query(models.Notification.id).filter( models.Notification.timestamp < date_limit diff --git a/app/models.py b/app/models.py index 7da5a97..c00659c 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,5 @@ from __future__ import annotations +import datetime import uuid from typing import List from sqlalchemy import ( @@ -12,7 +13,6 @@ func, ) from sqlalchemy.orm import relationship, backref, Session -from datetime import datetime from sqlalchemy.types import TypeDecorator, CHAR from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.associationproxy import association_proxy @@ -20,6 +20,34 @@ from . import schemas +def utcnow(): + # datetime.utcnow() is deprecated in 3.12 + # as it returns a naive datetime + return datetime.datetime.now(datetime.timezone.utc) + + +class TZDateTime(TypeDecorator): + """DateTime with TimeZone + + From https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc + """ + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is not None: + if not value.tzinfo or value.tzinfo.utcoffset(value) is None: + raise TypeError("tzinfo is required") + value = value.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return value + + def process_result_value(self, value, dialect): + if value is not None: + value = value.replace(tzinfo=datetime.timezone.utc) + return value + + class GUID(TypeDecorator): """Platform-independent GUID type. @@ -75,7 +103,7 @@ class User(Base): _device_tokens = Column(String, default="") is_active = Column(Boolean, default=True, nullable=False) is_admin = Column(Boolean, default=False, nullable=False) - login_token_expire_date = Column(DateTime, default=datetime.utcnow, nullable=False) + login_token_expire_date = Column(TZDateTime, default=utcnow, nullable=False) services = relationship( "Service", @@ -119,7 +147,9 @@ def android_tokens(self): @property def is_logged_in(self): - return datetime.utcnow() < self.login_token_expire_date + return ( + datetime.datetime.now(datetime.timezone.utc) < self.login_token_expire_date + ) def add_device_token(self, value: str): if value in self.device_tokens: @@ -180,7 +210,7 @@ class Notification(Base): __tablename__ = "notifications" id = Column(Integer, primary_key=True, index=True) - timestamp = Column(DateTime, index=True, default=datetime.utcnow, nullable=False) + timestamp = Column(TZDateTime, index=True, default=utcnow, nullable=False) title = Column(String, nullable=False) subtitle = Column(String) url = Column(String) diff --git a/app/schemas.py b/app/schemas.py index f274c11..87e361f 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -7,6 +7,7 @@ from typing_extensions import Annotated from pydantic import ConfigDict, BaseModel from pydantic.functional_validators import AfterValidator +from pydantic.functional_serializers import PlainSerializer RE_COLOR = re.compile(r"^[0-9a-fA-F]{6}$") @@ -18,6 +19,16 @@ def validate_color(color: str) -> str: Color = Annotated[str, AfterValidator(validate_color)] +# Pydantic serialization adds "Z" to timestamp in UTC timezone. +# To not brake current mobile clients, we should continue returning +# timestamp without timezone info and use a custom PlainSerializer. +# Note that python isoformat() doesn't return "Z" but "+00:00". +NoTZDateTime = Annotated[ + datetime.datetime, + PlainSerializer( + lambda x: x.isoformat().replace("+00:00", ""), return_type=str, when_used="json" + ), +] class SortOrder(str, Enum): @@ -105,7 +116,7 @@ class NotificationCreate(NotificationBase): class Notification(NotificationBase): id: int - timestamp: datetime.datetime + timestamp: NoTZDateTime service_id: uuid.UUID model_config = ConfigDict(from_attributes=True) diff --git a/app/utils.py b/app/utils.py index 6cfa4da..fda6dc6 100644 --- a/app/utils.py +++ b/app/utils.py @@ -3,7 +3,7 @@ import ipaddress import uuid import jwt -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Dict from sqlalchemy.orm import Session from . import models, ios, firebase @@ -75,7 +75,7 @@ async def sem_task(task): async def send_notification(db: Session, notification: models.Notification) -> None: """Send the notification to all subscribers""" tasks = [] - ios_headers = ios.create_headers(datetime.utcnow()) + ios_headers = ios.create_headers(datetime.now(timezone.utc)) ios_client = httpx.AsyncClient(http2=True, headers=ios_headers) android_headers = await firebase.create_headers(str(uuid.uuid4())) android_client = httpx.AsyncClient(headers=android_headers) diff --git a/tests/api/test_services.py b/tests/api/test_services.py index 7f6f88d..f7ce609 100644 --- a/tests/api/test_services.py +++ b/tests/api/test_services.py @@ -5,6 +5,7 @@ import packaging.version from fastapi.testclient import TestClient from app import models, schemas +from ..utils import no_tz_isoformat @pytest.fixture(scope="module") @@ -155,7 +156,7 @@ def test_read_service_notifications( "id": notification1.id, "service_id": str(notification1.service_id), "subtitle": notification1.subtitle, - "timestamp": notification1.timestamp.isoformat(), + "timestamp": no_tz_isoformat(notification1.timestamp), "title": notification1.title, "url": notification1.url, }, @@ -163,7 +164,7 @@ def test_read_service_notifications( "id": notification2.id, "service_id": str(notification2.service_id), "subtitle": notification2.subtitle, - "timestamp": notification2.timestamp.isoformat(), + "timestamp": no_tz_isoformat(notification2.timestamp), "title": notification2.title, "url": notification2.url, }, @@ -262,7 +263,7 @@ def test_create_notification_for_service( "id": db_notification.id, "service_id": str(service.id), "subtitle": sample_notification["subtitle"], - "timestamp": db_notification.timestamp.isoformat(), + "timestamp": no_tz_isoformat(db_notification.timestamp), "title": sample_notification["title"], "url": sample_notification["url"], } diff --git a/tests/api/test_users.py b/tests/api/test_users.py index 392d6a6..a1f63d7 100644 --- a/tests/api/test_users.py +++ b/tests/api/test_users.py @@ -1,12 +1,13 @@ import pytest -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from fastapi.testclient import TestClient from app import schemas, models, utils +from ..utils import no_tz_isoformat def user_authorization_headers(username): token = utils.create_access_token( - username, expire=datetime.utcnow() + timedelta(minutes=60) + username, expire=datetime.now(timezone.utc) + timedelta(minutes=60) ) return {"Authorization": f"Bearer {token}"} @@ -55,7 +56,7 @@ def test_read_current_user_profile_invalid_username(client: TestClient, api_vers def test_read_current_user_profile_expired_token(client: TestClient, user, api_version): token = utils.create_access_token( - user.username, expire=datetime.utcnow() + timedelta(minutes=-5) + user.username, expire=datetime.now(timezone.utc) + timedelta(minutes=-5) ) response = client.get( f"/api/{api_version}/users/user/profile", @@ -257,7 +258,7 @@ def test_read_current_user_notifications( "is_read": False, "service_id": str(notification1.service_id), "subtitle": notification1.subtitle, - "timestamp": notification1.timestamp.isoformat(), + "timestamp": no_tz_isoformat(notification1.timestamp), "title": notification1.title, "url": notification1.url, }, @@ -266,7 +267,7 @@ def test_read_current_user_notifications( "is_read": False, "service_id": str(notification2.service_id), "subtitle": notification2.subtitle, - "timestamp": notification2.timestamp.isoformat(), + "timestamp": no_tz_isoformat(notification2.timestamp), "title": notification2.title, "url": notification2.url, }, diff --git a/tests/conftest.py b/tests/conftest.py index b8b23f9..cd300a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,7 +71,8 @@ def client() -> Generator: def user_token_headers(user): token = create_access_token( user.username, - expire=datetime.datetime.utcnow() + datetime.timedelta(minutes=60), + expire=datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=60), ) return {"Authorization": f"Bearer {token}"} @@ -81,7 +82,8 @@ def admin_token_headers(user_factory): admin = user_factory(is_admin=True) token = create_access_token( admin.username, - expire=datetime.datetime.utcnow() + datetime.timedelta(minutes=60), + expire=datetime.datetime.now(datetime.timezone.utc) + + datetime.timedelta(minutes=60), ) return {"Authorization": f"Bearer {token}"} @@ -102,6 +104,8 @@ def _make_device_token(length): @pytest.fixture def notification_date(): def _notification_date(days_old): - return datetime.datetime.utcnow() - datetime.timedelta(days=days_old) + return datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + days=days_old + ) return _notification_date diff --git a/tests/test_crud.py b/tests/test_crud.py index a6d30c6..4e5d181 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -210,7 +210,7 @@ def test_get_user_notifications_limit(db, user, service, limit, sort): user.subscribe(service) db.commit() notifications = [] - now = datetime.datetime.now() + now = datetime.datetime.now(datetime.timezone.utc) for nb in range(20): notification = crud.create_service_notification( db, schemas.NotificationCreate(title=f"message{nb}"), service @@ -253,7 +253,7 @@ def test_get_user_notifications_filter_services_id(db, user, service_factory): user.subscribe(service3) db.commit() notifications = [] - now = datetime.datetime.now() + now = datetime.datetime.now(datetime.timezone.utc) for service_nb, service in enumerate((service1, service2, service3), start=1): for nb in range(10): notification = crud.create_service_notification( diff --git a/tests/test_models.py b/tests/test_models.py index b53e537..62d8917 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from sqlalchemy.orm import Session from app import schemas @@ -19,7 +19,7 @@ def test_user_is_logged_in(db: Session, user_factory) -> None: username = "johndoe" user = user_factory(username=username) assert not user.is_logged_in - user.login_token_expire_date = datetime.utcnow() + timedelta(minutes=5) + user.login_token_expire_date = datetime.now(timezone.utc) + timedelta(minutes=5) assert user.is_logged_in diff --git a/tests/test_utils.py b/tests/test_utils.py index 9439e28..2c97ad7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ import pytest -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from app import schemas, utils @@ -58,7 +58,7 @@ async def test_send_notification( android_token3 = make_device_token(128) android_token4 = make_device_token(128) android_token5 = make_device_token(128) - expire_date = datetime.utcnow() + timedelta(minutes=60) + expire_date = datetime.now(timezone.utc) + timedelta(minutes=60) user1 = user_factory( device_tokens=[ios_token1, ios_token2], login_token_expire_date=expire_date ) @@ -71,7 +71,7 @@ async def test_send_notification( ) user4 = user_factory( device_tokens=[ios_token5, android_token5], - login_token_expire_date=datetime.utcnow() + timedelta(minutes=-1), + login_token_expire_date=datetime.now(timezone.utc) + timedelta(minutes=-1), ) user5 = user_factory( device_tokens=[ios_token5, android_token5], @@ -167,7 +167,7 @@ async def test_send_notification( def test_create_and_decode_access_token(): username = "johndoe" encoded_token = utils.create_access_token( - username, expire=datetime.utcnow() + timedelta(days=1) + username, expire=datetime.now(timezone.utc) + timedelta(days=1) ) decoded_token = utils.decode_access_token(encoded_token) assert decoded_token["sub"] == username diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..e1a9dc7 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,12 @@ +import datetime + + +def no_tz_isoformat(dt: datetime.datetime) -> str: + """Return a datetime as string without the timezone information + + We were using naive datetime object previously (using utcnow()). + We switch to timezone aware datetime but must continue returning + timestamp without timezone info in json to not brake compatibility + with current mobile clients. + """ + return dt.isoformat().replace("+00:00", "") From 788aa92598d286b33ede2003b7a012c9324b6fbc Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Fri, 9 Feb 2024 17:15:43 +0100 Subject: [PATCH 28/78] Format datetime in local timezone in web app --- app/settings.py | 3 +++ app/templates/_helpers.html | 6 +----- app/templates/notifications_table.html | 4 ++-- app/views/__init__.py | 9 +++++++++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/settings.py b/app/settings.py index 63d73a3..171460f 100644 --- a/app/settings.py +++ b/app/settings.py @@ -78,3 +78,6 @@ ) APP_NAME = config("APP_NAME", cast=str, default="ESS Notify") + +# Local timezone used for timestamp in the web app +LOCAL_TIMEZONE = config("LOCAL_TIMEZONE", cast=str, default="Europe/Stockholm") diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html index 738c444..8e8cf2c 100644 --- a/app/templates/_helpers.html +++ b/app/templates/_helpers.html @@ -9,10 +9,6 @@ {%- endmacro %} -{% macro format_datetime(dt) -%} -{{ dt.strftime("%Y-%m-%d %H:%M:%S") }} -{%- endmacro %} - {% macro is_checked(value, expected) -%} {% if value == expected %}checked{% endif -%} -{%- endmacro %} \ No newline at end of file +{%- endmacro %} diff --git a/app/templates/notifications_table.html b/app/templates/notifications_table.html index adcd4bd..36b16f5 100644 --- a/app/templates/notifications_table.html +++ b/app/templates/notifications_table.html @@ -1,4 +1,4 @@ -{% from "_helpers.html" import format_notification, format_datetime %} +{% from "_helpers.html" import format_notification %} @@ -12,7 +12,7 @@ {% for notification in notifications %} - + {% endfor %} diff --git a/app/views/__init__.py b/app/views/__init__.py index 27df5b5..e5cdee9 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -1,5 +1,14 @@ from pathlib import Path +from zoneinfo import ZoneInfo from starlette.templating import Jinja2Templates +from ..settings import LOCAL_TIMEZONE app_dir = Path(__file__).parents[1].resolve() templates = Jinja2Templates(directory=str(app_dir / "templates")) + + +def format_datetime(dt): + return dt.astimezone(ZoneInfo(LOCAL_TIMEZONE)).strftime("%Y-%m-%d %H:%M:%S") + + +templates.env.filters["format_datetime"] = format_datetime From 7f6d6d2e96b9256aa57a0a283573b638f680bafe Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 15 Feb 2024 11:40:05 +0100 Subject: [PATCH 29/78] Drop support for Python 3.8 zoneinfo was introduced in 3.9 --- .github/workflows/pytest.yml | 2 +- pyproject.toml | 3 --- setup.py | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 3314464..378d268 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/pyproject.toml b/pyproject.toml index 04c5341..b16ce4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,3 @@ -[tool.black] -target_version = ['py37'] - [build-system] requires = ["setuptools >= 42", "wheel", "setuptools_scm[toml]>=3.4"] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 27f194a..048db0a 100644 --- a/setup.py +++ b/setup.py @@ -60,5 +60,5 @@ ], entry_points={"console_scripts": ["notify-server=app.command:cli"]}, extras_require={"postgres": postgres_requires, "tests": tests_requires}, - python_requires=">=3.8", + python_requires=">=3.9", ) From 6e00c8cea9c3eef5d715cb60c8bbe5f2560c17d9 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Mon, 26 Feb 2024 21:38:59 +0100 Subject: [PATCH 30/78] Fix send_notification background task We shouldn't pass the session to the background task. get_db() dependency will close the session after the request. There is no warranty the session won't be closed before the background task is done. Instead we create a local session in the background task. Avoid: File "/app/app/utils.py", line 82, in send_notification for user_notification in notification.users_notification: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/venv/lib/python3.11/site-packages/sqlalchemy/orm/attributes.py", line 294, in __get__ return self.impl.get(instance_state(instance), dict_) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/venv/lib/python3.11/site-packages/sqlalchemy/orm/attributes.py", line 730, in get value = self.callable_(state, passive) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/venv/lib/python3.11/site-packages/sqlalchemy/orm/strategies.py", line 717, in _load_for_state raise orm_exc.DetachedInstanceError( sqlalchemy.orm.exc.DetachedInstanceError: Parent instance is not bound to a Session; lazy load operation of attribute 'users_notification' cannot proceed (Background on this error at: http://sqlalche.me/e/13/bhk3) --- app/api/services.py | 2 +- app/crud.py | 10 ++++++ app/utils.py | 65 ++++++++++++++++++++++---------------- tests/api/test_services.py | 2 +- tests/test_utils.py | 35 +++++++++++++------- 5 files changed, 74 insertions(+), 40 deletions(-) diff --git a/app/api/services.py b/app/api/services.py index 8a2565f..f85fec4 100644 --- a/app/api/services.py +++ b/app/api/services.py @@ -115,5 +115,5 @@ def create_notification_for_service( db=db, notification=notification, service=db_service ) # Send notification using background task - background_tasks.add_task(utils.send_notification, db, db_notification) + background_tasks.add_task(utils.send_notification, db_notification.id) return db_notification diff --git a/app/crud.py b/app/crud.py index 5f0418e..30f6a62 100644 --- a/app/crud.py +++ b/app/crud.py @@ -180,6 +180,16 @@ def create_service_notification( return db_notification +def get_notification( + db: Session, notification_id: int +) -> Optional[models.Notification]: + return ( + db.query(models.Notification) + .filter(models.Notification.id == notification_id) + .first() + ) + + def get_user_notifications( db: Session, user: models.User, diff --git a/app/utils.py b/app/utils.py index fda6dc6..46a3e65 100644 --- a/app/utils.py +++ b/app/utils.py @@ -5,8 +5,9 @@ import jwt from datetime import datetime, timezone from typing import List, Optional, Dict -from sqlalchemy.orm import Session -from . import models, ios, firebase +from fastapi.logger import logger +from .database import SessionLocal +from . import crud, ios, firebase from .settings import ( ALLOWED_NETWORKS, SECRET_KEY, @@ -72,39 +73,49 @@ async def sem_task(task): ) -async def send_notification(db: Session, notification: models.Notification) -> None: +async def send_notification(notification_id: int) -> None: """Send the notification to all subscribers""" tasks = [] ios_headers = ios.create_headers(datetime.now(timezone.utc)) ios_client = httpx.AsyncClient(http2=True, headers=ios_headers) android_headers = await firebase.create_headers(str(uuid.uuid4())) android_client = httpx.AsyncClient(headers=android_headers) - for user_notification in notification.users_notification: - user = user_notification.user - if not user.is_logged_in or not user.is_active: - continue - ios_tokens = user.ios_tokens - if ios_tokens: - apn_payload = user_notification.to_apn_payload() - for ios_token in ios_tokens: + try: + db = SessionLocal() + notification = crud.get_notification(db, notification_id) + if notification is None: + logger.warning( + f"Can't send notification! Notification {notification_id} not found." + ) + return + for user_notification in notification.users_notification: + user = user_notification.user + if not user.is_logged_in or not user.is_active: + continue + ios_tokens = user.ios_tokens + if ios_tokens: + apn_payload = user_notification.to_apn_payload() + for ios_token in ios_tokens: + tasks.append( + ios.send_push( + ios_client, + ios_token, + apn_payload, + db, + user, + ) + ) + for android_token in user.android_tokens: tasks.append( - ios.send_push( - ios_client, - ios_token, - apn_payload, + firebase.send_push( + android_client, + user_notification.to_android_payload(android_token), db, user, ) ) - for android_token in user.android_tokens: - tasks.append( - firebase.send_push( - android_client, - user_notification.to_android_payload(android_token), - db, - user, - ) - ) - await gather_with_concurrency(NB_PARALLEL_PUSH, *tasks, return_exceptions=True) - await ios_client.aclose() - await android_client.aclose() + await gather_with_concurrency(NB_PARALLEL_PUSH, *tasks, return_exceptions=True) + await ios_client.aclose() + await android_client.aclose() + finally: + db.close() diff --git a/tests/api/test_services.py b/tests/api/test_services.py index f7ce609..dd4056a 100644 --- a/tests/api/test_services.py +++ b/tests/api/test_services.py @@ -258,7 +258,7 @@ def test_create_notification_for_service( ) assert response.status_code == 201 db_notification = db.query(models.Notification).first() - mock_send_notification.assert_called_once_with(db, db_notification) + mock_send_notification.assert_called_once_with(db_notification.id) assert response.json() == { "id": db_notification.id, "service_id": str(service.id), diff --git a/tests/test_utils.py b/tests/test_utils.py index 2c97ad7..166d0d9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -48,6 +48,7 @@ async def test_send_notification( ) notification1 = notification_factory() notification2 = notification_factory() + non_existing_id = notification1.id + notification2.id ios_token1 = make_device_token(64) ios_token2 = make_device_token(64) ios_token3 = make_device_token(64) @@ -86,7 +87,7 @@ async def test_send_notification( user4.notifications.append(notification1) user5.notifications.append(notification1) db.commit() - await utils.send_notification(db, notification1) + await utils.send_notification(notification1.id) # Check that send_push_to_ios was called 3 times # - twice for user1 (2 APN tokens) # - once for user2 (1 APN tokens) @@ -109,12 +110,13 @@ async def test_send_notification( badge=1, ) ) - # Remove the first arg (httpx client) from the list of calls - calls = [call.args[1:] for call in mock_send_push_to_ios.call_args_list] + # Only keep second and third arguments (token and payload) + # first arg is httpx client and others (db and user) are internal + calls = [call.args[1:3] for call in mock_send_push_to_ios.call_args_list] expected_calls_args = [ - (ios_token1, user1_payload, db, user1), - (ios_token2, user1_payload, db, user1), - (ios_token3, user2_payload, db, user2), + (ios_token1, user1_payload), + (ios_token2, user1_payload), + (ios_token3, user2_payload), ] assert calls == expected_calls_args # Check that send_push_to_android was called 3 times @@ -154,15 +156,26 @@ async def test_send_notification( ), ) ) - # Remove the first arg (httpx client) from the list of calls - calls = [call.args[1:] for call in mock_send_push_to_android.call_args_list] + # Only keep second argument (payload) + # first arg is httpx client and others (db and user) are internal + calls = [(call.args[1],) for call in mock_send_push_to_android.call_args_list] expected_calls_args = [ - (user2_payload1, db, user2), - (user3_payload1, db, user3), - (user3_payload2, db, user3), + (user2_payload1,), + (user3_payload1,), + (user3_payload2,), ] assert calls == expected_calls_args + # Check with invalid notification id + # send_push_to_ios or android shouldn't be called + mock_send_push_to_ios.reset_mock() + mock_send_push_to_android.reset_mock() + mock_get_firebase_access_token.reset_mock() + await utils.send_notification(non_existing_id) + assert mock_get_firebase_access_token.called + assert not mock_send_push_to_ios.called + assert not mock_send_push_to_android.called + def test_create_and_decode_access_token(): username = "johndoe" From f55de9b374b2542694fbcc953ef7ab3bd4aa0b5e Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 17 Oct 2024 11:37:03 +0200 Subject: [PATCH 31/78] Replace setup.py with pyproject.toml --- pyproject.toml | 55 ++++++++++++++++++++++++++++++++++++++++++- setup.py | 64 -------------------------------------------------- 2 files changed, 54 insertions(+), 65 deletions(-) delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml index b16ce4a..abffef7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,58 @@ [build-system] -requires = ["setuptools >= 42", "wheel", "setuptools_scm[toml]>=3.4"] +requires = ["setuptools>=64", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] +version_file = "app/_version.py" + +[tool.setuptools] +packages = ["app"] + +[project] +name = "ess-notify" +dynamic = ["version"] +description = "ESS notification server" +readme = "README.md" +dependencies = [ + "alembic", + "aiofiles", + "cryptography", + "fastapi", + "pydantic>=2.3", + "fastapi-versioning", + "google-auth", + "requests", + "h2", + "itsdangerous", + "jinja2", + "python-multipart", + "httpx", + "PyJWT", + "ldap3", + "SQLAlchemy<1.4", + "uvicorn[standard]", + "gunicorn", + "sentry-sdk", + "typer", +] +requires-python = ">= 3.9" +license = { file = "LICENSE" } + +[project.optional-dependencies] +postgres = ["psycopg2"] +tests = [ + "packaging", + "pytest", + "pytest-cov", + "pytest-asyncio", + "pytest-mock", + "pytest-factoryboy", + "respx", + "Faker", +] + +[project.urls] +Repository = "https://github.com/europeanspallationsource/notify-server" + +[project.scripts] +notify-server = "app.command:cli" diff --git a/setup.py b/setup.py deleted file mode 100644 index 048db0a..0000000 --- a/setup.py +++ /dev/null @@ -1,64 +0,0 @@ -import setuptools - -with open("README.md", "r") as f: - long_description = f.read() - - -postgres_requires = ["psycopg2"] -requirements = [ - "alembic", - "aiofiles", - "cryptography", - "fastapi", - "pydantic>=2.3", - "fastapi-versioning", - "google-auth", - "requests", - "h2", - "itsdangerous", - "jinja2", - "python-multipart", - "httpx", - "PyJWT", - "ldap3", - "SQLAlchemy<1.4", - "uvicorn[standard]", - "gunicorn", - "sentry-sdk", - "typer", -] -tests_requires = [ - "packaging", - "pytest", - "pytest-cov", - "pytest-asyncio", - "pytest-mock", - "pytest-factoryboy", - "respx", - "Faker", -] - -setuptools.setup( - name="ess-notify", - description="ESS notification server", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://gitlab.esss.lu.se/ics-software/ess-notify-server", - license="BSD-2 license", - setup_requires=["setuptools_scm"], - install_requires=requirements, - packages=setuptools.find_packages(exclude=["tests", "tests.*"]), - classifiers=[ - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - ], - entry_points={"console_scripts": ["notify-server=app.command:cli"]}, - extras_require={"postgres": postgres_requires, "tests": tests_requires}, - python_requires=">=3.9", -) From 4b28b7db27a904e38ad459222b3da215c5a06cf1 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 17 Oct 2024 11:37:32 +0200 Subject: [PATCH 32/78] Update requirements --- requirements.txt | 87 +++++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/requirements.txt b/requirements.txt index dfeba81..b5ee09e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,50 +1,55 @@ -aiofiles==23.2.1 -alembic==1.13.1 -annotated-types==0.6.0 -anyio==4.2.0 -cachetools==5.3.2 -certifi==2024.2.2 -cffi==1.16.0 -charset-normalizer==3.3.2 +aiofiles==24.1.0 +alembic==1.13.3 +annotated-types==0.7.0 +anyio==4.6.2.post1 +cachetools==5.5.0 +certifi==2024.8.30 +cffi==1.17.1 +charset-normalizer==3.4.0 click==8.1.7 -cryptography==42.0.2 -fastapi==0.109.2 +cryptography==43.0.1 +fastapi==0.115.2 fastapi-versioning==0.10.0 -google-auth==2.27.0 -gunicorn==21.2.0 +google-auth==2.35.0 +gunicorn==23.0.0 h11==0.14.0 h2==4.1.0 hpack==4.0.0 -httpcore==1.0.2 -httptools==0.6.1 -httpx==0.26.0 +httpcore==1.0.6 +httptools==0.6.4 +httpx==0.27.2 hyperframe==6.0.1 -idna==3.6 -itsdangerous==2.1.2 -Jinja2==3.1.3 +idna==3.10 +itsdangerous==2.2.0 +jinja2==3.1.4 ldap3==2.9.1 -Mako==1.3.2 -MarkupSafe==2.1.5 -packaging==23.2 -pyasn1==0.5.1 -pyasn1-modules==0.3.0 -pycparser==2.21 -pydantic==2.6.1 -pydantic_core==2.16.2 -PyJWT==2.8.0 +mako==1.3.5 +markdown-it-py==3.0.0 +markupsafe==3.0.1 +mdurl==0.1.2 +packaging==24.1 +pyasn1==0.6.1 +pyasn1-modules==0.4.1 +pycparser==2.22 +pydantic==2.9.2 +pydantic-core==2.23.4 +pygments==2.18.0 +pyjwt==2.9.0 python-dotenv==1.0.1 -python-multipart==0.0.7 -PyYAML==6.0.1 -requests==2.31.0 +python-multipart==0.0.12 +pyyaml==6.0.2 +requests==2.32.3 +rich==13.9.2 rsa==4.9 -sentry-sdk==1.40.1 -sniffio==1.3.0 -SQLAlchemy==1.3.24 -starlette==0.36.3 -typer==0.9.0 -typing_extensions==4.9.0 -urllib3==2.2.0 -uvicorn==0.27.0.post1 -uvloop==0.19.0 -watchfiles==0.21.0 -websockets==12.0 +sentry-sdk==2.17.0 +shellingham==1.5.4 +sniffio==1.3.1 +sqlalchemy==1.3.24 +starlette==0.40.0 +typer==0.12.5 +typing-extensions==4.12.2 +urllib3==2.2.3 +uvicorn==0.32.0 +uvloop==0.21.0 +watchfiles==0.24.0 +websockets==13.1 From 289a0d5aab60520f3d4154adb553f4ebe35b07fd Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 17 Oct 2024 11:42:48 +0200 Subject: [PATCH 33/78] Replace flake8 and black with ruff --- .flake8 | 5 ----- .pre-commit-config.yaml | 17 +++++++++-------- app/auth.py | 5 +---- app/crud.py | 5 +---- app/views/notifications.py | 2 +- pyproject.toml | 12 ++++++++++++ tests/conftest.py | 4 +--- 7 files changed, 25 insertions(+), 25 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 5e01308..0000000 --- a/.flake8 +++ /dev/null @@ -1,5 +0,0 @@ -[flake8] -# E501: let black handle line length -# W503 is incompatible with PEP 8 -ignore = E501,W503 - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d471ae..eb945d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,15 @@ repos: - - repo: https://github.com/ambv/black - rev: 24.1.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.6.9 hooks: - - id: black - - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 - hooks: - - id: flake8 + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.12.0 hooks: - id: mypy additional_dependencies: diff --git a/app/auth.py b/app/auth.py index 2936746..8363812 100644 --- a/app/auth.py +++ b/app/auth.py @@ -37,10 +37,7 @@ def ldap_authenticate_user(username: str, password: str) -> bool: validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLSv1_2, ciphers="ALL" ) server = ldap3.Server(LDAP_HOST, port=LDAP_PORT, use_ssl=LDAP_USE_SSL, tls=tls) - if LDAP_USER_DN: - user_search_dn = f"{LDAP_USER_DN},{LDAP_BASE_DN}" - else: - user_search_dn = LDAP_BASE_DN + user_search_dn = f"{LDAP_USER_DN},{LDAP_BASE_DN}" if LDAP_USER_DN else LDAP_BASE_DN bind_user = f"{LDAP_USER_RDN_ATTR}={username},{user_search_dn}" connection = ldap3.Connection( server=server, diff --git a/app/crud.py b/app/crud.py index 30f6a62..febd5ee 100644 --- a/app/crud.py +++ b/app/crud.py @@ -215,10 +215,7 @@ def get_user_notifications( if filter_services_id is not None: query = query.filter(models.Notification.service_id.in_(filter_services_id)) query = query.order_by(desc(models.Notification.timestamp)) - if limit > 0: - query = query.limit(limit) - else: - query = query.all() + query = query.limit(limit) if limit > 0 else query.all() notifications = [un.to_user_notification() for un in query] # Sorting in ascending order is mostly for backward compatibility if sort == schemas.SortOrder.asc: diff --git a/app/views/notifications.py b/app/views/notifications.py index fa25e93..37f928e 100644 --- a/app/views/notifications.py +++ b/app/views/notifications.py @@ -53,7 +53,7 @@ async def notifications_post( current_user: models.User = Depends(deps.get_current_user_from_cookie), ): form = await request.form() - selected_categories = [key for key in form.keys() if key != "notifications_limit"] + selected_categories = [key for key in form if key != "notifications_limit"] request.session["selected_categories"] = selected_categories request.session["notifications_limit"] = notifications_limit user_services = crud.get_user_services(db, current_user) diff --git a/pyproject.toml b/pyproject.toml index abffef7..008e00c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,3 +56,15 @@ Repository = "https://github.com/europeanspallationsource/notify-server" [project.scripts] notify-server = "app.command:cli" + +[tool.ruff.lint] +select = [ + # pycodestyle + "E4", # Import + "E7", # Statement + "E9", # Runtime + # Pyflakes + "F", + # flake8-simplify + "SIM", +] diff --git a/tests/conftest.py b/tests/conftest.py index cd300a7..b9b46d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,9 +14,7 @@ environ["LDAP_SERVER"] = "ldap.example.org" environ["APNS_KEY_ID"] = "UB40ZXKCDZ" environ["AUTHENTICATION_URL"] = "https://auth.example.org/login" -environ[ - "APNS_AUTH_KEY" -] = """-----BEGIN PRIVATE KEY----- +environ["APNS_AUTH_KEY"] = """-----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgtAParbMemenK/+8T JYWanX1jzKaFcgmupVALPHyaKKKhRANCAARVmMAXI+WPS/vjIsFBHb3B5dQKqgT8 ytZPnlbWNLGGR7tKdB1eLzyBlIVFe9El4Wlvs19ACPRMtE7l75IlbOT+ From 028b572bcbf489d9c5c332cd381628d4d658fcd9 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 17 Oct 2024 11:52:11 +0200 Subject: [PATCH 34/78] Fix test for new pydantic --- tests/api/test_services.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/api/test_services.py b/tests/api/test_services.py index dd4056a..539c05a 100644 --- a/tests/api/test_services.py +++ b/tests/api/test_services.py @@ -1,8 +1,6 @@ import json import uuid import pytest -import importlib.metadata -import packaging.version from fastapi.testclient import TestClient from app import models, schemas from ..utils import no_tz_isoformat @@ -105,7 +103,6 @@ def test_update_service_invalid_color( "input": color, "msg": "Value error, Color should match [0-9a-fA-F]{6}", "type": "value_error", - "url": f"{pydantic_errors_url()}/v/value_error", } ], } @@ -190,7 +187,6 @@ def test_read_service_notifications_invalid_service_id( "msg": "Input should be a valid UUID, invalid length: expected " "length 32 for simple format, found 4", "type": "uuid_parsing", - "url": f"{pydantic_errors_url()}/v/uuid_parsing", } ], } @@ -267,9 +263,3 @@ def test_create_notification_for_service( "title": sample_notification["title"], "url": sample_notification["url"], } - - -def pydantic_errors_url(): - version_str = importlib.metadata.version("pydantic") - version = packaging.version.parse(version_str) - return f"https://errors.pydantic.dev/{version.major}.{version.minor}" From 3c9530d0b105803a233c3e0a6b630ef77639a26d Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Fri, 18 Oct 2024 16:52:39 +0200 Subject: [PATCH 35/78] Add OpenID Connect authentication for web --- app/cookie_auth.py | 21 -------------- app/deps.py | 34 +++++++++++++++-------- app/main.py | 12 ++++++-- app/settings.py | 15 +++++++++- app/views/account.py | 57 +++++++++++++++++++++++++++----------- app/views/notifications.py | 6 ++-- app/views/settings.py | 4 +-- pyproject.toml | 1 + 8 files changed, 93 insertions(+), 57 deletions(-) delete mode 100644 app/cookie_auth.py diff --git a/app/cookie_auth.py b/app/cookie_auth.py deleted file mode 100644 index 9e86df0..0000000 --- a/app/cookie_auth.py +++ /dev/null @@ -1,21 +0,0 @@ -from fastapi.responses import Response -from itsdangerous.serializer import Serializer -from .settings import SECRET_KEY, ACCESS_TOKEN_EXPIRE_MINUTES, AUTH_COOKIE_NAME - -serializer = Serializer(str(SECRET_KEY)) - - -def set_auth(response: Response, user_id: int): - val = serializer.dumps(user_id) - response.set_cookie( - AUTH_COOKIE_NAME, - val, - secure=False, - expires=ACCESS_TOKEN_EXPIRE_MINUTES * 60, - httponly=True, - samesite="Lax", - ) - - -def logout(response: Response): - response.delete_cookie(AUTH_COOKIE_NAME) diff --git a/app/deps.py b/app/deps.py index 95f5ae6..1ab4c05 100644 --- a/app/deps.py +++ b/app/deps.py @@ -2,15 +2,30 @@ from starlette.requests import Request from fastapi.security import OAuth2PasswordBearer, APIKeyCookie from fastapi.logger import logger -from itsdangerous.exc import BadSignature from sqlalchemy.orm import Session from jwt import PyJWTError, ExpiredSignatureError -from . import crud, models, utils, cookie_auth +from authlib.integrations.starlette_client import OAuth +from . import crud, models, utils from .database import SessionLocal -from .settings import AUTH_COOKIE_NAME +from .settings import ( + AUTH_COOKIE_NAME, + OIDC_NAME, + OIDC_SERVER_URL, + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_SCOPE, +) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") cookie_sec = APIKeyCookie(name=AUTH_COOKIE_NAME) +oauth = OAuth() +oauth.register( + OIDC_NAME, + client_id=OIDC_CLIENT_ID, + client_secret=str(OIDC_CLIENT_SECRET), + server_metadata_url=OIDC_SERVER_URL, + client_kwargs={"scope": OIDC_SCOPE}, +) def get_db(): @@ -73,21 +88,16 @@ def get_current_admin_user( return current_user -def get_current_user_from_cookie( +def get_current_user_from_session( request: Request, db: Session = Depends(get_db) ) -> models.User: unauthorized_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication" ) - if AUTH_COOKIE_NAME not in request.cookies: + user_id = request.session.get("user_id") + if user_id is None: raise unauthorized_exception - cookie = request.cookies[AUTH_COOKIE_NAME] - try: - user_id = cookie_auth.serializer.loads(cookie) - except BadSignature as e: - logger.warning(f"Bad Signature, invalid cookie value: {e}") - raise unauthorized_exception - user = crud.get_user(db, user_id) + user = crud.get_user(db, int(user_id)) if user is None: logger.warning(f"Unknown user id {user_id}") raise unauthorized_exception diff --git a/app/main.py b/app/main.py index 3145ecb..307662e 100644 --- a/app/main.py +++ b/app/main.py @@ -11,7 +11,12 @@ from . import monitoring from .api import login, users, services from .views import exceptions, account, notifications, settings -from .settings import SENTRY_DSN, ESS_NOTIFY_SERVER_ENVIRONMENT, SECRET_KEY +from .settings import ( + SENTRY_DSN, + ESS_NOTIFY_SERVER_ENVIRONMENT, + SECRET_KEY, + SESSION_MAX_AGE, +) # The following logging setup assumes the app is run with gunicorn @@ -24,7 +29,10 @@ # Main application to serve HTML middleware = [ Middleware( - SessionMiddleware, secret_key=SECRET_KEY, session_cookie="notify_session" + SessionMiddleware, + secret_key=SECRET_KEY, + session_cookie="notify_session", + max_age=SESSION_MAX_AGE, ) ] app = FastAPI(exception_handlers=exceptions.exception_handlers, middleware=middleware) diff --git a/app/settings.py b/app/settings.py index 171460f..5057e8d 100644 --- a/app/settings.py +++ b/app/settings.py @@ -14,7 +14,7 @@ except FileNotFoundError: config = Config() -# Should be set to "ldap" or "url" +# Should be set to "ldap", "url" or "oidc" AUTHENTICATION_METHOD = config("AUTHENTICATION_METHOD", cast=str, default="ldap") # LDAP configuration LDAP_HOST = config("LDAP_HOST", cast=str, default="ldap.example.org") @@ -24,6 +24,17 @@ LDAP_USER_DN = config("LDAP_USER_DN", cast=str, default="") LDAP_USER_RDN_ATTR = config("LDAP_USER_RDN_ATTR", cast=str, default="uid") +# OpenID Connect configuration +OIDC_NAME = config("OIDC_NAME", cast=str, default="keycloak") +OIDC_SERVER_URL = config( + "OIDC_SERVER_URL", + cast=str, + default="https://keycloak.example.org/auth/realms/myrealm/.well-known/openid-configuration", +) +OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", cast=str, default="notify") +OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") +OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") + # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( "AUTHENTICATION_URL", cast=str, default="https//auth.example.org/login" @@ -36,6 +47,8 @@ "SQLALCHEMY_DATABASE_URL", cast=str, default="sqlite:///./sql_app.db" ) SQLALCHEMY_DEBUG = config("SQLALCHEMY_DEBUG", cast=bool, default=False) +# Session expiry time in seconds: 12 hours (12 * 60 * 60 = 43200) +SESSION_MAX_AGE = config("SESSION_MAX_AGE", cast=int, default=43200) APNS_ALGORITHM = "ES256" APNS_KEY_ID = config("APNS_KEY_ID", cast=Secret, default="key-id") APNS_AUTH_KEY = config("APNS_AUTH_KEY", cast=Secret, default=DUMMY_PRIVATE_KEY) diff --git a/app/views/account.py b/app/views/account.py index 1ba2f69..a756ba6 100644 --- a/app/views/account.py +++ b/app/views/account.py @@ -1,11 +1,11 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, HTTPException from fastapi.logger import logger from starlette.responses import HTMLResponse, RedirectResponse from starlette.requests import Request from sqlalchemy.orm import Session from . import templates -from .. import deps, cookie_auth, crud, auth, models -from ..settings import APP_NAME +from .. import deps, crud, auth, models +from ..settings import APP_NAME, AUTHENTICATION_METHOD router = APIRouter() @@ -13,22 +13,26 @@ @router.get("/", response_class=HTMLResponse, name="index") async def index( request: Request, - current_user: models.User = Depends(deps.get_current_user_from_cookie), + current_user: models.User = Depends(deps.get_current_user_from_session), ): return RedirectResponse(url="/notifications") @router.get("/login", response_class=HTMLResponse) async def login_get(request: Request): - return templates.TemplateResponse( - "login.html", - { - "request": request, - "username": "", - "password": "", - "error": "", - }, - ) + if AUTHENTICATION_METHOD == "oidc": + redirect_uri = request.url_for("oidc_auth") + return await deps.oauth.keycloak.authorize_redirect(request, redirect_uri) + else: + return templates.TemplateResponse( + "login.html", + { + "request": request, + "username": "", + "password": "", + "error": "", + }, + ) @router.post("/login", response_class=HTMLResponse) @@ -36,6 +40,10 @@ async def login_post( request: Request, db: Session = Depends(deps.get_db), ): + if AUTHENTICATION_METHOD == "oidc": + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Invalid method" + ) form = await request.form() username = form.get("username", "").lower().strip() password = form.get("password", "").strip() @@ -59,14 +67,31 @@ async def login_post( db_user = crud.create_user(db, username.lower()) resp = RedirectResponse("/", status_code=status.HTTP_302_FOUND) - cookie_auth.set_auth(resp, db_user.id) + request.session["user_id"] = db_user.id return resp +@router.get("/auth") +async def oidc_auth( + request: Request, + db: Session = Depends(deps.get_db), +): + token = await deps.oauth.keycloak.authorize_access_token(request) + user_info = token["userinfo"] + if user_info: + username = user_info["preferred_username"].lower() + db_user = crud.get_user_by_username(db, username) + if db_user is None: + db_user = crud.create_user(db, username) + request.session["user_id"] = db_user.id + return RedirectResponse(url=request.session.pop("next", "/")) + return RedirectResponse(url="/login") + + @router.get("/logout") -def logout(): +def logout(request: Request): response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND) - cookie_auth.logout(response) + request.session.pop("user_id", None) return response diff --git a/app/views/notifications.py b/app/views/notifications.py index 37f928e..67f0ab6 100644 --- a/app/views/notifications.py +++ b/app/views/notifications.py @@ -12,7 +12,7 @@ async def notifications_get( request: Request, db: Session = Depends(deps.get_db), - current_user: models.User = Depends(deps.get_current_user_from_cookie), + current_user: models.User = Depends(deps.get_current_user_from_session), ): try: notifications_limit = request.session["notifications_limit"] @@ -50,7 +50,7 @@ async def notifications_post( request: Request, notifications_limit: int = Form(50), db: Session = Depends(deps.get_db), - current_user: models.User = Depends(deps.get_current_user_from_cookie), + current_user: models.User = Depends(deps.get_current_user_from_session), ): form = await request.form() selected_categories = [key for key in form if key != "notifications_limit"] @@ -95,7 +95,7 @@ async def notifications_post( async def notifications_update( request: Request, db: Session = Depends(deps.get_db), - current_user: models.User = Depends(deps.get_current_user_from_cookie), + current_user: models.User = Depends(deps.get_current_user_from_session), ): user_services = crud.get_user_services(db, current_user) categories = {service.id: service.category for service in user_services} diff --git a/app/views/settings.py b/app/views/settings.py index 026ea5a..9307e0b 100644 --- a/app/views/settings.py +++ b/app/views/settings.py @@ -12,7 +12,7 @@ async def settings_get( request: Request, db: Session = Depends(deps.get_db), - current_user: models.User = Depends(deps.get_current_user_from_cookie), + current_user: models.User = Depends(deps.get_current_user_from_session), ): services = crud.get_user_services(db, current_user) return templates.TemplateResponse( @@ -25,7 +25,7 @@ async def settings_get( async def settings_post( request: Request, db: Session = Depends(deps.get_db), - current_user: models.User = Depends(deps.get_current_user_from_cookie), + current_user: models.User = Depends(deps.get_current_user_from_session), ): form = await request.form() selected_categories = list(form.keys()) diff --git a/pyproject.toml b/pyproject.toml index 008e00c..daee00f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ readme = "README.md" dependencies = [ "alembic", "aiofiles", + "authlib", "cryptography", "fastapi", "pydantic>=2.3", From 397164015f5ef43b23761119976ce4477ec13a0e Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 23 Oct 2024 11:52:12 +0200 Subject: [PATCH 36/78] Catch exception on authorize_access_token --- app/templates/400.html | 8 ++++++++ app/views/account.py | 7 ++++++- app/views/exceptions.py | 15 ++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 app/templates/400.html diff --git a/app/templates/400.html b/app/templates/400.html new file mode 100644 index 0000000..a2635a5 --- /dev/null +++ b/app/templates/400.html @@ -0,0 +1,8 @@ +{%- extends "base.html" %} + +{% block title %}Bad Request{% endblock %} + +{% block main %} +

Bad Request

+

{{ detail }}

+{%- endblock %} diff --git a/app/views/account.py b/app/views/account.py index a756ba6..6c4aec2 100644 --- a/app/views/account.py +++ b/app/views/account.py @@ -3,6 +3,7 @@ from starlette.responses import HTMLResponse, RedirectResponse from starlette.requests import Request from sqlalchemy.orm import Session +from authlib.integrations.base_client.errors import OAuthError from . import templates from .. import deps, crud, auth, models from ..settings import APP_NAME, AUTHENTICATION_METHOD @@ -76,7 +77,11 @@ async def oidc_auth( request: Request, db: Session = Depends(deps.get_db), ): - token = await deps.oauth.keycloak.authorize_access_token(request) + try: + token = await deps.oauth.keycloak.authorize_access_token(request) + except OAuthError as e: + logger.warning(f"OAuthError on OpenID Connect redirect: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) user_info = token["userinfo"] if user_info: username = user_info["preferred_username"].lower() diff --git a/app/views/exceptions.py b/app/views/exceptions.py index 18f8819..ba19e8c 100644 --- a/app/views/exceptions.py +++ b/app/views/exceptions.py @@ -9,6 +9,14 @@ async def not_authenticated(request: Request, exc: HTTPException): return RedirectResponse(url="/login") +async def bad_request(request: Request, exc: HTTPException): + return templates.TemplateResponse( + "400.html", + {"request": request, "detail": exc.detail}, + status_code=exc.status_code, + ) + + async def forbidden(request: Request, exc: HTTPException): return templates.TemplateResponse( "403.html", {"request": request}, status_code=exc.status_code @@ -29,4 +37,9 @@ async def server_error(request: Request, exc: HTTPException): ) -exception_handlers = {401: not_authenticated, 404: not_found, 500: server_error} +exception_handlers = { + 400: bad_request, + 401: not_authenticated, + 404: not_found, + 500: server_error, +} From 2aca0f5c9dbf5b1c0891be388b041ce7e4a2a4e6 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Tue, 18 Feb 2025 10:41:56 +0100 Subject: [PATCH 37/78] Add OpenID Connect Authentication Code Flow support --- .pre-commit-config.yaml | 4 +- app/api/login.py | 141 ++++++++++++++++++++++++++++++++++++---- app/main.py | 31 ++++++++- app/schemas.py | 7 ++ app/settings.py | 14 ++++ app/utils.py | 37 +++++++++++ 6 files changed, 217 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb945d3..ba1a70b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.6.9 + rev: v0.9.6 hooks: # Run the linter. - id: ruff @@ -9,7 +9,7 @@ repos: - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.12.0 + rev: v1.15.0 hooks: - id: mypy additional_dependencies: diff --git a/app/api/login.py b/app/api/login.py index 4d9e510..bbe66e6 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -1,32 +1,145 @@ +import httpx from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, HTTPException, Response, Request, status from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger from sqlalchemy.orm import Session -from .. import deps, crud, utils, auth -from ..settings import ACCESS_TOKEN_EXPIRE_MINUTES +from .. import deps, crud, utils, auth, schemas +from ..settings import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + OIDC_ANDROID_CLIENT_ID, + OIDC_ANDROID_CLIENT_SECRET, + OIDC_IOS_CLIENT_ID, + OIDC_IOS_CLIENT_SECRET, + OIDC_SCOPE, +) router = APIRouter() +def create_access_token(db, username, response) -> dict[str, str]: + db_user = crud.get_user_by_username(db, username) + if db_user is None: + db_user = crud.create_user(db, username) + response.status_code = status.HTTP_201_CREATED + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = utils.create_access_token(db_user.username, expire=expire) + crud.update_user_login_token_expire_date(db, db_user, expire) + logger.info(f"User {username} successfully logged in") + return {"access_token": access_token, "token_type": "bearer"} + + @router.post("/login", status_code=status.HTTP_200_OK) def login( response: Response, db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends(), ): - if not auth.authenticate_user(form_data.username.lower(), form_data.password): - logger.warning(f"Authentication failed for {form_data.username.lower()}") + """Login using username/password""" + username = form_data.username.lower() + if not auth.authenticate_user(username, form_data.password): + logger.warning(f"Authentication failed for {username}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", ) - logger.info(f"User {form_data.username.lower()} successfully logged in") - db_user = crud.get_user_by_username(db, form_data.username.lower()) - if db_user is None: - db_user = crud.create_user(db, form_data.username.lower()) - response.status_code = status.HTTP_201_CREATED - expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = utils.create_access_token(db_user.username, expire=expire) - crud.update_user_login_token_expire_date(db, db_user, expire) - return {"access_token": access_token, "token_type": "bearer"} + return create_access_token(db, username, response) + + +@router.post("/open_id_connect", status_code=status.HTTP_200_OK) +async def open_id_connect( + oidc_auth: schemas.OpenIdConnectAuth, + response: Response, + request: Request, + db: Session = Depends(deps.get_db), +): + """Login using OpenID Connect Authentication Code flow""" + if oidc_auth.client_id == OIDC_ANDROID_CLIENT_ID: + client_secret = OIDC_ANDROID_CLIENT_SECRET + elif oidc_auth.client_id == OIDC_IOS_CLIENT_ID: + client_secret = OIDC_IOS_CLIENT_SECRET + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Unknown client_id {oidc_auth.client_id}", + ) + oidc_config = request.state.oidc_config + data = { + "client_id": oidc_auth.client_id, + "client_secret": client_secret, + "code": oidc_auth.code, + "code_verifier": oidc_auth.code_verifier, + "grant_type": "authorization_code", + "redirect_uri": oidc_auth.redirect_uri, + } + logger.info( + "Login via OIDC Authentication Code flow. " + f"Sending {data} to {oidc_config['token_endpoint']} to retrieve token." + ) + async with httpx.AsyncClient() as client: + try: + response = await client.post( + oidc_config["token_endpoint"], + data=data, + ) + response.raise_for_status() + except httpx.RequestError as exc: + logger.error( + f"An error occurred while requesting {exc.request.url!r}: {exc}." + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"An error occurred while requesting {exc.request.url!r}", + ) + except httpx.HTTPStatusError as exc: + logger.error(f"Failed to get OIDC token: {response.content}") + raise HTTPException( + status_code=exc.response.status_code, detail="Failed to get OIDC token" + ) + result = response.json() + access_token = result["access_token"] + id_token = result["id_token"] + logger.debug("Retrieved access and id tokens. Validating id_token.") + try: + utils.validate_id_token( + id_token, + access_token, + request.state.jwks_client, + request.state.oidc_config["id_token_signing_alg_values_supported"], + oidc_auth.client_id, + ) + except Exception as e: + logger.warning(f"id_token validation failed: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="id_token validation failed", + ) + headers = {"Authorization": f"Bearer {access_token}"} + data = { + "client_id": oidc_auth.client_id, + "client_secret": client_secret, + "scope": OIDC_SCOPE, + } + logger.info("Retrieving user info.") + try: + response = await client.post( + oidc_config["userinfo_endpoint"], + headers=headers, + data=data, + ) + response.raise_for_status() + except httpx.RequestError as exc: + logger.error( + f"An error occurred while requesting {exc.request.url!r}: {exc}." + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"An error occurred while requesting {exc.request.url!r}", + ) + except httpx.HTTPStatusError as exc: + logger.error(f"Failed to get user info: {response.content}") + raise HTTPException( + status_code=exc.response.status_code, detail="Failed to get user info" + ) + username = response.json()["preferred_username"].lower() + return create_access_token(db, username, response) diff --git a/app/main.py b/app/main.py index 307662e..7dc84af 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,10 @@ +import contextlib import logging +import httpx +import jwt import sentry_sdk from pathlib import Path +from typing import AsyncIterator, TypedDict from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from fastapi import FastAPI from fastapi_versioning import VersionedFastAPI @@ -16,6 +20,8 @@ ESS_NOTIFY_SERVER_ENVIRONMENT, SECRET_KEY, SESSION_MAX_AGE, + OIDC_SERVER_URL, + AUTHENTICATION_METHOD, ) @@ -26,6 +32,25 @@ logger.handlers = gunicorn_error_logger.handlers logger.setLevel(gunicorn_error_logger.level) + +class State(TypedDict): + oidc_config: dict[str, str] + jwks_client: jwt.PyJWKClient | None + + +@contextlib.asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[State]: + if AUTHENTICATION_METHOD == "oidc": + async with httpx.AsyncClient() as client: + r = await client.get(OIDC_SERVER_URL) + oidc_config = r.json() + jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) + else: + oidc_config = {} + jwks_client = None + yield {"oidc_config": oidc_config, "jwks_client": jwks_client} + + # Main application to serve HTML middleware = [ Middleware( @@ -35,7 +60,11 @@ max_age=SESSION_MAX_AGE, ) ] -app = FastAPI(exception_handlers=exceptions.exception_handlers, middleware=middleware) +app = FastAPI( + exception_handlers=exceptions.exception_handlers, + middleware=middleware, + lifespan=lifespan, +) app.include_router(account.router) app.include_router(notifications.router, prefix="/notifications") app.include_router(settings.router, prefix="/settings") diff --git a/app/schemas.py b/app/schemas.py index 87e361f..a7effca 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -164,3 +164,10 @@ class Aps(BaseModel): class ApnPayload(BaseModel): aps: Aps + + +class OpenIdConnectAuth(BaseModel): + code: str + code_verifier: str + client_id: str + redirect_uri: str diff --git a/app/settings.py b/app/settings.py index 5057e8d..b30d76d 100644 --- a/app/settings.py +++ b/app/settings.py @@ -34,6 +34,20 @@ OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", cast=str, default="notify") OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") +OIDC_ANDROID_CLIENT_ID = config( + "OIDC_ANDROID_CLIENT_ID", cast=str, default="notify.android.maxiv.lu.se" +) +OIDC_ANDROID_CLIENT_SECRET = config( + "OIDC_ANDROID_CLIENT_SECRET", + cast=Secret, + default="!secret", +) +OIDC_IOS_CLIENT_ID = config( + "OIDC_IOS_CLIENT_ID", cast=str, default="notify.ios.maxiv.lu.se" +) +OIDC_IOS_CLIENT_SECRET = config( + "OIDC_IOS_CLIENT_SECRET", cast=Secret, default="!secret" +) # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( diff --git a/app/utils.py b/app/utils.py index 46a3e65..0378918 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,4 +1,5 @@ import asyncio +import base64 import httpx import ipaddress import uuid @@ -119,3 +120,39 @@ async def send_notification(notification_id: int) -> None: await android_client.aclose() finally: db.close() + + +def validate_id_token( + id_token: str, + access_token: str, + jwks_client: jwt.PyJWKClient, + signing_algos: list[str], + client_id: str, +) -> None: + """Raise an exception if the validation of the id token fails""" + # See https://pyjwt.readthedocs.io/en/stable/usage.html#oidc-login-flow + signing_key = jwks_client.get_signing_key_from_jwt(id_token) + # Decode and verify id_token claims + # expiration, issued at, not before, audience and issuer + data = jwt.decode_complete( + id_token, + key=signing_key, + audience=client_id, + algorithms=signing_algos, + require=["exp", "iat", "nbf", "aud", "iss"], + verify_signature=True, + ) + payload, header = data["payload"], data["header"] + alg_obj = jwt.get_algorithm_by_name(header["alg"]) + # compute at_hash, then validate + # access_token must be bytes (not str) + digest = alg_obj.compute_hash_digest(access_token.encode("utf-8")) + at_hash = ( + base64.urlsafe_b64encode(digest[: (len(digest) // 2)]) + .rstrip(b"=") + .decode("utf-8") + ) + if at_hash != payload["at_hash"]: + raise ValueError( + f"at_hash value {payload['at_hash']} doesn't match computed {at_hash}" + ) From 5f88978e8758d6538e74f73474fccf2abf8b81bd Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 19 Feb 2025 16:51:43 +0100 Subject: [PATCH 38/78] Vendor fastapi-versioning 0.10.0 Project is abandonned. No update in 4 years. Need a patch to customize the default swagger UI. --- app/_vendor/LICENSE.fastapi_versioning | 21 +++++ app/_vendor/__init__.py | 0 app/_vendor/fastapi_versioning/__init__.py | 8 ++ app/_vendor/fastapi_versioning/routing.py | 18 +++++ app/_vendor/fastapi_versioning/versioning.py | 81 ++++++++++++++++++++ app/api/users.py | 2 +- app/main.py | 2 +- pyproject.toml | 3 +- requirements.txt | 1 - 9 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 app/_vendor/LICENSE.fastapi_versioning create mode 100644 app/_vendor/__init__.py create mode 100644 app/_vendor/fastapi_versioning/__init__.py create mode 100644 app/_vendor/fastapi_versioning/routing.py create mode 100644 app/_vendor/fastapi_versioning/versioning.py diff --git a/app/_vendor/LICENSE.fastapi_versioning b/app/_vendor/LICENSE.fastapi_versioning new file mode 100644 index 0000000..d93181b --- /dev/null +++ b/app/_vendor/LICENSE.fastapi_versioning @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Dean Way + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/_vendor/__init__.py b/app/_vendor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/_vendor/fastapi_versioning/__init__.py b/app/_vendor/fastapi_versioning/__init__.py new file mode 100644 index 0000000..f86bcf7 --- /dev/null +++ b/app/_vendor/fastapi_versioning/__init__.py @@ -0,0 +1,8 @@ +from .routing import versioned_api_route +from .versioning import VersionedFastAPI, version + +__all__ = [ + "VersionedFastAPI", + "versioned_api_route", + "version", +] diff --git a/app/_vendor/fastapi_versioning/routing.py b/app/_vendor/fastapi_versioning/routing.py new file mode 100644 index 0000000..eeb34dc --- /dev/null +++ b/app/_vendor/fastapi_versioning/routing.py @@ -0,0 +1,18 @@ +from typing import Any, Type + +from fastapi.routing import APIRoute + + +def versioned_api_route( + major: int = 1, minor: int = 0, route_class: Type[APIRoute] = APIRoute +) -> Type[APIRoute]: + class VersionedAPIRoute(route_class): # type: ignore + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + try: + self.endpoint._api_version = (major, minor) + except AttributeError: + # Support bound methods + self.endpoint.__func__._api_version = (major, minor) + + return VersionedAPIRoute diff --git a/app/_vendor/fastapi_versioning/versioning.py b/app/_vendor/fastapi_versioning/versioning.py new file mode 100644 index 0000000..c91c39f --- /dev/null +++ b/app/_vendor/fastapi_versioning/versioning.py @@ -0,0 +1,81 @@ +from collections import defaultdict +from typing import Any, Callable, Dict, List, Tuple, TypeVar, cast + +from fastapi import FastAPI +from fastapi.routing import APIRoute +from starlette.routing import BaseRoute + +CallableT = TypeVar("CallableT", bound=Callable[..., Any]) + + +def version(major: int, minor: int = 0) -> Callable[[CallableT], CallableT]: + def decorator(func: CallableT) -> CallableT: + func._api_version = (major, minor) # type: ignore + return func + + return decorator + + +def version_to_route( + route: BaseRoute, + default_version: Tuple[int, int], +) -> Tuple[Tuple[int, int], APIRoute]: + api_route = cast(APIRoute, route) + version = getattr(api_route.endpoint, "_api_version", default_version) + return version, api_route + + +def VersionedFastAPI( + app: FastAPI, + version_format: str = "{major}.{minor}", + prefix_format: str = "/v{major}_{minor}", + default_version: Tuple[int, int] = (1, 0), + enable_latest: bool = False, + **kwargs: Any, +) -> FastAPI: + parent_app = FastAPI( + title=app.title, + **kwargs, + ) + version_route_mapping: Dict[Tuple[int, int], List[APIRoute]] = defaultdict(list) + version_routes = [version_to_route(route, default_version) for route in app.routes] + + for version, route in version_routes: + version_route_mapping[version].append(route) + + unique_routes = {} + versions = sorted(version_route_mapping.keys()) + for version in versions: + major, minor = version + prefix = prefix_format.format(major=major, minor=minor) + semver = version_format.format(major=major, minor=minor) + versioned_app = FastAPI( + title=app.title, + description=app.description, + version=semver, + ) + for route in version_route_mapping[version]: + for method in route.methods: + unique_routes[route.path + "|" + method] = route + for route in unique_routes.values(): + versioned_app.router.routes.append(route) + parent_app.mount(prefix, versioned_app) + + @parent_app.get(f"{prefix}/openapi.json", name=semver, tags=["Versions"]) + @parent_app.get(f"{prefix}/docs", name=semver, tags=["Documentations"]) + def noop() -> None: ... + + if enable_latest: + prefix = "/latest" + major, minor = version + semver = version_format.format(major=major, minor=minor) + versioned_app = FastAPI( + title=app.title, + description=app.description, + version=semver, + ) + for route in unique_routes.values(): + versioned_app.router.routes.append(route) + parent_app.mount(prefix, versioned_app) + + return parent_app diff --git a/app/api/users.py b/app/api/users.py index 9c4f216..b0695aa 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, Response, HTTPException, status -from fastapi_versioning import version +from .._vendor.fastapi_versioning import version from sqlalchemy.orm import Session from typing import List from .. import deps, crud, models, schemas diff --git a/app/main.py b/app/main.py index 7dc84af..8d24bee 100644 --- a/app/main.py +++ b/app/main.py @@ -7,7 +7,7 @@ from typing import AsyncIterator, TypedDict from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from fastapi import FastAPI -from fastapi_versioning import VersionedFastAPI +from ._vendor.fastapi_versioning import VersionedFastAPI from fastapi.logger import logger from fastapi.staticfiles import StaticFiles from starlette.middleware import Middleware diff --git a/pyproject.toml b/pyproject.toml index daee00f..53ac8ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ dependencies = [ "cryptography", "fastapi", "pydantic>=2.3", - "fastapi-versioning", "google-auth", "requests", "h2", @@ -37,7 +36,7 @@ dependencies = [ "typer", ] requires-python = ">= 3.9" -license = { file = "LICENSE" } +license = { text = "BSD-2-Clause AND MIT" } [project.optional-dependencies] postgres = ["psycopg2"] diff --git a/requirements.txt b/requirements.txt index b5ee09e..cfa4900 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,6 @@ charset-normalizer==3.4.0 click==8.1.7 cryptography==43.0.1 fastapi==0.115.2 -fastapi-versioning==0.10.0 google-auth==2.35.0 gunicorn==23.0.0 h11==0.14.0 From 70cf1d686d49cd91fddfe1a4da2f8fc161a068fa Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 19 Feb 2025 16:59:03 +0100 Subject: [PATCH 39/78] Disable autodoc in fastapi-versioning Custom doc will be added in main app. --- app/_vendor/fastapi_versioning/versioning.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/_vendor/fastapi_versioning/versioning.py b/app/_vendor/fastapi_versioning/versioning.py index c91c39f..5d9be98 100644 --- a/app/_vendor/fastapi_versioning/versioning.py +++ b/app/_vendor/fastapi_versioning/versioning.py @@ -53,6 +53,8 @@ def VersionedFastAPI( title=app.title, description=app.description, version=semver, + docs_url=None, + redoc_url=None, ) for route in version_route_mapping[version]: for method in route.methods: From 6f6c18849c29550bf0f1561b9d3127a4e84503d6 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 20 Feb 2025 11:13:08 +0100 Subject: [PATCH 40/78] Remove unused AUTH_COOKIE_NAME session is used --- app/deps.py | 4 +--- app/settings.py | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/app/deps.py b/app/deps.py index 1ab4c05..2a53f8c 100644 --- a/app/deps.py +++ b/app/deps.py @@ -1,6 +1,6 @@ from fastapi import Depends, HTTPException, status from starlette.requests import Request -from fastapi.security import OAuth2PasswordBearer, APIKeyCookie +from fastapi.security import OAuth2PasswordBearer from fastapi.logger import logger from sqlalchemy.orm import Session from jwt import PyJWTError, ExpiredSignatureError @@ -8,7 +8,6 @@ from . import crud, models, utils from .database import SessionLocal from .settings import ( - AUTH_COOKIE_NAME, OIDC_NAME, OIDC_SERVER_URL, OIDC_CLIENT_ID, @@ -17,7 +16,6 @@ ) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") -cookie_sec = APIKeyCookie(name=AUTH_COOKIE_NAME) oauth = OAuth() oauth.register( OIDC_NAME, diff --git a/app/settings.py b/app/settings.py index b30d76d..1917e39 100644 --- a/app/settings.py +++ b/app/settings.py @@ -90,8 +90,6 @@ ACCESS_TOKEN_EXPIRE_MINUTES = config( "ACCESS_TOKEN_EXPIRE_MINUTES", cast=int, default=43200 ) -# Cookie name -AUTH_COOKIE_NAME = config("AUTH_COOKIE_NAME", cast=str, default="notify_token") # Number of push notifications sent in parallel NB_PARALLEL_PUSH = config("NB_PARALLEL_PUSH", cast=int, default=50) From 2d5a9e4b9a464a9751338611ab11b8fecf247c60 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 20 Feb 2025 08:46:56 +0100 Subject: [PATCH 41/78] Add OIDC_ENABLED variable Allow to support old authentication method for the API even when OIDC is enabled. We need a period to support both until all clients are updated. --- app/main.py | 4 ++-- app/settings.py | 7 ++++++- app/views/account.py | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/main.py b/app/main.py index 8d24bee..3a3af5e 100644 --- a/app/main.py +++ b/app/main.py @@ -21,7 +21,7 @@ SECRET_KEY, SESSION_MAX_AGE, OIDC_SERVER_URL, - AUTHENTICATION_METHOD, + OIDC_ENABLED, ) @@ -40,7 +40,7 @@ class State(TypedDict): @contextlib.asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[State]: - if AUTHENTICATION_METHOD == "oidc": + if OIDC_ENABLED: async with httpx.AsyncClient() as client: r = await client.get(OIDC_SERVER_URL) oidc_config = r.json() diff --git a/app/settings.py b/app/settings.py index 1917e39..cd4824e 100644 --- a/app/settings.py +++ b/app/settings.py @@ -14,7 +14,8 @@ except FileNotFoundError: config = Config() -# Should be set to "ldap", "url" or "oidc" +# Should be set to "ldap" or "url" +# This is still supported for the API even when OIDC is enabled AUTHENTICATION_METHOD = config("AUTHENTICATION_METHOD", cast=str, default="ldap") # LDAP configuration LDAP_HOST = config("LDAP_HOST", cast=str, default="ldap.example.org") @@ -25,6 +26,10 @@ LDAP_USER_RDN_ATTR = config("LDAP_USER_RDN_ATTR", cast=str, default="uid") # OpenID Connect configuration +# When enabled OIDC will be used for: +# - web login (only method supported) +# - API login (old authentication method still supported as well) +OIDC_ENABLED = config("OIDC_ENABLED", cast=bool, default=False) OIDC_NAME = config("OIDC_NAME", cast=str, default="keycloak") OIDC_SERVER_URL = config( "OIDC_SERVER_URL", diff --git a/app/views/account.py b/app/views/account.py index 6c4aec2..099df0a 100644 --- a/app/views/account.py +++ b/app/views/account.py @@ -6,7 +6,7 @@ from authlib.integrations.base_client.errors import OAuthError from . import templates from .. import deps, crud, auth, models -from ..settings import APP_NAME, AUTHENTICATION_METHOD +from ..settings import APP_NAME, OIDC_ENABLED router = APIRouter() @@ -21,7 +21,7 @@ async def index( @router.get("/login", response_class=HTMLResponse) async def login_get(request: Request): - if AUTHENTICATION_METHOD == "oidc": + if OIDC_ENABLED: redirect_uri = request.url_for("oidc_auth") return await deps.oauth.keycloak.authorize_redirect(request, redirect_uri) else: @@ -41,7 +41,7 @@ async def login_post( request: Request, db: Session = Depends(deps.get_db), ): - if AUTHENTICATION_METHOD == "oidc": + if OIDC_ENABLED: raise HTTPException( status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Invalid method" ) From bc3a8d1d258a5cdfb3788725d3b4866369aaedd6 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 20 Feb 2025 11:11:39 +0100 Subject: [PATCH 42/78] Improve security --- app/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/main.py b/app/main.py index 3a3af5e..822411a 100644 --- a/app/main.py +++ b/app/main.py @@ -58,6 +58,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: secret_key=SECRET_KEY, session_cookie="notify_session", max_age=SESSION_MAX_AGE, + same_site="strict", + https_only=True, ) ] app = FastAPI( From ce5c07d41faf6d6f741aa6a86eae17737fd018d2 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 19 Feb 2025 17:05:38 +0100 Subject: [PATCH 43/78] Add custom swagger UI endpoint Allow to use the session cookie created when login via the web UI. No need to implement login via Authorize in Swagger UI. We load some custom javascript to inject a dummy bearer token that is required by most API endpoints. The session cookie is httponly and can't be retrieved from javascript. The token is only used to force the app to check the session to see if the user is logged in or not. --- app/deps.py | 10 ++++++++- app/main.py | 11 ++++++++-- app/static/js/swagger-ui-custom.js | 33 ++++++++++++++++++++++++++++++ app/views/docs.py | 27 ++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 app/static/js/swagger-ui-custom.js create mode 100644 app/views/docs.py diff --git a/app/deps.py b/app/deps.py index 2a53f8c..bf3a4d4 100644 --- a/app/deps.py +++ b/app/deps.py @@ -35,7 +35,7 @@ def get_db(): def get_current_user( - db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) + request: Request, db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) ) -> models.User: """Return the current user based on the bearer token from the header""" credentials_exception = HTTPException( @@ -43,6 +43,14 @@ def get_current_user( detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) + # Special case for swagger UI + # To avoid implementing OpenId Connect flow with the Authorize button, + # we use the cookie from the session that should be present if the user + # already logged in via the web UI + # We inject a dummy bearer token as one is expected by the oauth2_scheme + # If the user isn't logged in, this will return a 401 + if token == "swagger-ui": + return get_current_user_from_session(request, db) try: payload = utils.decode_access_token(token) except ExpiredSignatureError: diff --git a/app/main.py b/app/main.py index 822411a..9d1cc24 100644 --- a/app/main.py +++ b/app/main.py @@ -14,7 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware from . import monitoring from .api import login, users, services -from .views import exceptions, account, notifications, settings +from .views import exceptions, account, notifications, settings, docs from .settings import ( SENTRY_DSN, ESS_NOTIFY_SERVER_ENVIRONMENT, @@ -66,17 +66,21 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: exception_handlers=exceptions.exception_handlers, middleware=middleware, lifespan=lifespan, + docs_url=None, + redoc_url=None, ) app.include_router(account.router) app.include_router(notifications.router, prefix="/notifications") app.include_router(settings.router, prefix="/settings") +app.include_router(docs.router) + # Serve static files app_dir = Path(__file__).parent.resolve() app.mount("/static", StaticFiles(directory=str(app_dir / "static")), name="static") # API mounted under /api -original_api = FastAPI() +original_api = FastAPI(docs_url=None, redoc_url=None) original_api.include_router(monitoring.router, prefix="/-", tags=["monitoring"]) original_api.include_router(login.router, tags=["login"]) original_api.include_router(users.router, prefix="/users", tags=["users"]) @@ -90,10 +94,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: original_api, version_format="{major}", prefix_format="/v{major}", + docs_url=None, + redoc_url=None, ) app.mount("/api", versioned_api) + if SENTRY_DSN: sentry_sdk.init(dsn=SENTRY_DSN, environment=ESS_NOTIFY_SERVER_ENVIRONMENT) app = SentryAsgiMiddleware(app) diff --git a/app/static/js/swagger-ui-custom.js b/app/static/js/swagger-ui-custom.js new file mode 100644 index 0000000..81c9e7e --- /dev/null +++ b/app/static/js/swagger-ui-custom.js @@ -0,0 +1,33 @@ +window.onload = function () { + // Extract API version from the URL (e.g., "/api/v1/docs" -> "v1") + const pathParts = window.location.pathname.split("/"); + const version = pathParts.length >= 3 ? pathParts[2] : "v1"; // Default to v1 if missing + + // Construct the OpenAPI URL dynamically + const openapiUrl = `/api/${version}/openapi.json`; + + setTimeout(() => { + fetch(openapiUrl) // Load the correct OpenAPI schema + .then(response => response.json()) + .then(spec => { + spec.host = window.location.host; + spec.schemes = [window.location.protocol.replace(':', '')]; + + spec.info.description = 'To perform authenticated requests, do not use "Authorize" but login via the web UI first.'; + + window.ui = SwaggerUIBundle({ + spec: spec, + dom_id: '#swagger-ui', + deepLinking: true, + presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset], + requestInterceptor: request => { + // Add custom bearer token so that the user is retrieved from the session + // (if logged in) + request.headers['Authorization'] = "Bearer swagger-ui"; + return request; + }, + }); + }) + .catch(error => console.error(`Error loading OpenAPI spec for ${version}:`, error)); + }, 1000); +}; diff --git a/app/views/docs.py b/app/views/docs.py new file mode 100644 index 0000000..5be68c0 --- /dev/null +++ b/app/views/docs.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter +from starlette.responses import HTMLResponse + +router = APIRouter() + + +# Dynamic Swagger UI Route (works for `/api/v1/docs` and `/api/v2/docs`) +# Override the default Swagger UI endpoint to load some custom javascript +# and inject a bearer token +@router.get("/api/{version}/docs", include_in_schema=False) +async def custom_swagger_ui(version: str): + html = """ + + + + + Notify SwaggerUI + + +
+
+ + + + + """ + return HTMLResponse(html) From fe70ee6bdc12fb9e695610de8f775ae67751410d Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 20 Feb 2025 11:47:05 +0100 Subject: [PATCH 44/78] Update requirements --- pyproject.toml | 2 +- requirements.txt | 153 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 123 insertions(+), 32 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53ac8ef..39a66fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "jinja2", "python-multipart", "httpx", - "PyJWT", + "PyJWT>=2.10", "ldap3", "SQLAlchemy<1.4", "uvicorn[standard]", diff --git a/requirements.txt b/requirements.txt index cfa4900..5750dd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,54 +1,145 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml -o requirements.txt aiofiles==24.1.0 -alembic==1.13.3 + # via ess-notify (pyproject.toml) +alembic==1.14.1 + # via ess-notify (pyproject.toml) annotated-types==0.7.0 -anyio==4.6.2.post1 -cachetools==5.5.0 -certifi==2024.8.30 + # via pydantic +anyio==4.8.0 + # via + # httpx + # starlette + # watchfiles +authlib==1.4.1 + # via ess-notify (pyproject.toml) +cachetools==5.5.1 + # via google-auth +certifi==2025.1.31 + # via + # httpcore + # httpx + # requests + # sentry-sdk cffi==1.17.1 -charset-normalizer==3.4.0 -click==8.1.7 -cryptography==43.0.1 -fastapi==0.115.2 -google-auth==2.35.0 + # via cryptography +charset-normalizer==3.4.1 + # via requests +click==8.1.8 + # via + # typer + # uvicorn +cryptography==44.0.1 + # via + # ess-notify (pyproject.toml) + # authlib +fastapi==0.115.8 + # via ess-notify (pyproject.toml) +google-auth==2.38.0 + # via ess-notify (pyproject.toml) gunicorn==23.0.0 + # via ess-notify (pyproject.toml) h11==0.14.0 -h2==4.1.0 -hpack==4.0.0 -httpcore==1.0.6 + # via + # httpcore + # uvicorn +h2==4.2.0 + # via ess-notify (pyproject.toml) +hpack==4.1.0 + # via h2 +httpcore==1.0.7 + # via httpx httptools==0.6.4 -httpx==0.27.2 -hyperframe==6.0.1 + # via uvicorn +httpx==0.28.1 + # via ess-notify (pyproject.toml) +hyperframe==6.1.0 + # via h2 idna==3.10 + # via + # anyio + # httpx + # requests itsdangerous==2.2.0 -jinja2==3.1.4 + # via ess-notify (pyproject.toml) +jinja2==3.1.5 + # via ess-notify (pyproject.toml) ldap3==2.9.1 -mako==1.3.5 + # via ess-notify (pyproject.toml) +mako==1.3.9 + # via alembic markdown-it-py==3.0.0 -markupsafe==3.0.1 + # via rich +markupsafe==3.0.2 + # via + # jinja2 + # mako mdurl==0.1.2 -packaging==24.1 + # via markdown-it-py +packaging==24.2 + # via gunicorn pyasn1==0.6.1 + # via + # ldap3 + # pyasn1-modules + # rsa pyasn1-modules==0.4.1 + # via google-auth pycparser==2.22 -pydantic==2.9.2 -pydantic-core==2.23.4 -pygments==2.18.0 -pyjwt==2.9.0 + # via cffi +pydantic==2.10.6 + # via + # ess-notify (pyproject.toml) + # fastapi +pydantic-core==2.27.2 + # via pydantic +pygments==2.19.1 + # via rich +pyjwt==2.10.1 + # via ess-notify (pyproject.toml) python-dotenv==1.0.1 -python-multipart==0.0.12 + # via uvicorn +python-multipart==0.0.20 + # via ess-notify (pyproject.toml) pyyaml==6.0.2 + # via uvicorn requests==2.32.3 -rich==13.9.2 + # via ess-notify (pyproject.toml) +rich==13.9.4 + # via typer rsa==4.9 -sentry-sdk==2.17.0 + # via google-auth +sentry-sdk==2.22.0 + # via ess-notify (pyproject.toml) shellingham==1.5.4 + # via typer sniffio==1.3.1 + # via anyio sqlalchemy==1.3.24 -starlette==0.40.0 -typer==0.12.5 + # via + # ess-notify (pyproject.toml) + # alembic +starlette==0.45.3 + # via fastapi +typer==0.15.1 + # via ess-notify (pyproject.toml) typing-extensions==4.12.2 -urllib3==2.2.3 -uvicorn==0.32.0 + # via + # alembic + # anyio + # fastapi + # pydantic + # pydantic-core + # typer +urllib3==2.3.0 + # via + # requests + # sentry-sdk +uvicorn==0.34.0 + # via ess-notify (pyproject.toml) uvloop==0.21.0 -watchfiles==0.24.0 -websockets==13.1 + # via uvicorn +watchfiles==1.0.4 + # via uvicorn +websockets==15.0 + # via uvicorn From f481102d89a4cc4caac98c06fb5a0e709ba754ea Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Thu, 20 Feb 2025 13:32:35 +0100 Subject: [PATCH 45/78] Use same client_id for mobile apps and backend --- app/api/login.py | 20 ++++---------------- app/settings.py | 14 -------------- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/app/api/login.py b/app/api/login.py index bbe66e6..081cca7 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -7,10 +7,7 @@ from .. import deps, crud, utils, auth, schemas from ..settings import ( ACCESS_TOKEN_EXPIRE_MINUTES, - OIDC_ANDROID_CLIENT_ID, - OIDC_ANDROID_CLIENT_SECRET, - OIDC_IOS_CLIENT_ID, - OIDC_IOS_CLIENT_SECRET, + OIDC_CLIENT_SECRET, OIDC_SCOPE, ) @@ -53,20 +50,11 @@ async def open_id_connect( request: Request, db: Session = Depends(deps.get_db), ): - """Login using OpenID Connect Authentication Code flow""" - if oidc_auth.client_id == OIDC_ANDROID_CLIENT_ID: - client_secret = OIDC_ANDROID_CLIENT_SECRET - elif oidc_auth.client_id == OIDC_IOS_CLIENT_ID: - client_secret = OIDC_IOS_CLIENT_SECRET - else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"Unknown client_id {oidc_auth.client_id}", - ) + """Login using OpenID Connect Authentication Code flow from mobile client""" oidc_config = request.state.oidc_config data = { "client_id": oidc_auth.client_id, - "client_secret": client_secret, + "client_secret": OIDC_CLIENT_SECRET, "code": oidc_auth.code, "code_verifier": oidc_auth.code_verifier, "grant_type": "authorization_code", @@ -117,7 +105,7 @@ async def open_id_connect( headers = {"Authorization": f"Bearer {access_token}"} data = { "client_id": oidc_auth.client_id, - "client_secret": client_secret, + "client_secret": OIDC_CLIENT_SECRET, "scope": OIDC_SCOPE, } logger.info("Retrieving user info.") diff --git a/app/settings.py b/app/settings.py index cd4824e..c210de2 100644 --- a/app/settings.py +++ b/app/settings.py @@ -39,20 +39,6 @@ OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", cast=str, default="notify") OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") -OIDC_ANDROID_CLIENT_ID = config( - "OIDC_ANDROID_CLIENT_ID", cast=str, default="notify.android.maxiv.lu.se" -) -OIDC_ANDROID_CLIENT_SECRET = config( - "OIDC_ANDROID_CLIENT_SECRET", - cast=Secret, - default="!secret", -) -OIDC_IOS_CLIENT_ID = config( - "OIDC_IOS_CLIENT_ID", cast=str, default="notify.ios.maxiv.lu.se" -) -OIDC_IOS_CLIENT_SECRET = config( - "OIDC_IOS_CLIENT_SECRET", cast=Secret, default="!secret" -) # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( From 876d8fc5cc86e368b07cf4e7cca9d176921c4eca Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Fri, 28 Feb 2025 16:36:42 +0100 Subject: [PATCH 46/78] Update ruff --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba1a70b..e2e4161 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.9.6 + rev: v0.9.9 hooks: # Run the linter. - id: ruff From 1f8ee0f8218dcf6d62b062c9d7bc9736a15f9e83 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Mon, 3 Mar 2025 10:38:45 +0100 Subject: [PATCH 47/78] Upgrade requirements --- requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5750dd7..1bf9eb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,9 +11,9 @@ anyio==4.8.0 # httpx # starlette # watchfiles -authlib==1.4.1 +authlib==1.5.1 # via ess-notify (pyproject.toml) -cachetools==5.5.1 +cachetools==5.5.2 # via google-auth certifi==2025.1.31 # via @@ -29,11 +29,11 @@ click==8.1.8 # via # typer # uvicorn -cryptography==44.0.1 +cryptography==44.0.2 # via # ess-notify (pyproject.toml) # authlib -fastapi==0.115.8 +fastapi==0.115.11 # via ess-notify (pyproject.toml) google-auth==2.38.0 # via ess-notify (pyproject.toml) @@ -119,9 +119,9 @@ sqlalchemy==1.3.24 # via # ess-notify (pyproject.toml) # alembic -starlette==0.45.3 +starlette==0.46.0 # via fastapi -typer==0.15.1 +typer==0.15.2 # via ess-notify (pyproject.toml) typing-extensions==4.12.2 # via From 6ea92e1f61b1f5fcfcbe6aba29f439e0e04a04f4 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Mon, 3 Mar 2025 15:10:43 +0100 Subject: [PATCH 48/78] Requires python 3.11 To use "| None", we need at least 3.10. We use 3.11 in the docker image. No need to support below. --- .github/workflows/pytest.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 378d268..dec80ae 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v2 diff --git a/pyproject.toml b/pyproject.toml index 39a66fe..b0b1bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "sentry-sdk", "typer", ] -requires-python = ">= 3.9" +requires-python = ">= 3.11" license = { text = "BSD-2-Clause AND MIT" } [project.optional-dependencies] From 169b9ee7294d47a40b1c28fbdd2253a3beadba31 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Mon, 29 Sep 2025 11:07:30 +0200 Subject: [PATCH 49/78] Make BUNDLE_ID configurable --- app/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/settings.py b/app/settings.py index c210de2..046d7de 100644 --- a/app/settings.py +++ b/app/settings.py @@ -61,7 +61,7 @@ APPLE_SERVER = config( "APPLE_SERVER", cast=str, default="api.development.push.apple.com" ) -BUNDLE_ID = "eu.ess.ESS-Notify" +BUNDLE_ID = config("BUNDLE_ID", cast=str, default="eu.ess.ESS-Notify") ALLOWED_NETWORKS = config("ALLOWED_NETWORKS", cast=CommaSeparatedStrings, default="") # Firebase settings From b4acb3d48281772a3f63f65fbf929fa47d529f51 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Fri, 7 Nov 2025 13:51:49 +0100 Subject: [PATCH 50/78] Add realm discovery endpoint --- README.md | 87 ++++++++++- app/api/login.py | 77 +++++++++- app/realm_discovery.py | 133 +++++++++++++++++ app/schemas.py | 34 +++++ app/settings.py | 16 ++ tests/api/test_login_realm_discovery.py | 190 ++++++++++++++++++++++++ tests/test_realm_discovery.py | 38 +++++ 7 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 app/realm_discovery.py create mode 100644 tests/api/test_login_realm_discovery.py create mode 100644 tests/test_realm_discovery.py diff --git a/README.md b/README.md index c5b17d7..2ef38f7 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,91 @@ To be able to login, at least the following variables shall be overwritten: - LDAP_HOST - LDAP_USER_DN +### OpenID Connect with Automatic Realm Discovery for Mobile Apps + +The application supports automatic Keycloak realm discovery for mobile applications while maintaining standard web authentication. + +**Web Interface**: Uses standard OIDC authentication with a single configured realm. +**Mobile Apps**: Can discover and authenticate against different realms automatically based on username patterns. + +#### Configuration + +```bash +# Enable OIDC authentication +OIDC_ENABLED=true + +# Standard web authentication (single realm) +OIDC_SERVER_URL=https://keycloak.maxiv.lu.se/auth/realms/main/maxiv +OIDC_CLIENT_ID=notify +OIDC_CLIENT_SECRET=your-client-secret + +# Mobile app realm discovery configuration +OIDC_BASE_URL=https://keycloak.maxiv.lu.se/auth +OIDC_DEFAULT_REALM=maxiv +OIDC_REALM_MAPPING="demo:demo-realm" +OIDC_REALM_DISCOVERY_TTL_SECONDS=300 # Cache responses for 5 minutes +``` + +#### How It Works + +**For Web Users:** +- Standard OIDC authentication flow +- Single configured realm via `OIDC_SERVER_URL` +- Traditional login redirect to Keycloak + +**For Mobile Apps:** +1. App calls `/api/v1/realm-discovery/?username=` to get realm information +2. App receives realm-specific OIDC endpoints and configuration +3. App initiates OIDC flow with the appropriate realm +4. App exchanges authorization code for tokens using `/api/v1/open_id_connect` + +#### API Endpoints for Mobile Apps + +**Realm Discovery:** +```http +GET /api/v1/realm-discovery/{username} +``` + +Returns: +```json +{ + "username": "user", + "realm": "realm", + "issuer": "https://keycloak.maxiv.lu.se/auth/realms/company-realm", + "authorization_endpoint": "https://keycloak.maxiv.lu.se/auth/realms/company-realm/protocol/openid-connect/auth", + "token_endpoint": "https://keycloak.maxiv.lu.se/auth/realms/company-realm/protocol/openid-connect/token", + "client_id": "notify", + "scope": "openid email profile", + "type": "real" +} +``` + +**Response Fields:** +- `username`: The normalized username (lowercased, trimmed) +- `realm`: The discovered Keycloak realm name +- `issuer`: The OIDC issuer URL for the realm +- `authorization_endpoint`: OAuth2 authorization endpoint +- `token_endpoint`: OAuth2 token endpoint for code exchange +- `client_id`: OIDC client ID to use +- `scope`: OAuth2 scopes to request +- `type`: Realm type (`real`, `demo`, `sandbox`, `unknown`) +- `demo_instructions`: Optional instructions for demo/testing realms + +**OIDC Authentication:** +```http +POST /api/v1/open_id_connect +``` + +Body: +```json +{ + "code": "authorization_code", + "code_verifier": "pkce_verifier", + "client_id": "notify", + "redirect_uri": "app://callback" +} +``` + Refer to the default values defined in the [Ansible role](https://gitlab.esss.lu.se/ics-ansible-galaxy/ics-ans-role-ess-notify-server/-/blob/master/defaults/main.yml) and in the [ess_notify_servers](https://csentry.esss.lu.se/network/groups/view/ess_notify_servers) group in CSEntry. @@ -51,7 +136,7 @@ Create a virtual environment and install the requirements: python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -pip install -e .[tests] +pip install -e ".[tests]" ``` When using sqlite, it's not possible to run `alembic` for database migration diff --git a/app/api/login.py b/app/api/login.py index 081cca7..fec0a32 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -1,17 +1,22 @@ import httpx +import time from datetime import datetime, timedelta, timezone +from urllib.parse import unquote from fastapi import APIRouter, Depends, HTTPException, Response, Request, status from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger from sqlalchemy.orm import Session -from .. import deps, crud, utils, auth, schemas +from .. import deps, crud, utils, auth, schemas, realm_discovery from ..settings import ( ACCESS_TOKEN_EXPIRE_MINUTES, OIDC_CLIENT_SECRET, OIDC_SCOPE, + OIDC_ENABLED, + OIDC_REALM_DISCOVERY_TTL_SECONDS, ) router = APIRouter() +_DISCOVERY_CACHE: dict[str, dict] = {} def create_access_token(db, username, response) -> dict[str, str]: @@ -131,3 +136,73 @@ async def open_id_connect( ) username = response.json()["preferred_username"].lower() return create_access_token(db, username, response) + + +@router.get( + "/realm-discovery/", + status_code=status.HTTP_200_OK, + response_model=schemas.RealmDiscoveryResponse, +) +def get_realm_for_username(username: str) -> schemas.RealmDiscoveryResponse: + """ + Discover the appropriate Keycloak realm for a username/email. + + Mobile apps should call this endpoint first to determine which realm to authenticate against. + The response includes all necessary OIDC endpoints and configuration for the discovered realm. + + Note: If using email addresses, URL-encode them (e.g., alice%40example.com) + """ + if not OIDC_ENABLED: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="OIDC is not enabled", + ) + + try: + normalized = unquote(username).lower().strip() + except Exception: + normalized = username.lower().strip() + + if not normalized: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required" + ) + + # Check cache first + now = time.time() + cached = _DISCOVERY_CACHE.get(normalized) + if cached and cached["expires_at"] > now: + return cached["value"] + + # Discover realm + try: + realm = realm_discovery.discover_realm_for_username(normalized) + except Exception as e: + logger.error("Failed to discover realm for %s: %s", normalized, e) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Realm discovery failed" + ) + + # Build response with all OIDC endpoints + issuer = realm_discovery.get_keycloak_issuer(realm) + + response = schemas.RealmDiscoveryResponse( + username=normalized, + realm=realm, + issuer=issuer, + authorization_endpoint=realm_discovery.get_keycloak_authorization_endpoint( + realm + ), + token_endpoint=realm_discovery.get_keycloak_token_endpoint(realm), + client_id=realm_discovery.OIDC_CLIENT_ID, + scope=realm_discovery.OIDC_SCOPE, + type=schemas.RealmType.real, # Could be enhanced to detect demo/sandbox realms + ) + + # Cache the response + _DISCOVERY_CACHE[normalized] = { + "value": response, + "expires_at": now + OIDC_REALM_DISCOVERY_TTL_SECONDS, + } + + return response diff --git a/app/realm_discovery.py b/app/realm_discovery.py new file mode 100644 index 0000000..fd7f19a --- /dev/null +++ b/app/realm_discovery.py @@ -0,0 +1,133 @@ +""" +Realm discovery utilities for automatic Keycloak realm selection based on username. +""" + +from urllib.parse import urlencode +from .settings import ( + OIDC_BASE_URL, + OIDC_DEFAULT_REALM, + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_SCOPE, + OIDC_REALM_MAPPING, +) + + +def discover_realm_for_username(username: str) -> str: + """ + Discover the appropriate Keycloak realm for a given username. + + Args: + username: The username to check for realm mapping + + Returns: + The realm name to use for this username + """ + username = username.lower().strip() + + # Check realm mapping configuration + for mapping in OIDC_REALM_MAPPING: + if ":" in mapping: + pattern, realm = mapping.split(":", 1) + pattern = pattern.strip().lower() + realm = realm.strip() + + # Check if pattern matches username + if pattern == username: + return realm + + # Default realm fallback + return OIDC_DEFAULT_REALM + + +def get_keycloak_auth_url( + realm: str, username: str, redirect_uri: str, state: str +) -> str: + """ + Generate Keycloak authorization URL for a specific realm with username pre-filled. + + Args: + realm: The Keycloak realm name + username: The username to pre-fill + redirect_uri: The redirect URI after authentication + state: OAuth state parameter + + Returns: + Complete Keycloak authorization URL + """ + auth_endpoint = get_keycloak_authorization_endpoint(realm) + + params = { + "response_type": "code", + "client_id": OIDC_CLIENT_ID, + "redirect_uri": redirect_uri, + "scope": OIDC_SCOPE, + "state": state, + "login_hint": username, # Pre-fill username in Keycloak login form + } + + return f"{auth_endpoint}?{urlencode(params)}" + + +def get_keycloak_issuer(realm: str) -> str: + """ + Get the userinfo endpoint URL for a specific realm. + + Args: + realm: The Keycloak realm name + + Returns: + Userinfo endpoint URL + """ + return f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}" + + +def get_keycloak_token_endpoint(realm: str) -> str: + """ + Get the token endpoint URL for a specific realm. + + Args: + realm: The Keycloak realm name + + Returns: + Token endpoint URL + """ + return f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}/protocol/openid-connect/token" + + +def get_keycloak_authorization_endpoint(realm: str) -> str: + """ + Get the userinfo endpoint URL for a specific realm. + + Args: + realm: The Keycloak realm name + + Returns: + Userinfo endpoint URL + """ + return f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}/protocol/openid-connect/auth" + + +def get_keycloak_userinfo_endpoint(realm: str) -> str: + """ + Get the userinfo endpoint URL for a specific realm. + + Args: + realm: The Keycloak realm name + + Returns: + Userinfo endpoint URL + """ + return ( + f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}/protocol/openid-connect/userinfo" + ) + + +def get_client_secret() -> str: + """ + Get the OIDC client secret. + + Returns: + The client secret as a string + """ + return str(OIDC_CLIENT_SECRET) diff --git a/app/schemas.py b/app/schemas.py index a7effca..8ca733e 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -171,3 +171,37 @@ class OpenIdConnectAuth(BaseModel): code_verifier: str client_id: str redirect_uri: str + + +class RealmType(str, Enum): + real = "real" + demo = "demo" + sandbox = "sandbox" + unknown = "unknown" + + +class RealmDiscoveryResponse(BaseModel): + username: str + realm: str + issuer: str + authorization_endpoint: str + token_endpoint: str + client_id: str + scope: str + type: RealmType = RealmType.real + demo_instructions: Optional[str] = None + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "username": "user", + "realm": "company-realm", + "issuer": "https://keycloak.example.org/auth/realms/company-realm", + "authorization_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/auth", + "token_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/token", + "client_id": "notify", + "scope": "openid email profile", + "type": "real", + } + } + ) diff --git a/app/settings.py b/app/settings.py index 046d7de..8e45a66 100644 --- a/app/settings.py +++ b/app/settings.py @@ -31,6 +31,15 @@ # - API login (old authentication method still supported as well) OIDC_ENABLED = config("OIDC_ENABLED", cast=bool, default=False) OIDC_NAME = config("OIDC_NAME", cast=str, default="keycloak") +# Base Keycloak server URL (without realm path) +OIDC_BASE_URL = config( + "OIDC_BASE_URL", + cast=str, + default="https://keycloak.example.org/auth", +) +# Default realm when no specific mapping matches +OIDC_DEFAULT_REALM = config("OIDC_DEFAULT_REALM", cast=str, default="maxiv") +# Legacy single realm URL (for backward compatibility) OIDC_SERVER_URL = config( "OIDC_SERVER_URL", cast=str, @@ -39,6 +48,13 @@ OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", cast=str, default="notify") OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") +# Realm mapping: comma-separated "pattern:realm" pairs +OIDC_REALM_MAPPING = config( + "OIDC_REALM_MAPPING", cast=CommaSeparatedStrings, default="" +) +OIDC_REALM_DISCOVERY_TTL_SECONDS = config( + "OIDC_REALM_DISCOVERY_TTL_SECONDS", cast=int, default=300 +) # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py new file mode 100644 index 0000000..51c14d3 --- /dev/null +++ b/tests/api/test_login_realm_discovery.py @@ -0,0 +1,190 @@ +""" +Tests for mobile API authentication with realm discovery. +""" + +import pytest +from unittest.mock import patch +from fastapi.testclient import TestClient +from app.main import app +from app.schemas import RealmType + + +@pytest.fixture(autouse=True) +def reset_realm_discovery_state(): + """Reset realm discovery module state between tests.""" + import app.realm_discovery as rd + import app.api.login as login_api + + # Store original values + original_mapping = rd.OIDC_REALM_MAPPING + original_default = rd.OIDC_DEFAULT_REALM + original_base_url = rd.OIDC_BASE_URL + original_client_id = rd.OIDC_CLIENT_ID + original_scope = rd.OIDC_SCOPE + original_oidc_enabled = login_api.OIDC_ENABLED + + yield # Run the test + + # Clear any discovery cache + login_api._DISCOVERY_CACHE.clear() + + # Restore original values after test + rd.OIDC_REALM_MAPPING = original_mapping + rd.OIDC_DEFAULT_REALM = original_default + rd.OIDC_BASE_URL = original_base_url + rd.OIDC_CLIENT_ID = original_client_id + rd.OIDC_SCOPE = original_scope + login_api.OIDC_ENABLED = original_oidc_enabled + + +@patch("app.api.login.OIDC_ENABLED", False) +def test_realm_discovery_endpoint_oidc_disabled(): + """Test realm discovery endpoint when OIDC is disabled.""" + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?username=testuser") + + assert response.status_code == 503 + assert response.json() == {"detail": "OIDC is not enabled"} + + +@pytest.mark.parametrize( + "username,realm_mapping,expected_realm", + [ + ("demo", ["demo:demo-realm"], "demo-realm"), + ("unknown", ["demo:demo-realm"], "default"), + ("demo", [], "default"), + ], +) +def test_realm_discovery_endpoint_success(username, realm_mapping, expected_realm): + """Test realm discovery with various pattern matching (domain, prefix, exact).""" + import app.realm_discovery as rd + import app.api.login as login_api + + # Set up test configuration directly on modules + login_api.OIDC_ENABLED = True + rd.OIDC_BASE_URL = "https://keycloak.test.com/auth" + rd.OIDC_CLIENT_ID = "test-client" + rd.OIDC_SCOPE = "openid profile email" + rd.OIDC_DEFAULT_REALM = "default" + rd.OIDC_REALM_MAPPING = realm_mapping + + client = TestClient(app) + response = client.get(f"/api/v1/realm-discovery/?username={username}") + + assert response.status_code == 200 + data = response.json() + assert data["username"] == username + assert data["realm"] == expected_realm + assert data["issuer"] == f"https://keycloak.test.com/auth/realms/{expected_realm}" + assert ( + data["authorization_endpoint"] + == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/auth" + ) + assert ( + data["token_endpoint"] + == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/token" + ) + assert data["client_id"] == "test-client" + assert data["scope"] == "openid profile email" + assert data["type"] == "real" + + +@pytest.mark.parametrize( + "username,expected_realm", + [("demo", "demo-realm"), ("admin", "admin-realm"), ("unknown_user", "default")], +) +@patch("app.api.login.OIDC_ENABLED", True) +@patch("app.realm_discovery.OIDC_BASE_URL", "https://keycloak.test.com/auth") +@patch("app.realm_discovery.OIDC_CLIENT_ID", "test-client") +@patch("app.realm_discovery.OIDC_SCOPE", "openid profile email") +@patch( + "app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "admin:admin-realm"] +) +@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") +def test_realm_discovery_multiple_mappings(username, expected_realm): + """Test realm discovery with multiple mapping rules.""" + client = TestClient(app) + response = client.get(f"/api/v1/realm-discovery/?username={username}") + + assert response.status_code == 200 + data = response.json() + assert data["realm"] == expected_realm + + +@patch("app.api.login.OIDC_ENABLED", True) +@patch("app.realm_discovery.OIDC_BASE_URL", "https://keycloak.test.com/auth") +@patch("app.realm_discovery.OIDC_CLIENT_ID", "test-client") +@patch("app.realm_discovery.OIDC_SCOPE", "openid profile email") +@patch("app.realm_discovery.OIDC_REALM_MAPPING", []) +@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "master") +def test_realm_discovery_response_structure(): + """Test that response contains all required fields with correct types.""" + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?username=testuser") + + assert response.status_code == 200 + data = response.json() + + # Check required fields exist + required_fields = [ + "username", + "realm", + "issuer", + "authorization_endpoint", + "token_endpoint", + "client_id", + "scope", + "type", + ] + for field in required_fields: + assert field in data, f"Missing required field: {field}" + + # Check field types + string_fields = [ + "username", + "realm", + "issuer", + "authorization_endpoint", + "token_endpoint", + "client_id", + "scope", + "type", + ] + for field in string_fields: + assert isinstance(data[field], str), f"Field {field} should be string" + + # Check URLs are properly formatted + url_fields = ["issuer", "authorization_endpoint", "token_endpoint"] + for field in url_fields: + assert data[field].startswith("https://"), ( + f"Field {field} should start with https://" + ) + + # Check realm type is valid + assert data["type"] in [e.value for e in RealmType], "Invalid realm type" + + +@pytest.mark.parametrize( + "field_name,field_value", + [ + ("username", "testuser"), + ("realm", "master"), + ("client_id", "test-client"), + ("scope", "openid profile email"), + ("type", "real"), + ], +) +@patch("app.api.login.OIDC_ENABLED", True) +@patch("app.realm_discovery.OIDC_BASE_URL", "https://keycloak.test.com/auth") +@patch("app.realm_discovery.OIDC_CLIENT_ID", "test-client") +@patch("app.realm_discovery.OIDC_SCOPE", "openid profile email") +@patch("app.realm_discovery.OIDC_REALM_MAPPING", []) +@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "master") +def test_realm_discovery_response_field_values(field_name, field_value): + """Test that response fields contain expected values.""" + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?username=testuser") + + assert response.status_code == 200 + data = response.json() + assert data[field_name] == field_value diff --git a/tests/test_realm_discovery.py b/tests/test_realm_discovery.py new file mode 100644 index 0000000..fddb164 --- /dev/null +++ b/tests/test_realm_discovery.py @@ -0,0 +1,38 @@ +""" +Tests for realm discovery functionality. +""" + +import pytest +from unittest.mock import patch +from app.realm_discovery import discover_realm_for_username + + +@pytest.mark.parametrize( + "username,expected_realm", + [ + ("demo", "demo-realm"), + ("Demo", "demo-realm"), + ("guest", "guest-realm"), + ("anyuser", "default"), + ], +) +@patch( + "app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "Guest:guest-realm"] +) +@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") +def test_username_match(username, expected_realm): + """Test exact username matching.""" + + assert discover_realm_for_username(username) == expected_realm + + +@pytest.mark.parametrize( + "username,expected_realm", + [("demo", "default"), ("Demo", "default"), ("anyuser", "default")], +) +@patch("app.realm_discovery.OIDC_REALM_MAPPING", []) +@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") +def test_empty_configuration(username, expected_realm): + """Test behavior with empty realm mapping.""" + + assert discover_realm_for_username(username) == expected_realm From 6547c8480bc540000d3aeb01df54b6f9abeb2053 Mon Sep 17 00:00:00 2001 From: Dmitrii Ermakov Date: Fri, 7 Nov 2025 17:22:18 +0100 Subject: [PATCH 51/78] Update CI/CD (to trigger rebuild) --- .gitlab-ci.maxiv.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 6b7bc08..31003c5 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -4,7 +4,7 @@ include: file: "/PreCommit.gitlab-ci.yml" - project: kits-maxiv/kubernetes/k8s-gitlab-ci file: "/Docker-Helm-deploy.gitlab-ci.yml" - ref: "0.7" + ref: "0.8" # Override workflow rules to deploy on any branch workflow: From 6d01ca7105ebf5a6a505e706f7955ce7cf4fc356 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Thu, 13 Nov 2025 11:42:41 +0100 Subject: [PATCH 52/78] test redirect_uri --- app/api/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/login.py b/app/api/login.py index fec0a32..9d36d9e 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -63,7 +63,7 @@ async def open_id_connect( "code": oidc_auth.code, "code_verifier": oidc_auth.code_verifier, "grant_type": "authorization_code", - "redirect_uri": oidc_auth.redirect_uri, + "redirect_uri": "https://notify.maxiv.lu.seß/auth", } logger.info( "Login via OIDC Authentication Code flow. " From bbaff0d1ecff4c22b380ce981a9b3ab3e186846b Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Thu, 13 Nov 2025 13:26:11 +0100 Subject: [PATCH 53/78] test redirect_uri and test ingreess host --- .gitlab-ci.maxiv.yml | 2 +- app/api/login.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 31003c5..7f06b88 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -33,7 +33,7 @@ variables: PRODUCTION_BRANCH_NAME: "main" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" - HELM_SET_TEST_ingress_host: "notify-test-${CI_COMMIT_BRANCH}.apps.okdev.maxiv.lu.se" + HELM_SET_TEST_ingress_host: "notify-test-keycloak-new.apps.okdev.maxiv.lu.se" HELM_SET_PROD_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" HELM_SET_PROD_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" HELM_SET_TEST_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" diff --git a/app/api/login.py b/app/api/login.py index 9d36d9e..fec0a32 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -63,7 +63,7 @@ async def open_id_connect( "code": oidc_auth.code, "code_verifier": oidc_auth.code_verifier, "grant_type": "authorization_code", - "redirect_uri": "https://notify.maxiv.lu.seß/auth", + "redirect_uri": oidc_auth.redirect_uri, } logger.info( "Login via OIDC Authentication Code flow. " From 3e9678f086f84d62adb09a2b4ea1f5deef0a2fb0 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Wed, 19 Nov 2025 09:03:17 +0100 Subject: [PATCH 54/78] Realm discovery by type --- app/api/login.py | 23 +++++++++----------- app/realm_discovery.py | 12 +++++------ app/schemas.py | 3 --- tests/api/test_login_realm_discovery.py | 28 +++++++++++-------------- tests/test_realm_discovery.py | 24 ++++++++++----------- 5 files changed, 39 insertions(+), 51 deletions(-) diff --git a/app/api/login.py b/app/api/login.py index fec0a32..2a5cf41 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -2,7 +2,7 @@ import time from datetime import datetime, timedelta, timezone from urllib.parse import unquote -from fastapi import APIRouter, Depends, HTTPException, Response, Request, status +from fastapi import APIRouter, Depends, HTTPException, Response, Request, status, Query from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger from sqlalchemy.orm import Session @@ -143,14 +143,15 @@ async def open_id_connect( status_code=status.HTTP_200_OK, response_model=schemas.RealmDiscoveryResponse, ) -def get_realm_for_username(username: str) -> schemas.RealmDiscoveryResponse: +def get_realm( + realm_type: str = Query(..., alias="type"), +) -> schemas.RealmDiscoveryResponse: """ - Discover the appropriate Keycloak realm for a username/email. + Discover the appropriate Keycloak realm for a realm type. Mobile apps should call this endpoint first to determine which realm to authenticate against. The response includes all necessary OIDC endpoints and configuration for the discovered realm. - Note: If using email addresses, URL-encode them (e.g., alice%40example.com) """ if not OIDC_ENABLED: raise HTTPException( @@ -159,13 +160,10 @@ def get_realm_for_username(username: str) -> schemas.RealmDiscoveryResponse: ) try: - normalized = unquote(username).lower().strip() + normalized = schemas.RealmType(unquote(realm_type).lower().strip()) except Exception: - normalized = username.lower().strip() - - if not normalized: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required" + status_code=status.HTTP_400_BAD_REQUEST, detail="Valid type is required" ) # Check cache first @@ -176,9 +174,9 @@ def get_realm_for_username(username: str) -> schemas.RealmDiscoveryResponse: # Discover realm try: - realm = realm_discovery.discover_realm_for_username(normalized) + realm = realm_discovery.discover_realm(normalized) except Exception as e: - logger.error("Failed to discover realm for %s: %s", normalized, e) + logger.error("Failed to discover realm of type %s: %s", normalized, e) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Realm discovery failed" ) @@ -187,7 +185,6 @@ def get_realm_for_username(username: str) -> schemas.RealmDiscoveryResponse: issuer = realm_discovery.get_keycloak_issuer(realm) response = schemas.RealmDiscoveryResponse( - username=normalized, realm=realm, issuer=issuer, authorization_endpoint=realm_discovery.get_keycloak_authorization_endpoint( @@ -196,7 +193,7 @@ def get_realm_for_username(username: str) -> schemas.RealmDiscoveryResponse: token_endpoint=realm_discovery.get_keycloak_token_endpoint(realm), client_id=realm_discovery.OIDC_CLIENT_ID, scope=realm_discovery.OIDC_SCOPE, - type=schemas.RealmType.real, # Could be enhanced to detect demo/sandbox realms + type=normalized, ) # Cache the response diff --git a/app/realm_discovery.py b/app/realm_discovery.py index fd7f19a..e7443ed 100644 --- a/app/realm_discovery.py +++ b/app/realm_discovery.py @@ -11,19 +11,19 @@ OIDC_SCOPE, OIDC_REALM_MAPPING, ) +from .schemas import RealmType -def discover_realm_for_username(username: str) -> str: +def discover_realm(realm_type: RealmType) -> str: """ Discover the appropriate Keycloak realm for a given username. Args: - username: The username to check for realm mapping + realm_type: The realm type to check for realm mapping Returns: - The realm name to use for this username + The realm name to use for this realm type """ - username = username.lower().strip() # Check realm mapping configuration for mapping in OIDC_REALM_MAPPING: @@ -32,8 +32,8 @@ def discover_realm_for_username(username: str) -> str: pattern = pattern.strip().lower() realm = realm.strip() - # Check if pattern matches username - if pattern == username: + # Check if pattern matches realm_type + if pattern == realm_type.lower().strip(): return realm # Default realm fallback diff --git a/app/schemas.py b/app/schemas.py index 8ca733e..da1ef59 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -176,12 +176,10 @@ class OpenIdConnectAuth(BaseModel): class RealmType(str, Enum): real = "real" demo = "demo" - sandbox = "sandbox" unknown = "unknown" class RealmDiscoveryResponse(BaseModel): - username: str realm: str issuer: str authorization_endpoint: str @@ -194,7 +192,6 @@ class RealmDiscoveryResponse(BaseModel): model_config = ConfigDict( json_schema_extra={ "example": { - "username": "user", "realm": "company-realm", "issuer": "https://keycloak.example.org/auth/realms/company-realm", "authorization_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/auth", diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py index 51c14d3..02e4d14 100644 --- a/tests/api/test_login_realm_discovery.py +++ b/tests/api/test_login_realm_discovery.py @@ -41,21 +41,21 @@ def reset_realm_discovery_state(): def test_realm_discovery_endpoint_oidc_disabled(): """Test realm discovery endpoint when OIDC is disabled.""" client = TestClient(app) - response = client.get("/api/v1/realm-discovery/?username=testuser") + response = client.get("/api/v1/realm-discovery/?type=testuser") assert response.status_code == 503 assert response.json() == {"detail": "OIDC is not enabled"} @pytest.mark.parametrize( - "username,realm_mapping,expected_realm", + "realm_type,realm_mapping,expected_realm", [ ("demo", ["demo:demo-realm"], "demo-realm"), ("unknown", ["demo:demo-realm"], "default"), ("demo", [], "default"), ], ) -def test_realm_discovery_endpoint_success(username, realm_mapping, expected_realm): +def test_realm_discovery_endpoint_success(realm_type, realm_mapping, expected_realm): """Test realm discovery with various pattern matching (domain, prefix, exact).""" import app.realm_discovery as rd import app.api.login as login_api @@ -69,11 +69,11 @@ def test_realm_discovery_endpoint_success(username, realm_mapping, expected_real rd.OIDC_REALM_MAPPING = realm_mapping client = TestClient(app) - response = client.get(f"/api/v1/realm-discovery/?username={username}") + response = client.get(f"/api/v1/realm-discovery/?type={realm_type}") assert response.status_code == 200 data = response.json() - assert data["username"] == username + assert data["type"] == realm_type assert data["realm"] == expected_realm assert data["issuer"] == f"https://keycloak.test.com/auth/realms/{expected_realm}" assert ( @@ -86,25 +86,24 @@ def test_realm_discovery_endpoint_success(username, realm_mapping, expected_real ) assert data["client_id"] == "test-client" assert data["scope"] == "openid profile email" - assert data["type"] == "real" @pytest.mark.parametrize( - "username,expected_realm", - [("demo", "demo-realm"), ("admin", "admin-realm"), ("unknown_user", "default")], + "realm_type,expected_realm", + [("demo", "demo-realm"), ("real", "admin-realm"), ("unknown", "default")], ) @patch("app.api.login.OIDC_ENABLED", True) @patch("app.realm_discovery.OIDC_BASE_URL", "https://keycloak.test.com/auth") @patch("app.realm_discovery.OIDC_CLIENT_ID", "test-client") @patch("app.realm_discovery.OIDC_SCOPE", "openid profile email") @patch( - "app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "admin:admin-realm"] + "app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "real:admin-realm"] ) @patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") -def test_realm_discovery_multiple_mappings(username, expected_realm): +def test_realm_discovery_multiple_mappings(realm_type, expected_realm): """Test realm discovery with multiple mapping rules.""" client = TestClient(app) - response = client.get(f"/api/v1/realm-discovery/?username={username}") + response = client.get(f"/api/v1/realm-discovery/?type={realm_type}") assert response.status_code == 200 data = response.json() @@ -120,14 +119,13 @@ def test_realm_discovery_multiple_mappings(username, expected_realm): def test_realm_discovery_response_structure(): """Test that response contains all required fields with correct types.""" client = TestClient(app) - response = client.get("/api/v1/realm-discovery/?username=testuser") + response = client.get("/api/v1/realm-discovery/?type=demo") assert response.status_code == 200 data = response.json() # Check required fields exist required_fields = [ - "username", "realm", "issuer", "authorization_endpoint", @@ -141,7 +139,6 @@ def test_realm_discovery_response_structure(): # Check field types string_fields = [ - "username", "realm", "issuer", "authorization_endpoint", @@ -167,7 +164,6 @@ def test_realm_discovery_response_structure(): @pytest.mark.parametrize( "field_name,field_value", [ - ("username", "testuser"), ("realm", "master"), ("client_id", "test-client"), ("scope", "openid profile email"), @@ -183,7 +179,7 @@ def test_realm_discovery_response_structure(): def test_realm_discovery_response_field_values(field_name, field_value): """Test that response fields contain expected values.""" client = TestClient(app) - response = client.get("/api/v1/realm-discovery/?username=testuser") + response = client.get("/api/v1/realm-discovery/?type=real") assert response.status_code == 200 data = response.json() diff --git a/tests/test_realm_discovery.py b/tests/test_realm_discovery.py index fddb164..ce2e5f3 100644 --- a/tests/test_realm_discovery.py +++ b/tests/test_realm_discovery.py @@ -4,35 +4,33 @@ import pytest from unittest.mock import patch -from app.realm_discovery import discover_realm_for_username +from app.realm_discovery import discover_realm @pytest.mark.parametrize( - "username,expected_realm", + "realm_type,expected_realm", [ ("demo", "demo-realm"), ("Demo", "demo-realm"), - ("guest", "guest-realm"), - ("anyuser", "default"), + ("real", "real-realm"), + ("unknown", "default"), ], ) -@patch( - "app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "Guest:guest-realm"] -) +@patch("app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "real:real-realm"]) @patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") -def test_username_match(username, expected_realm): +def test_username_match(realm_type, expected_realm): """Test exact username matching.""" - assert discover_realm_for_username(username) == expected_realm + assert discover_realm(realm_type) == expected_realm @pytest.mark.parametrize( - "username,expected_realm", - [("demo", "default"), ("Demo", "default"), ("anyuser", "default")], + "realm_type,expected_realm", + [(" ", "default"), ("", "default"), ("Unknown", "default")], ) @patch("app.realm_discovery.OIDC_REALM_MAPPING", []) @patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") -def test_empty_configuration(username, expected_realm): +def test_empty_configuration(realm_type, expected_realm): """Test behavior with empty realm mapping.""" - assert discover_realm_for_username(username) == expected_realm + assert discover_realm(realm_type) == expected_realm From 75f7885513abaca2730656bd63d07291819fdb8b Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Wed, 19 Nov 2025 10:53:09 +0100 Subject: [PATCH 55/78] More robust defaulting --- README.md | 2 +- app/api/login.py | 8 +++----- app/schemas.py | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2ef38f7..426964e 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ OIDC_REALM_DISCOVERY_TTL_SECONDS=300 # Cache responses for 5 minutes - Traditional login redirect to Keycloak **For Mobile Apps:** -1. App calls `/api/v1/realm-discovery/?username=` to get realm information +1. App calls `/api/v1/realm-discovery/type?=` to get realm information 2. App receives realm-specific OIDC endpoints and configuration 3. App initiates OIDC flow with the appropriate realm 4. App exchanges authorization code for tokens using `/api/v1/open_id_connect` diff --git a/app/api/login.py b/app/api/login.py index 2a5cf41..0086db4 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -162,13 +162,11 @@ def get_realm( try: normalized = schemas.RealmType(unquote(realm_type).lower().strip()) except Exception: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Valid type is required" - ) + normalized = schemas.RealmType("unknown") # Check cache first now = time.time() - cached = _DISCOVERY_CACHE.get(normalized) + cached = _DISCOVERY_CACHE.get(normalized.value) if cached and cached["expires_at"] > now: return cached["value"] @@ -197,7 +195,7 @@ def get_realm( ) # Cache the response - _DISCOVERY_CACHE[normalized] = { + _DISCOVERY_CACHE[normalized.value] = { "value": response, "expires_at": now + OIDC_REALM_DISCOVERY_TTL_SECONDS, } diff --git a/app/schemas.py b/app/schemas.py index da1ef59..13a6c9e 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -186,7 +186,7 @@ class RealmDiscoveryResponse(BaseModel): token_endpoint: str client_id: str scope: str - type: RealmType = RealmType.real + type: RealmType demo_instructions: Optional[str] = None model_config = ConfigDict( From 56b683a8821bdcdd4dd51877149be7fdf83ad7dc Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Fri, 21 Nov 2025 10:33:37 +0100 Subject: [PATCH 56/78] fix import --- app/api/login.py | 5 +++-- app/realm_discovery.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/api/login.py b/app/api/login.py index 0086db4..3aca5d7 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -2,7 +2,7 @@ import time from datetime import datetime, timedelta, timezone from urllib.parse import unquote -from fastapi import APIRouter, Depends, HTTPException, Response, Request, status, Query +from fastapi import APIRouter, Depends, HTTPException, Response, Request, status from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger from sqlalchemy.orm import Session @@ -144,7 +144,7 @@ async def open_id_connect( response_model=schemas.RealmDiscoveryResponse, ) def get_realm( - realm_type: str = Query(..., alias="type"), + type: str, ) -> schemas.RealmDiscoveryResponse: """ Discover the appropriate Keycloak realm for a realm type. @@ -153,6 +153,7 @@ def get_realm( The response includes all necessary OIDC endpoints and configuration for the discovered realm. """ + realm_type = type if not OIDC_ENABLED: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, diff --git a/app/realm_discovery.py b/app/realm_discovery.py index e7443ed..9e424c7 100644 --- a/app/realm_discovery.py +++ b/app/realm_discovery.py @@ -11,10 +11,10 @@ OIDC_SCOPE, OIDC_REALM_MAPPING, ) -from .schemas import RealmType +from . import schemas -def discover_realm(realm_type: RealmType) -> str: +def discover_realm(realm_type: schemas.RealmType) -> str: """ Discover the appropriate Keycloak realm for a given username. From 8c34d19501d128c3ee8ff813b2586c7c2a04fc37 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Fri, 21 Nov 2025 12:26:37 +0100 Subject: [PATCH 57/78] Edit .gitlab-ci.maxiv.yml --- .gitlab-ci.maxiv.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 7f06b88..31003c5 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -33,7 +33,7 @@ variables: PRODUCTION_BRANCH_NAME: "main" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" - HELM_SET_TEST_ingress_host: "notify-test-keycloak-new.apps.okdev.maxiv.lu.se" + HELM_SET_TEST_ingress_host: "notify-test-${CI_COMMIT_BRANCH}.apps.okdev.maxiv.lu.se" HELM_SET_PROD_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" HELM_SET_PROD_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" HELM_SET_TEST_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" From eb024e6aabcd9bb9adf47da072eced2df110912a Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Mon, 24 Nov 2025 10:26:53 +0100 Subject: [PATCH 58/78] Fix CI --- .gitlab-ci.maxiv.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 31003c5..7f06b88 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -33,7 +33,7 @@ variables: PRODUCTION_BRANCH_NAME: "main" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" - HELM_SET_TEST_ingress_host: "notify-test-${CI_COMMIT_BRANCH}.apps.okdev.maxiv.lu.se" + HELM_SET_TEST_ingress_host: "notify-test-keycloak-new.apps.okdev.maxiv.lu.se" HELM_SET_PROD_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" HELM_SET_PROD_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" HELM_SET_TEST_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" From 22eedb91ff3a3d2490a0b1e2e8b92c9ebffc447a Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Wed, 26 Nov 2025 21:29:01 +0100 Subject: [PATCH 59/78] Update Readme [skip ci] --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 426964e..f91135d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ To be able to login, at least the following variables shall be overwritten: The application supports automatic Keycloak realm discovery for mobile applications while maintaining standard web authentication. **Web Interface**: Uses standard OIDC authentication with a single configured realm. -**Mobile Apps**: Can discover and authenticate against different realms automatically based on username patterns. +**Mobile Apps**: Can discover and authenticate against different realms depending on type. #### Configuration @@ -85,7 +85,6 @@ GET /api/v1/realm-discovery/{username} Returns: ```json { - "username": "user", "realm": "realm", "issuer": "https://keycloak.maxiv.lu.se/auth/realms/company-realm", "authorization_endpoint": "https://keycloak.maxiv.lu.se/auth/realms/company-realm/protocol/openid-connect/auth", @@ -97,14 +96,13 @@ Returns: ``` **Response Fields:** -- `username`: The normalized username (lowercased, trimmed) - `realm`: The discovered Keycloak realm name - `issuer`: The OIDC issuer URL for the realm - `authorization_endpoint`: OAuth2 authorization endpoint - `token_endpoint`: OAuth2 token endpoint for code exchange - `client_id`: OIDC client ID to use - `scope`: OAuth2 scopes to request -- `type`: Realm type (`real`, `demo`, `sandbox`, `unknown`) +- `type`: Realm type (`real`, `demo`, `unknown`) - `demo_instructions`: Optional instructions for demo/testing realms **OIDC Authentication:** From 22feea274cea05c51d884efd6134421f4ca23bcc Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Tue, 9 Dec 2025 15:23:40 +0100 Subject: [PATCH 60/78] Use well known OIDC discovery URI and oidc config per realm --- README.md | 18 +--- app/api/login.py | 80 +++++++------- app/deps.py | 5 +- app/main.py | 19 ++-- app/realm_discovery.py | 133 ------------------------ app/schemas.py | 22 +--- app/settings.py | 8 -- tests/api/test_login_realm_discovery.py | 91 +++++----------- tests/test_realm_discovery.py | 36 ------- 9 files changed, 88 insertions(+), 324 deletions(-) delete mode 100644 app/realm_discovery.py delete mode 100644 tests/test_realm_discovery.py diff --git a/README.md b/README.md index f91135d..e672660 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ OIDC_CLIENT_SECRET=your-client-secret OIDC_BASE_URL=https://keycloak.maxiv.lu.se/auth OIDC_DEFAULT_REALM=maxiv OIDC_REALM_MAPPING="demo:demo-realm" -OIDC_REALM_DISCOVERY_TTL_SECONDS=300 # Cache responses for 5 minutes ``` #### How It Works @@ -71,7 +70,7 @@ OIDC_REALM_DISCOVERY_TTL_SECONDS=300 # Cache responses for 5 minutes **For Mobile Apps:** 1. App calls `/api/v1/realm-discovery/type?=` to get realm information -2. App receives realm-specific OIDC endpoints and configuration +2. App receives realm-specific OIDC discovery URI 3. App initiates OIDC flow with the appropriate realm 4. App exchanges authorization code for tokens using `/api/v1/open_id_connect` @@ -79,31 +78,22 @@ OIDC_REALM_DISCOVERY_TTL_SECONDS=300 # Cache responses for 5 minutes **Realm Discovery:** ```http -GET /api/v1/realm-discovery/{username} +GET /api/v1/realm-discovery/{type} ``` Returns: ```json { "realm": "realm", - "issuer": "https://keycloak.maxiv.lu.se/auth/realms/company-realm", - "authorization_endpoint": "https://keycloak.maxiv.lu.se/auth/realms/company-realm/protocol/openid-connect/auth", - "token_endpoint": "https://keycloak.maxiv.lu.se/auth/realms/company-realm/protocol/openid-connect/token", - "client_id": "notify", - "scope": "openid email profile", + "discovery_uri": "https://keycloak.example.org/auth/realms/company-realm/.well-known/openid-configuratio", "type": "real" } ``` **Response Fields:** - `realm`: The discovered Keycloak realm name -- `issuer`: The OIDC issuer URL for the realm -- `authorization_endpoint`: OAuth2 authorization endpoint -- `token_endpoint`: OAuth2 token endpoint for code exchange -- `client_id`: OIDC client ID to use -- `scope`: OAuth2 scopes to request +- `discovery_uri`: The OIDC URI for the discovery endpoint - `type`: Realm type (`real`, `demo`, `unknown`) -- `demo_instructions`: Optional instructions for demo/testing realms **OIDC Authentication:** ```http diff --git a/app/api/login.py b/app/api/login.py index 3aca5d7..1598e98 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -1,22 +1,21 @@ import httpx -import time from datetime import datetime, timedelta, timezone -from urllib.parse import unquote -from fastapi import APIRouter, Depends, HTTPException, Response, Request, status +from fastapi import APIRouter, Depends, HTTPException, Response, Request, status, Query from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger from sqlalchemy.orm import Session -from .. import deps, crud, utils, auth, schemas, realm_discovery +from .. import deps, crud, utils, auth, schemas from ..settings import ( ACCESS_TOKEN_EXPIRE_MINUTES, OIDC_CLIENT_SECRET, OIDC_SCOPE, OIDC_ENABLED, - OIDC_REALM_DISCOVERY_TTL_SECONDS, + OIDC_BASE_URL, + OIDC_REALM_MAPPING, + OIDC_DEFAULT_REALM, ) router = APIRouter() -_DISCOVERY_CACHE: dict[str, dict] = {} def create_access_token(db, username, response) -> dict[str, str]: @@ -53,10 +52,11 @@ async def open_id_connect( oidc_auth: schemas.OpenIdConnectAuth, response: Response, request: Request, + realm: schemas.RealmType, db: Session = Depends(deps.get_db), ): """Login using OpenID Connect Authentication Code flow from mobile client""" - oidc_config = request.state.oidc_config + oidc_config = request.state.oidc_config[realm] data = { "client_id": oidc_auth.client_id, "client_secret": OIDC_CLIENT_SECRET, @@ -144,7 +144,7 @@ async def open_id_connect( response_model=schemas.RealmDiscoveryResponse, ) def get_realm( - type: str, + realm_type: str = Query(..., alias="type"), ) -> schemas.RealmDiscoveryResponse: """ Discover the appropriate Keycloak realm for a realm type. @@ -153,52 +153,58 @@ def get_realm( The response includes all necessary OIDC endpoints and configuration for the discovered realm. """ - realm_type = type if not OIDC_ENABLED: raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="OIDC is not enabled", ) try: - normalized = schemas.RealmType(unquote(realm_type).lower().strip()) - except Exception: - normalized = schemas.RealmType("unknown") - - # Check cache first - now = time.time() - cached = _DISCOVERY_CACHE.get(normalized.value) - if cached and cached["expires_at"] > now: - return cached["value"] - + normalized = schemas.RealmType(realm_type.lower().strip()) + except Exception as e: + logger.error("Failed to discover realm of type %s: %s", realm_type, e) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail="Realm discovery failed" + ) # Discover realm try: - realm = realm_discovery.discover_realm(normalized) + realm = discover_realm(normalized) except Exception as e: logger.error("Failed to discover realm of type %s: %s", normalized, e) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Realm discovery failed" ) - # Build response with all OIDC endpoints - issuer = realm_discovery.get_keycloak_issuer(realm) - response = schemas.RealmDiscoveryResponse( realm=realm, - issuer=issuer, - authorization_endpoint=realm_discovery.get_keycloak_authorization_endpoint( - realm - ), - token_endpoint=realm_discovery.get_keycloak_token_endpoint(realm), - client_id=realm_discovery.OIDC_CLIENT_ID, - scope=realm_discovery.OIDC_SCOPE, + discovery_uri=f"{OIDC_BASE_URL}/realms/{realm}/.well-known/openid-configuration", type=normalized, ) - # Cache the response - _DISCOVERY_CACHE[normalized.value] = { - "value": response, - "expires_at": now + OIDC_REALM_DISCOVERY_TTL_SECONDS, - } - return response + + +def discover_realm(realm_type: schemas.RealmType) -> str: + """ + Discover the appropriate Keycloak realm for a given username. + + Args: + realm_type: The realm type to check for realm mapping + + Returns: + The realm name to use for this realm type + """ + + # Check realm mapping configuration + for mapping in OIDC_REALM_MAPPING: + if ":" in mapping: + pattern, realm = mapping.split(":", 1) + pattern = pattern.strip().lower() + realm = realm.strip() + + # Check if pattern matches realm_type + if pattern == realm_type.lower().strip(): + return realm + + # Default realm fallback + return OIDC_DEFAULT_REALM diff --git a/app/deps.py b/app/deps.py index bf3a4d4..0067f4b 100644 --- a/app/deps.py +++ b/app/deps.py @@ -9,10 +9,11 @@ from .database import SessionLocal from .settings import ( OIDC_NAME, - OIDC_SERVER_URL, + OIDC_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SCOPE, + OIDC_DEFAULT_REALM, ) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") @@ -21,7 +22,7 @@ OIDC_NAME, client_id=OIDC_CLIENT_ID, client_secret=str(OIDC_CLIENT_SECRET), - server_metadata_url=OIDC_SERVER_URL, + server_metadata_url=f"{OIDC_BASE_URL}/realms/{OIDC_DEFAULT_REALM}/.well-known/openid-configuration", client_kwargs={"scope": OIDC_SCOPE}, ) diff --git a/app/main.py b/app/main.py index 9d1cc24..ad65bd2 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware import Middleware from starlette.middleware.sessions import SessionMiddleware -from . import monitoring +from . import monitoring, schemas from .api import login, users, services from .views import exceptions, account, notifications, settings, docs from .settings import ( @@ -20,7 +20,7 @@ ESS_NOTIFY_SERVER_ENVIRONMENT, SECRET_KEY, SESSION_MAX_AGE, - OIDC_SERVER_URL, + OIDC_BASE_URL, OIDC_ENABLED, ) @@ -34,20 +34,21 @@ class State(TypedDict): - oidc_config: dict[str, str] + oidc_config: dict[schemas.RealmType, dict[str, str]] jwks_client: jwt.PyJWKClient | None @contextlib.asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[State]: + oidc_config = {} + jwks_client = None if OIDC_ENABLED: async with httpx.AsyncClient() as client: - r = await client.get(OIDC_SERVER_URL) - oidc_config = r.json() - jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) - else: - oidc_config = {} - jwks_client = None + for realm in schemas.RealmType: + url = f"{OIDC_BASE_URL}/realms/{login.discover_realm(realm)}/.well-known/openid-configuration" + r = await client.get(url) + oidc_config[realm] = r.json() + jwks_client = jwt.PyJWKClient(oidc_config[realm]["jwks_uri"]) yield {"oidc_config": oidc_config, "jwks_client": jwks_client} diff --git a/app/realm_discovery.py b/app/realm_discovery.py deleted file mode 100644 index 9e424c7..0000000 --- a/app/realm_discovery.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -Realm discovery utilities for automatic Keycloak realm selection based on username. -""" - -from urllib.parse import urlencode -from .settings import ( - OIDC_BASE_URL, - OIDC_DEFAULT_REALM, - OIDC_CLIENT_ID, - OIDC_CLIENT_SECRET, - OIDC_SCOPE, - OIDC_REALM_MAPPING, -) -from . import schemas - - -def discover_realm(realm_type: schemas.RealmType) -> str: - """ - Discover the appropriate Keycloak realm for a given username. - - Args: - realm_type: The realm type to check for realm mapping - - Returns: - The realm name to use for this realm type - """ - - # Check realm mapping configuration - for mapping in OIDC_REALM_MAPPING: - if ":" in mapping: - pattern, realm = mapping.split(":", 1) - pattern = pattern.strip().lower() - realm = realm.strip() - - # Check if pattern matches realm_type - if pattern == realm_type.lower().strip(): - return realm - - # Default realm fallback - return OIDC_DEFAULT_REALM - - -def get_keycloak_auth_url( - realm: str, username: str, redirect_uri: str, state: str -) -> str: - """ - Generate Keycloak authorization URL for a specific realm with username pre-filled. - - Args: - realm: The Keycloak realm name - username: The username to pre-fill - redirect_uri: The redirect URI after authentication - state: OAuth state parameter - - Returns: - Complete Keycloak authorization URL - """ - auth_endpoint = get_keycloak_authorization_endpoint(realm) - - params = { - "response_type": "code", - "client_id": OIDC_CLIENT_ID, - "redirect_uri": redirect_uri, - "scope": OIDC_SCOPE, - "state": state, - "login_hint": username, # Pre-fill username in Keycloak login form - } - - return f"{auth_endpoint}?{urlencode(params)}" - - -def get_keycloak_issuer(realm: str) -> str: - """ - Get the userinfo endpoint URL for a specific realm. - - Args: - realm: The Keycloak realm name - - Returns: - Userinfo endpoint URL - """ - return f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}" - - -def get_keycloak_token_endpoint(realm: str) -> str: - """ - Get the token endpoint URL for a specific realm. - - Args: - realm: The Keycloak realm name - - Returns: - Token endpoint URL - """ - return f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}/protocol/openid-connect/token" - - -def get_keycloak_authorization_endpoint(realm: str) -> str: - """ - Get the userinfo endpoint URL for a specific realm. - - Args: - realm: The Keycloak realm name - - Returns: - Userinfo endpoint URL - """ - return f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}/protocol/openid-connect/auth" - - -def get_keycloak_userinfo_endpoint(realm: str) -> str: - """ - Get the userinfo endpoint URL for a specific realm. - - Args: - realm: The Keycloak realm name - - Returns: - Userinfo endpoint URL - """ - return ( - f"{OIDC_BASE_URL.rstrip('/')}/realms/{realm}/protocol/openid-connect/userinfo" - ) - - -def get_client_secret() -> str: - """ - Get the OIDC client secret. - - Returns: - The client secret as a string - """ - return str(OIDC_CLIENT_SECRET) diff --git a/app/schemas.py b/app/schemas.py index 13a6c9e..9a3bcaf 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -176,29 +176,9 @@ class OpenIdConnectAuth(BaseModel): class RealmType(str, Enum): real = "real" demo = "demo" - unknown = "unknown" class RealmDiscoveryResponse(BaseModel): realm: str - issuer: str - authorization_endpoint: str - token_endpoint: str - client_id: str - scope: str + discovery_uri: str type: RealmType - demo_instructions: Optional[str] = None - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "realm": "company-realm", - "issuer": "https://keycloak.example.org/auth/realms/company-realm", - "authorization_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/auth", - "token_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/token", - "client_id": "notify", - "scope": "openid email profile", - "type": "real", - } - } - ) diff --git a/app/settings.py b/app/settings.py index 8e45a66..ca440c4 100644 --- a/app/settings.py +++ b/app/settings.py @@ -40,11 +40,6 @@ # Default realm when no specific mapping matches OIDC_DEFAULT_REALM = config("OIDC_DEFAULT_REALM", cast=str, default="maxiv") # Legacy single realm URL (for backward compatibility) -OIDC_SERVER_URL = config( - "OIDC_SERVER_URL", - cast=str, - default="https://keycloak.example.org/auth/realms/myrealm/.well-known/openid-configuration", -) OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", cast=str, default="notify") OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") @@ -52,9 +47,6 @@ OIDC_REALM_MAPPING = config( "OIDC_REALM_MAPPING", cast=CommaSeparatedStrings, default="" ) -OIDC_REALM_DISCOVERY_TTL_SECONDS = config( - "OIDC_REALM_DISCOVERY_TTL_SECONDS", cast=int, default=300 -) # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py index 02e4d14..03ff07c 100644 --- a/tests/api/test_login_realm_discovery.py +++ b/tests/api/test_login_realm_discovery.py @@ -12,28 +12,20 @@ @pytest.fixture(autouse=True) def reset_realm_discovery_state(): """Reset realm discovery module state between tests.""" - import app.realm_discovery as rd import app.api.login as login_api # Store original values - original_mapping = rd.OIDC_REALM_MAPPING - original_default = rd.OIDC_DEFAULT_REALM - original_base_url = rd.OIDC_BASE_URL - original_client_id = rd.OIDC_CLIENT_ID - original_scope = rd.OIDC_SCOPE + original_mapping = login_api.OIDC_REALM_MAPPING + original_default = login_api.OIDC_DEFAULT_REALM + original_base_url = login_api.OIDC_BASE_URL original_oidc_enabled = login_api.OIDC_ENABLED yield # Run the test - # Clear any discovery cache - login_api._DISCOVERY_CACHE.clear() - # Restore original values after test - rd.OIDC_REALM_MAPPING = original_mapping - rd.OIDC_DEFAULT_REALM = original_default - rd.OIDC_BASE_URL = original_base_url - rd.OIDC_CLIENT_ID = original_client_id - rd.OIDC_SCOPE = original_scope + login_api.OIDC_REALM_MAPPING = original_mapping + login_api.OIDC_DEFAULT_REALM = original_default + login_api.OIDC_BASE_URL = original_base_url login_api.OIDC_ENABLED = original_oidc_enabled @@ -41,9 +33,9 @@ def reset_realm_discovery_state(): def test_realm_discovery_endpoint_oidc_disabled(): """Test realm discovery endpoint when OIDC is disabled.""" client = TestClient(app) - response = client.get("/api/v1/realm-discovery/?type=testuser") + response = client.get("/api/v1/realm-discovery/?type=test") - assert response.status_code == 503 + assert response.status_code == 405 assert response.json() == {"detail": "OIDC is not enabled"} @@ -51,23 +43,19 @@ def test_realm_discovery_endpoint_oidc_disabled(): "realm_type,realm_mapping,expected_realm", [ ("demo", ["demo:demo-realm"], "demo-realm"), - ("unknown", ["demo:demo-realm"], "default"), + ("real", ["demo:demo-realm"], "default"), ("demo", [], "default"), ], ) def test_realm_discovery_endpoint_success(realm_type, realm_mapping, expected_realm): """Test realm discovery with various pattern matching (domain, prefix, exact).""" - import app.realm_discovery as rd import app.api.login as login_api # Set up test configuration directly on modules login_api.OIDC_ENABLED = True - rd.OIDC_BASE_URL = "https://keycloak.test.com/auth" - rd.OIDC_CLIENT_ID = "test-client" - rd.OIDC_SCOPE = "openid profile email" - rd.OIDC_DEFAULT_REALM = "default" - rd.OIDC_REALM_MAPPING = realm_mapping - + login_api.OIDC_BASE_URL = "https://keycloak.test.com/auth" + login_api.OIDC_DEFAULT_REALM = "default" + login_api.OIDC_REALM_MAPPING = realm_mapping client = TestClient(app) response = client.get(f"/api/v1/realm-discovery/?type={realm_type}") @@ -75,31 +63,20 @@ def test_realm_discovery_endpoint_success(realm_type, realm_mapping, expected_re data = response.json() assert data["type"] == realm_type assert data["realm"] == expected_realm - assert data["issuer"] == f"https://keycloak.test.com/auth/realms/{expected_realm}" assert ( - data["authorization_endpoint"] - == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/auth" + data["discovery_uri"] + == f"https://keycloak.test.com/auth/realms/{expected_realm}/.well-known/openid-configuration" ) - assert ( - data["token_endpoint"] - == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/token" - ) - assert data["client_id"] == "test-client" - assert data["scope"] == "openid profile email" @pytest.mark.parametrize( "realm_type,expected_realm", - [("demo", "demo-realm"), ("real", "admin-realm"), ("unknown", "default")], + [("demo", "demo-realm"), ("real", "admin-realm")], ) @patch("app.api.login.OIDC_ENABLED", True) -@patch("app.realm_discovery.OIDC_BASE_URL", "https://keycloak.test.com/auth") -@patch("app.realm_discovery.OIDC_CLIENT_ID", "test-client") -@patch("app.realm_discovery.OIDC_SCOPE", "openid profile email") -@patch( - "app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "real:admin-realm"] -) -@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") +@patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") +@patch("app.api.login.OIDC_REALM_MAPPING", ["demo:demo-realm", "real:admin-realm"]) +@patch("app.api.login.OIDC_DEFAULT_REALM", "default") def test_realm_discovery_multiple_mappings(realm_type, expected_realm): """Test realm discovery with multiple mapping rules.""" client = TestClient(app) @@ -111,11 +88,9 @@ def test_realm_discovery_multiple_mappings(realm_type, expected_realm): @patch("app.api.login.OIDC_ENABLED", True) -@patch("app.realm_discovery.OIDC_BASE_URL", "https://keycloak.test.com/auth") -@patch("app.realm_discovery.OIDC_CLIENT_ID", "test-client") -@patch("app.realm_discovery.OIDC_SCOPE", "openid profile email") -@patch("app.realm_discovery.OIDC_REALM_MAPPING", []) -@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "master") +@patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") +@patch("app.api.login.OIDC_REALM_MAPPING", []) +@patch("app.api.login.OIDC_DEFAULT_REALM", "master") def test_realm_discovery_response_structure(): """Test that response contains all required fields with correct types.""" client = TestClient(app) @@ -127,11 +102,7 @@ def test_realm_discovery_response_structure(): # Check required fields exist required_fields = [ "realm", - "issuer", - "authorization_endpoint", - "token_endpoint", - "client_id", - "scope", + "discovery_uri", "type", ] for field in required_fields: @@ -140,18 +111,14 @@ def test_realm_discovery_response_structure(): # Check field types string_fields = [ "realm", - "issuer", - "authorization_endpoint", - "token_endpoint", - "client_id", - "scope", + "discovery_uri", "type", ] for field in string_fields: assert isinstance(data[field], str), f"Field {field} should be string" # Check URLs are properly formatted - url_fields = ["issuer", "authorization_endpoint", "token_endpoint"] + url_fields = ["discovery_uri"] for field in url_fields: assert data[field].startswith("https://"), ( f"Field {field} should start with https://" @@ -165,17 +132,13 @@ def test_realm_discovery_response_structure(): "field_name,field_value", [ ("realm", "master"), - ("client_id", "test-client"), - ("scope", "openid profile email"), ("type", "real"), ], ) @patch("app.api.login.OIDC_ENABLED", True) -@patch("app.realm_discovery.OIDC_BASE_URL", "https://keycloak.test.com/auth") -@patch("app.realm_discovery.OIDC_CLIENT_ID", "test-client") -@patch("app.realm_discovery.OIDC_SCOPE", "openid profile email") -@patch("app.realm_discovery.OIDC_REALM_MAPPING", []) -@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "master") +@patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") +@patch("app.api.login.OIDC_REALM_MAPPING", []) +@patch("app.api.login.OIDC_DEFAULT_REALM", "master") def test_realm_discovery_response_field_values(field_name, field_value): """Test that response fields contain expected values.""" client = TestClient(app) diff --git a/tests/test_realm_discovery.py b/tests/test_realm_discovery.py deleted file mode 100644 index ce2e5f3..0000000 --- a/tests/test_realm_discovery.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Tests for realm discovery functionality. -""" - -import pytest -from unittest.mock import patch -from app.realm_discovery import discover_realm - - -@pytest.mark.parametrize( - "realm_type,expected_realm", - [ - ("demo", "demo-realm"), - ("Demo", "demo-realm"), - ("real", "real-realm"), - ("unknown", "default"), - ], -) -@patch("app.realm_discovery.OIDC_REALM_MAPPING", ["demo:demo-realm", "real:real-realm"]) -@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") -def test_username_match(realm_type, expected_realm): - """Test exact username matching.""" - - assert discover_realm(realm_type) == expected_realm - - -@pytest.mark.parametrize( - "realm_type,expected_realm", - [(" ", "default"), ("", "default"), ("Unknown", "default")], -) -@patch("app.realm_discovery.OIDC_REALM_MAPPING", []) -@patch("app.realm_discovery.OIDC_DEFAULT_REALM", "default") -def test_empty_configuration(realm_type, expected_realm): - """Test behavior with empty realm mapping.""" - - assert discover_realm(realm_type) == expected_realm From 00c00e415b97fa775cce4117d591e3e86fbdbc5b Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Tue, 9 Dec 2025 17:12:08 +0100 Subject: [PATCH 61/78] Get the jwks_client for each realm and fastapi validation of realm discovery input --- app/api/login.py | 25 ++++++++++--------------- app/main.py | 8 ++++---- tests/api/test_login_realm_discovery.py | 6 +++--- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/app/api/login.py b/app/api/login.py index 1598e98..72cd26d 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -97,8 +97,10 @@ async def open_id_connect( utils.validate_id_token( id_token, access_token, - request.state.jwks_client, - request.state.oidc_config["id_token_signing_alg_values_supported"], + request.state.jwks_client[realm], + request.state.oidc_config[realm][ + "id_token_signing_alg_values_supported" + ], oidc_auth.client_id, ) except Exception as e: @@ -144,7 +146,7 @@ async def open_id_connect( response_model=schemas.RealmDiscoveryResponse, ) def get_realm( - realm_type: str = Query(..., alias="type"), + realm_type: schemas.RealmType = Query(..., alias="type"), ) -> schemas.RealmDiscoveryResponse: """ Discover the appropriate Keycloak realm for a realm type. @@ -159,26 +161,19 @@ def get_realm( detail="OIDC is not enabled", ) - try: - normalized = schemas.RealmType(realm_type.lower().strip()) - except Exception as e: - logger.error("Failed to discover realm of type %s: %s", realm_type, e) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, detail="Realm discovery failed" - ) # Discover realm try: - realm = discover_realm(normalized) + realm = discover_realm(realm_type) except Exception as e: - logger.error("Failed to discover realm of type %s: %s", normalized, e) + logger.error("Failed to discover realm of type %s: %s", realm_type, e) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="Realm discovery failed" ) response = schemas.RealmDiscoveryResponse( realm=realm, - discovery_uri=f"{OIDC_BASE_URL}/realms/{realm}/.well-known/openid-configuration", - type=normalized, + discovery_uri=f"{OIDC_BASE_URL}/realms/{realm}", + type=realm_type, ) return response @@ -186,7 +181,7 @@ def get_realm( def discover_realm(realm_type: schemas.RealmType) -> str: """ - Discover the appropriate Keycloak realm for a given username. + Discover the appropriate Keycloak realm for a given type. Args: realm_type: The realm type to check for realm mapping diff --git a/app/main.py b/app/main.py index ad65bd2..89fe663 100644 --- a/app/main.py +++ b/app/main.py @@ -35,20 +35,20 @@ class State(TypedDict): oidc_config: dict[schemas.RealmType, dict[str, str]] - jwks_client: jwt.PyJWKClient | None + jwks_client: dict[schemas.RealmType, jwt.PyJWKClient] | None @contextlib.asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[State]: oidc_config = {} - jwks_client = None + jwks_client = {} if OIDC_ENABLED: async with httpx.AsyncClient() as client: for realm in schemas.RealmType: - url = f"{OIDC_BASE_URL}/realms/{login.discover_realm(realm)}/.well-known/openid-configuration" + url = f"{OIDC_BASE_URL}/realms/{login.discover_realm(realm)}" r = await client.get(url) oidc_config[realm] = r.json() - jwks_client = jwt.PyJWKClient(oidc_config[realm]["jwks_uri"]) + jwks_client[realm] = jwt.PyJWKClient(oidc_config[realm]["jwks_uri"]) yield {"oidc_config": oidc_config, "jwks_client": jwks_client} diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py index 03ff07c..5b9104a 100644 --- a/tests/api/test_login_realm_discovery.py +++ b/tests/api/test_login_realm_discovery.py @@ -35,8 +35,8 @@ def test_realm_discovery_endpoint_oidc_disabled(): client = TestClient(app) response = client.get("/api/v1/realm-discovery/?type=test") - assert response.status_code == 405 - assert response.json() == {"detail": "OIDC is not enabled"} + assert response.status_code == 422 + assert response.json()["detail"][0]["ctx"] == {"expected": "'real' or 'demo'"} @pytest.mark.parametrize( @@ -65,7 +65,7 @@ def test_realm_discovery_endpoint_success(realm_type, realm_mapping, expected_re assert data["realm"] == expected_realm assert ( data["discovery_uri"] - == f"https://keycloak.test.com/auth/realms/{expected_realm}/.well-known/openid-configuration" + == f"https://keycloak.test.com/auth/realms/{expected_realm}" ) From 5b9474a32b1c763aff9cab2c079b63b9d73a39b7 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Tue, 9 Dec 2025 17:54:35 +0100 Subject: [PATCH 62/78] Update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e672660..b94db82 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Returns: **Response Fields:** - `realm`: The discovered Keycloak realm name - `discovery_uri`: The OIDC URI for the discovery endpoint -- `type`: Realm type (`real`, `demo`, `unknown`) +- `type`: Realm type (`real`, `demo`) **OIDC Authentication:** ```http From 21b88cf65f4189f3c2f6374da8cf64d9e3a3e930 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Tue, 9 Dec 2025 18:28:42 +0100 Subject: [PATCH 63/78] Issue on realm .well-known availability should not break the service startup --- app/main.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 89fe663..95a34f9 100644 --- a/app/main.py +++ b/app/main.py @@ -45,10 +45,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: if OIDC_ENABLED: async with httpx.AsyncClient() as client: for realm in schemas.RealmType: - url = f"{OIDC_BASE_URL}/realms/{login.discover_realm(realm)}" + url = f"{OIDC_BASE_URL}/realms/{login.discover_realm(realm)}/.well-known/openid-configuration" r = await client.get(url) - oidc_config[realm] = r.json() - jwks_client[realm] = jwt.PyJWKClient(oidc_config[realm]["jwks_uri"]) + try: + if r.status_code == 200: + oidc_config[realm] = r.json() + jwks_uri = oidc_config[realm]["jwks_uri"] + jwks_client[realm] = jwt.PyJWKClient(jwks_uri) + except Exception: + raise Exception(f"{url}: {r.status_code}: {r.json()}") yield {"oidc_config": oidc_config, "jwks_client": jwks_client} From b3271ff29143cf1074b424398204b262ace871a7 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Wed, 10 Dec 2025 16:15:40 +0100 Subject: [PATCH 64/78] make demo username configurable --- app/api/services.py | 3 ++- app/auth.py | 4 ++-- app/main.py | 11 ++++------- app/settings.py | 1 + 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/api/services.py b/app/api/services.py index f85fec4..ee2e1c5 100644 --- a/app/api/services.py +++ b/app/api/services.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from typing import List, Optional from .. import deps, crud, models, schemas, utils +from ..settings import DEMO_ACCOUNT_USERNAME router = APIRouter() @@ -22,7 +23,7 @@ def read_services( current_user: models.User = Depends(deps.get_current_user), ): """Read all services""" - if current_user.username == "demo": + if current_user.username == DEMO_ACCOUNT_USERNAME: db_services = crud.get_services(db, demo=True) else: db_services = crud.get_services(db) diff --git a/app/auth.py b/app/auth.py index 8363812..ba7ee95 100644 --- a/app/auth.py +++ b/app/auth.py @@ -12,12 +12,12 @@ LDAP_BASE_DN, LDAP_USER_DN, ) -from .settings import DEMO_ACCOUNT_PASSWORD +from .settings import DEMO_ACCOUNT_PASSWORD, DEMO_ACCOUNT_USERNAME def authenticate_user(username: str, password: str) -> bool: """Return True if the authentication is successful, False otherwise""" - if username == "demo" and password == str(DEMO_ACCOUNT_PASSWORD): + if username == DEMO_ACCOUNT_USERNAME and password == str(DEMO_ACCOUNT_PASSWORD): return True if AUTHENTICATION_METHOD == "ldap": return ldap_authenticate_user(username, password) diff --git a/app/main.py b/app/main.py index 95a34f9..7507c8a 100644 --- a/app/main.py +++ b/app/main.py @@ -47,13 +47,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: for realm in schemas.RealmType: url = f"{OIDC_BASE_URL}/realms/{login.discover_realm(realm)}/.well-known/openid-configuration" r = await client.get(url) - try: - if r.status_code == 200: - oidc_config[realm] = r.json() - jwks_uri = oidc_config[realm]["jwks_uri"] - jwks_client[realm] = jwt.PyJWKClient(jwks_uri) - except Exception: - raise Exception(f"{url}: {r.status_code}: {r.json()}") + if r.status_code == 200: + oidc_config[realm] = r.json() + jwks_uri = oidc_config[realm]["jwks_uri"] + jwks_client[realm] = jwt.PyJWKClient(jwks_uri) yield {"oidc_config": oidc_config, "jwks_client": jwks_client} diff --git a/app/settings.py b/app/settings.py index ca440c4..19e062a 100644 --- a/app/settings.py +++ b/app/settings.py @@ -55,6 +55,7 @@ ADMIN_USERS = config("ADMIN_USERS", cast=CommaSeparatedStrings, default="") # Demo account with "demo" username has access only to service defined in DEMO_ACCOUNT_SERVICE DEMO_ACCOUNT_SERVICE = config("DEMO_ACCOUNT_SERVICE", cast=str, default="demo") +DEMO_ACCOUNT_USERNAME = config("DEMO_ACCOUNT_USERNAME", cast=str, default="demo") DEMO_ACCOUNT_PASSWORD = config("DEMO_ACCOUNT_PASSWORD", cast=Secret, default="demo") SQLALCHEMY_DATABASE_URL = config( "SQLALCHEMY_DATABASE_URL", cast=str, default="sqlite:///./sql_app.db" From 3da40d4d7a7e084680f7da22253cf389ae4248cc Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Wed, 10 Dec 2025 16:49:21 +0100 Subject: [PATCH 65/78] simplify realm mapping --- app/api/login.py | 29 +---------------------------- app/deps.py | 8 ++++++++ app/main.py | 8 +++++--- app/settings.py | 4 +--- 4 files changed, 15 insertions(+), 34 deletions(-) diff --git a/app/api/login.py b/app/api/login.py index 72cd26d..f008f08 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -11,7 +11,6 @@ OIDC_SCOPE, OIDC_ENABLED, OIDC_BASE_URL, - OIDC_REALM_MAPPING, OIDC_DEFAULT_REALM, ) @@ -163,7 +162,7 @@ def get_realm( # Discover realm try: - realm = discover_realm(realm_type) + realm = deps.REALM_BY_TYPE.get(realm_type, OIDC_DEFAULT_REALM) except Exception as e: logger.error("Failed to discover realm of type %s: %s", realm_type, e) raise HTTPException( @@ -177,29 +176,3 @@ def get_realm( ) return response - - -def discover_realm(realm_type: schemas.RealmType) -> str: - """ - Discover the appropriate Keycloak realm for a given type. - - Args: - realm_type: The realm type to check for realm mapping - - Returns: - The realm name to use for this realm type - """ - - # Check realm mapping configuration - for mapping in OIDC_REALM_MAPPING: - if ":" in mapping: - pattern, realm = mapping.split(":", 1) - pattern = pattern.strip().lower() - realm = realm.strip() - - # Check if pattern matches realm_type - if pattern == realm_type.lower().strip(): - return realm - - # Default realm fallback - return OIDC_DEFAULT_REALM diff --git a/app/deps.py b/app/deps.py index 0067f4b..2ec44ea 100644 --- a/app/deps.py +++ b/app/deps.py @@ -14,7 +14,15 @@ OIDC_CLIENT_SECRET, OIDC_SCOPE, OIDC_DEFAULT_REALM, + OIDC_DEMO_REALM, ) +from . import schemas + + +REALM_BY_TYPE = { + schemas.RealmType.demo: OIDC_DEMO_REALM, + schemas.RealmType.real: OIDC_DEFAULT_REALM, +} oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") oauth = OAuth() diff --git a/app/main.py b/app/main.py index 7507c8a..c5db204 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware import Middleware from starlette.middleware.sessions import SessionMiddleware -from . import monitoring, schemas +from . import monitoring, schemas, deps from .api import login, users, services from .views import exceptions, account, notifications, settings, docs from .settings import ( @@ -22,6 +22,7 @@ SESSION_MAX_AGE, OIDC_BASE_URL, OIDC_ENABLED, + OIDC_DEFAULT_REALM, ) @@ -44,8 +45,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: jwks_client = {} if OIDC_ENABLED: async with httpx.AsyncClient() as client: - for realm in schemas.RealmType: - url = f"{OIDC_BASE_URL}/realms/{login.discover_realm(realm)}/.well-known/openid-configuration" + for realm_type in schemas.RealmType: + realm = deps.REALM_BY_TYPE.get(realm_type, OIDC_DEFAULT_REALM) + url = f"{OIDC_BASE_URL}/realms/{realm}/.well-known/openid-configuration" r = await client.get(url) if r.status_code == 200: oidc_config[realm] = r.json() diff --git a/app/settings.py b/app/settings.py index 19e062a..f274257 100644 --- a/app/settings.py +++ b/app/settings.py @@ -44,9 +44,7 @@ OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") # Realm mapping: comma-separated "pattern:realm" pairs -OIDC_REALM_MAPPING = config( - "OIDC_REALM_MAPPING", cast=CommaSeparatedStrings, default="" -) +OIDC_DEMO_REALM = config("OIDC_DEMO_REALM", cast=str, default="demo") # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( From 08d6b4d57b5fdf6cb4389252b959213d2c690a3d Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Wed, 10 Dec 2025 17:05:33 +0100 Subject: [PATCH 66/78] Provide authorization and token enpoints and client id --- app/api/login.py | 13 ++-- app/deps.py | 12 ++++ app/schemas.py | 4 +- app/settings.py | 5 +- tests/api/test_login_realm_discovery.py | 85 ++++++++++++++++++++----- 5 files changed, 94 insertions(+), 25 deletions(-) diff --git a/app/api/login.py b/app/api/login.py index f008f08..9002856 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -56,9 +56,10 @@ async def open_id_connect( ): """Login using OpenID Connect Authentication Code flow from mobile client""" oidc_config = request.state.oidc_config[realm] + jwks_client = request.state.jwks_client[realm] data = { "client_id": oidc_auth.client_id, - "client_secret": OIDC_CLIENT_SECRET, + "client_secret": deps.CLIENT_BY_REALM[realm]["client_secret"], "code": oidc_auth.code, "code_verifier": oidc_auth.code_verifier, "grant_type": "authorization_code", @@ -96,10 +97,8 @@ async def open_id_connect( utils.validate_id_token( id_token, access_token, - request.state.jwks_client[realm], - request.state.oidc_config[realm][ - "id_token_signing_alg_values_supported" - ], + jwks_client, + oidc_config["id_token_signing_alg_values_supported"], oidc_auth.client_id, ) except Exception as e: @@ -171,7 +170,9 @@ def get_realm( response = schemas.RealmDiscoveryResponse( realm=realm, - discovery_uri=f"{OIDC_BASE_URL}/realms/{realm}", + authorization_endpoint=f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/auth", + token_endpoint=f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token", + client_id=deps.CLIENT_BY_REALM[realm]["client_id"], type=realm_type, ) diff --git a/app/deps.py b/app/deps.py index 2ec44ea..82ac958 100644 --- a/app/deps.py +++ b/app/deps.py @@ -15,6 +15,8 @@ OIDC_SCOPE, OIDC_DEFAULT_REALM, OIDC_DEMO_REALM, + OIDC_DEMO_CLIENT_ID, + OIDC_DEMO_CLIENT_SECRET, ) from . import schemas @@ -23,6 +25,16 @@ schemas.RealmType.demo: OIDC_DEMO_REALM, schemas.RealmType.real: OIDC_DEFAULT_REALM, } +CLIENT_BY_REALM = { + OIDC_DEMO_REALM: { + "client_id": OIDC_DEMO_CLIENT_ID, + "client_secret": OIDC_DEMO_CLIENT_SECRET, + }, + OIDC_DEFAULT_REALM: { + "client_id": OIDC_CLIENT_ID, + "client_secret": OIDC_CLIENT_SECRET, + }, +} oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") oauth = OAuth() diff --git a/app/schemas.py b/app/schemas.py index 9a3bcaf..9b962bc 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -180,5 +180,7 @@ class RealmType(str, Enum): class RealmDiscoveryResponse(BaseModel): realm: str - discovery_uri: str + authorization_endpoint: str + token_endpoint: str + client_id: str type: RealmType diff --git a/app/settings.py b/app/settings.py index f274257..e0ad8bb 100644 --- a/app/settings.py +++ b/app/settings.py @@ -45,7 +45,10 @@ OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") # Realm mapping: comma-separated "pattern:realm" pairs OIDC_DEMO_REALM = config("OIDC_DEMO_REALM", cast=str, default="demo") - +OIDC_DEMO_CLIENT_ID = config("OIDC_DEMO_CLIENT_ID", cast=str, default="notify") +OIDC_DEMO_CLIENT_SECRET = config( + "OIDC_DEMO_CLIENT_SECRET", cast=Secret, default="!secret" +) # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( "AUTHENTICATION_URL", cast=str, default="https//auth.example.org/login" diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py index 5b9104a..9a45028 100644 --- a/tests/api/test_login_realm_discovery.py +++ b/tests/api/test_login_realm_discovery.py @@ -13,20 +13,26 @@ def reset_realm_discovery_state(): """Reset realm discovery module state between tests.""" import app.api.login as login_api + import app.deps as deps # Store original values - # Store original values - original_mapping = login_api.OIDC_REALM_MAPPING original_default = login_api.OIDC_DEFAULT_REALM original_base_url = login_api.OIDC_BASE_URL original_oidc_enabled = login_api.OIDC_ENABLED + original_demo_realm = deps.OIDC_DEMO_REALM + # store mappings built at import time so tests can mutate them safely + original_realm_by_type = dict(deps.REALM_BY_TYPE) + original_client_by_realm = dict(deps.CLIENT_BY_REALM) yield # Run the test # Restore original values after test - login_api.OIDC_REALM_MAPPING = original_mapping login_api.OIDC_DEFAULT_REALM = original_default login_api.OIDC_BASE_URL = original_base_url login_api.OIDC_ENABLED = original_oidc_enabled + deps.OIDC_DEMO_REALM = original_demo_realm + deps.OIDC_DEFAULT_REALM = original_default + deps.REALM_BY_TYPE = original_realm_by_type + deps.CLIENT_BY_REALM = original_client_by_realm @patch("app.api.login.OIDC_ENABLED", False) @@ -40,22 +46,37 @@ def test_realm_discovery_endpoint_oidc_disabled(): @pytest.mark.parametrize( - "realm_type,realm_mapping,expected_realm", + "realm_type,demo_realm,expected_realm", [ - ("demo", ["demo:demo-realm"], "demo-realm"), - ("real", ["demo:demo-realm"], "default"), - ("demo", [], "default"), + ("demo", "demo-realm", "demo-realm"), + ("real", "demo", "default"), + ("demo", "", "default"), ], ) -def test_realm_discovery_endpoint_success(realm_type, realm_mapping, expected_realm): +def test_realm_discovery_endpoint_success(realm_type, demo_realm, expected_realm): """Test realm discovery with various pattern matching (domain, prefix, exact).""" import app.api.login as login_api + import app.deps as deps # Set up test configuration directly on modules login_api.OIDC_ENABLED = True login_api.OIDC_BASE_URL = "https://keycloak.test.com/auth" login_api.OIDC_DEFAULT_REALM = "default" - login_api.OIDC_REALM_MAPPING = realm_mapping + deps.OIDC_DEFAULT_REALM = "default" + deps.OIDC_DEMO_REALM = demo_realm + # make realm mapping deterministic for the test + deps.REALM_BY_TYPE = { + RealmType.demo: demo_realm if demo_realm else deps.OIDC_DEFAULT_REALM, + RealmType.real: login_api.OIDC_DEFAULT_REALM, + } + # set predictable client ids for assertion + deps.CLIENT_BY_REALM = { + demo_realm: {"client_id": "demo-client"}, + deps.OIDC_DEFAULT_REALM: {"client_id": "real-client"}, + } + expected_client_id = deps.CLIENT_BY_REALM[ + deps.REALM_BY_TYPE[RealmType(realm_type)] + ]["client_id"] client = TestClient(app) response = client.get(f"/api/v1/realm-discovery/?type={realm_type}") @@ -63,10 +84,16 @@ def test_realm_discovery_endpoint_success(realm_type, realm_mapping, expected_re data = response.json() assert data["type"] == realm_type assert data["realm"] == expected_realm + # Response must expose the OIDC endpoints for the discovered realm assert ( - data["discovery_uri"] - == f"https://keycloak.test.com/auth/realms/{expected_realm}" + data["authorization_endpoint"] + == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/auth" ) + assert ( + data["token_endpoint"] + == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/token" + ) + assert data["client_id"] == expected_client_id @pytest.mark.parametrize( @@ -75,10 +102,18 @@ def test_realm_discovery_endpoint_success(realm_type, realm_mapping, expected_re ) @patch("app.api.login.OIDC_ENABLED", True) @patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") -@patch("app.api.login.OIDC_REALM_MAPPING", ["demo:demo-realm", "real:admin-realm"]) +@patch("app.deps.OIDC_DEMO_REALM", "demo") @patch("app.api.login.OIDC_DEFAULT_REALM", "default") def test_realm_discovery_multiple_mappings(realm_type, expected_realm): """Test realm discovery with multiple mapping rules.""" + import app.deps as deps + + # create explicit mapping for this test + deps.REALM_BY_TYPE = {RealmType.demo: "demo-realm", RealmType.real: "admin-realm"} + deps.CLIENT_BY_REALM = { + "demo-realm": {"client_id": "demo-client"}, + "admin-realm": {"client_id": "real-client"}, + } client = TestClient(app) response = client.get(f"/api/v1/realm-discovery/?type={realm_type}") @@ -89,7 +124,7 @@ def test_realm_discovery_multiple_mappings(realm_type, expected_realm): @patch("app.api.login.OIDC_ENABLED", True) @patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") -@patch("app.api.login.OIDC_REALM_MAPPING", []) +@patch("app.deps.OIDC_DEMO_REALM", "") @patch("app.api.login.OIDC_DEFAULT_REALM", "master") def test_realm_discovery_response_structure(): """Test that response contains all required fields with correct types.""" @@ -102,7 +137,9 @@ def test_realm_discovery_response_structure(): # Check required fields exist required_fields = [ "realm", - "discovery_uri", + "authorization_endpoint", + "token_endpoint", + "client_id", "type", ] for field in required_fields: @@ -111,14 +148,16 @@ def test_realm_discovery_response_structure(): # Check field types string_fields = [ "realm", - "discovery_uri", + "authorization_endpoint", + "token_endpoint", + "client_id", "type", ] for field in string_fields: assert isinstance(data[field], str), f"Field {field} should be string" # Check URLs are properly formatted - url_fields = ["discovery_uri"] + url_fields = ["authorization_endpoint", "token_endpoint"] for field in url_fields: assert data[field].startswith("https://"), ( f"Field {field} should start with https://" @@ -137,10 +176,22 @@ def test_realm_discovery_response_structure(): ) @patch("app.api.login.OIDC_ENABLED", True) @patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") -@patch("app.api.login.OIDC_REALM_MAPPING", []) +@patch("app.deps.OIDC_DEMO_REALM", "") @patch("app.api.login.OIDC_DEFAULT_REALM", "master") def test_realm_discovery_response_field_values(field_name, field_value): """Test that response fields contain expected values.""" + import app.deps as deps + import app.api.login as login_api + + # Ensure mapping returns master for real + deps.REALM_BY_TYPE = { + RealmType.real: "master", + RealmType.demo: login_api.OIDC_DEFAULT_REALM, + } + deps.CLIENT_BY_REALM = { + login_api.OIDC_DEFAULT_REALM: {"client_id": "demo-client"}, + "master": {"client_id": "real-client"}, + } client = TestClient(app) response = client.get("/api/v1/realm-discovery/?type=real") From b7625facb9afc41ec312c5f7ecc1dfd95ef5e997 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Thu, 11 Dec 2025 08:59:19 +0100 Subject: [PATCH 67/78] Update Readme [skip ci] --- README.md | 96 ++++++++++++++++++++++++++----------------------------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index b94db82..f3f396c 100644 --- a/README.md +++ b/README.md @@ -39,85 +39,81 @@ To be able to login, at least the following variables shall be overwritten: ### OpenID Connect with Automatic Realm Discovery for Mobile Apps -The application supports automatic Keycloak realm discovery for mobile applications while maintaining standard web authentication. +The implementation provides two OIDC-related endpoints used by mobile clients: -**Web Interface**: Uses standard OIDC authentication with a single configured realm. -**Mobile Apps**: Can discover and authenticate against different realms depending on type. +- GET /api/v1/realm-discovery/?type= — discover which realm and + client to use for a given app "type". +- POST /api/v1/open_id_connect?realm= — exchange an OIDC authorization + code (server performs token/userinfo calls, validates id_token and issues a + local access token). -#### Configuration +Exact environment variables used by the implementation -```bash -# Enable OIDC authentication -OIDC_ENABLED=true - -# Standard web authentication (single realm) -OIDC_SERVER_URL=https://keycloak.maxiv.lu.se/auth/realms/main/maxiv -OIDC_CLIENT_ID=notify -OIDC_CLIENT_SECRET=your-client-secret - -# Mobile app realm discovery configuration -OIDC_BASE_URL=https://keycloak.maxiv.lu.se/auth -OIDC_DEFAULT_REALM=maxiv -OIDC_REALM_MAPPING="demo:demo-realm" -``` - -#### How It Works +- `OIDC_ENABLED` (bool) — enable OIDC features +- `OIDC_BASE_URL` (string) — base Keycloak URL (e.g. https://keycloak.example.org/auth) +- `OIDC_DEFAULT_REALM` (string) — default realm used when no mapping applies +- `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` — default client credentials +- `OIDC_SCOPE` — scope used when requesting userinfo +- `OIDC_DEMO_REALM`, `OIDC_DEMO_CLIENT_ID`, `OIDC_DEMO_CLIENT_SECRET` — demo realm/client values -**For Web Users:** -- Standard OIDC authentication flow -- Single configured realm via `OIDC_SERVER_URL` -- Traditional login redirect to Keycloak +Realm discovery (mobile client) -**For Mobile Apps:** -1. App calls `/api/v1/realm-discovery/type?=` to get realm information -2. App receives realm-specific OIDC discovery URI -3. App initiates OIDC flow with the appropriate realm -4. App exchanges authorization code for tokens using `/api/v1/open_id_connect` - -#### API Endpoints for Mobile Apps - -**Realm Discovery:** +Request ```http -GET /api/v1/realm-discovery/{type} +GET /api/v1/realm-discovery/?type=real ``` -Returns: +Response (implemented schema) ```json { - "realm": "realm", - "discovery_uri": "https://keycloak.example.org/auth/realms/company-realm/.well-known/openid-configuratio", + "realm": "company-realm", + "authorization_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/auth", + "token_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/token", + "client_id": "notify", "type": "real" } ``` -**Response Fields:** -- `realm`: The discovered Keycloak realm name -- `discovery_uri`: The OIDC URI for the discovery endpoint -- `type`: Realm type (`real`, `demo`) +Notes +- The endpoint expects the query parameter `type` (alias for the internal + `RealmType` enum). The implementation maps types to realms using + `deps.REALM_BY_TYPE` and exposes client IDs from `deps.CLIENT_BY_REALM`. +- The discovery response provides `authorization_endpoint` and `token_endpoint` + (not a single discovery URI), and the `client_id` the mobile app should + include in the initial authorization request. -**OIDC Authentication:** +OpenID Connect token exchange (mobile -> server) + +After the mobile app completes the OIDC authorization code flow (using the +`client_id` provided and PKCE), the app should POST the authorization code to +the server which will perform the token/userinfo exchange and create a local +access token for the app. + +Request ```http -POST /api/v1/open_id_connect -``` +POST /api/v1/open_id_connect?realm=company-realm +Content-Type: application/json -Body: -```json { "code": "authorization_code", - "code_verifier": "pkce_verifier", + "code_verifier": "pkce_verifier", "client_id": "notify", "redirect_uri": "app://callback" } ``` -Refer to the default values defined in the [Ansible role](https://gitlab.esss.lu.se/ics-ansible-galaxy/ics-ans-role-ess-notify-server/-/blob/master/defaults/main.yml) -and in the [ess_notify_servers](https://csentry.esss.lu.se/network/groups/view/ess_notify_servers) group in CSEntry. +Behavior +- The server posts the code to the realm's token endpoint and validates the + returned id_token (using the realm's JWKS). +- Client secrets for token exchanges are looked up server-side from + `deps.CLIENT_BY_REALM`; mobile apps MUST NOT embed client secrets. + ## Development ### Virtual environment -Python >= 3.6 is required. +Python >= 3.11 is required. Create a virtual environment and install the requirements: ```bash From 9ee48efd30fee5c75fbc5c9a07e2c5d46300d65f Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Fri, 12 Dec 2025 10:30:17 +0100 Subject: [PATCH 68/78] improve defaulting and client by realm type references --- README.md | 4 +- app/api/login.py | 20 ++-- app/deps.py | 6 +- app/settings.py | 4 - tests/api/test_login_realm_discovery.py | 132 ++++++++++++++++++------ 5 files changed, 112 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index f3f396c..87c1cb9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Exact environment variables used by the implementation - `OIDC_ENABLED` (bool) — enable OIDC features - `OIDC_BASE_URL` (string) — base Keycloak URL (e.g. https://keycloak.example.org/auth) -- `OIDC_DEFAULT_REALM` (string) — default realm used when no mapping applies +- `OIDC_DEFAULT_REALM` (string) — default realm used - `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` — default client credentials - `OIDC_SCOPE` — scope used when requesting userinfo - `OIDC_DEMO_REALM`, `OIDC_DEMO_CLIENT_ID`, `OIDC_DEMO_CLIENT_SECRET` — demo realm/client values @@ -106,7 +106,7 @@ Behavior - The server posts the code to the realm's token endpoint and validates the returned id_token (using the realm's JWKS). - Client secrets for token exchanges are looked up server-side from - `deps.CLIENT_BY_REALM`; mobile apps MUST NOT embed client secrets. + `deps.CLIENT_BY_REALM_TYPE`; mobile apps MUST NOT embed client secrets. ## Development diff --git a/app/api/login.py b/app/api/login.py index 9002856..ec0f2ce 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -10,8 +10,6 @@ OIDC_CLIENT_SECRET, OIDC_SCOPE, OIDC_ENABLED, - OIDC_BASE_URL, - OIDC_DEFAULT_REALM, ) router = APIRouter() @@ -59,7 +57,7 @@ async def open_id_connect( jwks_client = request.state.jwks_client[realm] data = { "client_id": oidc_auth.client_id, - "client_secret": deps.CLIENT_BY_REALM[realm]["client_secret"], + "client_secret": deps.CLIENT_BY_REALM_TYPE[realm]["client_secret"], "code": oidc_auth.code, "code_verifier": oidc_auth.code_verifier, "grant_type": "authorization_code", @@ -144,6 +142,7 @@ async def open_id_connect( response_model=schemas.RealmDiscoveryResponse, ) def get_realm( + request: Request, realm_type: schemas.RealmType = Query(..., alias="type"), ) -> schemas.RealmDiscoveryResponse: """ @@ -160,19 +159,14 @@ def get_realm( ) # Discover realm - try: - realm = deps.REALM_BY_TYPE.get(realm_type, OIDC_DEFAULT_REALM) - except Exception as e: - logger.error("Failed to discover realm of type %s: %s", realm_type, e) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, detail="Realm discovery failed" - ) + realm = deps.REALM_BY_TYPE[realm_type] + oidc_config = request.state.oidc_config[realm_type] response = schemas.RealmDiscoveryResponse( realm=realm, - authorization_endpoint=f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/auth", - token_endpoint=f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token", - client_id=deps.CLIENT_BY_REALM[realm]["client_id"], + authorization_endpoint=oidc_config["authorization_endpoint"], + token_endpoint=oidc_config["token_endpoint"], + client_id=deps.CLIENT_BY_REALM_TYPE[realm_type]["client_id"], type=realm_type, ) diff --git a/app/deps.py b/app/deps.py index 82ac958..2adc899 100644 --- a/app/deps.py +++ b/app/deps.py @@ -25,12 +25,12 @@ schemas.RealmType.demo: OIDC_DEMO_REALM, schemas.RealmType.real: OIDC_DEFAULT_REALM, } -CLIENT_BY_REALM = { - OIDC_DEMO_REALM: { +CLIENT_BY_REALM_TYPE = { + schemas.RealmType.demo: { "client_id": OIDC_DEMO_CLIENT_ID, "client_secret": OIDC_DEMO_CLIENT_SECRET, }, - OIDC_DEFAULT_REALM: { + schemas.RealmType.real: { "client_id": OIDC_CLIENT_ID, "client_secret": OIDC_CLIENT_SECRET, }, diff --git a/app/settings.py b/app/settings.py index e0ad8bb..619d1ab 100644 --- a/app/settings.py +++ b/app/settings.py @@ -31,19 +31,15 @@ # - API login (old authentication method still supported as well) OIDC_ENABLED = config("OIDC_ENABLED", cast=bool, default=False) OIDC_NAME = config("OIDC_NAME", cast=str, default="keycloak") -# Base Keycloak server URL (without realm path) OIDC_BASE_URL = config( "OIDC_BASE_URL", cast=str, default="https://keycloak.example.org/auth", ) -# Default realm when no specific mapping matches OIDC_DEFAULT_REALM = config("OIDC_DEFAULT_REALM", cast=str, default="maxiv") -# Legacy single realm URL (for backward compatibility) OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", cast=str, default="notify") OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") -# Realm mapping: comma-separated "pattern:realm" pairs OIDC_DEMO_REALM = config("OIDC_DEMO_REALM", cast=str, default="demo") OIDC_DEMO_CLIENT_ID = config("OIDC_DEMO_CLIENT_ID", cast=str, default="notify") OIDC_DEMO_CLIENT_SECRET = config( diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py index 9a45028..3817482 100644 --- a/tests/api/test_login_realm_discovery.py +++ b/tests/api/test_login_realm_discovery.py @@ -9,35 +9,108 @@ from app.schemas import RealmType +# Test-only middleware: inject request.state.oidc_config and jwks_client so +# handlers that expect those attributes can run without needing the full +# application lifespan to execute. +from starlette.middleware.base import BaseHTTPMiddleware + + +class _InjectStateMiddleware(BaseHTTPMiddleware): + def __init__(self, app): + super().__init__(app) + + async def dispatch(self, request, call_next): + # Build a small, predictable oidc_config mapping based on the current + # deps.REALM_BY_TYPE. Tests mutate that mapping, so build it at request + # time to reflect test changes. + from app import deps + from app.settings import OIDC_BASE_URL + + oidc = {} + jwks = {} + # deps.REALM_BY_TYPE maps RealmType -> realm string. Build entries for + # both the RealmType key and the realm string so handlers can look up + # by either. + for realm_type, realm in deps.REALM_BY_TYPE.items(): + cfg = { + "authorization_endpoint": f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/auth", + "token_endpoint": f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token", + "userinfo_endpoint": f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/userinfo", + "id_token_signing_alg_values_supported": ["RS256"], + } + oidc[realm_type] = cfg + jwks[realm_type] = None + + request.state.oidc_config = oidc + request.state.jwks_client = jwks + return await call_next(request) + + +@pytest.fixture(scope="module", autouse=True) +def _inject_state_middleware(): + """Add middleware to the app used by these tests. Kept module-scoped so + middleware is added once for the test module.""" + # Import here to avoid circular imports at module import time + from app.main import app as _original_app + from fastapi import FastAPI + + # Create a fresh wrapper app and add the middleware to it before the + # application is started. Mount the original app under the wrapper so the + # wrapper's middleware runs first and injects the request.state values. + wrapper = FastAPI(docs_url=None, redoc_url=None) + wrapper.add_middleware(_InjectStateMiddleware) + wrapper.mount("/", _original_app) + + # Replace the module-level `app` so tests that do `TestClient(app)` get + # the wrapped app with our middleware. + globals()["app"] = wrapper + yield + + @pytest.fixture(autouse=True) def reset_realm_discovery_state(): """Reset realm discovery module state between tests.""" import app.api.login as login_api import app.deps as deps # Store original values - original_default = login_api.OIDC_DEFAULT_REALM - original_base_url = login_api.OIDC_BASE_URL original_oidc_enabled = login_api.OIDC_ENABLED + original_default = deps.OIDC_DEFAULT_REALM + original_base_url = deps.OIDC_BASE_URL original_demo_realm = deps.OIDC_DEMO_REALM # store mappings built at import time so tests can mutate them safely original_realm_by_type = dict(deps.REALM_BY_TYPE) - original_client_by_realm = dict(deps.CLIENT_BY_REALM) + original_client_by_realm_type = dict(deps.CLIENT_BY_REALM_TYPE) yield # Run the test # Restore original values after test - login_api.OIDC_DEFAULT_REALM = original_default - login_api.OIDC_BASE_URL = original_base_url login_api.OIDC_ENABLED = original_oidc_enabled + deps.OIDC_DEFAULT_REALM = original_default + deps.OIDC_BASE_URL = original_base_url deps.OIDC_DEMO_REALM = original_demo_realm deps.OIDC_DEFAULT_REALM = original_default deps.REALM_BY_TYPE = original_realm_by_type - deps.CLIENT_BY_REALM = original_client_by_realm + deps.CLIENT_BY_REALM_TYPE = original_client_by_realm_type @patch("app.api.login.OIDC_ENABLED", False) def test_realm_discovery_endpoint_oidc_disabled(): """Test realm discovery endpoint when OIDC is disabled.""" + import app.api.login as login_api + + # Set up test configuration directly on modules + login_api.OIDC_ENABLED = False + + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?type=real") + + assert response.status_code == 405 + assert response.json()["detail"] == "OIDC is not enabled" + + +@patch("app.api.login.OIDC_ENABLED", False) +def test_invalid_realm_type(): + """Test realm discovery endpoint when realm type is invalid.""" client = TestClient(app) response = client.get("/api/v1/realm-discovery/?type=test") @@ -48,19 +121,19 @@ def test_realm_discovery_endpoint_oidc_disabled(): @pytest.mark.parametrize( "realm_type,demo_realm,expected_realm", [ - ("demo", "demo-realm", "demo-realm"), - ("real", "demo", "default"), - ("demo", "", "default"), + (RealmType.demo, "demo-realm", "demo-realm"), + (RealmType.real, "demo", "default"), + (RealmType.demo, "", "default"), ], ) def test_realm_discovery_endpoint_success(realm_type, demo_realm, expected_realm): """Test realm discovery with various pattern matching (domain, prefix, exact).""" import app.api.login as login_api import app.deps as deps + from app.settings import OIDC_BASE_URL # Set up test configuration directly on modules login_api.OIDC_ENABLED = True - login_api.OIDC_BASE_URL = "https://keycloak.test.com/auth" login_api.OIDC_DEFAULT_REALM = "default" deps.OIDC_DEFAULT_REALM = "default" deps.OIDC_DEMO_REALM = demo_realm @@ -70,38 +143,35 @@ def test_realm_discovery_endpoint_success(realm_type, demo_realm, expected_realm RealmType.real: login_api.OIDC_DEFAULT_REALM, } # set predictable client ids for assertion - deps.CLIENT_BY_REALM = { - demo_realm: {"client_id": "demo-client"}, - deps.OIDC_DEFAULT_REALM: {"client_id": "real-client"}, + deps.CLIENT_BY_REALM_TYPE = { + RealmType.demo: {"client_id": "demo-client"}, + RealmType.real: {"client_id": "real-client"}, } - expected_client_id = deps.CLIENT_BY_REALM[ - deps.REALM_BY_TYPE[RealmType(realm_type)] - ]["client_id"] + expected_client_id = deps.CLIENT_BY_REALM_TYPE[realm_type]["client_id"] client = TestClient(app) - response = client.get(f"/api/v1/realm-discovery/?type={realm_type}") + response = client.get(f"/api/v1/realm-discovery/?type={realm_type.value}") assert response.status_code == 200 data = response.json() - assert data["type"] == realm_type + assert data["type"] == realm_type.value assert data["realm"] == expected_realm # Response must expose the OIDC endpoints for the discovered realm assert ( data["authorization_endpoint"] - == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/auth" + == f"{OIDC_BASE_URL}/realms/{expected_realm}/protocol/openid-connect/auth" ) assert ( data["token_endpoint"] - == f"https://keycloak.test.com/auth/realms/{expected_realm}/protocol/openid-connect/token" + == f"{OIDC_BASE_URL}/realms/{expected_realm}/protocol/openid-connect/token" ) assert data["client_id"] == expected_client_id @pytest.mark.parametrize( "realm_type,expected_realm", - [("demo", "demo-realm"), ("real", "admin-realm")], + [(RealmType.demo, "demo-realm"), (RealmType.real, "admin-realm")], ) @patch("app.api.login.OIDC_ENABLED", True) -@patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") @patch("app.deps.OIDC_DEMO_REALM", "demo") @patch("app.api.login.OIDC_DEFAULT_REALM", "default") def test_realm_discovery_multiple_mappings(realm_type, expected_realm): @@ -110,12 +180,12 @@ def test_realm_discovery_multiple_mappings(realm_type, expected_realm): # create explicit mapping for this test deps.REALM_BY_TYPE = {RealmType.demo: "demo-realm", RealmType.real: "admin-realm"} - deps.CLIENT_BY_REALM = { - "demo-realm": {"client_id": "demo-client"}, - "admin-realm": {"client_id": "real-client"}, + deps.CLIENT_BY_REALM_TYPE = { + RealmType.demo: {"client_id": "demo-client"}, + RealmType.real: {"client_id": "real-client"}, } client = TestClient(app) - response = client.get(f"/api/v1/realm-discovery/?type={realm_type}") + response = client.get(f"/api/v1/realm-discovery/?type={realm_type.value}") assert response.status_code == 200 data = response.json() @@ -123,11 +193,11 @@ def test_realm_discovery_multiple_mappings(realm_type, expected_realm): @patch("app.api.login.OIDC_ENABLED", True) -@patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") @patch("app.deps.OIDC_DEMO_REALM", "") @patch("app.api.login.OIDC_DEFAULT_REALM", "master") def test_realm_discovery_response_structure(): """Test that response contains all required fields with correct types.""" + client = TestClient(app) response = client.get("/api/v1/realm-discovery/?type=demo") @@ -175,9 +245,7 @@ def test_realm_discovery_response_structure(): ], ) @patch("app.api.login.OIDC_ENABLED", True) -@patch("app.api.login.OIDC_BASE_URL", "https://keycloak.test.com/auth") @patch("app.deps.OIDC_DEMO_REALM", "") -@patch("app.api.login.OIDC_DEFAULT_REALM", "master") def test_realm_discovery_response_field_values(field_name, field_value): """Test that response fields contain expected values.""" import app.deps as deps @@ -188,9 +256,9 @@ def test_realm_discovery_response_field_values(field_name, field_value): RealmType.real: "master", RealmType.demo: login_api.OIDC_DEFAULT_REALM, } - deps.CLIENT_BY_REALM = { - login_api.OIDC_DEFAULT_REALM: {"client_id": "demo-client"}, - "master": {"client_id": "real-client"}, + deps.CLIENT_BY_REALM_TYPE = { + RealmType.demo: {"client_id": "demo-client"}, + RealmType.real: {"client_id": "real-client"}, } client = TestClient(app) response = client.get("/api/v1/realm-discovery/?type=real") From 806d8d704a8527b3d5370ef0200bc14c5b829fc1 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Fri, 12 Dec 2025 14:26:38 +0100 Subject: [PATCH 69/78] fix wrong key --- app/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index c5db204..f909b22 100644 --- a/app/main.py +++ b/app/main.py @@ -50,9 +50,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[State]: url = f"{OIDC_BASE_URL}/realms/{realm}/.well-known/openid-configuration" r = await client.get(url) if r.status_code == 200: - oidc_config[realm] = r.json() - jwks_uri = oidc_config[realm]["jwks_uri"] - jwks_client[realm] = jwt.PyJWKClient(jwks_uri) + oidc_config[realm_type] = r.json() + jwks_uri = oidc_config[realm_type]["jwks_uri"] + jwks_client[realm_type] = jwt.PyJWKClient(jwks_uri) yield {"oidc_config": oidc_config, "jwks_client": jwks_client} From 5b88341a53c400b6c5849083a4e02cc66d1c8d8d Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Fri, 12 Dec 2025 15:43:41 +0100 Subject: [PATCH 70/78] Use standard ingress host for test server --- .gitlab-ci.maxiv.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 7f06b88..3fecc5a 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -33,7 +33,7 @@ variables: PRODUCTION_BRANCH_NAME: "main" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" - HELM_SET_TEST_ingress_host: "notify-test-keycloak-new.apps.okdev.maxiv.lu.se" + HELM_SET_TEST_ingress_host: "notify-test.apps.okdev.maxiv.lu.se" HELM_SET_PROD_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" HELM_SET_PROD_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" HELM_SET_TEST_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" From ff6d81d49658e91d67a9e29f3f3c78ecf654abf7 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Mon, 15 Dec 2025 11:30:16 +0100 Subject: [PATCH 71/78] Minor fixes --- app/api/login.py | 2 +- app/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/login.py b/app/api/login.py index ec0f2ce..fc5ebad 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -108,7 +108,7 @@ async def open_id_connect( headers = {"Authorization": f"Bearer {access_token}"} data = { "client_id": oidc_auth.client_id, - "client_secret": OIDC_CLIENT_SECRET, + "client_secret": deps.CLIENT_BY_REALM_TYPE[realm]["client_secret"] "scope": OIDC_SCOPE, } logger.info("Retrieving user info.") diff --git a/app/main.py b/app/main.py index f909b22..c908160 100644 --- a/app/main.py +++ b/app/main.py @@ -36,7 +36,7 @@ class State(TypedDict): oidc_config: dict[schemas.RealmType, dict[str, str]] - jwks_client: dict[schemas.RealmType, jwt.PyJWKClient] | None + jwks_client: dict[schemas.RealmType, jwt.PyJWKClient] @contextlib.asynccontextmanager From 2251188196a31766b3304a6fc74ac068237dd458 Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Mon, 15 Dec 2025 11:34:52 +0100 Subject: [PATCH 72/78] minor fixes --- .gitlab-ci.maxiv.yml | 2 +- app/api/login.py | 3 +-- tests/api/test_login_realm_discovery.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 3fecc5a..718ea78 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -33,7 +33,7 @@ variables: PRODUCTION_BRANCH_NAME: "main" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" - HELM_SET_TEST_ingress_host: "notify-test.apps.okdev.maxiv.lu.se" + HELM_SET_TEST_ingress_host: "notify-test.apps.okdev.maxiv.lu.se" # This URL needs to be registered on the OIDC provider as valid redirect URI HELM_SET_PROD_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" HELM_SET_PROD_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" HELM_SET_TEST_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" diff --git a/app/api/login.py b/app/api/login.py index fc5ebad..97a50e2 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -7,7 +7,6 @@ from .. import deps, crud, utils, auth, schemas from ..settings import ( ACCESS_TOKEN_EXPIRE_MINUTES, - OIDC_CLIENT_SECRET, OIDC_SCOPE, OIDC_ENABLED, ) @@ -108,7 +107,7 @@ async def open_id_connect( headers = {"Authorization": f"Bearer {access_token}"} data = { "client_id": oidc_auth.client_id, - "client_secret": deps.CLIENT_BY_REALM_TYPE[realm]["client_secret"] + "client_secret": deps.CLIENT_BY_REALM_TYPE[realm]["client_secret"], "scope": OIDC_SCOPE, } logger.info("Retrieving user info.") diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py index 3817482..6abb63e 100644 --- a/tests/api/test_login_realm_discovery.py +++ b/tests/api/test_login_realm_discovery.py @@ -108,7 +108,7 @@ def test_realm_discovery_endpoint_oidc_disabled(): assert response.json()["detail"] == "OIDC is not enabled" -@patch("app.api.login.OIDC_ENABLED", False) +@patch("app.api.login.OIDC_ENABLED", True) def test_invalid_realm_type(): """Test realm discovery endpoint when realm type is invalid.""" client = TestClient(app) From 0360aaa89085bf848a1b7727fe3b26cb54c923ca Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Mon, 15 Dec 2025 14:35:46 +0100 Subject: [PATCH 73/78] use DEMO_ACCOUNT_USERNAME in crud.py --- app/crud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/crud.py b/app/crud.py index febd5ee..b533f8f 100644 --- a/app/crud.py +++ b/app/crud.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from typing import List, Optional from . import models, schemas -from .settings import ADMIN_USERS, DEMO_ACCOUNT_SERVICE +from .settings import ADMIN_USERS, DEMO_ACCOUNT_SERVICE, DEMO_ACCOUNT_USERNAME def get_users(db: Session): @@ -136,7 +136,7 @@ def delete_service(db: Session, service: models.Service) -> None: def get_user_services(db: Session, user: models.User) -> List[schemas.UserService]: """Return all services for the user sorted by category""" - if user.username == "demo": + if user.username == DEMO_ACCOUNT_USERNAME: services = get_services(db, demo=True) else: services = get_services(db) From 6457d469ee6061c2868b402f3b8f119a7c411b3d Mon Sep 17 00:00:00 2001 From: Carla Takahashi Date: Tue, 16 Dec 2025 14:19:27 +0100 Subject: [PATCH 74/78] Send realm in payload when open id connecting --- app/api/login.py | 2 +- app/schemas.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/login.py b/app/api/login.py index 97a50e2..ac8adde 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -48,10 +48,10 @@ async def open_id_connect( oidc_auth: schemas.OpenIdConnectAuth, response: Response, request: Request, - realm: schemas.RealmType, db: Session = Depends(deps.get_db), ): """Login using OpenID Connect Authentication Code flow from mobile client""" + realm = oidc_auth.realm oidc_config = request.state.oidc_config[realm] jwks_client = request.state.jwks_client[realm] data = { diff --git a/app/schemas.py b/app/schemas.py index 9b962bc..40bd818 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -171,6 +171,7 @@ class OpenIdConnectAuth(BaseModel): code_verifier: str client_id: str redirect_uri: str + realm: RealmType class RealmType(str, Enum): From 5c01f6b00d58ec4450aa9c551a97148cc6057306 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 17 Dec 2025 15:04:23 +0100 Subject: [PATCH 75/78] Update requirements and python Move to python 3.14 --- .gitlab-ci.maxiv.yml | 4 +- Dockerfile | 2 +- requirements.txt | 90 +++++++++++++++++++++++--------------------- 3 files changed, 50 insertions(+), 46 deletions(-) diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 718ea78..c6fa0db 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -41,11 +41,11 @@ variables: GITLAB_ENVIRONMENT_NAME: notify # Test with latest versions of requirements -test-python311: +test-python314: stage: test tags: - kubernetes - image: harbor.maxiv.lu.se/dockerhub/library/python:3.11 + image: harbor.maxiv.lu.se/dockerhub/library/python:3.14 before_script: - pip install -e .[tests] script: diff --git a/Dockerfile b/Dockerfile index c701514..131c03e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM harbor.maxiv.lu.se/dockerhub/library/python:3.11-slim as base +FROM harbor.maxiv.lu.se/dockerhub/library/python:3.14-slim as base # Install Python dependencies in an intermediate image # as some requires a compiler (psycopg2) diff --git a/requirements.txt b/requirements.txt index 1bf9eb9..56696e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,145 +1,149 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml -o requirements.txt -aiofiles==24.1.0 +aiofiles==25.1.0 # via ess-notify (pyproject.toml) alembic==1.14.1 # via ess-notify (pyproject.toml) +annotated-doc==0.0.4 + # via fastapi annotated-types==0.7.0 # via pydantic -anyio==4.8.0 +anyio==4.12.0 # via # httpx # starlette # watchfiles -authlib==1.5.1 +authlib==1.6.6 # via ess-notify (pyproject.toml) -cachetools==5.5.2 +cachetools==6.2.4 # via google-auth -certifi==2025.1.31 +certifi==2025.11.12 # via # httpcore # httpx # requests # sentry-sdk -cffi==1.17.1 +cffi==2.0.0 # via cryptography -charset-normalizer==3.4.1 +charset-normalizer==3.4.4 # via requests -click==8.1.8 +click==8.3.1 # via # typer # uvicorn -cryptography==44.0.2 +cryptography==46.0.3 # via # ess-notify (pyproject.toml) # authlib -fastapi==0.115.11 +fastapi==0.124.4 # via ess-notify (pyproject.toml) -google-auth==2.38.0 +google-auth==2.45.0 # via ess-notify (pyproject.toml) gunicorn==23.0.0 # via ess-notify (pyproject.toml) -h11==0.14.0 +h11==0.16.0 # via # httpcore # uvicorn -h2==4.2.0 +h2==4.3.0 # via ess-notify (pyproject.toml) hpack==4.1.0 # via h2 -httpcore==1.0.7 +httpcore==1.0.9 # via httpx -httptools==0.6.4 +httptools==0.7.1 # via uvicorn httpx==0.28.1 # via ess-notify (pyproject.toml) hyperframe==6.1.0 # via h2 -idna==3.10 +idna==3.11 # via # anyio # httpx # requests itsdangerous==2.2.0 # via ess-notify (pyproject.toml) -jinja2==3.1.5 +jinja2==3.1.6 # via ess-notify (pyproject.toml) ldap3==2.9.1 # via ess-notify (pyproject.toml) -mako==1.3.9 +mako==1.3.10 # via alembic -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich -markupsafe==3.0.2 +markupsafe==3.0.3 # via # jinja2 # mako mdurl==0.1.2 # via markdown-it-py -packaging==24.2 +packaging==25.0 # via gunicorn pyasn1==0.6.1 # via # ldap3 # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via google-auth -pycparser==2.22 +pycparser==2.23 # via cffi -pydantic==2.10.6 +pydantic==2.12.5 # via # ess-notify (pyproject.toml) # fastapi -pydantic-core==2.27.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.19.1 +pygments==2.19.2 # via rich pyjwt==2.10.1 # via ess-notify (pyproject.toml) -python-dotenv==1.0.1 +python-dotenv==1.2.1 # via uvicorn -python-multipart==0.0.20 +python-multipart==0.0.21 # via ess-notify (pyproject.toml) -pyyaml==6.0.2 +pyyaml==6.0.3 # via uvicorn -requests==2.32.3 +requests==2.32.5 # via ess-notify (pyproject.toml) -rich==13.9.4 +rich==14.2.0 # via typer -rsa==4.9 +rsa==4.9.1 # via google-auth -sentry-sdk==2.22.0 +sentry-sdk==2.48.0 # via ess-notify (pyproject.toml) shellingham==1.5.4 # via typer -sniffio==1.3.1 - # via anyio sqlalchemy==1.3.24 # via # ess-notify (pyproject.toml) # alembic -starlette==0.46.0 +starlette==0.50.0 # via fastapi -typer==0.15.2 +typer==0.20.0 # via ess-notify (pyproject.toml) -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via # alembic # anyio # fastapi # pydantic # pydantic-core + # starlette # typer -urllib3==2.3.0 + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +urllib3==2.6.2 # via # requests # sentry-sdk -uvicorn==0.34.0 +uvicorn==0.38.0 # via ess-notify (pyproject.toml) -uvloop==0.21.0 +uvloop==0.22.1 # via uvicorn -watchfiles==1.0.4 +watchfiles==1.1.1 # via uvicorn -websockets==15.0 +websockets==15.0.1 # via uvicorn From ec029476f5d67edb84ede98c8dba20d727e82a03 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Wed, 17 Dec 2025 15:06:17 +0100 Subject: [PATCH 76/78] Update pre-commit config --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2e4161..1cbfc79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,15 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.9.9 + rev: v0.14.9 hooks: # Run the linter. - - id: ruff + - id: ruff-check # Run the formatter. - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 + rev: v1.19.1 hooks: - id: mypy additional_dependencies: From f2cb59ee40854d2bc8b6f50c1360708450012f08 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Fri, 19 Dec 2025 08:54:29 +0100 Subject: [PATCH 77/78] Remove harbor from Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 131c03e..a2f7b45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM harbor.maxiv.lu.se/dockerhub/library/python:3.14-slim as base +FROM dockerhub/library/python:3.14-slim as base # Install Python dependencies in an intermediate image # as some requires a compiler (psycopg2) From 2635505d2714f89fd1deded76dfe0da841691589 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand Date: Fri, 19 Dec 2025 09:11:09 +0100 Subject: [PATCH 78/78] Use python 3.14 in default gitlab-ci and github actions --- .github/workflows/pytest.yml | 2 +- .gitlab-ci.yml | 4 ++-- Dockerfile | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index dec80ae..f1aa990 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.14"] steps: - uses: actions/checkout@v2 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dd3c5a1..c46709f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,9 +5,9 @@ include: - remote: 'https://gitlab.esss.lu.se/ics-infrastructure/gitlab-ci-yml/raw/master/Docker.gitlab-ci.yml' -test-python311: +test-python314: stage: test - image: python:3.11 + image: docker.io/library/python:3.14 before_script: - pip install -e .[tests] script: diff --git a/Dockerfile b/Dockerfile index a2f7b45..61dc84a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM dockerhub/library/python:3.14-slim as base +FROM docker.io/library/python:3.14-slim as base # Install Python dependencies in an intermediate image # as some requires a compiler (psycopg2)
{{ categories[notification.service_id] }}{{ format_datetime(notification.timestamp) }}{{ notification.timestamp | format_datetime }} {{ format_notification(notification) }}