From 7315e4ac6f4d51843b2befe00972f59789aea0f3 Mon Sep 17 00:00:00 2001 From: ChrispyBacon-dev Date: Sun, 25 May 2025 21:52:33 +0200 Subject: [PATCH] refracturing - Testing Stage --- .github/workflows/docker-image.yml | 2 +- .dockerignore => dockflare/.dockerignore | 0 dockflare/.gitignore | 88 +++ .../DockFlare Testing Script.txt | 0 Dockerfile => dockflare/Dockerfile | 33 +- app.py => dockflare/app.py.old | 0 dockflare/app/__init__.py | 98 +++ dockflare/app/config.py | 92 +++ dockflare/app/core/__init__.py | 0 dockflare/app/core/access_manager.py | 380 +++++++++++ dockflare/app/core/cloudflare_api.py | 628 ++++++++++++++++++ dockflare/app/core/docker_handler.py | 390 +++++++++++ dockflare/app/core/reconciler.py | 409 ++++++++++++ dockflare/app/core/state_manager.py | 136 ++++ dockflare/app/core/tunnel_manager.py | 513 ++++++++++++++ dockflare/app/main.py | 280 ++++++++ dockflare/app/static/css/output.css | 1 + dockflare/app/static/js/main.js | 451 +++++++++++++ .../app/templates}/input.css | 0 .../app/templates}/status_page.html | 334 +--------- dockflare/app/web/__init__.py | 0 dockflare/app/web/forms.py | 0 dockflare/app/web/routes.py | 449 +++++++++++++ dockflare/app/web/utils.py | 0 .../package-lock.json | 6 +- package.json => dockflare/package.json | 2 +- .../postcss.config.js | 0 .../requirements.txt | 0 .../tailwind.config.js | 2 +- examples.txt | 34 - static/css/output.css | 1 - 31 files changed, 3941 insertions(+), 388 deletions(-) rename .dockerignore => dockflare/.dockerignore (100%) create mode 100644 dockflare/.gitignore rename DockFlare Testing Script.txt => dockflare/DockFlare Testing Script.txt (100%) rename Dockerfile => dockflare/Dockerfile (70%) rename app.py => dockflare/app.py.old (100%) create mode 100644 dockflare/app/__init__.py create mode 100644 dockflare/app/config.py create mode 100644 dockflare/app/core/__init__.py create mode 100644 dockflare/app/core/access_manager.py create mode 100644 dockflare/app/core/cloudflare_api.py create mode 100644 dockflare/app/core/docker_handler.py create mode 100644 dockflare/app/core/reconciler.py create mode 100644 dockflare/app/core/state_manager.py create mode 100644 dockflare/app/core/tunnel_manager.py create mode 100644 dockflare/app/main.py create mode 100644 dockflare/app/static/css/output.css create mode 100644 dockflare/app/static/js/main.js rename {templates => dockflare/app/templates}/input.css (100%) rename {templates => dockflare/app/templates}/status_page.html (61%) create mode 100644 dockflare/app/web/__init__.py create mode 100644 dockflare/app/web/forms.py create mode 100644 dockflare/app/web/routes.py create mode 100644 dockflare/app/web/utils.py rename package-lock.json => dockflare/package-lock.json (99%) rename package.json => dockflare/package.json (65%) rename postcss.config.js => dockflare/postcss.config.js (100%) rename requirements.txt => dockflare/requirements.txt (100%) rename tailwind.config.js => dockflare/tailwind.config.js (95%) delete mode 100644 examples.txt delete mode 100644 static/css/output.css diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 9bdf8de..00d7997 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -70,7 +70,7 @@ jobs: - name: Build and Push Docker Image (Multi-Arch) uses: docker/build-push-action@v5 with: - context: . + context: ./dockflare # Build for multiple architectures platforms: linux/amd64 #,linux/arm64 # Enable multi-architecture builds # Push only on direct pushes to stable or unstable branches diff --git a/.dockerignore b/dockflare/.dockerignore similarity index 100% rename from .dockerignore rename to dockflare/.dockerignore diff --git a/dockflare/.gitignore b/dockflare/.gitignore new file mode 100644 index 0000000..65fd47a --- /dev/null +++ b/dockflare/.gitignore @@ -0,0 +1,88 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +.python-version +.mypy_cache/ +.dmypy.json +dmypy.json +.pyright_cache/ +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ +.tox/ +.env/ +.venv/ +env/ +venv/ +ENV/ +VENV/ +venv.bak/ +env.bak/ +.spyderproject +.spyproject +.ropeproject +*.prof +*.pstat +*.cprof +node_modules/ +.npm/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-workspace +.idea/ +*.sublime-project +*.sublime-workspace +.atom/ +.project +.pydevproject +.settings/ +nbproject/ +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*~ +.directory +.env +data/state.json +*.tmp +*.bak +*.swp +*.log +logs/ \ No newline at end of file diff --git a/DockFlare Testing Script.txt b/dockflare/DockFlare Testing Script.txt similarity index 100% rename from DockFlare Testing Script.txt rename to dockflare/DockFlare Testing Script.txt diff --git a/Dockerfile b/dockflare/Dockerfile similarity index 70% rename from Dockerfile rename to dockflare/Dockerfile index df92997..7924fbe 100644 --- a/Dockerfile +++ b/dockflare/Dockerfile @@ -15,21 +15,26 @@ # along with this program. If not, see . # Use an official Python runtime as a parent image # Using slim variant for smaller size +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# (License header remains the same) FROM node:20-alpine as frontend-builder LABEL stage=frontend-builder -WORKDIR /usr/src/app -COPY package.json ./ -COPY package-lock.json* ./ +WORKDIR /usr/src/frontend_build # Changed WORKDIR to avoid conflict with runtime WORKDIR /app +COPY package.json ./package.json +COPY package-lock.json* ./ RUN npm install -COPY tailwind.config.js ./ -COPY postcss.config.js ./ -COPY ./templates/input.css ./templates/input.css -COPY ./templates ./templates + +COPY tailwind.config.js ./tailwind.config.js +COPY postcss.config.js ./postcss.config.js +COPY ./app/templates/input.css ./app/templates/input.css +COPY ./app/templates ./app/templates RUN npm run build:css FROM python:3.13-slim as runtime ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -WORKDIR /app + +WORKDIR /app # This is correct, our application will run from /app inside the container ENV CLOUDFLARED_VERSION="2024.1.5" RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ @@ -48,13 +53,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ rm cloudflared-$CLOUDFLARED_ARCH.deb && \ cloudflared --version && \ mkdir -p /root/.cloudflared + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -RUN mkdir -p /app/static/css -RUN mkdir -p /app/static/images -COPY --from=frontend-builder /usr/src/app/static/css/output.css /app/static/css/output.css -COPY app.py . -COPY templates /app/templates/ -COPY images /app/static/images/ +COPY --from=frontend-builder /usr/src/frontend_build/app/static/css/output.css /app/static/css/output.css +COPY ./app /app +COPY ./images /app/static/images/ EXPOSE 5000 -CMD ["python", "app.py"] \ No newline at end of file +CMD ["python", "main.py"] \ No newline at end of file diff --git a/app.py b/dockflare/app.py.old similarity index 100% rename from app.py rename to dockflare/app.py.old diff --git a/dockflare/app/__init__.py b/dockflare/app/__init__.py new file mode 100644 index 0000000..dc42e15 --- /dev/null +++ b/dockflare/app/__init__.py @@ -0,0 +1,98 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# app/__init__.py +import logging +import queue +import sys +import os + +from flask import Flask +import docker +from docker.errors import APIError + +from . import config + +tunnel_state = { "name": config.TUNNEL_NAME, "id": None, "token": None, "status_message": "Initializing...", "error": None } +cloudflared_agent_state = { "container_status": "unknown", "last_action_status": None } + +log_queue = queue.Queue(maxsize=config.MAX_LOG_QUEUE_SIZE) +log_formatter = logging.Formatter('%(asctime)s [%(levelname)s] [%(threadName)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') + +class QueueLogHandler(logging.Handler): + def __init__(self, log_queue_instance): + super().__init__() + self.log_queue_instance = log_queue_instance + + def emit(self, record): + log_entry = self.format(record) + try: + self.log_queue_instance.put_nowait(log_entry) + except queue.Full: + try: + self.log_queue_instance.get_nowait() + self.log_queue_instance.put_nowait(log_entry) + except queue.Empty: + pass + except queue.Full: + print("Log queue still full after attempting to make space, dropping message.", file=sys.stderr) + +root_logger = logging.getLogger() +root_logger.setLevel(logging.INFO) + +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setFormatter(log_formatter) +root_logger.addHandler(console_handler) + +queue_handler = QueueLogHandler(log_queue) +queue_handler.setFormatter(log_formatter) +queue_handler.setLevel(logging.INFO) +root_logger.addHandler(queue_handler) + + +docker_client = None +try: + docker_client = docker.from_env(timeout=10) + docker_client.ping() + logging.info("Successfully connected to Docker daemon.") +except APIError as e: + logging.error(f"FATAL: Docker API error during initial connection: {e}") + docker_client = None # Ensure it's None on APIError too +except Exception as e: + logging.error(f"FATAL: Failed to connect to Docker daemon: {e}") + docker_client = None + +def create_app(): + + app_instance = Flask(__name__) + app_instance.secret_key = os.urandom(24) + app_instance.config['PREFERRED_URL_SCHEME'] = 'http' + app_instance.reconciliation_info = { + "in_progress": False, + "progress": 0, + "total_items": 0, + "processed_items": 0, + "start_time": 0, + "status": "Not started" + } + + with app_instance.app_context(): + from .web import routes as web_routes + app_instance.register_blueprint(web_routes.bp) + logging.info("Web blueprint registered.") + + return app_instance + +app = create_app() \ No newline at end of file diff --git a/dockflare/app/config.py b/dockflare/app/config.py new file mode 100644 index 0000000..c7ea325 --- /dev/null +++ b/dockflare/app/config.py @@ -0,0 +1,92 @@ +# app/config.py +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# app/config.py +import os +import sys +import logging + +from dotenv import load_dotenv + +load_dotenv() + +MAX_CF_UPDATE_RETRIES = 3 +CF_UPDATE_RETRY_DELAY = 2 +CF_UPDATE_BACKOFF_FACTOR = 2 + +CF_API_TOKEN = os.getenv('CF_API_TOKEN') +CF_ACCOUNT_ID = os.getenv('CF_ACCOUNT_ID') +CF_ZONE_ID = os.getenv('CF_ZONE_ID') +CF_API_BASE_URL = "https://api.cloudflare.com/client/v4" + +if CF_API_TOKEN: + CF_HEADERS = { + "Authorization": f"Bearer {CF_API_TOKEN}", + "Content-Type": "application/json", + } +else: + CF_HEADERS = { + "Content-Type": "application/json", + } + + +USE_EXTERNAL_CLOUDFLARED = os.getenv('USE_EXTERNAL_CLOUDFLARED', 'false').lower() in ['true', '1', 't', 'yes'] +EXTERNAL_TUNNEL_ID = os.getenv('EXTERNAL_TUNNEL_ID') +SCAN_ALL_NETWORKS = os.getenv('SCAN_ALL_NETWORKS', 'false').lower() in ['true', '1', 't', 'yes'] +TUNNEL_DNS_SCAN_ZONE_NAMES_STR = os.getenv('TUNNEL_DNS_SCAN_ZONE_NAMES', '') +TUNNEL_DNS_SCAN_ZONE_NAMES = [name.strip() for name in TUNNEL_DNS_SCAN_ZONE_NAMES_STR.split(',') if name.strip()] +TUNNEL_NAME = os.getenv("TUNNEL_NAME", "dockflared-tunnel") + +if not USE_EXTERNAL_CLOUDFLARED: + CLOUDFLARED_NETWORK_NAME = os.getenv('CLOUDFLARED_NETWORK_NAME', 'cloudflare-net') + CLOUDFLARED_CONTAINER_NAME = os.getenv('CLOUDFLARED_CONTAINER_NAME', f"cloudflared-agent-{TUNNEL_NAME}") +else: + CLOUDFLARED_NETWORK_NAME = None + CLOUDFLARED_CONTAINER_NAME = None + +CLOUDFLARED_IMAGE = "cloudflare/cloudflared:latest" +LABEL_PREFIX = os.getenv('LABEL_PREFIX', 'cloudflare.tunnel') +GRACE_PERIOD_SECONDS = int(os.getenv('GRACE_PERIOD_SECONDS', 28800)) +CLEANUP_INTERVAL_SECONDS = int(os.getenv('CLEANUP_INTERVAL_SECONDS', 300)) +AGENT_STATUS_UPDATE_INTERVAL_SECONDS = int(os.getenv('AGENT_STATUS_UPDATE_INTERVAL_SECONDS', 10)) +STATE_FILE_PATH = os.getenv('STATE_FILE_PATH', '/app/data/state.json') +MAX_LOG_QUEUE_SIZE = 200 +MAX_CONCURRENT_DNS_OPS = int(os.getenv('MAX_CONCURRENT_DNS_OPS', 3)) +RECONCILIATION_BATCH_SIZE = int(os.getenv('RECONCILIATION_BATCH_SIZE', 3)) +ACCOUNT_EMAIL_CACHE_TTL = 3600 + +REQUIRED_VARS_BASE = ["CF_API_TOKEN", "CF_ACCOUNT_ID"] +missing_vars = [] + +if not USE_EXTERNAL_CLOUDFLARED: + if not TUNNEL_NAME: + REQUIRED_VARS_BASE.append("TUNNEL_NAME") +else: + if not EXTERNAL_TUNNEL_ID: + REQUIRED_VARS_BASE.append("EXTERNAL_TUNNEL_ID") + +for var_name in REQUIRED_VARS_BASE: + if not globals().get(var_name): + missing_vars.append(var_name) + +if missing_vars: + logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') + logging.error(f"FATAL: Missing required environment variables ({', '.join(missing_vars)})") + sys.exit(1) + +if not CF_ZONE_ID: + logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') + logging.warning("CF_ZONE_ID not set. DNS management requires 'cloudflare.tunnel.zonename' label on containers or manual zone specification.") \ No newline at end of file diff --git a/dockflare/app/core/__init__.py b/dockflare/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dockflare/app/core/access_manager.py b/dockflare/app/core/access_manager.py new file mode 100644 index 0000000..aadbdea --- /dev/null +++ b/dockflare/app/core/access_manager.py @@ -0,0 +1,380 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# app/core/access_manager.py +import logging +import json +import hashlib +import requests +import time + +from app import config +from app.core import cloudflare_api + +_ACCOUNT_EMAIL_CACHE_TTL = 3600 +_cached_account_email = None +_cached_account_email_timestamp = 0 + +def _build_access_app_payload(hostname, name, session_duration, app_launcher_visible, self_hosted_domains, access_policies, allowed_idps=None, auto_redirect_to_identity=False): + payload = { + "name": name, + "domain": hostname, + "type": "self_hosted", + "session_duration": session_duration, + "app_launcher_visible": app_launcher_visible, + "self_hosted_domains": self_hosted_domains, + "allowed_idps": allowed_idps if allowed_idps else [], + "auto_redirect_to_identity": auto_redirect_to_identity, + } + if access_policies is not None: + payload["policies"] = access_policies + + if allowed_idps is None: + if "allowed_idps" in payload: + del payload["allowed_idps"] + + return payload + +def check_for_tld_access_policy(zone_name): + if not zone_name: + logging.warning("check_for_tld_access_policy called with no zone_name.") + return False + + tld_hostname = f"*.{zone_name}" + logging.info(f"Checking for existing Access Policy for wildcard TLD: {tld_hostname}") + + try: + existing_app = find_cloudflare_access_application_by_hostname(tld_hostname) + if existing_app and existing_app.get("id"): + logging.info(f"Found existing Access Application ID '{existing_app.get('id')}' for TLD '{tld_hostname}'.") + return True + else: + logging.info(f"No specific Access Application found for TLD '{tld_hostname}'.") + return False + except Exception as e: + logging.error(f"Error while checking for TLD access policy for '{tld_hostname}': {e}", exc_info=True) + return False + +def get_cloudflare_account_email(): + global _cached_account_email, _cached_account_email_timestamp + + current_time = time.time() + if _cached_account_email and (current_time - _cached_account_email_timestamp < _ACCOUNT_EMAIL_CACHE_TTL): + logging.debug(f"Returning cached Cloudflare account email: {_cached_account_email}") + return _cached_account_email + + logging.info("Fetching Cloudflare account email from API.") + try: + response_data = cloudflare_api.cf_api_request("GET", "/user") + if response_data and response_data.get("success"): + email = response_data.get("result", {}).get("email") + if email: + logging.info(f"Successfully fetched Cloudflare account email: {email}") + _cached_account_email = email + _cached_account_email_timestamp = current_time + return email + else: + logging.warning("Cloudflare account email not found in API response.") + return None + else: + logging.warning(f"Failed to fetch Cloudflare account email, API call unsuccessful. Response: {response_data}") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error fetching Cloudflare account email: {e}") + return None + except Exception as e: + logging.error(f"Unexpected error fetching Cloudflare account email: {e}", exc_info=True) + return None + +def find_cloudflare_access_application_by_hostname(hostname): + logging.info(f"Finding Cloudflare Access Application for hostname '{hostname}' on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/access/apps" + try: + response_data_direct = cloudflare_api.cf_api_request("GET", endpoint, params={"domain": hostname}) + apps_direct = response_data_direct.get("result", []) + if apps_direct and isinstance(apps_direct, list): + for app in apps_direct: + if app.get("domain") == hostname: # Exact domain match + logging.info(f"Found Access Application ID '{app.get('id')}' for hostname '{hostname}' via direct domain query.") + return app + + logging.info(f"No exact match for '{hostname}' via domain query. Falling back to listing all Access Applications.") + + all_apps_response = cloudflare_api.cf_api_request("GET", endpoint, params={"per_page": 100}) + # Add pagination here if you expect > 100 access apps + all_apps = all_apps_response.get("result", []) + if all_apps and isinstance(all_apps, list): + for app in all_apps: + if app.get("domain") == hostname: + logging.info(f"Found Access Application ID '{app.get('id')}' for hostname '{hostname}' via full list scan (domain match).") + return app + # Also check self_hosted_domains for a match + if hostname in app.get("self_hosted_domains", []): + logging.info(f"Found Access Application ID '{app.get('id')}' for hostname '{hostname}' (in self_hosted_domains) via full list scan.") + return app + + logging.info(f"Access Application for hostname '{hostname}' not found after extensive search.") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error finding Cloudflare Access Application for '{hostname}': {e}") + return None # Or re-raise if the caller should handle API errors directly + except Exception as e: + logging.error(f"Unexpected error finding Cloudflare Access Application for '{hostname}': {e}", exc_info=True) + return None + +def create_cloudflare_access_application(hostname, name, session_duration, app_launcher_visible, self_hosted_domains, access_policies, allowed_idps=None, auto_redirect_to_identity=False): + logging.info(f"Creating Cloudflare Access Application for hostname '{hostname}' on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/access/apps" + payload = _build_access_app_payload(hostname, name, session_duration, app_launcher_visible, self_hosted_domains, access_policies, allowed_idps, auto_redirect_to_identity) + try: + response_data = cloudflare_api.cf_api_request("POST", endpoint, json_data=payload) + app_data = response_data.get("result") + if app_data and app_data.get("id"): + logging.info(f"Successfully created Access Application '{app_data.get('id')}' for '{hostname}'") + return app_data + else: + logging.error(f"Access Application creation for '{hostname}' API call successful but no ID in response: {app_data}") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error creating Access Application for '{hostname}': {e}") + return None + except Exception as e: + logging.error(f"Unexpected error creating Access Application for '{hostname}': {e}", exc_info=True) + return None + +def get_cloudflare_access_application(app_uuid): + logging.info(f"Getting Cloudflare Access Application details for ID '{app_uuid}' on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/access/apps/{app_uuid}" + try: + response_data = cloudflare_api.cf_api_request("GET", endpoint) + app_data = response_data.get("result") + if app_data: + logging.info(f"Successfully retrieved Access Application details for ID '{app_uuid}'") + return app_data + elif response_data.get("success"): + logging.warning(f"Successfully called API for Access App ID '{app_uuid}', but no result data found. Response: {response_data}") + return None + else: # Explicit failure + logging.error(f"API call failed or returned success=false for Access App ID '{app_uuid}'. Response: {response_data}") + return None + except requests.exceptions.RequestException as e: + if hasattr(e, 'response') and e.response is not None and e.response.status_code == 404: + logging.warning(f"Cloudflare Access Application with ID '{app_uuid}' not found (404).") + else: + logging.error(f"API error getting Access Application '{app_uuid}': {e}") + return None + except Exception as e: + logging.error(f"Unexpected error getting Access Application '{app_uuid}': {e}", exc_info=True) + return None + +def update_cloudflare_access_application(app_uuid, hostname, name, session_duration, app_launcher_visible, self_hosted_domains, access_policies, allowed_idps=None, auto_redirect_to_identity=False): + logging.info(f"Updating Cloudflare Access Application ID '{app_uuid}' for hostname '{hostname}' on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/access/apps/{app_uuid}" + payload = _build_access_app_payload(hostname, name, session_duration, app_launcher_visible, self_hosted_domains, access_policies, allowed_idps, auto_redirect_to_identity) + try: + response_data = cloudflare_api.cf_api_request("PUT", endpoint, json_data=payload) + app_data = response_data.get("result") + if app_data and app_data.get("id"): + logging.info(f"Successfully updated Access Application '{app_data.get('id')}' for '{hostname}'") + return app_data + else: + logging.error(f"Access Application update for '{app_uuid}' API call successful but no ID in response: {app_data}") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error updating Access Application '{app_uuid}': {e}") + return None + except Exception as e: + logging.error(f"Unexpected error updating Access Application '{app_uuid}': {e}", exc_info=True) + return None + +def delete_cloudflare_access_application(app_uuid): + logging.info(f"Deleting Cloudflare Access Application ID '{app_uuid}' on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/access/apps/{app_uuid}" + try: + response_data = cloudflare_api.cf_api_request("DELETE", endpoint) + if response_data and response_data.get("success"): + deleted_id = response_data.get("result", {}).get("id") if isinstance(response_data.get("result"), dict) else app_uuid + logging.info(f"Successfully submitted deletion for Access Application ID '{deleted_id if deleted_id else app_uuid}'") + return True + + elif response_data and response_data.get("success") and not response_data.get("result"): + logging.info(f"Access Application ID '{app_uuid}' deletion API call succeeded (success:true, no specific result ID).") + return True + + elif response_data is None and "success" not in str(response_data): + logging.info(f"Access Application ID '{app_uuid}' deletion API call likely succeeded (no content/error).") + return True + + logging.warning(f"Access Application deletion for '{app_uuid}' API call did not confirm success clearly. Response: {response_data}") + return False + except requests.exceptions.RequestException as e: + if hasattr(e, 'response') and e.response is not None and e.response.status_code == 404: + logging.warning(f"Cloudflare Access Application with ID '{app_uuid}' not found during delete attempt (404). Treating as success.") + return True + logging.error(f"API error deleting Access Application '{app_uuid}': {e}") + return False + except Exception as e: + logging.error(f"Unexpected error deleting Access Application '{app_uuid}': {e}", exc_info=True) + return False + +def generate_access_app_config_hash(policy_type, session_duration, app_launcher_visible, allowed_idps_str, auto_redirect_to_identity, custom_access_rules_str=None): + config_items = { + "policy_type": policy_type, + "session_duration": str(session_duration), + "app_launcher_visible": bool(app_launcher_visible), + "allowed_idps_str": str(allowed_idps_str) if allowed_idps_str is not None else None, + "auto_redirect_to_identity": bool(auto_redirect_to_identity), + "custom_access_rules_str": str(custom_access_rules_str) if custom_access_rules_str is not None else None + } + consistent_config_string = json.dumps(config_items, sort_keys=True) + hasher = hashlib.sha256() + hasher.update(consistent_config_string.encode('utf-8')) + return hasher.hexdigest() + +def handle_access_policy_from_labels(hostname_config_item, current_rule_in_state, state_manager_save_func): + hostname = hostname_config_item["hostname"] + + desired_access_policy_type_from_label = hostname_config_item.get("access_policy_type") + desired_access_app_name_from_label = hostname_config_item.get("access_app_name") or f"DockFlare-{hostname}" + desired_session_duration_from_label = hostname_config_item.get("access_session_duration", "24h") + desired_app_launcher_visible_from_label = hostname_config_item.get("access_app_launcher_visible", False) + desired_allowed_idps_str_from_label = hostname_config_item.get("access_allowed_idps_str") + desired_auto_redirect_from_label = hostname_config_item.get("access_auto_redirect", False) + desired_custom_rules_str_from_label = hostname_config_item.get("access_custom_rules_str") + + local_state_changed_by_access_policy = False + current_access_app_id = current_rule_in_state.get("access_app_id") + current_access_policy_type_in_state = current_rule_in_state.get("access_policy_type") + current_access_app_config_hash_in_state = current_rule_in_state.get("access_app_config_hash") + + if desired_access_policy_type_from_label: + desired_access_app_config_hash_from_label = generate_access_app_config_hash( + desired_access_policy_type_from_label, + desired_session_duration_from_label, + desired_app_launcher_visible_from_label, + desired_allowed_idps_str_from_label, + desired_auto_redirect_from_label, + desired_custom_rules_str_from_label + ) + + if desired_access_policy_type_from_label == "default_tld": + if current_access_app_id: + logging.info(f"Label policy for {hostname} is 'default_tld'. Deleting existing Access App {current_access_app_id}.") + if delete_cloudflare_access_application(current_access_app_id): + current_rule_in_state["access_app_id"] = None + current_rule_in_state["access_policy_type"] = "default_tld" + current_rule_in_state["access_app_config_hash"] = None + local_state_changed_by_access_policy = True + else: + logging.error(f"Failed to delete Access App {current_access_app_id} for {hostname} as per label 'default_tld'.") + elif current_access_policy_type_in_state != "default_tld": + current_rule_in_state["access_app_id"] = None + current_rule_in_state["access_policy_type"] = "default_tld" + current_rule_in_state["access_app_config_hash"] = None + local_state_changed_by_access_policy = True + logging.info(f"Label policy for {hostname} set to 'default_tld'. No specific app managed.") + + elif desired_access_policy_type_from_label in ["bypass", "authenticate"]: + cf_access_policies = [] + if desired_custom_rules_str_from_label: + try: + parsed_rules = json.loads(desired_custom_rules_str_from_label) + if isinstance(parsed_rules, list): + cf_access_policies = parsed_rules + else: + logging.error(f"Parsed 'custom_rules' label for {hostname} is not a list. Reverting to default for {desired_access_policy_type_from_label}.") + except json.JSONDecodeError as json_err: + logging.error(f"Error parsing 'custom_rules' label JSON for {hostname}: {json_err}. Reverting to default for {desired_access_policy_type_from_label}.") + + if not cf_access_policies: + if desired_access_policy_type_from_label == "bypass": + cf_access_policies = [{"name": "Label Default Bypass", "decision": "bypass", "include": [{"everyone": {}}]}] + elif desired_access_policy_type_from_label == "authenticate": + policy_include_rules = [] + if desired_allowed_idps_str_from_label: + idp_ids = [idp.strip() for idp in desired_allowed_idps_str_from_label.split(',') if idp.strip()] + if idp_ids: + policy_include_rules.append({"identity_provider": {"id": idp_ids}}) + + if not policy_include_rules: + policy_include_rules.append({"everyone": {}}) + + cf_access_policies = [{"name": "Label Default Authenticated Access", "decision": "allow", "include": policy_include_rules}] + + allowed_idps_list_for_app = [idp.strip() for idp in desired_allowed_idps_str_from_label.split(',') if idp.strip()] if desired_allowed_idps_str_from_label else None + + needs_api_action = False + if current_access_app_id: + if current_access_policy_type_in_state != desired_access_policy_type_from_label or \ + current_access_app_config_hash_in_state != desired_access_app_config_hash_from_label: + needs_api_action = True + logging.info(f"Access App {current_access_app_id} for {hostname} needs update. Current type: '{current_access_policy_type_in_state}', hash: '{current_access_app_config_hash_in_state}'. Desired type: '{desired_access_policy_type_from_label}', hash: '{desired_access_app_config_hash_from_label}'.") + else: + needs_api_action = True + logging.info(f"No Access App for {hostname}. Needs creation with type: '{desired_access_policy_type_from_label}'.") + + if needs_api_action: + if current_access_app_id: + logging.info(f"Updating Access App {current_access_app_id} for {hostname} based on labels (type: {desired_access_policy_type_from_label}).") + updated_app = update_cloudflare_access_application( + current_access_app_id, hostname, desired_access_app_name_from_label, + desired_session_duration_from_label, desired_app_launcher_visible_from_label, + [hostname], cf_access_policies, allowed_idps_list_for_app, desired_auto_redirect_from_label + ) + if updated_app: + current_rule_in_state["access_policy_type"] = desired_access_policy_type_from_label + current_rule_in_state["access_app_config_hash"] = desired_access_app_config_hash_from_label + local_state_changed_by_access_policy = True + else: + logging.error(f"Failed to update Access App {current_access_app_id} for {hostname} based on labels.") + else: + logging.info(f"Creating new Access App for {hostname} based on labels (type: '{desired_access_policy_type_from_label}').") + created_app = create_cloudflare_access_application( + hostname, desired_access_app_name_from_label, + desired_session_duration_from_label, desired_app_launcher_visible_from_label, + [hostname], cf_access_policies, allowed_idps_list_for_app, desired_auto_redirect_from_label + ) + if created_app and created_app.get("id"): + current_rule_in_state["access_app_id"] = created_app.get("id") + current_rule_in_state["access_policy_type"] = desired_access_policy_type_from_label + current_rule_in_state["access_app_config_hash"] = desired_access_app_config_hash_from_label + local_state_changed_by_access_policy = True + else: + logging.error(f"Failed to create Access App for {hostname} based on labels.") + else: # Unknown access policy type + logging.warning(f"Unknown access.policy type '{desired_access_policy_type_from_label}' from label for {hostname}. No Access App action taken based on this specific label type.") + + else: + if current_access_app_id: + logging.info(f"No access policy label for {hostname}, but found managed Access App {current_access_app_id}. Deleting it as per label configuration (or lack thereof).") + if delete_cloudflare_access_application(current_access_app_id): + current_rule_in_state["access_app_id"] = None + current_rule_in_state["access_policy_type"] = None + current_rule_in_state["access_app_config_hash"] = None + local_state_changed_by_access_policy = True + else: + logging.error(f"Failed to delete Access App {current_access_app_id} for {hostname} during label-based cleanup (no policy label).") + elif current_rule_in_state.get("access_policy_type") is not None : + current_rule_in_state["access_app_id"] = None + current_rule_in_state["access_policy_type"] = None + current_rule_in_state["access_app_config_hash"] = None + local_state_changed_by_access_policy = True + logging.debug(f"Ensuring access policy type is None for {hostname} as no access labels are present and no app was managed.") + + if local_state_changed_by_access_policy and state_manager_save_func: + logging.debug(f"Access policy changes for {hostname} triggered a state save.") + + return local_state_changed_by_access_policy \ No newline at end of file diff --git a/dockflare/app/core/cloudflare_api.py b/dockflare/app/core/cloudflare_api.py new file mode 100644 index 0000000..8485b10 --- /dev/null +++ b/dockflare/app/core/cloudflare_api.py @@ -0,0 +1,628 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# app/core/cloudflare_api.py +import logging +import requests +import json +import time +import threading + +from app import config + +zone_id_cache = {} +zone_details_by_id_cache = {} +_cached_account_email = None +_cached_account_email_timestamp = 0 +_cache_lock = threading.Lock() + +dns_semaphore = threading.Semaphore(config.MAX_CONCURRENT_DNS_OPS) + + +def cf_api_request(method, endpoint, json_data=None, params=None): + + url = f"{config.CF_API_BASE_URL}{endpoint}" + error_msg = None + try: + logging.info(f"CF API Request: {method} {url} Params: {params}") + if json_data: + + try: + log_data = json.dumps(json_data) + except TypeError: + log_data = str(json_data) + logging.debug(f"CF API Request Data: {log_data[:500]}") + + response = requests.request( + method, + url, + headers=config.CF_HEADERS, + json=json_data, + params=params, + timeout=30 + ) + response.raise_for_status() + logging.info(f"CF API Response Status: {response.status_code}") + + if response.status_code == 204 or not response.content: + return {"success": True, "result": None} + + try: + response_data = response.json() + logging.debug(f"CF API Response Body (first 500 chars): {str(response_data)[:500]}") + + if isinstance(response_data, dict) and 'success' in response_data: + if response_data['success']: + return response_data + else: + cf_errors = response_data.get('errors', []) + error_code = None + if cf_errors and isinstance(cf_errors, list) and len(cf_errors) > 0 and isinstance(cf_errors[0], dict): + error_msg = f"API Error: {cf_errors[0].get('message', 'Unknown error')}" + error_code = cf_errors[0].get('code') + else: + error_msg = f"API reported failure but no error details provided. Response: {response_data}" + logging.error(f"CF API Request Failed ({method} {url}): {error_msg} - Full Errors: {cf_errors}") + api_exception = requests.exceptions.RequestException(error_msg, response=response) + api_exception.cf_error_code = error_code + raise api_exception + else: + logging.warning(f"CF API response for {method} {url} was valid JSON but missing 'success' field. Status: {response.status_code}. Body: {str(response_data)[:200]}") + raise requests.exceptions.RequestException(f"Unexpected JSON response format from API. Status: {response.status_code}", response=response) + + except json.JSONDecodeError: + logging.error(f"CF API response for {method} {url} was not valid JSON. Status: {response.status_code}. Body: {response.text[:200]}") + raise requests.exceptions.RequestException(f"Invalid JSON response from API. Status: {response.status_code}", response=response) + + except requests.exceptions.RequestException as e: + if error_msg is None: + log_error_msg = f"CF API Request Failed: {method} {url}. Original Exception: {e}" + error_msg_for_exception = f"Request Exception: {e}" + + if e.response is not None: + try: + error_data = e.response.json() + cf_errors = error_data.get('errors', []) + if cf_errors and isinstance(cf_errors, list) and len(cf_errors) > 0 and isinstance(cf_errors[0], dict): + error_msg_for_exception = f"API Error: {cf_errors[0].get('message', 'Unknown error')}" + if not hasattr(e, 'cf_error_code'): + e.cf_error_code = cf_errors[0].get('code') + log_error_msg += f" - API Details: {cf_errors[0].get('message', 'Unknown error')}" + else: + error_msg_for_exception = f"HTTP {e.response.status_code} - {e.response.text[:100]}" + log_error_msg += f" - HTTP {e.response.status_code} - Response Text (first 100): {e.response.text[:100]}" + logging.error(f"CF API Error Response Body: {error_data}") + except (ValueError, AttributeError, json.JSONDecodeError): + error_msg_for_exception = f"HTTP {e.response.status_code} - {e.response.text[:100]}" + log_error_msg += f" - HTTP {e.response.status_code} - Response Text (first 100): {e.response.text[:100]}" + logging.error(log_error_msg) + raise + +def get_zone_id_from_name(zone_name): + + global zone_id_cache + if not zone_name: + logging.warning("get_zone_id_from_name called with empty zone_name.") + return None + + cache_ttl = config.ACCOUNT_EMAIL_CACHE_TTL + current_time = time.time() + + with _cache_lock: + cached_data = zone_id_cache.get(zone_name) + if cached_data: + zone_id, timestamp = cached_data + if current_time - timestamp < cache_ttl: + logging.debug(f"Zone ID for '{zone_name}' found in cache: {zone_id}") + return zone_id + else: + logging.debug(f"Cached Zone ID for '{zone_name}' expired, refreshing.") + + logging.info(f"Zone ID for '{zone_name}' not in cache or expired. Querying Cloudflare API...") + endpoint = "/zones" + params = {"name": zone_name, "status": "active", "account.id": config.CF_ACCOUNT_ID} + try: + response_data = cf_api_request("GET", endpoint, params=params) + results = response_data.get("result", []) + + if results and isinstance(results, list) and len(results) == 1: + zone_id = results[0].get("id") + zone_actual_name = results[0].get("name") + if zone_id and zone_actual_name == zone_name: + logging.info(f"Found Zone ID for '{zone_name}': {zone_id}") + with _cache_lock: + zone_id_cache[zone_name] = (zone_id, current_time) + return zone_id + else: + logging.error(f"API returned unexpected result or name mismatch for zone '{zone_name}': {results[0]}") + return None + elif results and len(results) > 1: + logging.error(f"API returned multiple ({len(results)}) active zones matching name '{zone_name}' for account {config.CF_ACCOUNT_ID}. Cannot determine correct zone.") + return None + else: + logging.warning(f"No active zone found matching name '{zone_name}' for account {config.CF_ACCOUNT_ID} via API.") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error looking up zone '{zone_name}': {e}") + return None + except Exception as e: + logging.error(f"Unexpected error looking up zone '{zone_name}': {e}", exc_info=True) + return None + +def get_zone_details_by_id(zone_id_to_check): + + global zone_details_by_id_cache + if not zone_id_to_check: + logging.warning("get_zone_details_by_id called with empty zone_id.") + return None + + with _cache_lock: + if zone_id_to_check in zone_details_by_id_cache: + logging.debug(f"Zone details for ID '{zone_id_to_check}' found in cache.") + return zone_details_by_id_cache[zone_id_to_check] + + logging.info(f"Zone details for ID '{zone_id_to_check}' not in cache. Querying Cloudflare API...") + endpoint = f"/zones/{zone_id_to_check}" + try: + response_data = cf_api_request("GET", endpoint) + if response_data and response_data.get("success"): + zone_data = response_data.get("result") + if zone_data and isinstance(zone_data, dict) and zone_data.get("name"): + logging.info(f"Found zone details for ID '{zone_id_to_check}': Name '{zone_data['name']}'") + with _cache_lock: + zone_details_by_id_cache[zone_id_to_check] = zone_data + return zone_data + else: + logging.error(f"API returned success for zone ID '{zone_id_to_check}' but result is missing or malformed: {zone_data}") + return None + else: + logging.error(f"API call failed or returned success=false for zone ID '{zone_id_to_check}': {response_data}") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error looking up zone ID '{zone_id_to_check}': {e}") + return None + except Exception as e: + logging.error(f"Unexpected error looking up zone ID '{zone_id_to_check}': {e}", exc_info=True) + return None + + +def find_tunnel_via_api(name): + logging.info(f"Finding tunnel '{name}' via API on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/cfd_tunnel" + params = {"name": name, "is_deleted": "false"} + try: + response_data = cf_api_request("GET", endpoint, params=params) + tunnels = response_data.get("result", []) + if tunnels and isinstance(tunnels, list): + for tunnel_entry in tunnels: + if tunnel_entry.get("name") == name: + tunnel_id = tunnel_entry.get("id") + if tunnel_id: + logging.info(f"Found existing tunnel '{name}' ID: {tunnel_id}. Getting token...") + token = get_tunnel_token_via_api(tunnel_id) + return tunnel_id, token + else: + logging.warning(f"Found tunnel entry for '{name}' but it has no ID: {tunnel_entry}") + return None, None + logging.info(f"Tunnel '{name}' not found among listed tunnels.") + return None, None + else: + logging.info(f"Tunnel '{name}' not found via API (no results array or empty).") + return None, None + except requests.exceptions.RequestException as e: + logging.error(f"API error finding tunnel '{name}': {e}") + raise + except Exception as e: + logging.error(f"Unexpected error finding tunnel '{name}': {e}", exc_info=True) + raise + +def get_tunnel_token_via_api(tunnel_id): + logging.info(f"Getting token for tunnel ID '{tunnel_id}' on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/cfd_tunnel/{tunnel_id}/token" + url = f"{config.CF_API_BASE_URL}{endpoint}" + try: + logging.info(f"API Request: GET {url} (for token, raw request)") + response = requests.request("GET", url, headers={"Authorization": f"Bearer {config.CF_API_TOKEN}"}, timeout=30) + response.raise_for_status() + token = response.text.strip() + if not token or len(token) < 50: + logging.error(f"Retrieved token for tunnel {tunnel_id} appears invalid (too short or empty).") + raise ValueError("Invalid token format received from API") + logging.info(f"Successfully retrieved token for tunnel {tunnel_id}") + return token + except requests.exceptions.RequestException as e: + error_msg = f"API Error getting token for tunnel {tunnel_id}: {e}" + if e.response is not None: + error_msg += f" Status: {e.response.status_code} Body (first 100): {e.response.text[:100]}" + logging.error(error_msg) + raise + except Exception as e: + logging.error(f"Unexpected error getting tunnel token for {tunnel_id}: {e}", exc_info=True) + raise + +def create_tunnel_via_api(name): + logging.info(f"Creating tunnel '{name}' via API on account {config.CF_ACCOUNT_ID}") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/cfd_tunnel" + payload = {"name": name, "config_src": "cloudflare"} + try: + response_data = cf_api_request("POST", endpoint, json_data=payload) + result = response_data.get("result", {}) + tunnel_id = result.get("id") + token = result.get("token") + if not tunnel_id or not token: + logging.error(f"API response for tunnel creation missing ID or Token: {result}") + raise ValueError("Missing ID or Token in API response for tunnel creation") + logging.info(f"Successfully created tunnel '{name}' with ID {tunnel_id}.") + return tunnel_id, token + except requests.exceptions.RequestException as e: + logging.error(f"API error creating tunnel '{name}': {e}") + raise + except Exception as e: + logging.error(f"Unexpected error creating tunnel '{name}': {e}", exc_info=True) + raise + +def create_cloudflare_dns_record(zone_id, hostname, tunnel_id): + acquired = False + try: + acquired = dns_semaphore.acquire(timeout=30) + if not acquired: + logging.error(f"Timed out waiting for DNS semaphore - too many concurrent operations. Skipping DNS creation for {hostname}") + return "semaphore_timeout" + + if not zone_id or not hostname or not tunnel_id: + logging.error("create_cloudflare_dns_record: Missing required arguments zone_id, hostname, or tunnel_id.") + return None + + existing_record_id, correct_tunnel = find_dns_record_id(zone_id, hostname, tunnel_id) + + if existing_record_id: + if correct_tunnel: + logging.info(f"DNS record for {hostname} in zone {zone_id} already exists with ID {existing_record_id} and correct tunnel. Using existing record.") + return existing_record_id + else: + logging.warning(f"DNS record for {hostname} in zone {zone_id} exists (ID: {existing_record_id}) but points to wrong tunnel. Updating...") + update_payload = { + "type": "CNAME", "name": hostname, + "content": f"{tunnel_id}.cfargotunnel.com", + "ttl": 1, "proxied": True + } + update_endpoint = f"/zones/{zone_id}/dns_records/{existing_record_id}" + try: + update_response = cf_api_request("PUT", update_endpoint, json_data=update_payload) + updated_record = update_response.get("result", {}) + updated_id = updated_record.get("id") + if updated_id: + logging.info(f"Successfully updated DNS record for {hostname} to point to correct tunnel. ID: {updated_id}") + return updated_id + else: + logging.error(f"DNS record update API call for {hostname} reported success but response missing ID") + return existing_record_id + except Exception as update_err: + logging.error(f"Error updating existing DNS record for {hostname}: {update_err}") + return existing_record_id # Return old ID + + record_name = hostname + record_content = f"{tunnel_id}.cfargotunnel.com" + endpoint = f"/zones/{zone_id}/dns_records" + payload = { + "type": "CNAME", "name": record_name, "content": record_content, + "ttl": 1, "proxied": True + } + + try: + logging.info(f"Attempting to create DNS CNAME in zone {zone_id}: Name={record_name}, Content={record_content}, Proxied=True") + response_data = cf_api_request("POST", endpoint, json_data=payload) + result = response_data.get("result", {}) + new_record_id = result.get("id") + if new_record_id: + logging.info(f"Successfully created DNS record for {hostname} in zone {zone_id}. New ID: {new_record_id}") + return new_record_id + else: + logging.error(f"DNS record creation API call for {hostname} reported success but response missing ID: {result}") + return None + except requests.exceptions.RequestException as e: + cf_error_code = getattr(e, 'cf_error_code', None) + if (cf_error_code == 81057 or + (e.response is not None and + ("record already exists" in e.response.text.lower() or + "a, aaaa, or cname record with that host already exists" in e.response.text.lower()))): + logging.warning(f"DNS record for {hostname} already exists in zone {zone_id} (API error code indicates conflict). Verifying...") + time.sleep(1) # Give API a moment + existing_id, _ = find_dns_record_id(zone_id, hostname, tunnel_id) + if existing_id: + logging.info(f"Found existing record ID for {hostname} after conflict: {existing_id}") + return existing_id + return "existing_record_unconfirmed" + else: + logging.error(f"API error creating DNS record for {hostname}: {e}") + return None + except Exception as e: + logging.error(f"Unexpected error creating DNS record for {hostname}: {e}", exc_info=True) + return None + finally: + if acquired: + dns_semaphore.release() + logging.debug(f"Released DNS semaphore after processing {hostname}") + +def find_dns_record_id(zone_id, hostname, tunnel_id): + acquired = False + try: + acquired = dns_semaphore.acquire(timeout=15) + if not acquired: + logging.error(f"Timed out waiting for DNS semaphore in find_dns_record_id for {hostname}") + return None, False + + if not zone_id or not hostname or not tunnel_id: + logging.error("find_dns_record_id: Missing required arguments.") + return None, False + + expected_content = f"{tunnel_id}.cfargotunnel.com" + endpoint = f"/zones/{zone_id}/dns_records" + + # First, try a very specific query + params_specific = {"type": "CNAME", "name": hostname, "content": expected_content, "match": "all"} + try: + logging.info(f"Searching DNS (specific): Zone={zone_id}, Type=CNAME, Name={hostname}, Content={expected_content}") + response_data = cf_api_request("GET", endpoint, params=params_specific) + results = response_data.get("result", []) + if results and isinstance(results, list) and len(results) == 1: # Expecting one exact match + record = results[0] + if record.get("id"): + logging.info(f"Found exact DNS record for {hostname} in zone {zone_id} with ID: {record.get('id')}") + return record.get("id"), True + + logging.info(f"Exact DNS record for {hostname} (content: {expected_content}) not found. Searching by name only.") + params_by_name = {"type": "CNAME", "name": hostname} + response_data_by_name = cf_api_request("GET", endpoint, params=params_by_name) + results_by_name = response_data_by_name.get("result", []) + + if results_by_name and isinstance(results_by_name, list): + for record in results_by_name: + if record.get("id"): + record_content = record.get("content", "") + if record_content.lower() == expected_content.lower(): + logging.info(f"Found DNS record for {hostname} by name search (correct content) with ID: {record.get('id')}") + return record.get("id"), True + else: + logging.warning(f"Found DNS CNAME for {hostname} (ID: {record.get('id')}) but it points to '{record_content}' instead of '{expected_content}'.") + return record.get("id"), False # Found a record, but wrong tunnel + logging.info(f"Found CNAME(s) for {hostname}, but none match expected content '{expected_content}'.") + + if results_by_name[0].get("id"): + return results_by_name[0].get("id"), False + + logging.info(f"No CNAME DNS record found for {hostname} in zone {zone_id} after both searches.") + return None, False + + except requests.exceptions.RequestException as e: + logging.error(f"API error finding DNS record for {hostname}: {e}") + return None, False + except Exception as e: + logging.error(f"Unexpected error finding DNS record for {hostname}: {e}", exc_info=True) + return None, False + finally: + if acquired: + dns_semaphore.release() + logging.debug(f"Released DNS semaphore after find_dns_record_id for {hostname}") + +def delete_cloudflare_dns_record(zone_id, hostname, tunnel_id): + acquired = False + try: + acquired = dns_semaphore.acquire(timeout=30) + if not acquired: + logging.error(f"Timed out waiting for DNS semaphore in delete_cloudflare_dns_record for {hostname}") + return False + + if not zone_id or not hostname or not tunnel_id: + logging.error("delete_cloudflare_dns_record: Missing required arguments.") + return False + + record_id, is_correct_tunnel = find_dns_record_id(zone_id, hostname, tunnel_id) + if not record_id: + logging.warning(f"DNS record for {hostname} in zone {zone_id} (for tunnel {tunnel_id}) not found to delete. Assuming success or already deleted.") + return True + + + logging.info(f"Attempting to delete DNS record for {hostname} in zone {zone_id} (ID: {record_id})") + endpoint = f"/zones/{zone_id}/dns_records/{record_id}" + try: + cf_api_request("DELETE", endpoint) + logging.info(f"Successfully submitted deletion for DNS record {hostname} (ID: {record_id}) in zone {zone_id}.") + return True + except requests.exceptions.RequestException as e: + if e.response is not None and e.response.status_code == 404: + logging.warning(f"DNS record {record_id} for {hostname} in zone {zone_id} not found during delete attempt (404). Treating as success.") + return True + logging.error(f"API error deleting DNS record {record_id} for {hostname} in zone {zone_id}: {e}") + return False + except Exception as e: + logging.error(f"Unexpected error deleting DNS record {record_id} for {hostname} in zone {zone_id}: {e}", exc_info=True) + return False + finally: + if acquired: + dns_semaphore.release() + +def get_cloudflare_account_email(): + global _cached_account_email, _cached_account_email_timestamp + current_time = time.time() + + with _cache_lock: + if _cached_account_email and (current_time - _cached_account_email_timestamp < config.ACCOUNT_EMAIL_CACHE_TTL): + logging.debug(f"Returning cached Cloudflare account email: {_cached_account_email}") + return _cached_account_email + + logging.info("Fetching Cloudflare account email from API.") + try: + response_data = cf_api_request("GET", "/user") + if response_data and response_data.get("success"): + email = response_data.get("result", {}).get("email") + if email: + logging.info(f"Successfully fetched Cloudflare account email: {email}") + with _cache_lock: # Protect cache write + _cached_account_email = email + _cached_account_email_timestamp = current_time + return email + else: + logging.warning("Cloudflare account email not found in API response.") + return None + else: + logging.warning(f"Failed to fetch Cloudflare account email, API call unsuccessful. Response: {response_data}") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error fetching Cloudflare account email: {e}") + return None + except Exception as e: + logging.error(f"Unexpected error fetching Cloudflare account email: {e}", exc_info=True) + return None + +def get_current_cf_config(tunnel_id_to_query): + if not tunnel_id_to_query: + logging.warning("get_current_cf_config: tunnel_id_to_query not provided.") + return None + + logging.debug(f"Fetching current CF tunnel configuration for tunnel ID {tunnel_id_to_query}.") + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/cfd_tunnel/{tunnel_id_to_query}/configurations" + try: + response_data = cf_api_request("GET", endpoint) + if response_data and response_data.get("success"): + result_data = response_data.get("result") + config_data = None + if isinstance(result_data, dict): + config_data = result_data.get("config") + + if isinstance(config_data, dict): + logging.debug(f"Fetched config for tunnel {tunnel_id_to_query}: {config_data}") + return config_data + elif config_data is None: + logging.info(f"Fetched 'config' for tunnel {tunnel_id_to_query} is null. Returning empty dict.") + return {} + else: + logging.warning(f"Unexpected type for 'config' field in API response for tunnel {tunnel_id_to_query}: {type(config_data)}. Result: {result_data}") + return {} + else: + logging.error(f"Get config API call failed or returned success=false for tunnel {tunnel_id_to_query}: {response_data}") + return None + except requests.exceptions.RequestException as e: + logging.error(f"API error fetching config for tunnel {tunnel_id_to_query}: {e}") + raise + except Exception as e: + logging.error(f"Unexpected error fetching config for tunnel {tunnel_id_to_query}: {e}", exc_info=True) + raise + +def get_all_account_cloudflare_tunnels(): + if not config.CF_ACCOUNT_ID: + logging.warning("CF_ACCOUNT_ID is not configured. Cannot list all Cloudflare tunnels.") + return [] + if not config.CF_API_TOKEN: + logging.error("Cloudflare API token not configured. Cannot list all account tunnels.") + return [] + + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/cfd_tunnel" + params = {"is_deleted": "false", "per_page": 100} + + logging.info(f"Attempting to list all Cloudflare tunnels for account ID {config.CF_ACCOUNT_ID}") + all_tunnels = [] + page = 1 + while True: + params["page"] = page + try: + response_data = cf_api_request("GET", endpoint, params=params) + tunnels_page = response_data.get("result", []) + if not isinstance(tunnels_page, list): + logging.error(f"Unexpected data format for account tunnels list page {page}: {type(tunnels_page)}. Response: {response_data}") + break + + all_tunnels.extend(tunnels_page) + + + if len(tunnels_page) < params["per_page"]: + break + page += 1 + if page > 10: + logging.warning("Exceeded 10 pages fetching tunnels. Assuming all fetched or API issue.") + break + except requests.exceptions.RequestException as e: + logging.error(f"API error listing Cloudflare tunnels (page {page}): {e}") + + return [] + except Exception as e: + logging.error(f"Unexpected error listing Cloudflare tunnels (page {page}): {e}", exc_info=True) + return [] + + logging.info(f"Successfully retrieved {len(all_tunnels)} Cloudflare tunnels from the account (any status).") + + + desired_statuses = {"healthy", "degraded", "down", "inactive", "pending"} + filtered_tunnels = [ + tunnel for tunnel in all_tunnels if tunnel.get("status", "").lower() in desired_statuses + ] + + logging.info(f"Returning {len(filtered_tunnels)} tunnels after client-side status check for relevant statuses.") + filtered_tunnels.sort(key=lambda t: t.get("name", "").lower()) + return filtered_tunnels + +def get_dns_records_for_tunnel(zone_id, tunnel_id): + if not zone_id or not tunnel_id: + logging.warning("get_dns_records_for_tunnel: Missing zone_id or tunnel_id.") + return [] + + + zone_details = get_zone_details_by_id(zone_id) + zone_name_for_display = zone_details.get("name") if zone_details else zone_id + + expected_cname_content = f"{tunnel_id}.cfargotunnel.com" + endpoint = f"/zones/{zone_id}/dns_records" + params = {"type": "CNAME", "content": expected_cname_content, "per_page": 100} + + logging.info(f"Fetching DNS records for tunnel {tunnel_id} in zone '{zone_name_for_display}' ({zone_id}) with content '{expected_cname_content}'") + + all_records_for_tunnel_in_zone = [] + page = 1 + while True: + params["page"] = page + try: + response_data = cf_api_request("GET", endpoint, params=params) + dns_records_page = response_data.get("result", []) + + if not isinstance(dns_records_page, list): + logging.error(f"Unexpected data format for DNS records list in zone {zone_name_for_display}, page {page}: {type(dns_records_page)}") + break + + processed_page_records = [] + for record in dns_records_page: + if record.get("name"): + processed_page_records.append({ + "name": record.get("name"), + "id": record.get("id"), + "zone_id": zone_id, + "zone_name": zone_name_for_display + }) + all_records_for_tunnel_in_zone.extend(processed_page_records) + + if len(dns_records_page) < params["per_page"]: + break + page += 1 + if page > 10: # Safety break + logging.warning(f"Exceeded 10 pages fetching DNS records for tunnel {tunnel_id} in zone {zone_name_for_display}.") + break + except requests.exceptions.RequestException as e: + logging.error(f"API error fetching DNS records for tunnel {tunnel_id} in zone {zone_name_for_display} (page {page}): {e}") + return [] + except Exception as e: + logging.error(f"Unexpected error fetching DNS records for tunnel {tunnel_id} in zone {zone_name_for_display} (page {page}): {e}", exc_info=True) + return [] + + return all_records_for_tunnel_in_zone \ No newline at end of file diff --git a/dockflare/app/core/docker_handler.py b/dockflare/app/core/docker_handler.py new file mode 100644 index 0000000..6275d4b --- /dev/null +++ b/dockflare/app/core/docker_handler.py @@ -0,0 +1,390 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# app/core/docker_handler.py + +import logging +import time +import requests +from docker.errors import NotFound, APIError + +from app import config, docker_client, cloudflared_agent_state, tunnel_state + +from app.core.state_manager import managed_rules, state_lock, save_state +from app.core.tunnel_manager import update_cloudflare_config +from app.core.cloudflare_api import create_cloudflare_dns_record, get_zone_id_from_name +from app.core.access_manager import handle_access_policy_from_labels + +def is_valid_hostname(hostname): + if not hostname: return False + if hostname.startswith('*.'): + domain_part = hostname[2:] + if not domain_part or len(domain_part) > 253: return False + for label in domain_part.split('.'): + if not label or len(label) > 63: return False + if not all(c.isalnum() or c == '-' for c in label): return False + if label.startswith('-') or label.endswith('-'): return False + return True + if len(hostname) > 253: return False + labels = hostname.split('.') + for label in labels: + if not label or len(label) > 63: return False + if not all(c.isalnum() or c == '-' for c in label): return False + if label.startswith('-') or label.endswith('-'): return False + return True + +def is_valid_service(service): + import re + if not service: return False + return (re.match(r"^(https?|tcp|unix)://", service) or + re.match(r"^[a-zA-Z0-9._-]+:\d+$", service)) is not None + + +def process_container_start(container_obj): + if not container_obj: + return + + container_id_val = None + container_name_val = "UnknownContainer" + try: + container_id_val = container_obj.id + container_obj.reload() + container_name_val = container_obj.name + labels = container_obj.labels + + enabled_label_key = f"{config.LABEL_PREFIX}.enable" + is_enabled = labels.get(enabled_label_key, "false").lower() in ["true", "1", "t", "yes"] + if not is_enabled: + logging.debug(f"Ignoring start: {container_name_val} ({container_id_val[:12]}): '{enabled_label_key}' not true.") + return + + hostnames_to_process = [] + + default_access_policy_type_label = labels.get(f"{config.LABEL_PREFIX}.access.policy") + default_access_app_name_label = labels.get(f"{config.LABEL_PREFIX}.access.name") + default_access_session_duration_label = labels.get(f"{config.LABEL_PREFIX}.access.session_duration", "24h") + default_access_app_launcher_visible_label = labels.get(f"{config.LABEL_PREFIX}.access.app_launcher_visible", "false").lower() in ["true", "1", "t", "yes"] + default_access_allowed_idps_label_str = labels.get(f"{config.LABEL_PREFIX}.access.allowed_idps") + default_access_auto_redirect_label = labels.get(f"{config.LABEL_PREFIX}.access.auto_redirect_to_identity", "false").lower() in ["true", "1", "t", "yes"] + default_access_custom_rules_label_str = labels.get(f"{config.LABEL_PREFIX}.access.custom_rules") + + hostname_label = labels.get(f"{config.LABEL_PREFIX}.hostname") + service_label = labels.get(f"{config.LABEL_PREFIX}.service") + zone_name_label = labels.get(f"{config.LABEL_PREFIX}.zonename") + no_tls_verify_label = labels.get(f"{config.LABEL_PREFIX}.no_tls_verify", "false").lower() in ["true", "1", "t", "yes"] + + if hostname_label and service_label: + if is_valid_hostname(hostname_label) and is_valid_service(service_label): + hostnames_to_process.append({ + "hostname": hostname_label, "service": service_label, "zone_name": zone_name_label, + "no_tls_verify": no_tls_verify_label, + "access_policy_type": default_access_policy_type_label, + "access_app_name": default_access_app_name_label, + "access_session_duration": default_access_session_duration_label, + "access_app_launcher_visible": default_access_app_launcher_visible_label, + "access_allowed_idps_str": default_access_allowed_idps_label_str, + "access_auto_redirect": default_access_auto_redirect_label, + "access_custom_rules_str": default_access_custom_rules_label_str + }) + else: + logging.warning(f"Ignoring invalid direct label pair for {container_name_val}: Hostname '{hostname_label}', Service '{service_label}'") + + index = 0 + while True: + prefix = f"{config.LABEL_PREFIX}.{index}" + hostname_indexed = labels.get(f"{prefix}.hostname") + if not hostname_indexed: break + + service_indexed = labels.get(f"{prefix}.service", service_label) + if not service_indexed: + logging.warning(f"Ignoring indexed hostname {hostname_indexed} for {container_name_val}: Missing service for index {index} and no default service label.") + index += 1 + continue + + zone_name_indexed = labels.get(f"{prefix}.zonename", zone_name_label) + no_tls_verify_indexed_val = labels.get(f"{prefix}.no_tls_verify", str(no_tls_verify_label).lower()) + no_tls_verify_indexed = no_tls_verify_indexed_val.lower() in ["true", "1", "t", "yes"] + + access_policy_type_indexed = labels.get(f"{prefix}.access.policy", default_access_policy_type_label) + access_app_name_indexed = labels.get(f"{prefix}.access.name", default_access_app_name_label) + access_session_duration_indexed = labels.get(f"{prefix}.access.session_duration", default_access_session_duration_label) + acc_launcher_val_idx = labels.get(f"{prefix}.access.app_launcher_visible", str(default_access_app_launcher_visible_label).lower()) + access_app_launcher_visible_indexed = acc_launcher_val_idx.lower() in ["true", "1", "t", "yes"] + access_allowed_idps_indexed_str = labels.get(f"{prefix}.access.allowed_idps", default_access_allowed_idps_label_str) + acc_redirect_val_idx = labels.get(f"{prefix}.access.auto_redirect_to_identity", str(default_access_auto_redirect_label).lower()) + access_auto_redirect_indexed = acc_redirect_val_idx.lower() in ["true", "1", "t", "yes"] + access_custom_rules_indexed_str = labels.get(f"{prefix}.access.custom_rules", default_access_custom_rules_label_str) + + if is_valid_hostname(hostname_indexed) and is_valid_service(service_indexed): + hostnames_to_process.append({ + "hostname": hostname_indexed, "service": service_indexed, "zone_name": zone_name_indexed, + "no_tls_verify": no_tls_verify_indexed, + "access_policy_type": access_policy_type_indexed, + "access_app_name": access_app_name_indexed, + "access_session_duration": access_session_duration_indexed, + "access_app_launcher_visible": access_app_launcher_visible_indexed, + "access_allowed_idps_str": access_allowed_idps_indexed_str, + "access_auto_redirect": access_auto_redirect_indexed, + "access_custom_rules_str": access_custom_rules_indexed_str + }) + else: + logging.warning(f"Ignoring invalid indexed label pair for {container_name_val} (idx {index}): Hostname '{hostname_indexed}', Service '{service_indexed}'") + index += 1 + + if not hostnames_to_process: + logging.warning(f"No valid hostname configurations found for {container_name_val} ({container_id_val[:12]}) despite being enabled.") + return + + logging.info(f"Found {len(hostnames_to_process)} hostname configurations for container {container_name_val}") + + state_changed_locally = False + needs_tunnel_config_update_due_to_container = False + + for config_item in hostnames_to_process: + hostname = config_item["hostname"] + service = config_item["service"] + zone_name_from_item = config_item["zone_name"] + no_tls_verify_from_item = config_item["no_tls_verify"] + + target_zone_id = None + if zone_name_from_item: + target_zone_id = get_zone_id_from_name(zone_name_from_item) + if not target_zone_id: + logging.error(f"Failed to find Zone ID for '{zone_name_from_item}' for hostname {hostname}. Skipping this hostname.") + continue + elif config.CF_ZONE_ID: + target_zone_id = config.CF_ZONE_ID + else: + logging.error(f"Cannot manage DNS for {hostname}: No Zone ID (label or default). Skipping.") + continue + + with state_lock: + existing_rule = managed_rules.get(hostname) + + if existing_rule and existing_rule.get("source") == "manual": + logging.warning(f"Container {container_name_val} wants hostname '{hostname}', but it's a manual entry. Skipping for this container.") + continue + + current_rule_copy = existing_rule.copy() if existing_rule else {} + + if existing_rule: + if existing_rule.get("status") == "pending_deletion": + existing_rule["status"] = "active" + existing_rule["delete_at"] = None + needs_tunnel_config_update_due_to_container = True + + existing_rule["service"] = service + existing_rule["container_id"] = container_id_val + existing_rule["zone_id"] = target_zone_id + existing_rule["no_tls_verify"] = no_tls_verify_from_item + existing_rule["source"] = "docker" + + if (current_rule_copy.get("service") != service or + current_rule_copy.get("zone_id") != target_zone_id or + current_rule_copy.get("no_tls_verify") != no_tls_verify_from_item or + current_rule_copy.get("status") == "pending_deletion"): + needs_tunnel_config_update_due_to_container = True + + if current_rule_copy != existing_rule: + state_changed_locally = True + + else: # New rule + managed_rules[hostname] = { + "service": service, "container_id": container_id_val, + "status": "active", "delete_at": None, "zone_id": target_zone_id, + "no_tls_verify": no_tls_verify_from_item, + "access_app_id": None, "access_policy_type": None, + "access_app_config_hash": None, "access_policy_ui_override": False, + "source": "docker" + } + existing_rule = managed_rules[hostname] + state_changed_locally = True + needs_tunnel_config_update_due_to_container = True + + if existing_rule.get("access_policy_ui_override", False): + logging.info(f"Access policy for {hostname} is UI-managed. Skipping label-based Access Policy processing.") + else: + if handle_access_policy_from_labels(config_item, existing_rule, None): + state_changed_locally = True + + + if state_changed_locally: + save_state() + + if needs_tunnel_config_update_due_to_container: + logging.info(f"Triggering Cloudflare tunnel config update due to changes for container {container_name_val}.") + if update_cloudflare_config(): # From tunnel_manager + logging.info(f"Tunnel config update successful for container {container_name_val}.") + + effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID + if effective_tunnel_id: + for config_item_dns in hostnames_to_process: + hostname_dns = config_item_dns["hostname"] + + + zone_name_dns = config_item_dns["zone_name"] + target_zone_id_dns_create = get_zone_id_from_name(zone_name_dns) if zone_name_dns else config.CF_ZONE_ID + + if managed_rules.get(hostname_dns, {}).get("source") == "manual": continue + + if target_zone_id_dns_create: + dns_record_id_status = create_cloudflare_dns_record(target_zone_id_dns_create, hostname_dns, effective_tunnel_id) + if dns_record_id_status and dns_record_id_status not in ["semaphore_timeout", "existing_record_unconfirmed"]: + logging.info(f"DNS record management in zone {target_zone_id_dns_create} for {hostname_dns} successful (ID/Status: {dns_record_id_status}).") + elif not dns_record_id_status: + logging.error(f"CRITICAL: Tunnel config for {hostname_dns} may be active but failed to create/verify DNS record in zone {target_zone_id_dns_create}!") + if cloudflared_agent_state: cloudflared_agent_state["last_action_status"] = f"Error: Failed creating DNS for {hostname_dns}." + else: + logging.error(f"Missing Zone ID for {hostname_dns} - cannot manage DNS record.") + else: + logging.error(f"Missing effective Tunnel ID - cannot manage DNS records for {container_name_val}.") + else: + logging.error(f"Failed to update Cloudflare tunnel config for {container_name_val}. DNS records not managed.") + + except NotFound: + logging.warning(f"Container {container_name_val} ({container_id_val[:12] if container_id_val else 'UnknownID'}) not found during start processing.") + except APIError as e: + logging.error(f"Docker API error processing start for {container_name_val}: {e}", exc_info=True) + except requests.exceptions.ConnectionError as e: + logging.error(f"Docker connection error processing start for {container_name_val}: {e}", exc_info=True) + + except Exception as e: + logging.error(f"Unexpected error processing start for {container_name_val}: {e}", exc_info=True) + +def schedule_container_stop(container_id_val): + from datetime import datetime, timedelta, timezone + if not container_id_val: return + logging.info(f"Processing stop event for container {container_id_val[:12]}.") + + state_changed = False + with state_lock: + hostnames_affected = [] + for hn, details in managed_rules.items(): + if details.get("container_id") == container_id_val and details.get("status") == "active" and details.get("source", "docker") == "docker": + hostnames_affected.append(hn) + + if hostnames_affected: + for hostname_to_schedule in hostnames_affected: + rule = managed_rules[hostname_to_schedule] + if rule.get("status") != "pending_deletion": + rule["status"] = "pending_deletion" + grace_delta = timedelta(seconds=config.GRACE_PERIOD_SECONDS) + rule["delete_at"] = datetime.now(timezone.utc) + grace_delta + logging.info(f"Rule for {hostname_to_schedule} (from container {container_id_val[:12]}) scheduled for deletion at {rule['delete_at'].isoformat()}") + state_changed = True + else: + logging.info(f"Rule for {hostname_to_schedule} was already pending deletion.") + else: + logging.info(f"Stop event for {container_id_val[:12]}, but it didn't manage any active Docker-sourced rules.") + + if state_changed: + save_state() + +def docker_event_listener(stop_event_param): + if not docker_client: + logging.error("Docker client unavailable, event listener cannot start.") + return + + logging.info("Starting Docker event listener...") + error_count = 0 + max_errors = 5 + + if stop_event_param is None: + logging.error("docker_event_listener called with None stop_event_param. Listener will not run correctly.") + return + + while not stop_event_param.is_set() and error_count < max_errors: + try: + logging.info("Connecting to Docker event stream...") + + events = docker_client.events(decode=True, since=int(time.time())) + logging.info("Successfully connected to Docker event stream.") + error_count = 0 # Reset on successful connection + + for event in events: + if stop_event_param.is_set(): + logging.info("Stop event received in listener, exiting loop.") + break + + ev_type = event.get("Type") + action = event.get("Action") + actor = event.get("Actor", {}) + cont_id = actor.get("ID") + + logging.debug(f"Docker Event: Type={ev_type}, Action={action}, ID={cont_id[:12] if cont_id else 'N/A'}") + + if ev_type == "container" and cont_id: + if action == "start": + container_instance = None + for attempt in range(3): + try: + container_instance = docker_client.containers.get(cont_id) + + if attempt == 0 and not container_instance.labels.get(f"{config.LABEL_PREFIX}.enable"): + time.sleep(0.2) + container_instance.reload() + + if container_instance.labels.get(f"{config.LABEL_PREFIX}.hostname") or container_instance.labels.get(f"{config.LABEL_PREFIX}.0.hostname"): + logging.debug(f"Container {cont_id[:12]} details retrieved on attempt {attempt+1}.") + break + else: + logging.debug(f"Container {cont_id[:12]} found but key labels missing, retrying ({attempt+1}/3)...") + except NotFound: + logging.debug(f"Container {cont_id[:12]} not found on attempt {attempt+1}, retrying...") + except APIError as e_get_cont: + logging.error(f"Docker API error getting container {cont_id[:12]} on attempt {attempt+1}: {e_get_cont}") + break + except requests.exceptions.ConnectionError as e_conn_cont: + logging.error(f"Docker connection error getting container {cont_id[:12]}: {e_conn_cont}") + raise + except Exception as e_unexp_cont: + logging.error(f"Unexpected error getting container {cont_id[:12]} details: {e_unexp_cont}", exc_info=True) + break + + if attempt < 2: time.sleep(0.2 * (attempt + 1)) + else: logging.warning(f"Failed to get container {cont_id[:12]} details or key labels after multiple attempts.") + + if container_instance: + try: + process_container_start(container_instance) + except Exception as e_proc_start: + logging.error(f"Error processing start event for {cont_id[:12]}: {e_proc_start}", exc_info=True) + + elif action in ["stop", "die", "destroy", "kill"]: + try: + schedule_container_stop(cont_id) + except Exception as e_proc_stop: + logging.error(f"Error processing stop/die/destroy/kill event for {cont_id[:12]}: {e_proc_stop}", exc_info=True) + + except requests.exceptions.ConnectionError as e_conn_stream: # From docker_client.events() + error_count += 1 + logging.error(f"Docker listener connection error: {e_conn_stream}. Reconnecting ({error_count}/{max_errors})...") + if not stop_event_param.is_set(): stop_event_param.wait(min(30, 2 * error_count)) # Increased base wait + except APIError as e_api_stream: + error_count += 1 + logging.error(f"Docker listener API error: {e_api_stream}. Reconnecting ({error_count}/{max_errors})...") + if not stop_event_param.is_set(): stop_event_param.wait(min(30, 2 * error_count)) + except Exception as e_unexp_stream: + error_count += 1 + logging.error(f"Unexpected error in Docker event listener: {e_unexp_stream}. Reconnecting ({error_count}/{max_errors})...", exc_info=True) + if not stop_event_param.is_set(): stop_event_param.wait(min(30, 2 * error_count)) + + if stop_event_param.is_set(): + break + + if error_count >= max_errors: + logging.error("Docker event listener stopping after multiple consecutive errors.") + logging.info("Docker event listener stopped.") \ No newline at end of file diff --git a/dockflare/app/core/reconciler.py b/dockflare/app/core/reconciler.py new file mode 100644 index 0000000..8ee978a --- /dev/null +++ b/dockflare/app/core/reconciler.py @@ -0,0 +1,409 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# app/core/reconciler.py + +import logging +import time +import threading +from datetime import datetime, timedelta, timezone +import json + +from app import config, docker_client, tunnel_state, cloudflared_agent_state, app as flask_app + + +from app.core.state_manager import managed_rules, state_lock, save_state +from app.core.cloudflare_api import ( + get_zone_id_from_name, + create_cloudflare_dns_record, + delete_cloudflare_dns_record +) +from app.core.access_manager import ( + handle_access_policy_from_labels, + delete_cloudflare_access_application +) +from app.core.tunnel_manager import update_cloudflare_config + + +def _get_hostname_configs_from_container(container_obj): + """Helper to extract hostname configurations from a container's labels.""" + labels = container_obj.labels + container_id_val = container_obj.id + container_name_val = container_obj.name + + hostnames_configs = [] + + default_access_policy_type = labels.get(f"{config.LABEL_PREFIX}.access.policy") + default_access_app_name = labels.get(f"{config.LABEL_PREFIX}.access.name") + default_session_duration = labels.get(f"{config.LABEL_PREFIX}.access.session_duration", "24h") + default_app_launcher_visible = labels.get(f"{config.LABEL_PREFIX}.access.app_launcher_visible", "false").lower() in ["true", "1", "t", "yes"] + default_allowed_idps_str = labels.get(f"{config.LABEL_PREFIX}.access.allowed_idps") + default_auto_redirect = labels.get(f"{config.LABEL_PREFIX}.access.auto_redirect_to_identity", "false").lower() in ["true", "1", "t", "yes"] + default_custom_rules_str = labels.get(f"{config.LABEL_PREFIX}.access.custom_rules") + + h_main = labels.get(f"{config.LABEL_PREFIX}.hostname") + s_main = labels.get(f"{config.LABEL_PREFIX}.service") + zn_main = labels.get(f"{config.LABEL_PREFIX}.zonename") + ntv_main_str = labels.get(f"{config.LABEL_PREFIX}.no_tls_verify", "false") + ntv_main = ntv_main_str.lower() in ["true", "1", "t", "yes"] + + if h_main and s_main: # Direct labels + + hostnames_configs.append({ + "hostname": h_main, "service": s_main, "zone_name": zn_main, "no_tls_verify": ntv_main, + "container_id": container_id_val, "container_name": container_name_val, + "access_policy_type": default_access_policy_type, + "access_app_name": default_access_app_name, + "access_session_duration": default_session_duration, + "access_app_launcher_visible": default_app_launcher_visible, + "access_allowed_idps_str": default_allowed_idps_str, + "access_auto_redirect": default_auto_redirect, + "access_custom_rules_str": default_custom_rules_str + }) + + idx = 0 + while True: + pfx = f"{config.LABEL_PREFIX}.{idx}" + h_idx = labels.get(f"{pfx}.hostname") + if not h_idx: break + + s_idx = labels.get(f"{pfx}.service", s_main) + if not s_idx: idx += 1; continue + + zn_idx = labels.get(f"{pfx}.zonename", zn_main) + ntv_idx_str = labels.get(f"{pfx}.no_tls_verify", ntv_main_str) + ntv_idx = ntv_idx_str.lower() in ["true", "1", "t", "yes"] + + acc_pol_idx = labels.get(f"{pfx}.access.policy", default_access_policy_type) + acc_name_idx = labels.get(f"{pfx}.access.name", default_access_app_name) + acc_sess_idx = labels.get(f"{pfx}.access.session_duration", default_session_duration) + acc_vis_idx_str = labels.get(f"{pfx}.access.app_launcher_visible", str(default_app_launcher_visible).lower()) + acc_vis_idx = acc_vis_idx_str.lower() in ["true", "1", "t", "yes"] + acc_idps_idx = labels.get(f"{pfx}.access.allowed_idps", default_allowed_idps_str) + acc_redir_idx_str = labels.get(f"{pfx}.access.auto_redirect_to_identity", str(default_auto_redirect).lower()) + acc_redir_idx = acc_redir_idx_str.lower() in ["true", "1", "t", "yes"] + acc_custom_idx = labels.get(f"{pfx}.access.custom_rules", default_custom_rules_str) + + hostnames_configs.append({ + "hostname": h_idx, "service": s_idx, "zone_name": zn_idx, "no_tls_verify": ntv_idx, + "container_id": container_id_val, "container_name": container_name_val, + "access_policy_type": acc_pol_idx, "access_app_name": acc_name_idx, + "access_session_duration": acc_sess_idx, "access_app_launcher_visible": acc_vis_idx, + "access_allowed_idps_str": acc_idps_idx, "access_auto_redirect": acc_redir_idx, + "access_custom_rules_str": acc_custom_idx + }) + idx += 1 + return hostnames_configs + + +def _run_reconciliation_logic(): + logging.info("[Reconcile Thread] Starting state reconciliation logic...") + needs_tunnel_config_update = False + state_changed_locally = False + max_total_time = 480 + reconciliation_start_time = time.time() + + flask_app.reconciliation_info = { + "in_progress": True, "progress": 0, "total_items": 0, + "processed_items": 0, "start_time": reconciliation_start_time, + "status": "Initializing reconciliation..." + } + + running_labeled_hostnames_details = {} + try: + flask_app.reconciliation_info["status"] = "Scanning containers for services and access policies..." + containers = docker_client.containers.list(sparse=False, all=config.SCAN_ALL_NETWORKS) + container_count = len(containers) + flask_app.reconciliation_info["total_items"] = container_count + processed_container_count = 0 + batch_size = 3 if not config.USE_EXTERNAL_CLOUDFLARED else 2 + + for i in range(0, container_count, batch_size): + if time.time() - reconciliation_start_time > 60: + logging.warning("[Reconcile] Timeout during container scanning phase.") + flask_app.reconciliation_info["status"] = "Container scan timeout (partial data)" + break + + batch = containers[i:i+batch_size] + processed_container_count += len(batch) + flask_app.reconciliation_info["progress"] = min(100, int((processed_container_count / container_count) * 100)) if container_count > 0 else 0 + flask_app.reconciliation_info["processed_items"] = processed_container_count + flask_app.reconciliation_info["status"] = f"Scanning containers: batch {i//batch_size + 1}/{(container_count+batch_size-1)//batch_size}" + + for c_obj in batch: + try: + c_obj.reload() + if c_obj.labels.get(f"{config.LABEL_PREFIX}.enable", "false").lower() in ["true", "1", "t", "yes"]: + configs = _get_hostname_configs_from_container(c_obj) + for conf in configs: + if conf["hostname"] in running_labeled_hostnames_details: + logging.warning(f"[Reconcile] Duplicate hostname '{conf['hostname']}' found. Using from: {conf['container_name']}.") + running_labeled_hostnames_details[conf["hostname"]] = conf + except Exception as e_cont_scan: + logging.error(f"[Reconcile] Error processing container {c_obj.id[:12] if c_obj and c_obj.id else 'N/A'}: {e_cont_scan}") + logging.info(f"[Reconcile] Found {len(running_labeled_hostnames_details)} running hostnames with DockFlare labels.") + except Exception as e_phase1: + logging.error(f"[Reconcile] Error in container scanning phase: {e_phase1}", exc_info=True) + flask_app.reconciliation_info["status"] = f"Container scan error: {str(e_phase1)}" + + flask_app.reconciliation_info["status"] = "Comparing state and reconciling cloud resources..." + flask_app.reconciliation_info["total_items"] = len(running_labeled_hostnames_details) + len(managed_rules) + flask_app.reconciliation_info["processed_items"] = 0 # Reset for this phase + processed_reconcile_items = 0 + hostnames_requiring_dns_setup = [] + + with state_lock: + now_utc = datetime.now(timezone.utc) + current_managed_hostnames_in_state = set(managed_rules.keys()) + + for hostname, desired_details in running_labeled_hostnames_details.items(): + processed_reconcile_items +=1 + flask_app.reconciliation_info["processed_items"] = processed_reconcile_items + flask_app.reconciliation_info["progress"] = min(100, int((processed_reconcile_items / flask_app.reconciliation_info["total_items"]) * 100)) if flask_app.reconciliation_info["total_items"] > 0 else 0 + flask_app.reconciliation_info["status"] = f"Reconciling (active): {hostname}" + + if time.time() - reconciliation_start_time > max_total_time - 30: break + + existing_rule = managed_rules.get(hostname) + if existing_rule and existing_rule.get("source") == "manual": + continue + + target_zone_id = get_zone_id_from_name(desired_details["zone_name"]) if desired_details["zone_name"] else config.CF_ZONE_ID + if not target_zone_id: + logging.error(f"[Reconcile] No zone ID for {hostname}, skipping its reconciliation.") + continue + + if not existing_rule: + managed_rules[hostname] = { + "service": desired_details["service"], "container_id": desired_details["container_id"], + "status": "active", "delete_at": None, "zone_id": target_zone_id, + "no_tls_verify": desired_details["no_tls_verify"], + "access_app_id": None, "access_policy_type": None, "access_app_config_hash": None, + "access_policy_ui_override": False, "source": "docker" + } + existing_rule = managed_rules[hostname] + state_changed_locally = True + needs_tunnel_config_update = True + hostnames_requiring_dns_setup.append((hostname, target_zone_id)) + else: + changed_in_reconcile = False + if existing_rule.get("status") == "pending_deletion": + existing_rule["status"] = "active"; existing_rule["delete_at"] = None + changed_in_reconcile = True; needs_tunnel_config_update = True + + if existing_rule.get("service") != desired_details["service"]: + existing_rule["service"] = desired_details["service"]; changed_in_reconcile = True; needs_tunnel_config_update = True + if existing_rule.get("no_tls_verify") != desired_details["no_tls_verify"]: + existing_rule["no_tls_verify"] = desired_details["no_tls_verify"]; changed_in_reconcile = True; needs_tunnel_config_update = True + if existing_rule.get("zone_id") != target_zone_id: + existing_rule["zone_id"] = target_zone_id; changed_in_reconcile = True; needs_tunnel_config_update = True # DNS needs re-check for new zone + if existing_rule.get("container_id") != desired_details["container_id"]: + existing_rule["container_id"] = desired_details["container_id"]; changed_in_reconcile = True + + existing_rule["source"] = "docker" + if changed_in_reconcile: state_changed_locally = True + hostnames_requiring_dns_setup.append((hostname, target_zone_id)) + + if existing_rule.get("access_policy_ui_override", False): + pass # Skip label processing if UI override + else: + if handle_access_policy_from_labels(desired_details, existing_rule, None): + state_changed_locally = True + + hostnames_in_state_but_not_running = list(current_managed_hostnames_in_state - set(running_labeled_hostnames_details.keys())) + for hostname_to_check in hostnames_in_state_but_not_running: + processed_reconcile_items +=1 + flask_app.reconciliation_info["processed_items"] = processed_reconcile_items + + if time.time() - reconciliation_start_time > max_total_time - 20: break + + rule = managed_rules.get(hostname_to_check) + if rule and rule.get("status") == "active" and rule.get("source", "docker") == "docker": + logging.info(f"[Reconcile] Docker-managed rule {hostname_to_check} active but container/labels gone. Marking for deletion.") + rule["status"] = "pending_deletion" + rule["delete_at"] = now_utc + timedelta(seconds=config.GRACE_PERIOD_SECONDS) + state_changed_locally = True + elif rule and rule.get("source") == "manual" and rule.get("zone_id"): + hostnames_requiring_dns_setup.append((hostname_to_check, rule.get("zone_id"))) + + + if state_changed_locally: + flask_app.reconciliation_info["status"] = "Saving reconciled state..." + save_state() + + if time.time() - reconciliation_start_time > max_total_time - 15: + logging.warning("[Reconcile] Timeout before Tunnel/DNS operations.") + needs_tunnel_config_update = False # Skip if timeout + + if needs_tunnel_config_update: + flask_app.reconciliation_info["status"] = "Updating Cloudflare tunnel configuration..." + if not config.USE_EXTERNAL_CLOUDFLARED: + if not update_cloudflare_config(): + logging.error("[Reconcile] Failed to update Cloudflare tunnel configuration.") + flask_app.reconciliation_info["status"] = "Error: Failed tunnel config update." + else: + logging.info("[Reconcile] Cloudflare tunnel configuration updated successfully.") + flask_app.reconciliation_info["status"] = "Tunnel configuration updated." + else: + logging.info("[Reconcile] External mode: Skipping DockFlare-managed tunnel config update.") + flask_app.reconciliation_info["status"] = "Tunnel config update skipped (external mode)." + + if hostnames_requiring_dns_setup: + dns_total = len(hostnames_requiring_dns_setup) + flask_app.reconciliation_info["status"] = f"Setting up DNS for {dns_total} hostnames..." + dns_processed_count = 0 + effective_tunnel_id_for_dns = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID + + if effective_tunnel_id_for_dns: + unique_dns_setups = list(set(hostnames_requiring_dns_setup)) + logging.info(f"[Reconcile] Unique hostnames for DNS setup/check: {len(unique_dns_setups)}") + for hostname_dns, zone_id_dns in unique_dns_setups: + dns_processed_count +=1 + flask_app.reconciliation_info["status"] = f"DNS for {hostname_dns} ({dns_processed_count}/{len(unique_dns_setups)})" + if time.time() - reconciliation_start_time > max_total_time - 5: break + create_cloudflare_dns_record(zone_id_dns, hostname_dns, effective_tunnel_id_for_dns) + if config.USE_EXTERNAL_CLOUDFLARED: time.sleep(0.1) + else: + logging.error("[Reconcile] Cannot setup DNS: Effective tunnel ID is missing.") + flask_app.reconciliation_info["status"] = "Error: Missing tunnel ID for DNS setup." + + flask_app.reconciliation_info["in_progress"] = False + flask_app.reconciliation_info["progress"] = 100 + final_status = flask_app.reconciliation_info.get("status", "Reconciliation finished.") + if not final_status.endswith("(Final)"): final_status += " (Final)" + flask_app.reconciliation_info["status"] = final_status + flask_app.reconciliation_info["completed_at"] = time.time() + duration = flask_app.reconciliation_info["completed_at"] - flask_app.reconciliation_info["start_time"] + logging.info(f"[Reconcile Thread] Reconciliation complete. Duration: {duration:.2f}s. Status: {flask_app.reconciliation_info['status']}") + + +def reconcile_state_threaded(): + if not docker_client: + logging.warning("Docker client unavailable, skipping reconciliation.") + return + if not tunnel_state.get("id") and not config.EXTERNAL_TUNNEL_ID: + logging.warning("Tunnel not initialized (no ID), skipping reconciliation.") + return + + if not hasattr(flask_app, 'reconciliation_info'): + logging.error("flask_app.reconciliation_info not initialized. Cannot start reconciliation.") + + flask_app.reconciliation_info = {"in_progress": False} + + if flask_app.reconciliation_info.get("in_progress", False): + logging.info("Reconciliation is already in progress. Skipping new request.") + return + + reconcile_thread = threading.Thread( + target=_run_reconciliation_logic, + name="ReconciliationThread", + daemon=True + ) + reconcile_thread.start() + logging.info(f"Started reconciliation in background thread {reconcile_thread.name}") + +def cleanup_expired_rules(stop_event_param): + logging.info("Starting cleanup task for expired rules...") + if stop_event_param is None: + logging.error("cleanup_expired_rules called with None stop_event_param. Task will not run correctly.") + return + + while not stop_event_param.is_set(): + next_check_time = time.time() + config.CLEANUP_INTERVAL_SECONDS + try: + logging.debug("Running cleanup check for expired rules...") + rules_to_process_for_deletion = {} + now_utc = datetime.now(timezone.utc) + state_changed_in_cleanup = False + + with state_lock: + for hostname, details in list(managed_rules.items()): + if details.get("status") == "pending_deletion" and details.get("source", "docker") == "docker": + delete_at = details.get("delete_at") + is_expired = False + if isinstance(delete_at, datetime): + delete_at_utc = delete_at.astimezone(timezone.utc) if delete_at.tzinfo else delete_at.replace(tzinfo=timezone.utc) + if delete_at_utc <= now_utc: + is_expired = True + else: + logging.warning(f"Rule {hostname} pending delete but has invalid/missing delete_at: {delete_at}. Marking for immediate deletion.") + is_expired = True + + if is_expired: + rules_to_process_for_deletion[hostname] = { + "zone_id": details.get("zone_id", config.CF_ZONE_ID), + "access_app_id": details.get("access_app_id") + } + elif details.get("source") == "manual" and details.get("status") == "pending_deletion": + logging.warning(f"Manual rule {hostname} found 'pending_deletion'. Resetting to 'active'.") + details["status"] = "active"; details["delete_at"] = None + state_changed_in_cleanup = True + + if state_changed_in_cleanup and not rules_to_process_for_deletion: + save_state() + + if rules_to_process_for_deletion: + hostnames_fully_cleaned = [] + effective_tunnel_id_cleanup = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID + + for hostname, delete_info in rules_to_process_for_deletion.items(): + zone_id_del = delete_info["zone_id"] + access_app_id_del = delete_info["access_app_id"] + + dns_deleted = False + if zone_id_del and effective_tunnel_id_cleanup: + if delete_cloudflare_dns_record(zone_id_del, hostname, effective_tunnel_id_cleanup): + dns_deleted = True + else: logging.error(f"Failed DNS delete for expired rule {hostname} in zone {zone_id_del}.") + elif not zone_id_del: logging.warning(f"Skipping DNS delete for {hostname}: Zone ID unavailable.") + elif not effective_tunnel_id_cleanup: logging.warning(f"Skipping DNS delete for {hostname}: Tunnel ID unavailable.") + + access_app_deleted = False + if access_app_id_del: + if delete_cloudflare_access_application(access_app_id_del): + access_app_deleted = True + else: logging.error(f"Failed Access App delete for {hostname}, App ID: {access_app_id_del}.") + else: access_app_deleted = True # No app to delete + + hostnames_fully_cleaned.append(hostname) + + if hostnames_fully_cleaned: + config_updated_after_delete = False + if not config.USE_EXTERNAL_CLOUDFLARED: + if update_cloudflare_config(): + config_updated_after_delete = True + else: + logging.error("Failed to update Cloudflare tunnel config during rule cleanup. Rules may remain in local state temporarily.") + else: + config_updated_after_delete = True + + if config_updated_after_delete: + with state_lock: + deleted_count = 0 + for hostname_rem in hostnames_fully_cleaned: + if hostname_rem in managed_rules and managed_rules[hostname_rem].get("status") == "pending_deletion": + del managed_rules[hostname_rem] + deleted_count += 1 + if deleted_count > 0: + logging.info(f"Removed {deleted_count} rules from local state after cleanup.") + save_state() + except Exception as e_cleanup: + logging.error(f"Error in cleanup task loop: {e_cleanup}", exc_info=True) + + wait_duration = max(0, next_check_time - time.time()) + if not stop_event_param.is_set(): stop_event_param.wait(wait_duration) + + logging.info("Cleanup task for expired rules stopped.") \ No newline at end of file diff --git a/dockflare/app/core/state_manager.py b/dockflare/app/core/state_manager.py new file mode 100644 index 0000000..a96f2a1 --- /dev/null +++ b/dockflare/app/core/state_manager.py @@ -0,0 +1,136 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# app/core/state_manager.py +import json +import logging +import os +import threading +from datetime import datetime, timezone + +from app import config + +managed_rules = {} +state_lock = threading.Lock() + +def _deserialize_datetime(dt_str): + if not dt_str: + return None + try: + if dt_str.endswith('Z'): + dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00')) + else: + dt = datetime.fromisoformat(dt_str) + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc) + except ValueError as date_err: + logging.warning(f"Could not parse datetime string '{dt_str}': {date_err}. Returning None.") + return None + +def load_state(): + global managed_rules + state_dir = os.path.dirname(config.STATE_FILE_PATH) + if not os.path.exists(state_dir): + try: + os.makedirs(state_dir, exist_ok=True) + logging.info(f"Created directory for state file: {state_dir}") + except OSError as e: + logging.error(f"FATAL: Could not create directory for state file {state_dir}: {e}. State persistence will fail.") + managed_rules = {} + return + + if not os.path.exists(config.STATE_FILE_PATH): + logging.info(f"State file '{config.STATE_FILE_PATH}' not found, starting fresh.") + managed_rules = {} + return + + with state_lock: + try: + with open(config.STATE_FILE_PATH, 'r') as f: + loaded_data = json.load(f) + + processed_rules = {} + for hostname, rule in loaded_data.items(): + rule_copy = rule.copy() + delete_at_val = rule_copy.get("delete_at") + if isinstance(delete_at_val, str): + rule_copy["delete_at"] = _deserialize_datetime(delete_at_val) + elif not isinstance(delete_at_val, (datetime, type(None))): + logging.warning(f"Invalid type for delete_at for {hostname}: {type(delete_at_val)}. Setting to None.") + rule_copy["delete_at"] = None + + if "zone_id" not in rule_copy: + logging.warning(f"Rule for {hostname} loaded from state is missing 'zone_id'. Will attempt to re-determine on reconcile.") + rule_copy["zone_id"] = None + + rule_copy.setdefault("access_app_id", None) + rule_copy.setdefault("access_policy_type", None) + rule_copy.setdefault("access_app_config_hash", None) + rule_copy.setdefault("access_policy_ui_override", False) + rule_copy.setdefault("source", "docker") + processed_rules[hostname] = rule_copy + + managed_rules = processed_rules + logging.info(f"Loaded state for {len(managed_rules)} rules from {config.STATE_FILE_PATH}") + except (json.JSONDecodeError, IOError, OSError) as e: + logging.error(f"Error loading state from {config.STATE_FILE_PATH}: {e}. Starting fresh.", exc_info=True) + managed_rules = {} + except Exception as e: + logging.error(f"Unexpected error during state loading from {config.STATE_FILE_PATH}: {e}. Starting fresh.", exc_info=True) + managed_rules = {} + + +def save_state(): + global managed_rules + serializable_state = {} + + with state_lock: + for hostname, rule in managed_rules.items(): + rule_copy = rule.copy() + delete_at_val = rule_copy.get("delete_at") + if isinstance(delete_at_val, datetime): + rule_copy["delete_at"] = delete_at_val.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z') + + if "zone_id" not in rule_copy: + logging.warning(f"Attempting to save rule for {hostname} without zone_id!") + rule_copy["zone_id"] = None + + rule_copy.setdefault("access_app_id", None) + rule_copy.setdefault("access_policy_type", None) + rule_copy.setdefault("access_app_config_hash", None) + rule_copy.setdefault("access_policy_ui_override", False) + rule_copy.setdefault("source", "docker") + + serializable_state[hostname] = rule_copy + + try: + state_dir = os.path.dirname(config.STATE_FILE_PATH) + if not os.path.exists(state_dir): + try: + os.makedirs(state_dir, exist_ok=True) + logging.info(f"Created directory {state_dir} before saving state.") + except OSError as e: + logging.error(f"Could not create directory {state_dir} for state file: {e}. Save failed.") + return + + temp_file_path = config.STATE_FILE_PATH + ".tmp" + with open(temp_file_path, 'w') as f: + json.dump(serializable_state, f, indent=2) + os.replace(temp_file_path, config.STATE_FILE_PATH) + logging.debug(f"Saved state for {len(managed_rules)} rules to {config.STATE_FILE_PATH}") + except (IOError, OSError) as e: + logging.error(f"Error saving state to {config.STATE_FILE_PATH}: {e}", exc_info=True) + except Exception as e: + logging.error(f"Unexpected error during state saving to {config.STATE_FILE_PATH}: {e}", exc_info=True) \ No newline at end of file diff --git a/dockflare/app/core/tunnel_manager.py b/dockflare/app/core/tunnel_manager.py new file mode 100644 index 0000000..a2ec077 --- /dev/null +++ b/dockflare/app/core/tunnel_manager.py @@ -0,0 +1,513 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# app/core/tunnel_manager.py + +import logging +import json +import time + +from app import config, docker_client +from app import tunnel_state, cloudflared_agent_state +from app.core import cloudflare_api +from app.core.state_manager import managed_rules, state_lock + +from docker.errors import NotFound, APIError +import requests + +def initialize_tunnel(): + logging.info("Initializing tunnel...") + logging.info(f"Using Cloudflare Account ID: {config.CF_ACCOUNT_ID}") + logging.info(f"API Token available: {'Yes' if config.CF_API_TOKEN else 'No'}") + logging.info(f"Zone ID available: {'Yes: ' + config.CF_ZONE_ID if config.CF_ZONE_ID else 'No'}") + logging.info(f"External mode: {config.USE_EXTERNAL_CLOUDFLARED}") + logging.info(f"External tunnel ID: {config.EXTERNAL_TUNNEL_ID}") + + tunnel_state["status_message"] = "Checking tunnel configuration..." + tunnel_state["error"] = None + + if config.USE_EXTERNAL_CLOUDFLARED: + logging.info("External cloudflared configuration detected.") + if config.EXTERNAL_TUNNEL_ID: + tunnel_id = config.EXTERNAL_TUNNEL_ID + logging.info(f"Using external tunnel ID: {tunnel_id}") + tunnel_state["id"] = tunnel_id + tunnel_state["token"] = None + tunnel_state["status_message"] = "Using external tunnel to manage DNS and inbound routes." + logging.info(f"External tunnel (ID: {tunnel_id}) initialized for DNS and routes.") + return + else: + logging.warning("USE_EXTERNAL_CLOUDFLARED is true but EXTERNAL_TUNNEL_ID is not provided.") + tunnel_state["status_message"] = "Error: External tunnel config missing tunnel ID." + tunnel_state["error"] = "External cloudflared enabled but missing tunnel ID." + return + + if not config.TUNNEL_NAME: + logging.error("TUNNEL_NAME not provided. Required when not using external cloudflared.") + tunnel_state["status_message"] = "Error: Missing required TUNNEL_NAME." + tunnel_state["error"] = "TUNNEL_NAME not provided." + return + + try: + tunnel_id, token = cloudflare_api.find_tunnel_via_api(config.TUNNEL_NAME) + + if not tunnel_id and not tunnel_state.get("error"): + tunnel_state["status_message"] = f"Tunnel '{config.TUNNEL_NAME}' not found. Creating..." + tunnel_id, token = cloudflare_api.create_tunnel_via_api(config.TUNNEL_NAME) + + if tunnel_id and token: + tunnel_state["id"] = tunnel_id + tunnel_state["token"] = token + tunnel_state["status_message"] = "Tunnel setup complete (using API)." + tunnel_state["error"] = None + logging.info(f"Tunnel '{config.TUNNEL_NAME}' initialized. ID: {tunnel_id}") + elif not tunnel_state.get("error"): + tunnel_state["status_message"] = "Tunnel initialization failed." + tunnel_state["error"] = "Failed to find/create tunnel or get token." + logging.error(f"Tunnel init failed for '{config.TUNNEL_NAME}'.") + else: + tunnel_state["status_message"] = "Tunnel initialization failed (see error details)." + + except requests.exceptions.RequestException as e: + logging.error(f"API exception during tunnel initialization for '{config.TUNNEL_NAME}': {e}") + if not tunnel_state.get("error"): + tunnel_state["error"] = f"API error: {e}" + tunnel_state["status_message"] = "Tunnel initialization failed (API error)." + except Exception as e: + logging.error(f"Unhandled exception during tunnel initialization for '{config.TUNNEL_NAME}': {e}", exc_info=True) + if not tunnel_state.get("error"): + tunnel_state["error"] = f"Unexpected init error: {e}" + tunnel_state["status_message"] = "Tunnel initialization failed (unexpected error)." + +def update_cloudflare_config(): + if not tunnel_state.get("id"): + logging.warning("Cannot update CF config, tunnel ID missing in state.") + return False + + with state_lock: + logging.info("Constructing desired Cloudflare tunnel configuration from managed rules...") + desired_dockflare_rules = [] + for hostname, rule_details in managed_rules.items(): + if rule_details.get("status") == "active": + service = rule_details.get("service") + if service: + no_tls_verify = rule_details.get("no_tls_verify", False) + rule_config = {"hostname": hostname, "service": service} + if no_tls_verify: + rule_config["originRequest"] = {"noTLSVerify": True} + desired_dockflare_rules.append(rule_config) + else: + logging.warning(f"Rule {hostname} is active but missing 'service'. Skipping.") + + try: + current_api_config_ruleset = cloudflare_api.get_current_cf_config(tunnel_state["id"]) + except Exception as e: + logging.error(f"Failed to fetch current CF config to compare: {e}") + tunnel_state["error"] = f"Failed get tunnel config: {e}" + return False + + if current_api_config_ruleset is None: + logging.error("Failed to fetch current CF config ruleset; cannot reliably update.") + return False + + current_api_ingress_rules = current_api_config_ruleset.get("ingress", []) + + preserved_api_rules = [] + catch_all_rule_template = {"service": "http_status:404"} + + for api_rule in current_api_ingress_rules: + api_hostname = api_rule.get("hostname") + api_service = api_rule.get("service") + + is_catch_all = api_service == catch_all_rule_template["service"] and not api_hostname + is_wildcard_not_managed_by_dockflare = api_hostname and '*' in api_hostname and not any( + managed_host == api_hostname and managed_rules[managed_host].get("status") == "active" + for managed_host in managed_rules + ) + + if is_catch_all or is_wildcard_not_managed_by_dockflare: + preserved_api_rules.append(api_rule) + continue + + is_managed_by_dockflare = False + for df_rule_hostname, df_rule_details in managed_rules.items(): + if df_rule_hostname == api_hostname and df_rule_details.get("status") == "active": + + is_managed_by_dockflare = True + break + + if not is_managed_by_dockflare and not is_catch_all and not is_wildcard_not_managed_by_dockflare: + logging.info(f"Non-DockFlare, non-wildcard, non-catch-all rule found in API: {api_rule}. It will be removed by authoritative update.") + + + final_ingress_rules_to_put = list(desired_dockflare_rules) + + + for p_rule in preserved_api_rules: + is_duplicate = False + p_hostname = p_rule.get("hostname") + p_service = p_rule.get("service") + for f_rule in final_ingress_rules_to_put: + if f_rule.get("hostname") == p_hostname and f_rule.get("service") == p_service: + is_duplicate = True + break + if not is_duplicate: + final_ingress_rules_to_put.append(p_rule) + + has_catch_all = any(r.get("service") == catch_all_rule_template["service"] and not r.get("hostname") for r in final_ingress_rules_to_put) + if not has_catch_all: + final_ingress_rules_to_put.append(catch_all_rule_template) + logging.info("Adding default catch-all rule as none was found/preserved.") + + def rule_to_comparable_dict(rule): + comp_dict = {"hostname": rule.get("hostname"), "service": rule.get("service")} + if rule.get("originRequest", {}).get("noTLSVerify"): + comp_dict["noTLSVerify"] = True + return comp_dict + + current_api_comparable_set = {json.dumps(rule_to_comparable_dict(r), sort_keys=True) for r in current_api_ingress_rules} + final_put_comparable_set = {json.dumps(rule_to_comparable_dict(r), sort_keys=True) for r in final_ingress_rules_to_put} + + needs_api_update = False + if current_api_comparable_set != final_put_comparable_set: + logging.info("Ingress rule configuration content differs from Cloudflare. Update required.") + needs_api_update = True + else: + + if not needs_api_update and len(current_api_ingress_rules) == len(final_ingress_rules_to_put): + + logging.info("Cloudflare configuration content matches desired state. No API update needed.") + return True + + logging.info(f"Updating Cloudflare tunnel config. Rules to PUT ({len(final_ingress_rules_to_put)} total):") + for r_idx, r_val in enumerate(final_ingress_rules_to_put): + logging.debug(f" Rule {r_idx+1}: {json.dumps(r_val)}") + + if needs_api_update or not (len(current_api_ingress_rules) == len(final_ingress_rules_to_put) and current_api_comparable_set == final_put_comparable_set): + endpoint = f"/accounts/{config.CF_ACCOUNT_ID}/cfd_tunnel/{tunnel_state['id']}/configurations" + config_payload = {"config": {"ingress": final_ingress_rules_to_put}} + + try: + cloudflare_api.cf_api_request("PUT", endpoint, json_data=config_payload) + logging.info("Successfully updated Cloudflare tunnel configuration.") + return True + except Exception as e: + logging.error(f"Failed to update CF tunnel config: {e}", exc_info=True) + tunnel_state["error"] = f"Failed update tunnel config: {e}" + return False + return True + +def get_cloudflared_container(): + if not docker_client: + logging.debug("Docker client unavailable in get_cloudflared_container.") + return None + if config.USE_EXTERNAL_CLOUDFLARED: + return None + if not config.CLOUDFLARED_CONTAINER_NAME: + logging.debug("CLOUDFLARED_CONTAINER_NAME is not set.") + return None + try: + return docker_client.containers.get(config.CLOUDFLARED_CONTAINER_NAME) + except NotFound: + logging.debug(f"Agent container '{config.CLOUDFLARED_CONTAINER_NAME}' not found.") + return None + except APIError as e: + logging.error(f"Docker API error getting agent container '{config.CLOUDFLARED_CONTAINER_NAME}': {e}") + cloudflared_agent_state["last_action_status"] = f"Error get agent: {e}" + return None + except requests.exceptions.ConnectionError as e: + logging.error(f"Docker connection error getting agent container: {e}") + cloudflared_agent_state["last_action_status"] = f"Error connect Docker: {e}" + + return None + except Exception as e: + logging.error(f"Unexpected error getting agent container '{config.CLOUDFLARED_CONTAINER_NAME}': {e}", exc_info=True) + cloudflared_agent_state["last_action_status"] = f"Error unexpected get agent: {e}" + return None + +def update_cloudflared_container_status(): + global docker_client + current_status = cloudflared_agent_state.get("container_status") + + if not docker_client: + if current_status != "docker_unavailable": + logging.warning("Docker client unavailable in update_cloudflared_container_status, attempting reconnect...") + try: + + import docker as docker_lib # Use an alias to avoid conflict if any + docker_client = docker_lib.from_env(timeout=5) + docker_client.ping() + logging.info("Reconnected to Docker daemon during agent status update.") + + except Exception as e_reconnect: + logging.error(f"Failed to reconnect to Docker daemon: {e_reconnect}") + if current_status != "docker_unavailable": + logging.info(f"Agent status changing to docker_unavailable.") + cloudflared_agent_state["container_status"] = "docker_unavailable" + + from app import docker_client as global_dc_ref + if global_dc_ref is not None: + logging.warning("Global docker_client was not None, but reconnect failed. This needs careful handling.") + return + else: + return + + container = get_cloudflared_container() + new_status = "not_found" + if container: + try: + container.reload() + new_status = container.status + except (NotFound, APIError) as e_reload: + new_status = "not_found" + logging.warning(f"Error reloading agent container status (now 'not_found'): {e_reload}") + if cloudflared_agent_state.get("container_status") != "running": + cloudflared_agent_state["last_action_status"] = "Agent container disappeared or API error." + except requests.exceptions.ConnectionError as e_conn: + new_status = "docker_unavailable" + logging.error(f"Docker connection error during agent status reload: {e_conn}") + + from app import docker_client as global_dc_ref + + except Exception as e_unexpected: + logging.error(f"Unexpected error reloading agent status for {container.name}: {e_unexpected}", exc_info=True) + + return + + if current_status != new_status: + logging.info(f"Agent container '{config.CLOUDFLARED_CONTAINER_NAME}' status changed: {current_status} -> {new_status}") + cloudflared_agent_state["container_status"] = new_status + if new_status == 'running' and cloudflared_agent_state.get("last_action_status", "").startswith("Error"): + cloudflared_agent_state["last_action_status"] = None # Clear error if now running + +def ensure_docker_network_exists(network_name): + if not docker_client: + logging.error("Docker client unavailable, cannot check/create network.") + return False + if not network_name: + logging.error("Network name not provided to ensure_docker_network_exists.") + return False + try: + docker_client.networks.get(network_name) + logging.info(f"Docker network '{network_name}' already exists.") + return True + except NotFound: + logging.info(f"Docker network '{network_name}' not found. Creating...") + try: + docker_client.networks.create(network_name, driver="bridge", check_duplicate=True) + logging.info(f"Successfully created Docker network '{network_name}'.") + return True + except APIError as e_create: + if "already exists" in str(e_create).lower(): # More robust check + logging.warning(f"Network '{network_name}' creation reported conflict but NotFound was raised? Assuming it exists now.") + return True # Race condition likely + logging.error(f"Failed to create Docker network '{network_name}': {e_create}", exc_info=True) + cloudflared_agent_state["last_action_status"] = f"Error create net: {e_create}" + return False + except Exception as e_unexp_create: + logging.error(f"Unexpected error creating Docker network '{network_name}': {e_unexp_create}", exc_info=True) + cloudflared_agent_state["last_action_status"] = f"Error: Unexpected create net: {e_unexp_create}" + return False + except APIError as e_get: + logging.error(f"Docker API error checking network '{network_name}': {e_get}", exc_info=True) + cloudflared_agent_state["last_action_status"] = f"Error check net: {e_get}" + return False + except requests.exceptions.ConnectionError as e_conn: + logging.error(f"Docker connection error checking network '{network_name}': {e_conn}") + cloudflared_agent_state["last_action_status"] = f"Error: Docker connect check net." + return False + except Exception as e_unexp_get: + logging.error(f"Unexpected error checking network '{network_name}': {e_unexp_get}", exc_info=True) + cloudflared_agent_state["last_action_status"] = f"Error: Unexpected check net: {e_unexp_get}" + return False + +def start_cloudflared_container(): + logging.info(f"Attempting to start agent container '{config.CLOUDFLARED_CONTAINER_NAME}'...") + cloudflared_agent_state["last_action_status"] = "Starting..." + success_flag = False + + if not docker_client: + msg = "Docker client not available." + logging.error(msg) + cloudflared_agent_state["last_action_status"] = f"Error: {msg}" + return False + if not tunnel_state.get("token"): + msg = "Tunnel token not available." + logging.error(msg) + cloudflared_agent_state["last_action_status"] = f"Error: {msg}" + return False + if not config.CLOUDFLARED_NETWORK_NAME or not ensure_docker_network_exists(config.CLOUDFLARED_NETWORK_NAME): + logging.error(f"Failed network check/create for '{config.CLOUDFLARED_NETWORK_NAME}'. Cannot start agent.") + + return False + + token = tunnel_state["token"] + container = get_cloudflared_container() + needs_recreate = False + + if container: + try: + container.reload() + logging.info(f"Found existing agent container '{container.name}' status: {container.status}") + if container.status == 'running': + msg = f"Agent container '{container.name}' is already running." + logging.info(msg) + cloudflared_agent_state["last_action_status"] = msg + success_flag = True + return True + + current_networks = container.attrs.get('NetworkSettings', {}).get('Networks', {}) + network_mode = container.attrs.get('HostConfig', {}).get('NetworkMode', 'default') + + + is_on_correct_network = config.CLOUDFLARED_NETWORK_NAME in current_networks + if network_mode != config.CLOUDFLARED_NETWORK_NAME and not is_on_correct_network : + logging.warning(f"Existing agent container '{container.name}' is in network mode '{network_mode}' / not on '{config.CLOUDFLARED_NETWORK_NAME}'. Networks: {list(current_networks.keys())}. Needs recreation.") + needs_recreate = True + + if needs_recreate: + logging.info(f"Removing misconfigured/stopped agent container '{container.name}'...") + try: + container.remove(force=True) + container = None + except (APIError, requests.exceptions.ConnectionError) as rm_err: + logging.error(f"Failed to remove misconfigured agent '{container.name}': {rm_err}. Cannot proceed.") + cloudflared_agent_state["last_action_status"] = f"Error: Failed remove old agent: {rm_err}" + return False + else: + logging.info(f"Starting existing stopped agent container '{container.name}'..."); + container.start() + msg = f"Started existing agent container '{container.name}'." + cloudflared_agent_state["last_action_status"] = msg + logging.info(msg) + success_flag = True + + except (NotFound, APIError) as e_check: + logging.warning(f"Error checking existing agent container '{config.CLOUDFLARED_CONTAINER_NAME}': {e_check}. Assuming creation is needed.") + container = None + except requests.exceptions.ConnectionError as e_conn: + logging.error(f"Docker connection error checking existing agent container: {e_conn}") + cloudflared_agent_state["last_action_status"] = f"Error: Docker connect check agent." + return False + + if not container and not success_flag: + logging.info(f"Agent container '{config.CLOUDFLARED_CONTAINER_NAME}' not found or needs recreation. Creating...") + try: + logging.info(f"Pulling image {config.CLOUDFLARED_IMAGE}..."); + docker_client.images.pull(config.CLOUDFLARED_IMAGE) + logging.info("Image pull complete.") + except APIError as img_err: + logging.warning(f"Could not pull image {config.CLOUDFLARED_IMAGE}: {img_err}. Will attempt using local if available.") + except requests.exceptions.ConnectionError as e_conn_pull: + logging.error(f"Docker connection failed during image pull: {e_conn_pull}") + cloudflared_agent_state["last_action_status"] = f"Error: Docker connect pull image." + return False + + try: + container_params = { + "image": config.CLOUDFLARED_IMAGE, + "command": f"tunnel --no-autoupdate run --token {token}", + "name": config.CLOUDFLARED_CONTAINER_NAME, + "network": config.CLOUDFLARED_NETWORK_NAME, + "restart_policy": {"Name": "unless-stopped"}, + "detach": True, + "remove": False, + "labels": {"managed-by": "dockflare"} + } + new_container = docker_client.containers.run(**container_params) + msg = f"Successfully created and started agent container '{new_container.name}' ({new_container.id[:12]})." + cloudflared_agent_state["last_action_status"] = msg + logging.info(msg) + success_flag = True + except APIError as create_err: + if "is already in use" in str(create_err): + msg = f"Error: Agent container name '{config.CLOUDFLARED_CONTAINER_NAME}' conflict." + else: + msg = f"Docker API error creating agent container: {create_err}" + logging.error(msg, exc_info=True) + cloudflared_agent_state["last_action_status"] = msg + success_flag = False + except requests.exceptions.ConnectionError as e_conn_run: + logging.error(f"Docker connection failed running agent container: {e_conn_run}") + cloudflared_agent_state["last_action_status"] = f"Error: Docker connect run agent." + success_flag = False + + if success_flag: + time.sleep(2) + + update_cloudflared_container_status() + logging.info(f"Exiting start_cloudflared_container (Success: {success_flag}).") + return success_flag + +def stop_cloudflared_container(): + logging.info(f"Attempting to stop agent container '{config.CLOUDFLARED_CONTAINER_NAME}'...") + cloudflared_agent_state["last_action_status"] = "Stopping..." + success_flag = False + + if not docker_client: + msg = "Docker client unavailable." + logging.error(msg) + cloudflared_agent_state["last_action_status"] = f"Error: {msg}" + return False + + container = get_cloudflared_container() + if not container: + msg = f"Agent container '{config.CLOUDFLARED_CONTAINER_NAME}' not found (already stopped/removed?)." + logging.warning(msg) + cloudflared_agent_state["last_action_status"] = msg + if cloudflared_agent_state["container_status"] != "not_found": + cloudflared_agent_state["container_status"] = "not_found" + success_flag = True + return True + + try: + container.reload() + if container.status != 'running': + msg = f"Agent container '{container.name}' is not running (status: {container.status})." + logging.info(msg) + cloudflared_agent_state["last_action_status"] = msg + if cloudflared_agent_state["container_status"] != container.status: + cloudflared_agent_state["container_status"] = container.status + success_flag = True + return True + + logging.info(f"Stopping running agent container '{container.name}'..."); + container.stop(timeout=30) + msg = f"Successfully stopped agent container '{container.name}'." + cloudflared_agent_state["last_action_status"] = msg + logging.info(msg) + success_flag = True + except (APIError, NotFound) as e_stop: + msg = f"Docker API error stopping agent container '{config.CLOUDFLARED_CONTAINER_NAME}': {e_stop}" + logging.error(msg, exc_info=True) + cloudflared_agent_state["last_action_status"] = f"Error: {msg}" + success_flag = False + except requests.exceptions.ConnectionError as e_conn: + msg = f"Docker connection error stopping agent container: {e_conn}" + logging.error(msg) + cloudflared_agent_state["last_action_status"] = f"Error: {msg}" + success_flag = False + except Exception as e_unexp: + msg = f"Unexpected error stopping agent container '{config.CLOUDFLARED_CONTAINER_NAME}': {e_unexp}" + logging.error(msg, exc_info=True) + cloudflared_agent_state["last_action_status"] = f"Error: {msg}" + success_flag = False + + if success_flag: + time.sleep(2) + + update_cloudflared_container_status() + logging.info(f"Exiting stop_cloudflared_container (Success: {success_flag}).") + return success_flag \ No newline at end of file diff --git a/dockflare/app/main.py b/dockflare/app/main.py new file mode 100644 index 0000000..2520672 --- /dev/null +++ b/dockflare/app/main.py @@ -0,0 +1,280 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# app/main.py + +import logging +import threading +import time +import sys +from app import app, docker_client, tunnel_state, cloudflared_agent_state, config, log_queue + +from app.core.state_manager import load_state +from app.core.tunnel_manager import ( + initialize_tunnel, + update_cloudflared_container_status, + start_cloudflared_container +) +from app.core.docker_handler import docker_event_listener, process_container_start +from app.core.reconciler import cleanup_expired_rules, reconcile_state_threaded + +stop_event = threading.Event() +background_threads_list = [] +agent_status_updater_thread = None +main_initialization_thread = None + +def run_all_background_tasks(): + global background_threads_list, agent_status_updater_thread + + threads_to_start = [] + if not docker_client: + logging.warning("Docker client unavailable. Core background tasks (Event Listener, Cleanup) cannot start.") + else: + tunnel_ready_for_tasks = False + if config.USE_EXTERNAL_CLOUDFLARED: + if config.EXTERNAL_TUNNEL_ID: + tunnel_ready_for_tasks = True + else: + logging.warning("External mode: EXTERNAL_TUNNEL_ID missing. Background tasks needing tunnel ID may fail.") + elif tunnel_state.get("id") and tunnel_state.get("token"): + tunnel_ready_for_tasks = True + else: + logging.warning("Managed tunnel not fully initialized (ID/token missing). Background tasks needing tunnel ID may fail.") + + if tunnel_ready_for_tasks: + logging.info("Starting core background task threads (Docker Listener, Cleanup Task)...") + event_thread = threading.Thread(target=docker_event_listener, args=(stop_event,), name="DockerEventListener", daemon=True) + threads_to_start.append(event_thread) + + cleanup_thread = threading.Thread(target=cleanup_expired_rules, args=(stop_event,), name="CleanupTask", daemon=True) + threads_to_start.append(cleanup_thread) + else: + logging.warning("Tunnel not ready. Skipping Docker event listener and cleanup task.") + + if not config.USE_EXTERNAL_CLOUDFLARED and docker_client: + logging.info("Starting periodic agent status updater thread...") + # Ensure periodic_agent_status_updater is defined or imported + agent_status_updater_thread = threading.Thread(target=periodic_agent_status_updater, name="AgentStatusUpdater", daemon=True) + threads_to_start.append(agent_status_updater_thread) + + for t in threads_to_start: + t.start() + + background_threads_list.extend(threads_to_start) + if threads_to_start: # Only log if some threads were actually initiated + logging.info(f"{len(threads_to_start)} background tasks initiated.") + return threads_to_start + +def periodic_agent_status_updater(): + logging.info("Periodic agent status updater task starting...") + while not stop_event.is_set(): + try: + logging.debug("Running periodic agent status update check...") + update_cloudflared_container_status() # From tunnel_manager + except Exception as e_status_update: + logging.error(f"Error in periodic agent status updater loop: {e_status_update}", exc_info=True) + + if stop_event.is_set(): break + stop_event.wait(config.AGENT_STATUS_UPDATE_INTERVAL_SECONDS) + logging.info("Periodic agent status updater task stopped.") + +def perform_initial_setup_and_tasks(): + global background_threads_list + + logging.info("Main initialization process started in background thread.") + if not docker_client: + logging.error("Docker client unavailable during initialization process. Critical functionalities will be affected.") + return + + initialize_tunnel() + logging.info(f"Tunnel initialization attempt complete. Status: {tunnel_state.get('status_message')}, Error: {tunnel_state.get('error')}") + + initial_scan_needed_and_possible = True + if config.USE_EXTERNAL_CLOUDFLARED: + if not config.EXTERNAL_TUNNEL_ID: + logging.error("External mode enabled, but EXTERNAL_TUNNEL_ID is missing. Skipping initial scan.") + initial_scan_needed_and_possible = False + elif not (tunnel_state.get("id") and tunnel_state.get("token")): + logging.error("Managed tunnel not fully initialized (missing ID or token). Skipping initial scan.") + initial_scan_needed_and_possible = False + + if initial_scan_needed_and_possible: + logging.info("Performing initial container scan and rule processing...") + flask_app_instance = app + max_initial_scan_time = 120 + scan_start_time = time.time() + + if not hasattr(flask_app_instance, 'reconciliation_info'): + flask_app_instance.reconciliation_info = {} + + flask_app_instance.reconciliation_info.update({ + "in_progress": True, "progress": 0, "total_items": 0, + "processed_items": 0, "start_time": scan_start_time, + "status": "Starting initial container scan..." + }) + + try: + containers = docker_client.containers.list(all=config.SCAN_ALL_NETWORKS) + container_count = len(containers) + logging.info(f"[InitialScan] Found {container_count} total containers to scan.") + flask_app_instance.reconciliation_info["total_items"] = container_count + + processed_count = 0 + batch_size = 5 + for i in range(0, container_count, batch_size): + if time.time() - scan_start_time > max_initial_scan_time: + logging.warning("[InitialScan] Timeout reached during initial container processing.") + break + + current_batch = containers[i:i+batch_size] + flask_app_instance.reconciliation_info["status"] = f"Initial scan: batch {i//batch_size + 1}/{(container_count+batch_size-1)//batch_size if container_count > 0 else 1}" + + for container_obj in current_batch: + process_container_start(container_obj) + processed_count += 1 + if container_count > 0: + flask_app_instance.reconciliation_info["progress"] = min(100, int((processed_count / container_count) * 100)) + flask_app_instance.reconciliation_info["processed_items"] = processed_count + + time.sleep(0.1) + if stop_event.is_set(): break + + except Exception as e_scan: + logging.error(f"Error during initial container scan/processing: {e_scan}", exc_info=True) + if hasattr(flask_app_instance, 'reconciliation_info'): + flask_app_instance.reconciliation_info["status"] = f"Error during initial scan: {str(e_scan)[:100]}" + + if hasattr(flask_app_instance, 'reconciliation_info'): + flask_app_instance.reconciliation_info.update({"in_progress": False, "progress": 100, "status": "Initial container scan complete.", "completed_at": time.time()}) + logging.info("Initial container scan and rule processing complete.") + + logging.info("Scheduling full background reconciliation after initial setup (15s delay).") + threading.Timer(15, reconcile_state_threaded).start() + + if not config.USE_EXTERNAL_CLOUDFLARED and tunnel_state.get("id") and tunnel_state.get("token"): + logging.info("Checking managed cloudflared agent container status post-initialization...") + update_cloudflared_container_status() + if cloudflared_agent_state.get("container_status") != 'running': + logging.info("Managed agent container not running, attempting auto-start...") + start_cloudflared_container() + else: + logging.info(f"Managed agent container '{config.CLOUDFLARED_CONTAINER_NAME}' is already running.") + + run_all_background_tasks() + +def main_application_entrypoint(): + global main_initialization_thread + + logging.info("-" * 52) + logging.info("--- DockFlare Starting (Refactored Structure) ---") + logging.info(f"--- Version: 1.7.1 ---") + logging.info("-" * 52) + + load_state() + logging.info("Initial state loading from file complete.") + + if not docker_client: + logging.error("Docker client is unavailable. Dockflare will operate with limited functionality.") + if tunnel_state: tunnel_state["status_message"] = "Error: Docker client unavailable." + if tunnel_state: tunnel_state["error"] = "Failed to connect to Docker daemon." + if cloudflared_agent_state: cloudflared_agent_state["container_status"] = "docker_unavailable" + else: + logging.info("Docker client connected. Proceeding with full initialization in background.") + main_initialization_thread = threading.Thread( + target=perform_initial_setup_and_tasks, + name="MainInitializationThread", + daemon=True + ) + main_initialization_thread.start() + + logging.info("Starting Flask web server...") + flask_server_thread = None + try: + from waitress import serve + flask_server_thread = threading.Thread( + target=serve, + args=(app,), + kwargs={'host': '0.0.0.0', 'port': 5000, 'threads': 10, 'expose_tracebacks': False}, + daemon=True, + name="FlaskWaitressServer" + ) + flask_server_thread.start() + logging.info("Flask server started using waitress on 0.0.0.0:5000.") + + while not stop_event.is_set(): + if flask_server_thread and not flask_server_thread.is_alive(): + logging.error("Flask server thread terminated unexpectedly! Initiating shutdown.") + stop_event.set() + break + + all_daemons_or_stopped = True + for bg_thread in background_threads_list: + if bg_thread and bg_thread.is_alive() and not bg_thread.daemon: + all_daemons_or_stopped = False + break + if agent_status_updater_thread and agent_status_updater_thread.is_alive() and not agent_status_updater_thread.daemon: + all_daemons_or_stopped = False + # Check main_initialization_thread + if main_initialization_thread and main_initialization_thread.is_alive() and not main_initialization_thread.daemon: + all_daemons_or_stopped = False + + if not all_daemons_or_stopped: + time.sleep(5) + else: + if not (flask_server_thread and flask_server_thread.is_alive()): + logging.info("All critical threads seem to have completed. Initiating shutdown.") + stop_event.set() + else: + time.sleep(5) + + + except ImportError: + logging.warning("Waitress not found. Using Flask development server (NOT FOR PRODUCTION).") + app.run(host='0.0.0.0', port=5000, threaded=True, debug=False) + except KeyboardInterrupt: + logging.info("KeyboardInterrupt received. Shutting down...") + except Exception as server_startup_err: + logging.error(f"Web server failed to start or crashed: {server_startup_err}", exc_info=True) + finally: + logging.info("Shutdown sequence initiated...") + stop_event.set() + + if main_initialization_thread and main_initialization_thread.is_alive(): + logging.info("Waiting for main initialization thread to complete (timeout 15s)...") + main_initialization_thread.join(timeout=15) + + threads_to_join = list(background_threads_list) # Create a copy + if agent_status_updater_thread: threads_to_join.append(agent_status_updater_thread) + + for bg_thread in threads_to_join: + if bg_thread and bg_thread.is_alive(): + logging.info(f"Waiting for background thread {bg_thread.name} to complete (timeout 5s)...") + bg_thread.join(timeout=5) + + if flask_server_thread and flask_server_thread.is_alive(): + logging.info("Flask server thread (Waitress) is a daemon; process exit will terminate it.") + + logging.info("Dockflare application shutdown complete.") + + exit_code = 0 + if (tunnel_state and tunnel_state.get("error")) or \ + (cloudflared_agent_state and cloudflared_agent_state.get("container_status") == "docker_unavailable") or \ + not docker_client: + exit_code = 1 + sys.exit(exit_code) + +if __name__ == '__main__': + main_application_entrypoint() \ No newline at end of file diff --git a/dockflare/app/static/css/output.css b/dockflare/app/static/css/output.css new file mode 100644 index 0000000..882a3ef --- /dev/null +++ b/dockflare/app/static/css/output.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0% 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}*{scrollbar-color:color-mix(in oklch,currentColor 35%,transparent) transparent}:hover{scrollbar-color:color-mix(in oklch,currentColor 60%,transparent) transparent}:root{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}[data-theme=cupcake]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:15.2344% 0.017892 200.026556;--sc:15.787% 0.020249 356.29965;--ac:15.8762% 0.029206 78.618794;--nc:84.7148% 0.013247 313.189598;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--p:76.172% 0.089459 200.026556;--s:78.9351% 0.101246 356.29965;--a:79.3811% 0.146032 78.618794;--n:23.5742% 0.066235 313.189598;--b1:97.7882% 0.00418 56.375637;--b2:93.9822% 0.007638 61.449292;--b3:91.5861% 0.006811 53.440502;--bc:23.5742% 0.066235 313.189598;--rounded-btn:1.9rem;--tab-border:2px;--tab-radius:0.7rem}[data-theme=bumblebee]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:20% 0 0;--ac:16.254% 0.0314 56.52;--nc:82.55% 0.015 281.99;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:89.51% 0.2132 96.61;--pc:38.92% 0.046 96.61;--s:80.39% 0.194 70.76;--sc:39.38% 0.068 70.76;--a:81.27% 0.157 56.52;--n:12.75% 0.075 281.99;--b1:100% 0 0}[data-theme=emerald]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:76.6626% 0.135433 153.450024;--pc:33.3872% 0.040618 162.240129;--s:61.3028% 0.202368 261.294233;--sc:100% 0 0;--a:72.7725% 0.149783 33.200363;--ac:0% 0 0;--n:35.5192% 0.032071 262.988584;--nc:98.4625% 0.001706 247.838921;--b1:100% 0 0;--bc:35.5192% 0.032071 262.988584;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=corporate]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:12.078% 0.0456 269.1;--sc:13.0739% 0.010951 256.688055;--ac:15.3934% 0.022799 163.57888;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--border-btn:1px;--tab-border:1px;--p:60.39% 0.228 269.1;--s:65.3694% 0.054756 256.688055;--a:76.9669% 0.113994 163.57888;--n:22.3899% 0.031305 278.07229;--nc:95.8796% 0.008588 247.915135;--b1:100% 0 0;--bc:22.3899% 0.031305 278.07229;--rounded-box:0.25rem;--rounded-btn:.125rem;--rounded-badge:.125rem;--tab-radius:0.25rem;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=synthwave]{color-scheme:dark;--b2:20.2941% 0.076211 287.835609;--b3:18.7665% 0.070475 287.835609;--pc:14.4421% 0.031903 342.009383;--sc:15.6543% 0.02362 227.382405;--ac:17.608% 0.0412 93.72;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:72.2105% 0.159514 342.009383;--s:78.2714% 0.118101 227.382405;--a:88.04% 0.206 93.72;--n:25.5554% 0.103537 286.507967;--nc:97.9365% 0.00819 301.358346;--b1:21.8216% 0.081948 287.835609;--bc:97.9365% 0.00819 301.358346;--in:76.5197% 0.12273 231.831603;--inc:23.5017% 0.096418 290.329844;--su:86.0572% 0.115038 178.624677;--suc:23.5017% 0.096418 290.329844;--wa:85.531% 0.122117 93.722227;--wac:23.5017% 0.096418 290.329844;--er:73.7005% 0.121339 32.639257;--erc:23.5017% 0.096418 290.329844}[data-theme=retro]{color-scheme:light;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:13.144% 0.0398 27.33;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:76.8664% 0.104092 22.664655;--pc:26.5104% 0.006243 0.522862;--s:80.7415% 0.052534 159.094608;--sc:26.5104% 0.006243 0.522862;--a:70.3919% 0.125455 52.953428;--ac:26.5104% 0.006243 0.522862;--n:28.4181% 0.009519 355.534017;--nc:92.5604% 0.025113 89.217311;--b1:91.6374% 0.034554 90.51575;--b2:88.2722% 0.049418 91.774344;--b3:84.133% 0.065952 90.856665;--bc:26.5104% 0.006243 0.522862;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:65.72% 0.199 27.33;--rounded-box:0.4rem;--rounded-btn:0.4rem;--rounded-badge:0.4rem;--tab-radius:0.4rem}[data-theme=cyberpunk]{color-scheme:light;--b2:87.8943% 0.16647 104.32;--b3:81.2786% 0.15394 104.32;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:18.902% 0.0358 104.32;--pc:14.844% 0.0418 6.35;--sc:16.666% 0.0368 204.72;--ac:14.372% 0.04352 310.43;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;--p:74.22% 0.209 6.35;--s:83.33% 0.184 204.72;--a:71.86% 0.2176 310.43;--n:23.04% 0.065 269.31;--nc:94.51% 0.179 104.32;--b1:94.51% 0.179 104.32;--rounded-box:0;--rounded-btn:0;--rounded-badge:0;--tab-radius:0}[data-theme=valentine]{color-scheme:light;--b2:88.0567% 0.024834 337.06289;--b3:81.4288% 0.022964 337.06289;--pc:13.7239% 0.030755 15.066527;--sc:14.3942% 0.029258 293.189609;--ac:14.2537% 0.014961 197.828857;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:14.614% 0.0414 27.33;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:68.6197% 0.153774 15.066527;--s:71.971% 0.14629 293.189609;--a:71.2685% 0.074804 197.828857;--n:54.6053% 0.143342 358.004839;--nc:90.2701% 0.037202 336.955191;--b1:94.6846% 0.026703 337.06289;--bc:37.3085% 0.081131 4.606426;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:73.07% 0.207 27.33;--rounded-btn:1.9rem;--tab-radius:0.7rem}[data-theme=halloween]{color-scheme:dark;--b2:23.0416% 0 0;--b3:21.3072% 0 0;--bc:84.9552% 0 0;--sc:89.196% 0.0496 305.03;--nc:84.8742% 0.009322 65.681484;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:13.144% 0.0398 27.33;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:77.48% 0.204 60.62;--pc:19.6935% 0.004671 196.779412;--s:45.98% 0.248 305.03;--a:64.8% 0.223 136.073479;--ac:0% 0 0;--n:24.371% 0.046608 65.681484;--b1:24.7759% 0 0;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:65.72% 0.199 27.33}[data-theme=garden]{color-scheme:light;--b2:86.4453% 0.002011 17.197414;--b3:79.9386% 0.00186 17.197414;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--sc:89.699% 0.022197 355.095988;--ac:11.2547% 0.010859 154.390187;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:62.45% 0.278 3.83636;--pc:100% 0 0;--s:48.4952% 0.110985 355.095988;--a:56.2735% 0.054297 154.390187;--n:24.1559% 0.049362 89.070594;--nc:92.9519% 0.002163 17.197414;--b1:92.9519% 0.002163 17.197414;--bc:16.9617% 0.001664 17.32068}[data-theme=forest]{color-scheme:dark;--b2:17.522% 0.007709 17.911578;--b3:16.2032% 0.007129 17.911578;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:83.7682% 0.001658 17.911578;--sc:13.9553% 0.027077 168.327128;--ac:14.1257% 0.02389 185.713193;--nc:86.1397% 0.007806 171.364646;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:68.6283% 0.185567 148.958922;--pc:0% 0 0;--s:69.7764% 0.135385 168.327128;--a:70.6285% 0.119451 185.713193;--n:30.6985% 0.039032 171.364646;--b1:18.8409% 0.00829 17.911578;--rounded-btn:1.9rem}[data-theme=aqua]{color-scheme:dark;--b2:45.3464% 0.118611 261.181672;--b3:41.9333% 0.109683 261.181672;--bc:89.7519% 0.025508 261.181672;--sc:12.1365% 0.02175 309.782946;--ac:18.6854% 0.020445 94.555431;--nc:12.2124% 0.023402 243.760661;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:14.79% 0.038 27.33;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:85.6617% 0.14498 198.6458;--pc:40.1249% 0.068266 197.603872;--s:60.6827% 0.108752 309.782946;--a:93.4269% 0.102225 94.555431;--n:61.0622% 0.117009 243.760661;--b1:48.7596% 0.127539 261.181672;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:73.95% 0.19 27.33}[data-theme=lofi]{color-scheme:light;--inc:15.908% 0.0206 205.9;--suc:18.026% 0.0306 164.14;--wac:17.674% 0.027 79.94;--erc:15.732% 0.03 28.47;--border-btn:1px;--tab-border:1px;--p:15.9066% 0 0;--pc:100% 0 0;--s:21.455% 0.001566 17.278957;--sc:100% 0 0;--a:26.8618% 0 0;--ac:100% 0 0;--n:0% 0 0;--nc:100% 0 0;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.268% 0.001082 17.17934;--bc:0% 0 0;--in:79.54% 0.103 205.9;--su:90.13% 0.153 164.14;--wa:88.37% 0.135 79.94;--er:78.66% 0.15 28.47;--rounded-box:0.25rem;--rounded-btn:0.125rem;--rounded-badge:0.125rem;--tab-radius:0.125rem;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=pastel]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:20% 0 0;--pc:16.6166% 0.006979 316.8737;--sc:17.6153% 0.009839 8.688364;--ac:17.8419% 0.012056 170.923263;--nc:14.2681% 0.014702 228.183906;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:83.0828% 0.034896 316.8737;--s:88.0763% 0.049197 8.688364;--a:89.2096% 0.06028 170.923263;--n:71.3406% 0.07351 228.183906;--b1:100% 0 0;--b2:98.4625% 0.001706 247.838921;--b3:87.1681% 0.009339 258.338227;--rounded-btn:1.9rem;--tab-radius:0.7rem}[data-theme=fantasy]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:87.49% 0.0378 325.02;--sc:90.784% 0.0324 241.36;--ac:15.196% 0.0408 56.72;--nc:85.5616% 0.005919 256.847952;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:37.45% 0.189 325.02;--s:53.92% 0.162 241.36;--a:75.98% 0.204 56.72;--n:27.8078% 0.029596 256.847952;--b1:100% 0 0;--bc:27.8078% 0.029596 256.847952}[data-theme=wireframe]{color-scheme:light;--bc:20% 0 0;--pc:15.6521% 0 0;--sc:15.6521% 0 0;--ac:15.6521% 0 0;--nc:18.8014% 0 0;--inc:89.0403% 0.062643 264.052021;--suc:90.395% 0.035372 142.495339;--wac:14.1626% 0.019994 108.702381;--erc:12.5591% 0.051537 29.233885;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;font-family:Chalkboard,comic sans ms,"sans-serif";--p:78.2604% 0 0;--s:78.2604% 0 0;--a:78.2604% 0 0;--n:94.007% 0 0;--b1:100% 0 0;--b2:94.9119% 0 0;--b3:89.7547% 0 0;--in:45.2014% 0.313214 264.052021;--su:51.9752% 0.176858 142.495339;--wa:70.8131% 0.099969 108.702381;--er:62.7955% 0.257683 29.233885;--rounded-box:0.2rem;--rounded-btn:0.2rem;--rounded-badge:0.2rem;--tab-radius:0.2rem}[data-theme=black]{color-scheme:dark;--pc:86.736% 0 0;--sc:86.736% 0 0;--ac:86.736% 0 0;--nc:86.736% 0 0;--inc:89.0403% 0.062643 264.052021;--suc:90.395% 0.035372 142.495339;--wac:19.3597% 0.042201 109.769232;--erc:12.5591% 0.051537 29.233885;--border-btn:1px;--tab-border:1px;--p:33.6799% 0 0;--s:33.6799% 0 0;--a:33.6799% 0 0;--b1:0% 0 0;--b2:19.1251% 0 0;--b3:26.8618% 0 0;--bc:87.6096% 0 0;--n:33.6799% 0 0;--in:45.2014% 0.313214 264.052021;--su:51.9752% 0.176858 142.495339;--wa:96.7983% 0.211006 109.769232;--er:62.7955% 0.257683 29.233885;--rounded-box:0;--rounded-btn:0;--rounded-badge:0;--animation-btn:0;--animation-input:0;--btn-focus-scale:1;--tab-radius:0}[data-theme=luxury]{color-scheme:dark;--pc:20% 0 0;--sc:85.5163% 0.012821 261.069149;--ac:87.3349% 0.010348 338.82597;--inc:15.8122% 0.024356 237.133883;--suc:15.6239% 0.038579 132.154381;--wac:17.2255% 0.027305 102.89115;--erc:14.3506% 0.035271 22.568916;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:100% 0 0;--s:27.5815% 0.064106 261.069149;--a:36.6744% 0.051741 338.82597;--n:24.27% 0.057015 59.825019;--nc:93.2033% 0.089631 90.861683;--b1:14.0765% 0.004386 285.822869;--b2:20.2191% 0.004211 308.22937;--b3:29.8961% 0.003818 308.318612;--bc:75.6879% 0.123666 76.890484;--in:79.0612% 0.121778 237.133883;--su:78.1197% 0.192894 132.154381;--wa:86.1274% 0.136524 102.89115;--er:71.7531% 0.176357 22.568916}[data-theme=dracula]{color-scheme:dark;--b2:26.8053% 0.020556 277.508664;--b3:24.7877% 0.019009 277.508664;--pc:15.0922% 0.036614 346.812432;--sc:14.8405% 0.029709 301.883095;--ac:16.6785% 0.024826 66.558491;--nc:87.8891% 0.006515 275.524078;--inc:17.6526% 0.018676 212.846491;--suc:17.4199% 0.043903 148.024881;--wac:19.1068% 0.026849 112.757109;--erc:13.6441% 0.041266 24.430965;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:75.4611% 0.18307 346.812432;--s:74.2023% 0.148546 301.883095;--a:83.3927% 0.124132 66.558491;--n:39.4456% 0.032576 275.524078;--b1:28.8229% 0.022103 277.508664;--bc:97.7477% 0.007913 106.545019;--in:88.263% 0.09338 212.846491;--su:87.0995% 0.219516 148.024881;--wa:95.5338% 0.134246 112.757109;--er:68.2204% 0.206328 24.430965}[data-theme=cmyk]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--bc:20% 0 0;--pc:14.3544% 0.02666 239.443325;--sc:12.8953% 0.040552 359.339283;--ac:18.8458% 0.037948 105.306968;--nc:84.3557% 0 0;--inc:13.6952% 0.0189 217.284104;--suc:89.3898% 0.032505 321.406278;--wac:14.2473% 0.031969 52.023412;--erc:12.4027% 0.041677 28.717543;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:71.7722% 0.133298 239.443325;--s:64.4766% 0.202758 359.339283;--a:94.2289% 0.189741 105.306968;--n:21.7787% 0 0;--b1:100% 0 0;--in:68.4759% 0.094499 217.284104;--su:46.949% 0.162524 321.406278;--wa:71.2364% 0.159843 52.023412;--er:62.0133% 0.208385 28.717543}[data-theme=autumn]{color-scheme:light;--b2:89.1077% 0 0;--b3:82.4006% 0 0;--bc:19.1629% 0 0;--pc:88.1446% 0.032232 17.530175;--sc:12.3353% 0.033821 23.865865;--ac:14.6851% 0.018999 60.729616;--nc:90.8734% 0.007475 51.902819;--inc:13.8449% 0.019596 207.284192;--suc:12.199% 0.016032 174.616213;--wac:14.0163% 0.032982 56.844303;--erc:90.614% 0.0482 24.16;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:40.7232% 0.16116 17.530175;--s:61.6763% 0.169105 23.865865;--a:73.4253% 0.094994 60.729616;--n:54.3672% 0.037374 51.902819;--b1:95.8147% 0 0;--in:69.2245% 0.097979 207.284192;--su:60.9951% 0.080159 174.616213;--wa:70.0817% 0.164909 56.844303;--er:53.07% 0.241 24.16}[data-theme=business]{color-scheme:dark;--b2:22.6487% 0 0;--b3:20.944% 0 0;--bc:84.8707% 0 0;--pc:88.3407% 0.019811 251.473931;--sc:12.8185% 0.005481 229.389418;--ac:13.4542% 0.033545 35.791525;--nc:85.4882% 0.00265 253.041249;--inc:12.5233% 0.028702 240.033697;--suc:14.0454% 0.018919 156.59611;--wac:15.4965% 0.023141 81.519177;--erc:90.3221% 0.029356 29.674507;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:41.7036% 0.099057 251.473931;--s:64.0924% 0.027405 229.389418;--a:67.271% 0.167726 35.791525;--n:27.441% 0.01325 253.041249;--b1:24.3535% 0 0;--in:62.6163% 0.143511 240.033697;--su:70.2268% 0.094594 156.59611;--wa:77.4824% 0.115704 81.519177;--er:51.6105% 0.14678 29.674507;--rounded-box:0.25rem;--rounded-btn:.125rem;--rounded-badge:.125rem}[data-theme=acid]{color-scheme:light;--b2:91.6146% 0 0;--b3:84.7189% 0 0;--bc:19.7021% 0 0;--pc:14.38% 0.0714 330.759573;--sc:14.674% 0.0448 48.250878;--ac:18.556% 0.0528 122.962951;--nc:84.262% 0.0256 278.68;--inc:12.144% 0.0454 252.05;--suc:17.144% 0.0532 158.53;--wac:18.202% 0.0424 100.5;--erc:12.968% 0.0586 29.349188;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:71.9% 0.357 330.759573;--s:73.37% 0.224 48.250878;--a:92.78% 0.264 122.962951;--n:21.31% 0.128 278.68;--b1:98.5104% 0 0;--in:60.72% 0.227 252.05;--su:85.72% 0.266 158.53;--wa:91.01% 0.212 100.5;--er:64.84% 0.293 29.349188;--rounded-box:1.25rem;--rounded-btn:1rem;--rounded-badge:1rem;--tab-radius:0.7rem}[data-theme=lemonade]{color-scheme:light;--b2:91.8003% 0.0186 123.72;--b3:84.8906% 0.0172 123.72;--bc:19.742% 0.004 123.72;--pc:11.784% 0.0398 134.6;--sc:15.55% 0.0392 111.09;--ac:17.078% 0.0402 100.73;--nc:86.196% 0.015 108.6;--inc:17.238% 0.0094 224.14;--suc:17.238% 0.0094 157.85;--wac:17.238% 0.0094 102.15;--erc:17.238% 0.0094 25.85;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:58.92% 0.199 134.6;--s:77.75% 0.196 111.09;--a:85.39% 0.201 100.73;--n:30.98% 0.075 108.6;--b1:98.71% 0.02 123.72;--in:86.19% 0.047 224.14;--su:86.19% 0.047 157.85;--wa:86.19% 0.047 102.15;--er:86.19% 0.047 25.85}[data-theme=night]{color-scheme:dark;--b2:19.3144% 0.037037 265.754874;--b3:17.8606% 0.034249 265.754874;--bc:84.1536% 0.007965 265.754874;--pc:15.0703% 0.027798 232.66148;--sc:13.6023% 0.031661 276.934902;--ac:14.4721% 0.035244 350.048739;--nc:85.5899% 0.00737 260.030984;--suc:15.6904% 0.026506 181.911977;--wac:16.6486% 0.027912 82.95003;--erc:14.3572% 0.034051 13.11834;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:75.3513% 0.138989 232.66148;--s:68.0113% 0.158303 276.934902;--a:72.3603% 0.176218 350.048739;--n:27.9495% 0.036848 260.030984;--b1:20.7682% 0.039824 265.754874;--in:68.4553% 0.148062 237.25135;--inc:0% 0 0;--su:78.452% 0.132529 181.911977;--wa:83.2428% 0.139558 82.95003;--er:71.7858% 0.170255 13.11834}[data-theme=coffee]{color-scheme:dark;--b2:20.1585% 0.021457 329.708637;--b3:18.6412% 0.019842 329.708637;--pc:14.3993% 0.024765 62.756393;--sc:86.893% 0.00597 199.19444;--ac:88.5243% 0.014881 224.389184;--nc:83.3022% 0.003149 326.261446;--inc:15.898% 0.012774 184.558367;--suc:14.9445% 0.014491 131.116276;--wac:17.6301% 0.028162 87.722413;--erc:15.4637% 0.025644 31.871922;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:71.9967% 0.123825 62.756393;--s:34.465% 0.029849 199.19444;--a:42.6213% 0.074405 224.389184;--n:16.5109% 0.015743 326.261446;--b1:21.6758% 0.023072 329.708637;--bc:72.3547% 0.092794 79.129387;--in:79.4902% 0.063869 184.558367;--su:74.7224% 0.072456 131.116276;--wa:88.1503% 0.140812 87.722413;--er:77.3187% 0.12822 31.871922}[data-theme=winter]{color-scheme:light;--pc:91.372% 0.051 257.57;--sc:88.5103% 0.03222 282.339433;--ac:11.988% 0.038303 335.171434;--nc:83.9233% 0.012704 257.651965;--inc:17.6255% 0.017178 214.515264;--suc:16.0988% 0.015404 197.823719;--wac:17.8345% 0.009167 71.47031;--erc:14.6185% 0.022037 20.076293;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:56.86% 0.255 257.57;--s:42.5516% 0.161098 282.339433;--a:59.9398% 0.191515 335.171434;--n:19.6166% 0.063518 257.651965;--b1:100% 0 0;--b2:97.4663% 0.011947 259.822565;--b3:93.2686% 0.016223 262.751375;--bc:41.8869% 0.053885 255.824911;--in:88.1275% 0.085888 214.515264;--su:80.4941% 0.077019 197.823719;--wa:89.1725% 0.045833 71.47031;--er:73.0926% 0.110185 20.076293}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.alert{display:grid;width:100%;grid-auto-flow:row;align-content:flex-start;align-items:center;justify-items:center;gap:1rem;text-align:center;border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{display:flex;align-items:center;justify-content:center}.badge{display:inline-flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;height:1.25rem;font-size:.875rem;line-height:1.25rem;width:-moz-fit-content;width:fit-content;padding-left:.563rem;padding-right:.563rem;border-radius:var(--rounded-badge,1.9rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.link-hover:hover{text-decoration-line:underline}.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.tab:hover{--tw-text-opacity:1}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{display:inline-flex;height:3rem;min-height:3rem;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;border-radius:var(--rounded-btn,.5rem);border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));padding-left:1rem;padding-right:1rem;text-align:center;font-size:.875rem;line-height:1em;gap:.5rem;font-weight:600;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);border-width:var(--border-btn,1px);transition-property:color,background-color,border-color,opacity,box-shadow,transform;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}.btn-circle{height:3rem;width:3rem;border-radius:9999px;padding:0}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){width:auto;-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{position:relative;display:flex;flex-direction:column;border-radius:var(--rounded-box,1rem)}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;padding:var(--padding-card,2rem);gap:.5rem}.card-body :where(p){flex-grow:1}.card figure{display:flex;align-items:center;justify-content:center}.card.image-full{display:grid}.card.image-full:before{position:relative;content:"";z-index:10;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));height:1.5rem;width:1.5rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2}.dropdown{position:relative;display:inline-block}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{visibility:hidden;opacity:0;transform-origin:top;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{visibility:visible;opacity:1}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{visibility:visible;opacity:1}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0% 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0% 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0% 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.footer{width:100%;grid-auto-flow:row;-moz-column-gap:1rem;column-gap:1rem;row-gap:2.5rem;font-size:.875rem;line-height:1.25rem}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;justify-content:space-between;padding:.5rem .25rem}.input{flex-shrink:1;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;padding-left:1rem;padding-right:1rem;font-size:1rem;line-height:2;line-height:1.5rem;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-top:-1rem;margin-bottom:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-top:-.25rem;margin-bottom:-.25rem;margin-inline-end:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-start-end-radius:inherit;border-end-end-radius:inherit}.link{cursor:pointer;text-decoration-line:underline}.link-hover{text-decoration-line:none}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){position:relative;white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){display:grid;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none;color:var(--fallback-bc,oklch(var(--bc)/.3))}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){position:relative;display:flex;flex-shrink:0;flex-direction:column;flex-wrap:wrap;align-items:stretch}:where(.menu li) .badge{justify-self:end}.mockup-code{position:relative;overflow:hidden;overflow-x:auto;min-width:18rem;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-top:1.25rem;padding-bottom:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;text-align:right;width:2rem;opacity:.5}.modal{pointer-events:none;position:fixed;inset:0;margin:0;display:grid;height:100%;max-height:none;width:100%;max-width:none;justify-items:center;padding:0;opacity:0;overscroll-behavior:contain;z-index:999;background-color:transparent;color:inherit;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity,visibility;overflow-y:hidden}:where(.modal){align-items:center}.modal-box{max-height:calc(100vh - 5em);grid-column-start:1;grid-row-start:1;width:91.666667%;max-width:32rem;--tw-scale-x:.9;--tw-scale-y:.9;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:1.5rem;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{pointer-events:auto;visibility:visible;opacity:1}.modal-action{display:flex;margin-top:1.5rem;justify-content:flex-end}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden;scrollbar-gutter:stable}.select{display:inline-flex;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;min-height:3rem;padding-inline-start:1rem;padding-inline-end:2.5rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-size:4px 4px,4px 4px;background-repeat:no-repeat}.select[multiple]{height:auto}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])){border-bottom-color:transparent}.tab{position:relative;grid-row-start:1;display:inline-flex;height:2rem;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;flex-wrap:wrap;align-items:center;justify-content:center;text-align:center;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem)}.tab:is(input[type=radio]){width:auto;border-bottom-right-radius:0;border-bottom-left-radius:0}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}:is(.tab-active,[aria-selected=true])+.tab-content,input.tab:checked+.tab-content{display:block}.table{position:relative;width:100%;border-radius:var(--rounded-box,1rem);text-align:left;font-size:.875rem;line-height:1.25rem}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){position:sticky;bottom:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){position:sticky;left:0;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-info{background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)));color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.badge-info,.badge-success{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-error{background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}@media (prefers-reduced-motion:no-preference){.btn{animation:button-pop var(--animation-btn,.25s) ease-out}}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0% 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0% 0 0)){.btn-primary{--btn-color:var(--p)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{border-width:1px;border-color:transparent;background-color:transparent;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{border-color:transparent;background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.btn-outline{border-color:currentColor;background-color:transparent;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){overflow:hidden;border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-start-radius:unset;border-end-end-radius:unset}.card :where(figure:last-child){overflow:hidden;border-start-start-radius:unset;border-start-end-radius:unset;border-end-start-radius:inherit;border-end-end-radius:inherit}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-title{display:flex;align-items:center;gap:.5rem;font-size:1.25rem;line-height:1.75rem;font-weight:600}.card.image-full :where(figure){overflow:hidden;border-radius:inherit}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/1))}.checkbox:disabled{border-width:0;cursor:not-allowed;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}.checkbox:checked,.checkbox[aria-checked=true]{background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%)}.checkbox:indeterminate{--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%)}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{box-shadow:none;border-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input-disabled,.input:disabled,.input:has(>input[disabled]),.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input:has(>input[disabled])::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input:has(>input[disabled])::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1)}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{pointer-events:none;display:inline-block;aspect-ratio:1/1;width:1.5rem;background-color:currentColor;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E")}.loading-xs{width:1rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;margin:.5rem 1rem;height:1px}.menu :where(li ul):before{position:absolute;bottom:.75rem;inset-inline-start:0;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;content:""}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;text-wrap:balance}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{cursor:pointer;background-color:var(--fallback-bc,oklch(var(--bc)/.1));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{justify-self:end;display:block;margin-top:-.5rem;height:.5rem;width:.5rem;transform:rotate(45deg);transition-property:transform,margin-top;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);content:"";transform-origin:75% 75%;box-shadow:2px 2px;pointer-events:none}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{transform:rotate(225deg);margin-top:0}.mockup-code:before{content:"";margin-bottom:1rem;display:block;height:.75rem;width:.75rem;border-radius:9999px;opacity:.3;box-shadow:1.4em 0,2.8em 0,4.2em 0}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-browser .mockup-browser-toolbar .input{position:relative;margin-left:auto;margin-right:auto;display:block;height:1.75rem;width:24rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding-left:2rem;direction:ltr}.mockup-browser .mockup-browser-toolbar .input:before{left:.5rem;aspect-ratio:1/1;height:.75rem;--tw-translate-y:-50%;border-radius:9999px;border-width:2px;border-color:currentColor}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));opacity:.6}.mockup-browser .mockup-browser-toolbar .input:after{left:1.25rem;height:.5rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-radius:9999px;border-width:1px;border-color:currentColor}.modal::backdrop,.modal:not(dialog:not(.modal-open)){background-color:#0006;animation:modal-pop .2s ease-out}.modal-backdrop{z-index:-1;grid-column-start:1;grid-row-start:1;display:grid;align-self:stretch;justify-self:stretch;color:transparent}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.modal-action:where([dir=rtl],[dir=rtl] *)>:not([hidden])~:not([hidden]){--tw-space-x-reverse:1}@keyframes modal-pop{0%{opacity:0}}@keyframes progress-loading{50%{background-position-x:-115%}}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-style:solid;border-bottom-width:calc(var(--tab-border, 1px) + 1px)}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-width:0 0 var(--tab-border,1px) 0;border-start-start-radius:var(--tab-radius,.5rem);border-start-end-radius:var(--tab-radius,.5rem);border-bottom-color:var(--tab-border-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);border-inline-start-color:var(--tab-border-color);border-inline-end-color:var(--tab-border-color);border-top-color:var(--tab-border-color);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-top:0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{z-index:1;content:"";display:block;position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);height:var(--tab-radius,.5rem);bottom:0;background-size:var(--tab-radius,.5rem);background-position:0 0,100% 0;background-repeat:no-repeat;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before,.tabs-lifted>:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled])+.tabs-lifted :is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.table:where([dir=rtl],[dir=rtl] *){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead tr,tbody tr:not(:last-child),tbody tr:first-child:last-child){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){white-space:nowrap;font-size:.75rem;line-height:1rem;font-weight:700;color:var(--fallback-bc,oklch(var(--bc)/.6))}.table :where(tfoot){border-top-width:1px;--tw-border-opacity:1;border-top-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}@keyframes toast-pop{0%{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}.badge-xs{height:.75rem;font-size:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{height:1rem;font-size:.75rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.75rem}.btn-sm{height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem}.btn-square:where(.btn-xs){height:1.5rem;width:1.5rem;padding:0}.btn-square:where(.btn-sm){height:2rem;width:2rem;padding:0}.btn-circle:where(.btn-xs){height:1.5rem;width:1.5rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-sm){height:2rem;width:2rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-md){height:3rem;width:3rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-lg){height:4rem;width:4rem;border-radius:9999px;padding:0}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.input-xs{padding-right:.5rem}.input-xs,.select-xs{height:1.5rem;padding-left:.5rem;font-size:.75rem;line-height:1rem;line-height:1.625}.select-xs{min-height:1.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.tabs-md :where(.tab){height:2rem;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){height:3rem;font-size:1.125rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){height:1.5rem;font-size:.875rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){height:1.25rem;font-size:.75rem;line-height:.75rem;--tab-padding:0.5rem}.card-compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{padding:var(--padding-card,2rem);font-size:1rem;line-height:1.5rem}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)):is(.btn){margin-top:calc(var(--border-btn)*-1)}.join.join-horizontal>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1);margin-top:0}.modal-top :where(.modal-box){width:100%;max-width:none;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0}.modal-middle :where(.modal-box){width:91.666667%;max-width:32rem;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-bottom-left-radius:var(--rounded-box,1rem)}.modal-bottom :where(.modal-box){width:100%;max-width:none;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);border-bottom-right-radius:0;border-bottom-left-radius:0}.table-sm :not(thead):not(tfoot) tr{font-size:.875rem;line-height:1.25rem}.table-sm :where(th,td){padding:.5rem .75rem}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-1\/2{left:50%}.left-\[-38px\]{left:-38px}.right-2{right:.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-2{top:.5rem}.top-6{top:1.5rem}.z-40{z-index:40}.z-50,.z-\[50\]{z-index:50}.-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-full{height:100%}.max-h-96{max-height:24rem}.min-h-0{min-height:0}.min-h-screen{min-height:100vh}.w-1\/12{width:8.333333%}.w-1\/4{width:25%}.w-1\/5{width:20%}.w-1\/6{width:16.666667%}.w-11\/12{width:91.666667%}.w-12{width:3rem}.w-28{width:7rem}.w-36{width:9rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-auto{width:auto}.w-full{width:100%}.max-w-lg{max-width:32rem}.max-w-screen-2xl{max-width:1536px}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-rotate-45{--tw-rotate:-45deg}.-rotate-45,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.gap-4{gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.whitespace-nowrap{white-space:nowrap}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-100\/90{background-color:var(--fallback-b1,oklch(var(--b1)/.9))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-200\/30{background-color:var(--fallback-b2,oklch(var(--b2)/.3))}.bg-base-200\/50{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.stroke-current{stroke:currentColor}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.text-center{text-align:center}.align-middle{vertical-align:middle}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tracking-wider{letter-spacing:.05em}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-pink-500{--tw-text-opacity:1;color:rgb(236 72 153/var(--tw-text-opacity,1))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark\:bg-base-200:is(.dark *){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}@media (min-width:640px){.sm\:left-\[-35px\]{left:-35px}.sm\:top-7{top:1.75rem}.sm\:-mx-8{margin-left:-2rem;margin-right:-2rem}.sm\:mb-12{margin-bottom:3rem}.sm\:inline{display:inline}.sm\:h-16{height:4rem}.sm\:h-24{height:6rem}.sm\:w-72{width:18rem}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-6{gap:1.5rem}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-12{padding-top:3rem;padding-bottom:3rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file diff --git a/dockflare/app/static/js/main.js b/dockflare/app/static/js/main.js new file mode 100644 index 0000000..0fe7e5b --- /dev/null +++ b/dockflare/app/static/js/main.js @@ -0,0 +1,451 @@ +// app/static/js/main.js + +const maxLogLines = 250; +let initialConnectMessageCleared = false; +let activeLogSource = null; +let eventSourceHealthCheck = null; +let pingInterval = null; + +const themeManager = (function() { + let themeMenuScoped; + const htmlElementScoped = document.documentElement; + const availableThemes = [ + "light", "dark", "cupcake", "bumblebee", "emerald", "corporate", + "synthwave", "retro", "cyberpunk", "valentine", "halloween", "garden", + "forest", "aqua", "lofi", "pastel", "fantasy", "wireframe", "black", + "luxury", "dracula", "cmyk", "autumn", "business", "acid", + "lemonade", "night", "coffee", "winter" + ]; + + function setTheme(theme) { + if (!availableThemes.includes(theme)) { + console.warn(`Theme "${theme}" not available, defaulting to light.`); + theme = 'light'; + } + localStorage.setItem('theme', theme); + htmlElementScoped.setAttribute('data-theme', theme); + + if (themeMenuScoped) updateSelectedThemeInMenu(theme); + } + + function populateThemeMenu() { + if (!themeMenuScoped) return; + themeMenuScoped.innerHTML = ''; + availableThemes.forEach(themeName => { + const listItem = document.createElement('li'); + listItem.classList.add('w-full'); + const link = document.createElement('a'); + link.textContent = themeName.charAt(0).toUpperCase() + themeName.slice(1); + link.setAttribute('data-theme-value', themeName); + link.href = "#"; + link.classList.add('flex', 'items-center', 'flex-grow', 'w-full', 'px-4', 'py-2'); + link.addEventListener('click', (e) => { + e.preventDefault(); + const selectedTheme = e.target.getAttribute('data-theme-value'); + setTheme(selectedTheme); + if (document.activeElement && typeof document.activeElement.blur === 'function') { + document.activeElement.blur(); + } + }); + listItem.appendChild(link); + themeMenuScoped.appendChild(listItem); + }); + } + + function updateSelectedThemeInMenu(currentTheme) { + if (!themeMenuScoped) return; + themeMenuScoped.querySelectorAll('li a').forEach(a => { + if (a.getAttribute('data-theme-value') === currentTheme) { + a.parentElement.classList.add('font-bold', 'text-primary'); + a.classList.add('active'); + } else { + a.parentElement.classList.remove('font-bold', 'text-primary'); + a.classList.remove('active'); + } + }); + } + + function initTheme() { + const savedTheme = localStorage.getItem('theme'); + const defaultTheme = 'light'; + setTheme(savedTheme || defaultTheme); + } + + return { + initialize: function() { + themeMenuScoped = document.getElementById('theme-menu'); + const themeSelectorBtn = document.getElementById('theme-selector-btn'); + + if (themeMenuScoped && themeSelectorBtn) { + populateThemeMenu(); + initTheme(); + } else { + console.error("DockFlare Theme Error: UI elements for theme selector not found."); + } + } + }; +})(); + + +function fixResourcesAndBase() { + const currentProtocol = window.location.protocol; + const currentHost = window.location.host; + + document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) { + const href = link.getAttribute('href'); + if (href && href.startsWith('http:') && currentProtocol === 'https:') { + link.setAttribute('href', href.replace('http:', 'https:')); + } + }); + document.querySelectorAll('script[src]').forEach(function(script) { + const src = script.getAttribute('src'); + if (src && src.startsWith('http:') && currentProtocol === 'https:') { + script.setAttribute('src', src.replace('http:', 'https:')); + } + }); + document.querySelectorAll('link[rel="preconnect"]').forEach(function(link) { + const href = link.getAttribute('href'); + if (href && href.startsWith('http:') && currentProtocol === 'https:') { + const urlObj = new URL(href); + link.setAttribute('href', currentProtocol + '//' + urlObj.host + (urlObj.pathname || '') + (urlObj.search || '')); + } + }); + + let baseTag = document.querySelector('base'); + if (!baseTag) { + baseTag = document.createElement('base'); + document.head.insertBefore(baseTag, document.head.firstChild); // Insert at the beginning + } + baseTag.href = currentProtocol + '//' + currentHost + '/'; + + const origFetch = window.fetch; + window.fetch = function(url, options) { + let processedUrl = url; + if (url && typeof url === 'string') { + try { + const urlObj = new URL(url, document.baseURI); + if (urlObj.host === currentHost && urlObj.protocol !== currentProtocol) { + urlObj.protocol = currentProtocol; + processedUrl = urlObj.toString(); + } + } catch (e) { + } + } + return origFetch.call(this, processedUrl, options); + }; +} + +function addLogLine(message, type = 'log') { + const logOutput = document.getElementById('log-output'); + if (!logOutput) { console.error("Log output element not found."); return; } + if (!initialConnectMessageCleared && logOutput.textContent.includes('Connecting to log stream...')) { + logOutput.textContent = ''; + initialConnectMessageCleared = true; + } + const newLogLine = document.createElement('div'); + newLogLine.textContent = message; + if (type === 'status') newLogLine.classList.add('text-neutral-content', 'opacity-70', 'italic'); + else if (type === 'error') newLogLine.classList.add('text-red-400', 'font-semibold'); + else if (type === 'connected') newLogLine.classList.add('text-green-400'); + + const isScrolledToBottom = logOutput.scrollHeight - logOutput.clientHeight <= logOutput.scrollTop + 10; + logOutput.appendChild(newLogLine); + while (logOutput.childNodes.length > maxLogLines) { + logOutput.removeChild(logOutput.firstChild); + } + if (isScrolledToBottom) { + logOutput.scrollTop = logOutput.scrollHeight; + } +} + +function connectEventSource() { + const logOutput = document.getElementById('log-output'); + if (!logOutput) { console.error("Log output element not found for EventSource."); return; } + if (!window.EventSource) { + addLogLine("Browser doesn't support Server-Sent Events.", 'error'); + return; + } + if (activeLogSource) { + try { activeLogSource.close(); } catch (e) { console.error("Error closing existing log stream:", e); } + activeLogSource = null; + } + + const streamUrl = `${document.baseURI}stream-logs?t=${Date.now()}`; + try { + activeLogSource = new EventSource(streamUrl); + let connectionTimeout; + const resetConnectionTimeout = () => { + if (connectionTimeout) clearTimeout(connectionTimeout); + connectionTimeout = setTimeout(() => { + if (activeLogSource) { + activeLogSource.close(); activeLogSource = null; + addLogLine("--- Log stream connection timeout. Reconnecting... ---", 'error'); + setTimeout(connectEventSource, 2000); + } + }, 10000); // 10s timeout + }; + resetConnectionTimeout(); + + activeLogSource.onopen = function() { + if (connectionTimeout) clearTimeout(connectionTimeout); + addLogLine("--- Log stream connected ---", 'connected'); + }; + activeLogSource.onmessage = function(event) { + resetConnectionTimeout(); + if (event.data === "heartbeat" || event.data === ": keepalive") { return; } + addLogLine(event.data, 'log'); + }; + + let retryAttempt = 0; + activeLogSource.onerror = function(err) { + if (connectionTimeout) clearTimeout(connectionTimeout); + if (activeLogSource && activeLogSource.readyState !== EventSource.CLOSED) { + addLogLine("--- Log stream connection error. Retrying... ---", 'error'); + } + if (activeLogSource) { activeLogSource.close(); activeLogSource = null; } + + retryAttempt++; + const delay = Math.min(5000 * Math.pow(1.5, Math.min(retryAttempt - 1, 5)), 30000); + setTimeout(connectEventSource, delay); + }; + } catch (e) { + addLogLine(`--- Failed to establish log stream connection: ${e.message} ---`, 'error'); + setTimeout(connectEventSource, 5000); + } + + if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck); + eventSourceHealthCheck = setInterval(() => { + if (!activeLogSource || activeLogSource.readyState === EventSource.CLOSED) { + addLogLine("--- Health check: Log stream disconnected. Reconnecting... ---", 'status'); + connectEventSource(); + } + }, 15000); +} + +function formatTimeDifference(diffMillis) { + const totalSeconds = Math.round(Math.abs(diffMillis / 1000)); + if (totalSeconds < 60) return diffMillis >= 0 ? 'in <1m' : '<1m ago'; + const days = Math.floor(totalSeconds / (3600 * 24)); + const hours = Math.floor((totalSeconds % (3600 * 24)) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + let parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 || (days === 0 && hours === 0)) parts.push(`${minutes}m`); + const timeString = parts.join(' '); + return diffMillis >= 0 ? `in ${timeString}` : `${timeString} ago`; +} + +function updateCountdowns() { + document.querySelectorAll('div[data-delete-at]').forEach(div => { + const deleteAtISO = div.dataset.deleteAt; + if (!deleteAtISO) return; + const absoluteTimeSpan = div.querySelector('.absolute-time-display'); + const countdownSpan = div.querySelector('.countdown-timer'); + if (!absoluteTimeSpan || !countdownSpan) return; + + try { + const targetDate = new Date(deleteAtISO); + if (isNaN(targetDate.getTime())) throw new Error("Invalid date"); + const options = { hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short', year: 'numeric' }; + absoluteTimeSpan.textContent = targetDate.toLocaleString(undefined, options); + const now = new Date(); + const diff = targetDate - now; + countdownSpan.textContent = `(${formatTimeDifference(diff)})`; + if (diff < 0) { + countdownSpan.classList.add('text-error'); absoluteTimeSpan.classList.add('text-error'); + } else { + countdownSpan.classList.remove('text-error'); absoluteTimeSpan.classList.remove('text-error'); + } + } catch (e) { + absoluteTimeSpan.textContent = "(Invalid Date)"; countdownSpan.textContent = ""; + console.error("Error processing date for countdown:", deleteAtISO, e); + } + }); +} + +function startServerPing() { + if (pingInterval) clearInterval(pingInterval); + pingInterval = setInterval(() => { + fetch(`${document.baseURI}ping?t=${Date.now()}`) // Use baseURI + .then(response => response.ok ? response.json() : Promise.reject(`Ping failed: ${response.status}`)) + .then(data => { /* console.debug("Ping success:", data) */ }) + .catch(error => console.warn("Server ping failed:", error)); + }, 30000); +} + +function updateReconciliationStatus() { + fetch(`${document.baseURI}reconciliation-status?t=${Date.now()}`) + .then(response => response.json()) + .then(data => { + const statusElement = document.getElementById('reconciliation-status'); + const messageElement = document.getElementById('reconciliation-status-message'); + if (!statusElement || !messageElement) return; + + if (data.status) { + messageElement.textContent = data.status; + messageElement.style.display = data.in_progress ? 'block' : 'none'; + } + if (data.in_progress) { + statusElement.innerHTML = ``; + } else { + // Only clear if it was previously showing reconciliation + if (statusElement.innerHTML.includes('Reconciliation:')) { + statusElement.innerHTML = ``; + setTimeout(() => { + if (statusElement.innerHTML.includes('Reconciliation complete')) { + statusElement.innerHTML = ''; + if (messageElement) messageElement.style.display = 'none'; + } + }, 5000); + } + } + }).catch(err => console.warn("Failed to fetch reconciliation status:", err)); +} + +document.addEventListener('DOMContentLoaded', function() { + fixResourcesAndBase(); + themeManager.initialize(); + + document.querySelectorAll('form.protocol-aware-form').forEach(form => { + if (form.getAttribute('action')) { + let actionUrl = form.getAttribute('action'); + try { + const fullActionUrl = new URL(actionUrl, document.baseURI); + if (fullActionUrl.protocol !== window.location.protocol && fullActionUrl.host === window.location.host) { + fullActionUrl.protocol = window.location.protocol; + form.setAttribute('action', fullActionUrl.toString()); + } else if (!actionUrl.startsWith('http')) { // Ensure relative paths become full + form.setAttribute('action', fullActionUrl.toString()); + } + } catch (e) { /* console.error("Error processing form action URL:", actionUrl, e); */ } + } + }); + document.querySelectorAll('a[href]').forEach(link => { + const href = link.getAttribute('href'); + if (href && href !== "#" && !href.startsWith('mailto:') && !href.startsWith('tel:')) { + try { + const fullLinkUrl = new URL(href, document.baseURI); + if (fullLinkUrl.protocol !== window.location.protocol && fullLinkUrl.host === window.location.host) { + fullLinkUrl.protocol = window.location.protocol; + link.setAttribute('href', fullLinkUrl.toString()); + } else if (!href.startsWith('http')) { + link.setAttribute('href', fullLinkUrl.toString()); + } + } catch (e) { /* console.error("Error processing link href URL:", href, e); */ } + } + }); + + updateCountdowns(); + setInterval(updateCountdowns, 30000); + connectEventSource(); + + updateReconciliationStatus(); + setInterval(updateReconciliationStatus, 2000); + + // Policy type select logic + function toggleAuthEmailField(policyType, selectElement) { + const form = selectElement.closest('form'); + if (!form) return; + const emailFieldDiv = form.querySelector('.auth-email-field'); + if (emailFieldDiv) { + if (policyType === 'authenticate_email') { + emailFieldDiv.classList.remove('hidden'); + } else { + emailFieldDiv.classList.add('hidden'); + const emailInput = emailFieldDiv.querySelector('input[name="auth_email"]'); + if (emailInput) emailInput.value = ''; + } + } + } + document.querySelectorAll('.policy-type-select').forEach(select => { + select.addEventListener('change', function() { + toggleAuthEmailField(this.value, this); + }); + toggleAuthEmailField(select.value, select); + }); + + // Tunnel DNS toggle logic + document.querySelectorAll('.tunnel-dns-toggle').forEach(button => { + button.addEventListener('click', async function() { + const tunnelId = this.dataset.tunnelId; + const tunnelDetailsRow = this.closest('tr'); + const dnsRecordsDisplayRow = tunnelDetailsRow.nextElementSibling; + const targetDivId = this.getAttribute('aria-controls'); + const targetDiv = document.getElementById(targetDivId); + const isExpanded = this.getAttribute('aria-expanded') === 'true'; + const expandIcon = this.querySelector('.expand-icon'); + const collapseIcon = this.querySelector('.collapse-icon'); + + if (!dnsRecordsDisplayRow || !targetDiv) return; + + if (isExpanded) { + dnsRecordsDisplayRow.classList.add('hidden'); + this.setAttribute('aria-expanded', 'false'); + if (expandIcon) expandIcon.classList.remove('hidden'); + if (collapseIcon) collapseIcon.classList.add('hidden'); + } else { + this.setAttribute('aria-expanded', 'true'); + if (expandIcon) expandIcon.classList.add('hidden'); + if (collapseIcon) collapseIcon.classList.remove('hidden'); + + if (targetDiv.dataset.loaded !== 'true' || targetDiv.dataset.loaded === 'error') { + targetDiv.innerHTML = '

Loading DNS records...

'; + dnsRecordsDisplayRow.classList.remove('hidden'); + + try { + const fetchUrl = `${document.baseURI}tunnel-dns-records/${encodeURIComponent(tunnelId)}?t=${Date.now()}`; + const response = await fetch(fetchUrl); + if (!response.ok) { + let errorDetail = `HTTP error ${response.status}`; + try { const errorData = await response.json(); errorDetail = errorData.error || errorData.message || errorDetail; } catch (e) {} + throw new Error(errorDetail); + } + const data = await response.json(); + + const currentTargetDiv = document.getElementById(`dns-records-${tunnelId}`); + if (!currentTargetDiv) {return;} + + + if (data.dns_records && data.dns_records.length > 0) { + let dnsHtml = '
    '; + data.dns_records.forEach(record => { + const recordUrl = `https://${record.name}`; + const zoneDisplay = record.zone_name ? record.zone_name : record.zone_id; + dnsHtml += `
  • + + ${record.name} + (Zone: ${zoneDisplay}) +
  • `; + }); + dnsHtml += '
'; + currentTargetDiv.innerHTML = dnsHtml; + currentTargetDiv.dataset.loaded = 'true'; + } else if (data.message) { + currentTargetDiv.innerHTML = `

${data.message}

`; + currentTargetDiv.dataset.loaded = 'info'; + } else { + currentTargetDiv.innerHTML = '

No CNAME DNS records found pointing to this tunnel in the configured zones.

'; + currentTargetDiv.dataset.loaded = 'true'; + } + } catch (error) { + const errorTargetDiv = document.getElementById(`dns-records-${tunnelId}`); + if (errorTargetDiv) { + errorTargetDiv.innerHTML = `

Error loading DNS records: ${error.message}

`; + errorTargetDiv.dataset.loaded = 'error'; + } + } + } + dnsRecordsDisplayRow.classList.remove('hidden'); + } + }); + }); + + startServerPing(); + + window.addEventListener('beforeunload', function() { + if (activeLogSource) activeLogSource.close(); + if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck); + if (pingInterval) clearInterval(pingInterval); + }); +}); \ No newline at end of file diff --git a/templates/input.css b/dockflare/app/templates/input.css similarity index 100% rename from templates/input.css rename to dockflare/app/templates/input.css diff --git a/templates/status_page.html b/dockflare/app/templates/status_page.html similarity index 61% rename from templates/status_page.html rename to dockflare/app/templates/status_page.html index b33c6ed..5ef377e 100644 --- a/templates/status_page.html +++ b/dockflare/app/templates/status_page.html @@ -22,149 +22,7 @@ DockFlare v1.7.1 - Cloudflare Tunnel ingress Manager - - - + @@ -608,195 +466,7 @@ - - const absoluteTimeSpan = div.querySelector('.absolute-time-display'); - const countdownSpan = div.querySelector('.countdown-timer'); - - if (!absoluteTimeSpan || !countdownSpan) { - return; - } - - try { - const targetDate = new Date(deleteAtISO); - if (isNaN(targetDate.getTime())) throw new Error("Invalid date parsed from data-delete-at"); - - const options = { - hour: '2-digit', minute: '2-digit', - day: '2-digit', month: 'short', year: 'numeric', - - }; - absoluteTimeSpan.textContent = targetDate.toLocaleString(undefined, options); - - const now = new Date(); - const diff = targetDate - now; - countdownSpan.textContent = `(${formatTimeDifference(diff)})`; - - if (diff < 0) { - countdownSpan.classList.add('text-error'); - absoluteTimeSpan.classList.add('text-error'); - } else { - countdownSpan.classList.remove('text-error'); - absoluteTimeSpan.classList.remove('text-error'); - } - } catch (e) { - absoluteTimeSpan.textContent = "(Invalid Date)"; - countdownSpan.textContent = ""; - console.error("Error processing date for countdown:", deleteAtISO, e); - } - }); - } - function startServerPing() {if (pingInterval) clearInterval(pingInterval);pingInterval = setInterval(() => {const currentProtocol = window.location.protocol; const currentHost = window.location.host;const pingUrl = `${currentProtocol}//${currentHost}/ping?t=${new Date().getTime()}`;fetch(pingUrl).then(response => response.ok ? response.json() : Promise.reject(`Ping failed: ${response.status}`)).then(data => {}).catch(error => {});}, 30000);} - function updateReconciliationStatus() {const currentProtocol = window.location.protocol;const currentHost = window.location.host;const statusUrl = `${currentProtocol}//${currentHost}/reconciliation-status?t=${new Date().getTime()}`;fetch(statusUrl).then(response => response.json()).then(data => {const statusElement = document.getElementById('reconciliation-status');const messageElement = document.getElementById('reconciliation-status-message');if (!statusElement || !messageElement) {return;}if (data.status) {messageElement.textContent = data.status;messageElement.style.display = data.in_progress ? 'block' : 'none';}if (data.in_progress) {statusElement.innerHTML = ``;} else if (statusElement.innerHTML.includes('Reconciliation')) {statusElement.innerHTML = ``;setTimeout(() => {if (statusElement.innerHTML.includes('Reconciliation complete')) {statusElement.innerHTML = '';if (messageElement) {messageElement.style.display = 'none';}}}, 5000);}}).catch(err => {});} - - document.addEventListener('DOMContentLoaded', function() { - if (window.DOCKFLARE_THEME_MODULE && typeof window.DOCKFLARE_THEME_MODULE.initialize === 'function') { - window.DOCKFLARE_THEME_MODULE.initialize(); - } - - const currentProtocol = window.location.protocol;const currentHost = window.location.host; - document.querySelectorAll('form.protocol-aware-form').forEach(form => {if (form.getAttribute('action')) {let actionUrl = form.getAttribute('action');if (actionUrl.startsWith('/')) {actionUrl = `${currentProtocol}//${currentHost}${actionUrl}`; form.setAttribute('action', actionUrl);} else if (actionUrl.startsWith('http')) {try {const parsedUrl = new URL(actionUrl);if (parsedUrl.host === currentHost && parsedUrl.protocol !== currentProtocol) {parsedUrl.protocol = currentProtocol; form.setAttribute('action', parsedUrl.toString());}} catch (e) {}}}}); - document.querySelectorAll('a[href]').forEach(link => {const href = link.getAttribute('href');if (href && (href.startsWith('/') || (href.startsWith('http') && new URL(href, window.location.origin).host === currentHost))) {try {const url = new URL(href, window.location.origin);if (url.host === currentHost && url.protocol !== currentProtocol) {url.protocol = currentProtocol;link.setAttribute('href', url.toString());}} catch (e) {}}}); - updateCountdowns();const countdownInterval = setInterval(updateCountdowns, 30000);connectEventSource(); - - updateReconciliationStatus();setInterval(updateReconciliationStatus, 2000); - - function toggleAuthEmailField(policyType, hostname, selectElementId) { - if (!selectElementId || typeof selectElementId !== 'string') { - return; - } - const parts = selectElementId.split('-'); - if (parts.length < 3) { - return; - } - const source = parts[1]; - const hostnameIdentifier = parts.slice(2).join('-'); - const emailFieldDivId = `auth-email-field-${source}-${hostnameIdentifier}`; - const emailFieldDiv = document.getElementById(emailFieldDivId); - if (emailFieldDiv) { - if (policyType === 'authenticate_email') { - emailFieldDiv.classList.remove('hidden'); - } else { - emailFieldDiv.classList.add('hidden'); - const emailInput = emailFieldDiv.querySelector('input[name="auth_email"]'); - if (emailInput) { - emailInput.value = ''; - } - } - } - } - - document.querySelectorAll('.policy-type-select').forEach(select => { - if (select.id) { - select.addEventListener('change', function() { - if (this.id) { - toggleAuthEmailField(this.value, this.dataset.hostname, this.id); - } else { - console.warn("Select element in change event is missing an ID:", this); - } - }); - toggleAuthEmailField(select.value, select.dataset.hostname, select.id); - } else { - console.warn("Found a .policy-type-select element missing an ID. Cannot attach listener or set initial state for its email field.", select); - } - }); - - document.querySelectorAll('.tunnel-dns-toggle').forEach(button => { - button.addEventListener('click', async function() { - const tunnelId = this.dataset.tunnelId; - const tunnelDetailsRow = this.closest('tr'); - const dnsRecordsDisplayRow = tunnelDetailsRow.nextElementSibling; - const targetDivId = this.getAttribute('aria-controls'); - const targetDiv = document.getElementById(targetDivId); - const isExpanded = this.getAttribute('aria-expanded') === 'true'; - const expandIcon = this.querySelector('.expand-icon'); - const collapseIcon = this.querySelector('.collapse-icon'); - - if (!dnsRecordsDisplayRow || !dnsRecordsDisplayRow.classList.contains('dns-records-row')) { - console.error(`Could not find the dedicated DNS records row for tunnel ${tunnelId}. Button was in:`, tunnelDetailsRow, "Next sibling is:", dnsRecordsDisplayRow); - return; - } - if (!targetDiv) { - console.error(`Could not find targetDiv with ID ${targetDivId} for tunnel ${tunnelId}`); - return; - } - - if (isExpanded) { - dnsRecordsDisplayRow.classList.add('hidden'); - this.setAttribute('aria-expanded', 'false'); - if (expandIcon) expandIcon.classList.remove('hidden'); - if (collapseIcon) collapseIcon.classList.add('hidden'); - } else { - this.setAttribute('aria-expanded', 'true'); - if (expandIcon) expandIcon.classList.add('hidden'); - if (collapseIcon) collapseIcon.classList.remove('hidden'); - - if (targetDiv.dataset.loaded !== 'true' || targetDiv.dataset.loaded === 'error') { - targetDiv.innerHTML = '

Loading DNS records...

'; - dnsRecordsDisplayRow.classList.remove('hidden'); - - try { - const currentProtocol = window.location.protocol; - const currentHost = window.location.host; - const fetchUrl = `${currentProtocol}//${currentHost}/tunnel-dns-records/${encodeURIComponent(tunnelId)}?t=${Date.now()}`; - - const response = await fetch(fetchUrl); - if (!response.ok) { - let errorDetail = `HTTP error ${response.status}`; - try { const errorData = await response.json(); errorDetail = errorData.error || errorData.message || errorDetail; } catch (e) {} - throw new Error(errorDetail); - } - const data = await response.json(); - const currentTargetDiv = document.getElementById(`dns-records-${tunnelId}`); - if (!currentTargetDiv) { - console.error("PANIC: Target DIV disappeared or was wrong during fetch for tunnel " + tunnelId); - return; - } - - if (data.dns_records && data.dns_records.length > 0) { - let dnsHtml = '
    '; - data.dns_records.forEach(record => { - const recordUrl = `https://${record.name}`; - const zoneDisplay = record.zone_name ? record.zone_name : record.zone_id; - dnsHtml += `
  • - - ${record.name} - (Zone: ${zoneDisplay}) -
  • `; - }); - dnsHtml += '
'; - currentTargetDiv.innerHTML = dnsHtml; - currentTargetDiv.dataset.loaded = 'true'; - } else if (data.message) { - currentTargetDiv.innerHTML = `

${data.message}

`; - currentTargetDiv.dataset.loaded = 'info'; - } else { - currentTargetDiv.innerHTML = '

No CNAME DNS records found pointing to this tunnel in the configured zones.

'; - currentTargetDiv.dataset.loaded = 'true'; - } - } catch (error) { - const errorTargetDiv = document.getElementById(`dns-records-${tunnelId}`); - if (errorTargetDiv) { - errorTargetDiv.innerHTML = `

Error loading DNS records: ${error.message}

`; - errorTargetDiv.dataset.loaded = 'error'; - } - } - } - dnsRecordsDisplayRow.classList.remove('hidden'); - } - }); - }); - }); - startServerPing(); - \ No newline at end of file diff --git a/dockflare/app/web/__init__.py b/dockflare/app/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dockflare/app/web/forms.py b/dockflare/app/web/forms.py new file mode 100644 index 0000000..e69de29 diff --git a/dockflare/app/web/routes.py b/dockflare/app/web/routes.py new file mode 100644 index 0000000..8aac8b6 --- /dev/null +++ b/dockflare/app/web/routes.py @@ -0,0 +1,449 @@ +# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. +# Copyright (C) 2025 ChrispyBacon-Dev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# app/web/routes.py + +import logging +import time +import copy +import os +import random +import queue +from datetime import datetime, timezone +import traceback +import json + +from flask import ( + Blueprint, render_template, jsonify, redirect, url_for, request, Response, + stream_with_context, current_app +) + +from app import config, docker_client, tunnel_state, cloudflared_agent_state, log_queue # Globals from app/__init__ +from app.core.state_manager import managed_rules, state_lock, save_state, load_state # load_state if UI triggers it +from app.core.tunnel_manager import ( + start_cloudflared_container, + stop_cloudflared_container, + update_cloudflare_config +) +from app.core.cloudflare_api import ( + get_all_account_cloudflare_tunnels, + get_dns_records_for_tunnel, + create_cloudflare_dns_record, + delete_cloudflare_dns_record, + get_zone_id_from_name, + get_zone_details_by_id +) +from app.core.access_manager import ( + check_for_tld_access_policy, + get_cloudflare_account_email, + delete_cloudflare_access_application, + create_cloudflare_access_application, + update_cloudflare_access_application, + generate_access_app_config_hash +) +from app.core.reconciler import reconcile_state_threaded +from app.core.docker_handler import is_valid_hostname, is_valid_service + +bp = Blueprint('web', __name__) + + +def get_display_token_ui(token_value): + if not token_value: return "Not available" + return f"{token_value[:5]}...{token_value[-5:]}" if len(token_value) > 10 else "Token (short)" + + +@bp.before_app_request +def detect_protocol_bp(): + + forwarded_proto = request.headers.get('X-Forwarded-Proto', '').lower() + current_app.config['PREFERRED_URL_SCHEME'] = 'https' if forwarded_proto == 'https' or request.is_secure else 'http' + +@bp.after_app_request +def add_security_headers_bp(response): + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['X-XSS-Protection'] = '1; mode=block' + + is_https = current_app.config.get('PREFERRED_URL_SCHEME') == 'https' + + csp = ("default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; " + "script-src * 'unsafe-inline' 'unsafe-eval'; " + "style-src * 'unsafe-inline'; " + "img-src * data: blob:; font-src * data:; " + "connect-src *; frame-src *; ") + if is_https: csp += "upgrade-insecure-requests; " + response.headers['Content-Security-Policy'] = csp + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + if is_https: response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Requested-With, Authorization' + return response + +@bp.context_processor +def inject_protocol_bp(): + + preferred_scheme = current_app.config.get('PREFERRED_URL_SCHEME', 'http') + base_url = f"{preferred_scheme}://{request.host}" + return { + 'protocol': preferred_scheme, + 'is_https': preferred_scheme == 'https', + 'base_url': base_url, + 'host': request.host, + 'request_scheme': request.scheme + } + +@bp.route('/') +def status_page(): + rules_for_template = {} + template_tunnel_state = {} + template_agent_state = {} + initialization_status = {} + tld_policy_exists_val = False + account_email_for_tld_val = None + relevant_zone_name_for_tld_policy_val = None + + with state_lock: + for hostname, rule in managed_rules.items(): + rule_copy = copy.deepcopy(rule) + if rule_copy.get("delete_at") and isinstance(rule_copy["delete_at"], datetime): + rule_copy["delete_at"] = rule_copy["delete_at"].replace(tzinfo=timezone.utc) if rule_copy["delete_at"].tzinfo is None else rule_copy["delete_at"].astimezone(timezone.utc) + rules_for_template[hostname] = rule_copy + template_tunnel_state = tunnel_state.copy() + template_agent_state = cloudflared_agent_state.copy() + + initialization_status = { + "complete": template_tunnel_state.get("id") is not None or config.EXTERNAL_TUNNEL_ID, + "in_progress": not (template_tunnel_state.get("id") or config.EXTERNAL_TUNNEL_ID) and \ + template_tunnel_state.get("status_message", "").lower().startswith("init") + } + + if config.CF_ZONE_ID and docker_client: + + zone_details = get_zone_details_by_id(config.CF_ZONE_ID) + if zone_details and zone_details.get("name"): + relevant_zone_name_for_tld_policy_val = zone_details.get("name") + + if relevant_zone_name_for_tld_policy_val: + tld_policy_exists_val = check_for_tld_access_policy(relevant_zone_name_for_tld_policy_val) + if not tld_policy_exists_val: + account_email_for_tld_val = get_cloudflare_account_email() + else: + logging.info("Relevant zone name for TLD policy check (from CF_ZONE_ID) could not be determined.") + + display_token_val = get_display_token_ui(template_tunnel_state.get("token")) + all_account_tunnels_list = get_all_account_cloudflare_tunnels() + + return render_template('status_page.html', + tunnel_state=template_tunnel_state, + agent_state=template_agent_state, + initialization=initialization_status, + display_token=display_token_val, + cloudflared_container_name=config.CLOUDFLARED_CONTAINER_NAME, + docker_available=docker_client is not None, + external_cloudflared=config.USE_EXTERNAL_CLOUDFLARED, + external_tunnel_id=config.EXTERNAL_TUNNEL_ID, + rules=rules_for_template, + all_account_tunnels=all_account_tunnels_list, + CF_ACCOUNT_ID_CONFIGURED=bool(config.CF_ACCOUNT_ID), + ACCOUNT_ID_FOR_DISPLAY=config.CF_ACCOUNT_ID if config.CF_ACCOUNT_ID else "Not Configured", + relevant_zone_name_for_tld_policy=relevant_zone_name_for_tld_policy_val, + tld_policy_exists=tld_policy_exists_val, + account_email_for_tld=account_email_for_tld_val, + CF_ZONE_ID_CONFIGURED=bool(config.CF_ZONE_ID) + ) + +@bp.route('/ui_update_access_policy/', methods=['POST']) +def ui_update_access_policy(hostname): + + if not docker_client: + cloudflared_agent_state["last_action_status"] = "Error: UI Policy Update - Docker client unavailable." + return redirect(url_for('web.status_page')) + + new_policy_type = request.form.get('access_policy_type') + auth_email = request.form.get('auth_email', '').strip() + action_status_message = f"Processing UI policy update for {hostname}..." + + with state_lock: + current_rule = managed_rules.get(hostname) + if not current_rule: + cloudflared_agent_state["last_action_status"] = f"Error: Rule for {hostname} not found." + return redirect(url_for('web.status_page')) + + current_access_app_id = current_rule.get("access_app_id") + desired_session_duration = request.form.get("session_duration", current_rule.get("access_session_duration", "24h")) + + + cf_access_policies = [] + final_policy_type_for_state = new_policy_type + custom_rules_for_hash = None + operation_successful = False + + + if new_policy_type == "none" or new_policy_type == "public_no_policy": + if current_access_app_id: + if delete_cloudflare_access_application(current_access_app_id): + current_rule["access_app_id"] = None + operation_successful = True + # ... + final_policy_type_for_state = None + elif new_policy_type == "default_tld": + + final_policy_type_for_state = "default_tld" + elif new_policy_type == "bypass": + cf_access_policies = [{"name": "UI Public Bypass", "decision": "bypass", "include": [{"everyone": {}}]}] + custom_rules_for_hash = json.dumps(cf_access_policies) + final_policy_type_for_state = "bypass" + elif new_policy_type == "authenticate_email": + if not auth_email: + return redirect(url_for('web.status_page')) + cf_access_policies = [ + {"name": f"UI Allow Email {auth_email}", "decision": "allow", "include": [{"email": {"email": auth_email}}]}, + {"name": "UI Deny Fallback", "decision": "deny", "include": [{"everyone": {}}]} + ] + custom_rules_for_hash = json.dumps(cf_access_policies) + final_policy_type_for_state = "authenticate_email" + + if new_policy_type in ["bypass", "authenticate_email"]: + if not cf_access_policies: # ... error ... + return redirect(url_for('web.status_page')) + + new_config_hash = generate_access_app_config_hash( + final_policy_type_for_state, desired_session_duration, # ... + custom_access_rules_str=custom_rules_for_hash + ) + + if current_access_app_id: + + pass + else: + + pass + + if operation_successful: + current_rule["access_policy_ui_override"] = True + # ... + if current_rule.get("access_policy_ui_override") or operation_successful : + current_rule["access_policy_ui_override"] = True + if operation_successful: state_changed_locally = True + + + if state_changed_locally: save_state() + + cloudflared_agent_state["last_action_status"] = action_status_message + return redirect(url_for('web.status_page')) + + +@bp.route('/revert_access_policy_to_labels/', methods=['POST']) +def revert_access_policy_to_labels(hostname): + + if not docker_client: # ... + return redirect(url_for('web.status_page')) + + action_status_message = f"Attempting to revert Access Policy for '{hostname}' to label configuration..." + app_id_to_delete_if_any = None + state_changed_for_revert = False + + with state_lock: + current_rule = managed_rules.get(hostname) + if not current_rule: # ... + return redirect(url_for('web.status_page')) + if not current_rule.get("access_policy_ui_override", False): # ... + return redirect(url_for('web.status_page')) + + app_id_to_delete_if_any = current_rule.get("access_app_id") + current_rule["access_policy_ui_override"] = False + # ... + state_changed_for_revert = True + if state_changed_for_revert: save_state() + + if app_id_to_delete_if_any: + if delete_cloudflare_access_application(app_id_to_delete_if_any): + pass # ... + + reconcile_state_threaded() + action_status_message += " Reconciliation triggered." + cloudflared_agent_state["last_action_status"] = action_status_message + return redirect(url_for('web.status_page')) + + +@bp.route('/tunnel-dns-records/') +def tunnel_dns_records(tunnel_id): + if not tunnel_id: return jsonify({"error": "Tunnel ID is required"}), 400 + all_found_dns_records = [] + zone_ids_to_scan = set() + if config.CF_ZONE_ID: zone_ids_to_scan.add(config.CF_ZONE_ID) + for zone_name in config.TUNNEL_DNS_SCAN_ZONE_NAMES: + resolved_zone_id = get_zone_id_from_name(zone_name) + if resolved_zone_id: zone_ids_to_scan.add(resolved_zone_id) + + if not zone_ids_to_scan: + return jsonify({"dns_records": [], "message": "No zones configured or resolved for DNS scan."}) + + for z_id in zone_ids_to_scan: + records_in_zone = get_dns_records_for_tunnel(z_id, tunnel_id) + if records_in_zone: all_found_dns_records.extend(records_in_zone) + + all_found_dns_records.sort(key=lambda r: r.get("name", "").lower()) + return jsonify({"dns_records": all_found_dns_records}) + +@bp.route('/ping') +def ping(): + return jsonify({ "status": "ok", "timestamp": int(time.time()), "version": "1.7.1", + "protocol": request.environ.get('wsgi.url_scheme', 'unknown')}) + +@bp.route('/debug') +def debug_info(): + try: + headers = {k: v for k, v in request.headers.items()} + return jsonify({ + "request": { "scheme": request.scheme, "is_secure": request.is_secure, "host": request.host, + "path": request.path, "url": request.url, "headers": headers }, + "environment": { "wsgi.url_scheme": request.environ.get('wsgi.url_scheme'), + "HTTP_X_FORWARDED_PROTO": request.environ.get('HTTP_X_FORWARDED_PROTO') }, + "timestamp": int(time.time()) + }) + except Exception as e: + return jsonify({ "error": str(e), "traceback": traceback.format_exc() }), 500 + +@bp.route('/reconciliation-status') +def reconciliation_status_route(): + + reconciliation_info_data = getattr(current_app, 'reconciliation_info', {}) + return jsonify({ + "in_progress": reconciliation_info_data.get("in_progress", False), + "progress": reconciliation_info_data.get("progress", 0), + "total_items": reconciliation_info_data.get("total_items", 0), + "processed_items": reconciliation_info_data.get("processed_items", 0), + "status": reconciliation_info_data.get("status", "Not started") + }) + +@bp.route('/start-tunnel', methods=['POST']) +def start_tunnel_route(): + start_cloudflared_container() + time.sleep(1) + return redirect(url_for('web.status_page')) + +@bp.route('/stop-tunnel', methods=['POST']) +def stop_tunnel_route(): + stop_cloudflared_container() + time.sleep(1) + return redirect(url_for('web.status_page')) + +@bp.route('/force_delete_rule/', methods=['POST']) +def force_delete_rule_route(hostname): + + rule_removed_from_state = False; dns_delete_success = False; access_app_delete_success = False + zone_id_for_delete = None; access_app_id_for_delete = None + with state_lock: + rule_details = managed_rules.get(hostname) + if rule_details: # ... + zone_id_for_delete = rule_details.get("zone_id") + access_app_id_for_delete = rule_details.get("access_app_id") + # ... + effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID + if zone_id_for_delete and effective_tunnel_id: + dns_delete_success = delete_cloudflare_dns_record(zone_id_for_delete, hostname, effective_tunnel_id) + if access_app_id_for_delete: + access_app_delete_success = delete_cloudflare_access_application(access_app_id_for_delete) + # ... + with state_lock: + if hostname in managed_rules: del managed_rules[hostname]; rule_removed_from_state = True; save_state() + # ... + if rule_removed_from_state and not config.USE_EXTERNAL_CLOUDFLARED: + if update_cloudflare_config(): pass # ... + # ... + return redirect(url_for('web.status_page')) + +@bp.route('/stream-logs') +def stream_logs_route(): + client_id = f"client-{random.randint(1000, 9999)}" + logging.info(f"Log stream client {client_id} connected.") + def event_stream(): + try: + yield f"data: --- Log stream connected (client {client_id}) ---\n\n" + last_heartbeat = time.time() + while True: + try: + log_entry = log_queue.get(timeout=0.25) + yield f"data: {log_entry}\n\n" + last_heartbeat = time.time() + except queue.Empty: + if time.time() - last_heartbeat > 2: + yield f": keepalive\n\n" + last_heartbeat = time.time() + time.sleep(0.1) + except GeneratorExit: + logging.info(f"Log stream client {client_id} disconnected.") + except Exception as e_stream: + logging.error(f"Error in log stream for {client_id}: {e_stream}", exc_info=True) + finally: + logging.info(f"Log stream for client {client_id} ended.") + + response = Response(event_stream(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response.headers['Pragma'] = 'no-cache'; response.headers['Expires'] = '0' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET' + return response + +@bp.route('/ui/manual-rules/add', methods=['POST']) +def ui_add_manual_rule_route(): + + if not docker_client or (not tunnel_state.get("id") and not config.EXTERNAL_TUNNEL_ID): # ... + return redirect(url_for('web.status_page')) + hostname = request.form.get('manual_hostname', '').strip() + + with state_lock: + + save_state() + effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID + if update_cloudflare_config(): + create_cloudflare_dns_record(target_zone_id, hostname, effective_tunnel_id) + # ... + return redirect(url_for('web.status_page')) + +@bp.route('/ui/manual-rules/delete/', methods=['POST']) +def ui_delete_manual_rule_route(hostname): + + with state_lock: + rule_details = managed_rules.get(hostname) + if rule_details and rule_details.get("source") == "manual": + # ... (get zone_id, access_app_id) ... + del managed_rules[hostname]; save_state() + # ... + effective_tunnel_id = tunnel_state.get("id") if not config.USE_EXTERNAL_CLOUDFLARED else config.EXTERNAL_TUNNEL_ID + if zone_id_for_delete and effective_tunnel_id: delete_cloudflare_dns_record(...) + if access_app_id_for_delete: delete_cloudflare_access_application(...) + if update_cloudflare_config(): pass # ... + return redirect(url_for('web.status_page')) + +@bp.route('/cloudflare-ping') +def cloudflare_ping_route(): # Renamed + try: + cf_headers = {k: v for k, v in request.headers.items() if k.lower().startswith('cf-')} + visitor_data = json.loads(request.headers.get('Cf-Visitor', '{}')) + return jsonify({ + "status": "ok", "timestamp": int(time.time()), + "cloudflare": { "connecting_ip": request.headers.get('Cf-Connecting-Ip') or request.remote_addr, + "visitor": visitor_data, "ray": request.headers.get('Cf-Ray') }, + "request": { "host": request.host, "path": request.path, "scheme": request.scheme }, + "server": { "wsgi_url_scheme": request.environ.get('wsgi.url_scheme') } + }) + except Exception as e_cfping: + return jsonify({ "error": str(e_cfping), "status": "error", "timestamp": int(time.time()) }), 500 \ No newline at end of file diff --git a/dockflare/app/web/utils.py b/dockflare/app/web/utils.py new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/dockflare/package-lock.json similarity index 99% rename from package-lock.json rename to dockflare/package-lock.json index c343a4a..cfe79bc 100644 --- a/package-lock.json +++ b/dockflare/package-lock.json @@ -468,9 +468,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.154", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.154.tgz", - "integrity": "sha512-G4VCFAyKbp1QJ+sWdXYIRYsPGvlV5sDACfCmoMFog3rjm1syLhI41WXm/swZypwCIWIm4IFLWzHY14joWMQ5Fw==", + "version": "1.5.157", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", + "integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==", "dev": true }, "node_modules/emoji-regex": { diff --git a/package.json b/dockflare/package.json similarity index 65% rename from package.json rename to dockflare/package.json index 127d17a..40c8c64 100644 --- a/package.json +++ b/dockflare/package.json @@ -3,7 +3,7 @@ "version": "1.5.0", "private": true, "scripts": { - "build:css": "tailwindcss -c ./tailwind.config.js -i ./templates/input.css -o ./static/css/output.css --minify" + "build:css": "tailwindcss -c ./tailwind.config.js -i ./app/templates/input.css -o ./app/static/css/output.css --minify" }, "devDependencies": { "tailwindcss": "^3.4.3", diff --git a/postcss.config.js b/dockflare/postcss.config.js similarity index 100% rename from postcss.config.js rename to dockflare/postcss.config.js diff --git a/requirements.txt b/dockflare/requirements.txt similarity index 100% rename from requirements.txt rename to dockflare/requirements.txt diff --git a/tailwind.config.js b/dockflare/tailwind.config.js similarity index 95% rename from tailwind.config.js rename to dockflare/tailwind.config.js index e4239f8..b4a5b66 100644 --- a/tailwind.config.js +++ b/dockflare/tailwind.config.js @@ -1,7 +1,7 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: [ - "./templates/**/*.html", + "./app/templates/**/*.html", ], darkMode: 'class', theme: { diff --git a/examples.txt b/examples.txt deleted file mode 100644 index a94783c..0000000 --- a/examples.txt +++ /dev/null @@ -1,34 +0,0 @@ -# DockFlare: Automates Cloudflare Tunnel ingress from Docker labels. -# Copyright (C) 2025 ChrispyBacon-Dev -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -docker run -d \ - --name my-nginx-web \ - --network cloudflare-net \ - -l cloudflare.tunnel.enable="true" \ - -l cloudflare.tunnel.hostname="nginx.your-domain.com" \ - -l cloudflare.tunnel.service="http://my-nginx-web:80" \ - nginx:latest - -### For Multi DNS Zones on your CloudFlare - - docker run -d \ - --name my-nginx-web2 \ - --network cloudflare-net \ - -l cloudflare.tunnel.enable="true" \ - -l cloudflare.tunnel.hostname="nginx.your-other-domain.com" \ - -l cloudflare.tunnel.service="http://my-nginx-web2:80" \ - -l cloudflare.tunnel.zonename="your-other-domain.com" \ - nginx:latest \ No newline at end of file diff --git a/static/css/output.css b/static/css/output.css deleted file mode 100644 index 6a1ca7d..0000000 --- a/static/css/output.css +++ /dev/null @@ -1 +0,0 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0% 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}*{scrollbar-color:color-mix(in oklch,currentColor 35%,transparent) transparent}:hover{scrollbar-color:color-mix(in oklch,currentColor 60%,transparent) transparent}:root{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}[data-theme=cupcake]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:15.2344% 0.017892 200.026556;--sc:15.787% 0.020249 356.29965;--ac:15.8762% 0.029206 78.618794;--nc:84.7148% 0.013247 313.189598;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--p:76.172% 0.089459 200.026556;--s:78.9351% 0.101246 356.29965;--a:79.3811% 0.146032 78.618794;--n:23.5742% 0.066235 313.189598;--b1:97.7882% 0.00418 56.375637;--b2:93.9822% 0.007638 61.449292;--b3:91.5861% 0.006811 53.440502;--bc:23.5742% 0.066235 313.189598;--rounded-btn:1.9rem;--tab-border:2px;--tab-radius:0.7rem}[data-theme=bumblebee]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:20% 0 0;--ac:16.254% 0.0314 56.52;--nc:82.55% 0.015 281.99;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:89.51% 0.2132 96.61;--pc:38.92% 0.046 96.61;--s:80.39% 0.194 70.76;--sc:39.38% 0.068 70.76;--a:81.27% 0.157 56.52;--n:12.75% 0.075 281.99;--b1:100% 0 0}[data-theme=emerald]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:76.6626% 0.135433 153.450024;--pc:33.3872% 0.040618 162.240129;--s:61.3028% 0.202368 261.294233;--sc:100% 0 0;--a:72.7725% 0.149783 33.200363;--ac:0% 0 0;--n:35.5192% 0.032071 262.988584;--nc:98.4625% 0.001706 247.838921;--b1:100% 0 0;--bc:35.5192% 0.032071 262.988584;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=corporate]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:12.078% 0.0456 269.1;--sc:13.0739% 0.010951 256.688055;--ac:15.3934% 0.022799 163.57888;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--border-btn:1px;--tab-border:1px;--p:60.39% 0.228 269.1;--s:65.3694% 0.054756 256.688055;--a:76.9669% 0.113994 163.57888;--n:22.3899% 0.031305 278.07229;--nc:95.8796% 0.008588 247.915135;--b1:100% 0 0;--bc:22.3899% 0.031305 278.07229;--rounded-box:0.25rem;--rounded-btn:.125rem;--rounded-badge:.125rem;--tab-radius:0.25rem;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=synthwave]{color-scheme:dark;--b2:20.2941% 0.076211 287.835609;--b3:18.7665% 0.070475 287.835609;--pc:14.4421% 0.031903 342.009383;--sc:15.6543% 0.02362 227.382405;--ac:17.608% 0.0412 93.72;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:72.2105% 0.159514 342.009383;--s:78.2714% 0.118101 227.382405;--a:88.04% 0.206 93.72;--n:25.5554% 0.103537 286.507967;--nc:97.9365% 0.00819 301.358346;--b1:21.8216% 0.081948 287.835609;--bc:97.9365% 0.00819 301.358346;--in:76.5197% 0.12273 231.831603;--inc:23.5017% 0.096418 290.329844;--su:86.0572% 0.115038 178.624677;--suc:23.5017% 0.096418 290.329844;--wa:85.531% 0.122117 93.722227;--wac:23.5017% 0.096418 290.329844;--er:73.7005% 0.121339 32.639257;--erc:23.5017% 0.096418 290.329844}[data-theme=retro]{color-scheme:light;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:13.144% 0.0398 27.33;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:76.8664% 0.104092 22.664655;--pc:26.5104% 0.006243 0.522862;--s:80.7415% 0.052534 159.094608;--sc:26.5104% 0.006243 0.522862;--a:70.3919% 0.125455 52.953428;--ac:26.5104% 0.006243 0.522862;--n:28.4181% 0.009519 355.534017;--nc:92.5604% 0.025113 89.217311;--b1:91.6374% 0.034554 90.51575;--b2:88.2722% 0.049418 91.774344;--b3:84.133% 0.065952 90.856665;--bc:26.5104% 0.006243 0.522862;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:65.72% 0.199 27.33;--rounded-box:0.4rem;--rounded-btn:0.4rem;--rounded-badge:0.4rem;--tab-radius:0.4rem}[data-theme=cyberpunk]{color-scheme:light;--b2:87.8943% 0.16647 104.32;--b3:81.2786% 0.15394 104.32;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:18.902% 0.0358 104.32;--pc:14.844% 0.0418 6.35;--sc:16.666% 0.0368 204.72;--ac:14.372% 0.04352 310.43;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;--p:74.22% 0.209 6.35;--s:83.33% 0.184 204.72;--a:71.86% 0.2176 310.43;--n:23.04% 0.065 269.31;--nc:94.51% 0.179 104.32;--b1:94.51% 0.179 104.32;--rounded-box:0;--rounded-btn:0;--rounded-badge:0;--tab-radius:0}[data-theme=valentine]{color-scheme:light;--b2:88.0567% 0.024834 337.06289;--b3:81.4288% 0.022964 337.06289;--pc:13.7239% 0.030755 15.066527;--sc:14.3942% 0.029258 293.189609;--ac:14.2537% 0.014961 197.828857;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:14.614% 0.0414 27.33;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:68.6197% 0.153774 15.066527;--s:71.971% 0.14629 293.189609;--a:71.2685% 0.074804 197.828857;--n:54.6053% 0.143342 358.004839;--nc:90.2701% 0.037202 336.955191;--b1:94.6846% 0.026703 337.06289;--bc:37.3085% 0.081131 4.606426;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:73.07% 0.207 27.33;--rounded-btn:1.9rem;--tab-radius:0.7rem}[data-theme=halloween]{color-scheme:dark;--b2:23.0416% 0 0;--b3:21.3072% 0 0;--bc:84.9552% 0 0;--sc:89.196% 0.0496 305.03;--nc:84.8742% 0.009322 65.681484;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:13.144% 0.0398 27.33;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:77.48% 0.204 60.62;--pc:19.6935% 0.004671 196.779412;--s:45.98% 0.248 305.03;--a:64.8% 0.223 136.073479;--ac:0% 0 0;--n:24.371% 0.046608 65.681484;--b1:24.7759% 0 0;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:65.72% 0.199 27.33}[data-theme=garden]{color-scheme:light;--b2:86.4453% 0.002011 17.197414;--b3:79.9386% 0.00186 17.197414;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--sc:89.699% 0.022197 355.095988;--ac:11.2547% 0.010859 154.390187;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:62.45% 0.278 3.83636;--pc:100% 0 0;--s:48.4952% 0.110985 355.095988;--a:56.2735% 0.054297 154.390187;--n:24.1559% 0.049362 89.070594;--nc:92.9519% 0.002163 17.197414;--b1:92.9519% 0.002163 17.197414;--bc:16.9617% 0.001664 17.32068}[data-theme=forest]{color-scheme:dark;--b2:17.522% 0.007709 17.911578;--b3:16.2032% 0.007129 17.911578;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:83.7682% 0.001658 17.911578;--sc:13.9553% 0.027077 168.327128;--ac:14.1257% 0.02389 185.713193;--nc:86.1397% 0.007806 171.364646;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:68.6283% 0.185567 148.958922;--pc:0% 0 0;--s:69.7764% 0.135385 168.327128;--a:70.6285% 0.119451 185.713193;--n:30.6985% 0.039032 171.364646;--b1:18.8409% 0.00829 17.911578;--rounded-btn:1.9rem}[data-theme=aqua]{color-scheme:dark;--b2:45.3464% 0.118611 261.181672;--b3:41.9333% 0.109683 261.181672;--bc:89.7519% 0.025508 261.181672;--sc:12.1365% 0.02175 309.782946;--ac:18.6854% 0.020445 94.555431;--nc:12.2124% 0.023402 243.760661;--inc:90.923% 0.043042 262.880917;--suc:12.541% 0.033982 149.213788;--wac:13.3168% 0.031484 58.31834;--erc:14.79% 0.038 27.33;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:85.6617% 0.14498 198.6458;--pc:40.1249% 0.068266 197.603872;--s:60.6827% 0.108752 309.782946;--a:93.4269% 0.102225 94.555431;--n:61.0622% 0.117009 243.760661;--b1:48.7596% 0.127539 261.181672;--in:54.615% 0.215208 262.880917;--su:62.7052% 0.169912 149.213788;--wa:66.584% 0.157422 58.31834;--er:73.95% 0.19 27.33}[data-theme=lofi]{color-scheme:light;--inc:15.908% 0.0206 205.9;--suc:18.026% 0.0306 164.14;--wac:17.674% 0.027 79.94;--erc:15.732% 0.03 28.47;--border-btn:1px;--tab-border:1px;--p:15.9066% 0 0;--pc:100% 0 0;--s:21.455% 0.001566 17.278957;--sc:100% 0 0;--a:26.8618% 0 0;--ac:100% 0 0;--n:0% 0 0;--nc:100% 0 0;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.268% 0.001082 17.17934;--bc:0% 0 0;--in:79.54% 0.103 205.9;--su:90.13% 0.153 164.14;--wa:88.37% 0.135 79.94;--er:78.66% 0.15 28.47;--rounded-box:0.25rem;--rounded-btn:0.125rem;--rounded-badge:0.125rem;--tab-radius:0.125rem;--animation-btn:0;--animation-input:0;--btn-focus-scale:1}[data-theme=pastel]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--bc:20% 0 0;--pc:16.6166% 0.006979 316.8737;--sc:17.6153% 0.009839 8.688364;--ac:17.8419% 0.012056 170.923263;--nc:14.2681% 0.014702 228.183906;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:83.0828% 0.034896 316.8737;--s:88.0763% 0.049197 8.688364;--a:89.2096% 0.06028 170.923263;--n:71.3406% 0.07351 228.183906;--b1:100% 0 0;--b2:98.4625% 0.001706 247.838921;--b3:87.1681% 0.009339 258.338227;--rounded-btn:1.9rem;--tab-radius:0.7rem}[data-theme=fantasy]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:87.49% 0.0378 325.02;--sc:90.784% 0.0324 241.36;--ac:15.196% 0.0408 56.72;--nc:85.5616% 0.005919 256.847952;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:37.45% 0.189 325.02;--s:53.92% 0.162 241.36;--a:75.98% 0.204 56.72;--n:27.8078% 0.029596 256.847952;--b1:100% 0 0;--bc:27.8078% 0.029596 256.847952}[data-theme=wireframe]{color-scheme:light;--bc:20% 0 0;--pc:15.6521% 0 0;--sc:15.6521% 0 0;--ac:15.6521% 0 0;--nc:18.8014% 0 0;--inc:89.0403% 0.062643 264.052021;--suc:90.395% 0.035372 142.495339;--wac:14.1626% 0.019994 108.702381;--erc:12.5591% 0.051537 29.233885;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;font-family:Chalkboard,comic sans ms,"sans-serif";--p:78.2604% 0 0;--s:78.2604% 0 0;--a:78.2604% 0 0;--n:94.007% 0 0;--b1:100% 0 0;--b2:94.9119% 0 0;--b3:89.7547% 0 0;--in:45.2014% 0.313214 264.052021;--su:51.9752% 0.176858 142.495339;--wa:70.8131% 0.099969 108.702381;--er:62.7955% 0.257683 29.233885;--rounded-box:0.2rem;--rounded-btn:0.2rem;--rounded-badge:0.2rem;--tab-radius:0.2rem}[data-theme=black]{color-scheme:dark;--pc:86.736% 0 0;--sc:86.736% 0 0;--ac:86.736% 0 0;--nc:86.736% 0 0;--inc:89.0403% 0.062643 264.052021;--suc:90.395% 0.035372 142.495339;--wac:19.3597% 0.042201 109.769232;--erc:12.5591% 0.051537 29.233885;--border-btn:1px;--tab-border:1px;--p:33.6799% 0 0;--s:33.6799% 0 0;--a:33.6799% 0 0;--b1:0% 0 0;--b2:19.1251% 0 0;--b3:26.8618% 0 0;--bc:87.6096% 0 0;--n:33.6799% 0 0;--in:45.2014% 0.313214 264.052021;--su:51.9752% 0.176858 142.495339;--wa:96.7983% 0.211006 109.769232;--er:62.7955% 0.257683 29.233885;--rounded-box:0;--rounded-btn:0;--rounded-badge:0;--animation-btn:0;--animation-input:0;--btn-focus-scale:1;--tab-radius:0}[data-theme=luxury]{color-scheme:dark;--pc:20% 0 0;--sc:85.5163% 0.012821 261.069149;--ac:87.3349% 0.010348 338.82597;--inc:15.8122% 0.024356 237.133883;--suc:15.6239% 0.038579 132.154381;--wac:17.2255% 0.027305 102.89115;--erc:14.3506% 0.035271 22.568916;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:100% 0 0;--s:27.5815% 0.064106 261.069149;--a:36.6744% 0.051741 338.82597;--n:24.27% 0.057015 59.825019;--nc:93.2033% 0.089631 90.861683;--b1:14.0765% 0.004386 285.822869;--b2:20.2191% 0.004211 308.22937;--b3:29.8961% 0.003818 308.318612;--bc:75.6879% 0.123666 76.890484;--in:79.0612% 0.121778 237.133883;--su:78.1197% 0.192894 132.154381;--wa:86.1274% 0.136524 102.89115;--er:71.7531% 0.176357 22.568916}[data-theme=dracula]{color-scheme:dark;--b2:26.8053% 0.020556 277.508664;--b3:24.7877% 0.019009 277.508664;--pc:15.0922% 0.036614 346.812432;--sc:14.8405% 0.029709 301.883095;--ac:16.6785% 0.024826 66.558491;--nc:87.8891% 0.006515 275.524078;--inc:17.6526% 0.018676 212.846491;--suc:17.4199% 0.043903 148.024881;--wac:19.1068% 0.026849 112.757109;--erc:13.6441% 0.041266 24.430965;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:75.4611% 0.18307 346.812432;--s:74.2023% 0.148546 301.883095;--a:83.3927% 0.124132 66.558491;--n:39.4456% 0.032576 275.524078;--b1:28.8229% 0.022103 277.508664;--bc:97.7477% 0.007913 106.545019;--in:88.263% 0.09338 212.846491;--su:87.0995% 0.219516 148.024881;--wa:95.5338% 0.134246 112.757109;--er:68.2204% 0.206328 24.430965}[data-theme=cmyk]{color-scheme:light;--b2:93% 0 0;--b3:86% 0 0;--bc:20% 0 0;--pc:14.3544% 0.02666 239.443325;--sc:12.8953% 0.040552 359.339283;--ac:18.8458% 0.037948 105.306968;--nc:84.3557% 0 0;--inc:13.6952% 0.0189 217.284104;--suc:89.3898% 0.032505 321.406278;--wac:14.2473% 0.031969 52.023412;--erc:12.4027% 0.041677 28.717543;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:71.7722% 0.133298 239.443325;--s:64.4766% 0.202758 359.339283;--a:94.2289% 0.189741 105.306968;--n:21.7787% 0 0;--b1:100% 0 0;--in:68.4759% 0.094499 217.284104;--su:46.949% 0.162524 321.406278;--wa:71.2364% 0.159843 52.023412;--er:62.0133% 0.208385 28.717543}[data-theme=autumn]{color-scheme:light;--b2:89.1077% 0 0;--b3:82.4006% 0 0;--bc:19.1629% 0 0;--pc:88.1446% 0.032232 17.530175;--sc:12.3353% 0.033821 23.865865;--ac:14.6851% 0.018999 60.729616;--nc:90.8734% 0.007475 51.902819;--inc:13.8449% 0.019596 207.284192;--suc:12.199% 0.016032 174.616213;--wac:14.0163% 0.032982 56.844303;--erc:90.614% 0.0482 24.16;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:40.7232% 0.16116 17.530175;--s:61.6763% 0.169105 23.865865;--a:73.4253% 0.094994 60.729616;--n:54.3672% 0.037374 51.902819;--b1:95.8147% 0 0;--in:69.2245% 0.097979 207.284192;--su:60.9951% 0.080159 174.616213;--wa:70.0817% 0.164909 56.844303;--er:53.07% 0.241 24.16}[data-theme=business]{color-scheme:dark;--b2:22.6487% 0 0;--b3:20.944% 0 0;--bc:84.8707% 0 0;--pc:88.3407% 0.019811 251.473931;--sc:12.8185% 0.005481 229.389418;--ac:13.4542% 0.033545 35.791525;--nc:85.4882% 0.00265 253.041249;--inc:12.5233% 0.028702 240.033697;--suc:14.0454% 0.018919 156.59611;--wac:15.4965% 0.023141 81.519177;--erc:90.3221% 0.029356 29.674507;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:41.7036% 0.099057 251.473931;--s:64.0924% 0.027405 229.389418;--a:67.271% 0.167726 35.791525;--n:27.441% 0.01325 253.041249;--b1:24.3535% 0 0;--in:62.6163% 0.143511 240.033697;--su:70.2268% 0.094594 156.59611;--wa:77.4824% 0.115704 81.519177;--er:51.6105% 0.14678 29.674507;--rounded-box:0.25rem;--rounded-btn:.125rem;--rounded-badge:.125rem}[data-theme=acid]{color-scheme:light;--b2:91.6146% 0 0;--b3:84.7189% 0 0;--bc:19.7021% 0 0;--pc:14.38% 0.0714 330.759573;--sc:14.674% 0.0448 48.250878;--ac:18.556% 0.0528 122.962951;--nc:84.262% 0.0256 278.68;--inc:12.144% 0.0454 252.05;--suc:17.144% 0.0532 158.53;--wac:18.202% 0.0424 100.5;--erc:12.968% 0.0586 29.349188;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--p:71.9% 0.357 330.759573;--s:73.37% 0.224 48.250878;--a:92.78% 0.264 122.962951;--n:21.31% 0.128 278.68;--b1:98.5104% 0 0;--in:60.72% 0.227 252.05;--su:85.72% 0.266 158.53;--wa:91.01% 0.212 100.5;--er:64.84% 0.293 29.349188;--rounded-box:1.25rem;--rounded-btn:1rem;--rounded-badge:1rem;--tab-radius:0.7rem}[data-theme=lemonade]{color-scheme:light;--b2:91.8003% 0.0186 123.72;--b3:84.8906% 0.0172 123.72;--bc:19.742% 0.004 123.72;--pc:11.784% 0.0398 134.6;--sc:15.55% 0.0392 111.09;--ac:17.078% 0.0402 100.73;--nc:86.196% 0.015 108.6;--inc:17.238% 0.0094 224.14;--suc:17.238% 0.0094 157.85;--wac:17.238% 0.0094 102.15;--erc:17.238% 0.0094 25.85;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:58.92% 0.199 134.6;--s:77.75% 0.196 111.09;--a:85.39% 0.201 100.73;--n:30.98% 0.075 108.6;--b1:98.71% 0.02 123.72;--in:86.19% 0.047 224.14;--su:86.19% 0.047 157.85;--wa:86.19% 0.047 102.15;--er:86.19% 0.047 25.85}[data-theme=night]{color-scheme:dark;--b2:19.3144% 0.037037 265.754874;--b3:17.8606% 0.034249 265.754874;--bc:84.1536% 0.007965 265.754874;--pc:15.0703% 0.027798 232.66148;--sc:13.6023% 0.031661 276.934902;--ac:14.4721% 0.035244 350.048739;--nc:85.5899% 0.00737 260.030984;--suc:15.6904% 0.026506 181.911977;--wac:16.6486% 0.027912 82.95003;--erc:14.3572% 0.034051 13.11834;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:75.3513% 0.138989 232.66148;--s:68.0113% 0.158303 276.934902;--a:72.3603% 0.176218 350.048739;--n:27.9495% 0.036848 260.030984;--b1:20.7682% 0.039824 265.754874;--in:68.4553% 0.148062 237.25135;--inc:0% 0 0;--su:78.452% 0.132529 181.911977;--wa:83.2428% 0.139558 82.95003;--er:71.7858% 0.170255 13.11834}[data-theme=coffee]{color-scheme:dark;--b2:20.1585% 0.021457 329.708637;--b3:18.6412% 0.019842 329.708637;--pc:14.3993% 0.024765 62.756393;--sc:86.893% 0.00597 199.19444;--ac:88.5243% 0.014881 224.389184;--nc:83.3022% 0.003149 326.261446;--inc:15.898% 0.012774 184.558367;--suc:14.9445% 0.014491 131.116276;--wac:17.6301% 0.028162 87.722413;--erc:15.4637% 0.025644 31.871922;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:71.9967% 0.123825 62.756393;--s:34.465% 0.029849 199.19444;--a:42.6213% 0.074405 224.389184;--n:16.5109% 0.015743 326.261446;--b1:21.6758% 0.023072 329.708637;--bc:72.3547% 0.092794 79.129387;--in:79.4902% 0.063869 184.558367;--su:74.7224% 0.072456 131.116276;--wa:88.1503% 0.140812 87.722413;--er:77.3187% 0.12822 31.871922}[data-theme=winter]{color-scheme:light;--pc:91.372% 0.051 257.57;--sc:88.5103% 0.03222 282.339433;--ac:11.988% 0.038303 335.171434;--nc:83.9233% 0.012704 257.651965;--inc:17.6255% 0.017178 214.515264;--suc:16.0988% 0.015404 197.823719;--wac:17.8345% 0.009167 71.47031;--erc:14.6185% 0.022037 20.076293;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:56.86% 0.255 257.57;--s:42.5516% 0.161098 282.339433;--a:59.9398% 0.191515 335.171434;--n:19.6166% 0.063518 257.651965;--b1:100% 0 0;--b2:97.4663% 0.011947 259.822565;--b3:93.2686% 0.016223 262.751375;--bc:41.8869% 0.053885 255.824911;--in:88.1275% 0.085888 214.515264;--su:80.4941% 0.077019 197.823719;--wa:89.1725% 0.045833 71.47031;--er:73.0926% 0.110185 20.076293}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.alert{display:grid;width:100%;grid-auto-flow:row;align-content:flex-start;align-items:center;justify-items:center;gap:1rem;text-align:center;border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{display:flex;align-items:center;justify-content:center}.badge{display:inline-flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;height:1.25rem;font-size:.875rem;line-height:1.25rem;width:-moz-fit-content;width:fit-content;padding-left:.563rem;padding-right:.563rem;border-radius:var(--rounded-badge,1.9rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.link-hover:hover{text-decoration-line:underline}.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.tab:hover{--tw-text-opacity:1}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{display:inline-flex;height:3rem;min-height:3rem;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;border-radius:var(--rounded-btn,.5rem);border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));padding-left:1rem;padding-right:1rem;text-align:center;font-size:.875rem;line-height:1em;gap:.5rem;font-weight:600;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);border-width:var(--border-btn,1px);transition-property:color,background-color,border-color,opacity,box-shadow,transform;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}.btn-circle{height:3rem;width:3rem;border-radius:9999px;padding:0}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){width:auto;-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{position:relative;display:flex;flex-direction:column;border-radius:var(--rounded-box,1rem)}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;padding:var(--padding-card,2rem);gap:.5rem}.card-body :where(p){flex-grow:1}.card-actions{display:flex;flex-wrap:wrap;align-items:flex-start;gap:.5rem}.card figure{display:flex;align-items:center;justify-content:center}.card.image-full{display:grid}.card.image-full:before{position:relative;content:"";z-index:10;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));height:1.5rem;width:1.5rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2}.diff{position:relative;display:grid;width:100%;overflow:hidden;direction:ltr;container-type:inline-size;grid-template-columns:auto 1fr}.dropdown{position:relative;display:inline-block}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{visibility:hidden;opacity:0;transform-origin:top;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{visibility:visible;opacity:1}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{visibility:visible;opacity:1}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0% 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0% 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0% 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.footer{width:100%;grid-auto-flow:row;-moz-column-gap:1rem;column-gap:1rem;row-gap:2.5rem;font-size:.875rem;line-height:1.25rem}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;justify-content:space-between;padding:.5rem .25rem}.input{flex-shrink:1;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;padding-left:1rem;padding-right:1rem;font-size:1rem;line-height:2;line-height:1.5rem;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-top:-1rem;margin-bottom:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-top:-.25rem;margin-bottom:-.25rem;margin-inline-end:0}.join{display:inline-flex;align-items:stretch;border-radius:var(--rounded-btn,.5rem)}.join :where(.join-item){border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-start-end-radius:inherit;border-end-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-start-end-radius:inherit;border-end-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.link-hover{text-decoration-line:none}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){position:relative;white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){display:grid;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none;color:var(--fallback-bc,oklch(var(--bc)/.3))}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){position:relative;display:flex;flex-shrink:0;flex-direction:column;flex-wrap:wrap;align-items:stretch}:where(.menu li) .badge{justify-self:end}.mockup-code{position:relative;overflow:hidden;overflow-x:auto;min-width:18rem;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-top:1.25rem;padding-bottom:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;text-align:right;width:2rem;opacity:.5}.modal{pointer-events:none;position:fixed;inset:0;margin:0;display:grid;height:100%;max-height:none;width:100%;max-width:none;justify-items:center;padding:0;opacity:0;overscroll-behavior:contain;z-index:999;background-color:transparent;color:inherit;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity,visibility;overflow-y:hidden}:where(.modal){align-items:center}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{pointer-events:auto;visibility:visible;opacity:1}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden;scrollbar-gutter:stable}.progress{position:relative;width:100%;overflow:hidden;height:.5rem;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.progress,.select{-webkit-appearance:none;-moz-appearance:none;appearance:none}.select{display:inline-flex;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;height:3rem;min-height:3rem;padding-inline-start:1rem;padding-inline-end:2.5rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-size:4px 4px,4px 4px;background-repeat:no-repeat}.select[multiple]{height:auto}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])){border-bottom-color:transparent}.tab{position:relative;grid-row-start:1;display:inline-flex;height:2rem;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;flex-wrap:wrap;align-items:center;justify-content:center;text-align:center;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem)}.tab:is(input[type=radio]){width:auto;border-bottom-right-radius:0;border-bottom-left-radius:0}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}:is(.tab-active,[aria-selected=true])+.tab-content,input.tab:checked+.tab-content{display:block}.table{position:relative;width:100%;border-radius:var(--rounded-box,1rem);text-align:left;font-size:.875rem;line-height:1.25rem}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){position:sticky;bottom:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){position:sticky;left:0;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-neutral{border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-accent,.badge-neutral{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-info{background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)));color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.badge-info,.badge-success{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-error{background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}@media (prefers-reduced-motion:no-preference){.btn{animation:button-pop var(--animation-btn,.25s) ease-out}}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0% 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0% 0 0)){.btn-primary{--btn-color:var(--p)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{border-width:1px;border-color:transparent;background-color:transparent;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{border-color:transparent;background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.btn-outline{border-color:currentColor;background-color:transparent;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){overflow:hidden;border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-start-radius:unset;border-end-end-radius:unset}.card :where(figure:last-child){overflow:hidden;border-start-start-radius:unset;border-start-end-radius:unset;border-end-start-radius:inherit;border-end-end-radius:inherit}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-title{display:flex;align-items:center;gap:.5rem;font-size:1.25rem;line-height:1.75rem;font-weight:600}.card.image-full :where(figure){overflow:hidden;border-radius:inherit}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/1))}.checkbox:disabled{border-width:0;cursor:not-allowed;border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}.checkbox:checked,.checkbox[aria-checked=true]{background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%)}.checkbox:indeterminate{--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out;background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%)}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{box-shadow:none;border-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input-disabled,.input:disabled,.input:has(>input[disabled]),.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input:has(>input[disabled])::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input:has(>input[disabled])::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-top:0;margin-bottom:0;margin-inline-start:-1px}.join>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1)}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{pointer-events:none;display:inline-block;aspect-ratio:1/1;width:1.5rem;background-color:currentColor;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-linecap='round' stroke-width='3'%3E%3CanimateTransform attributeName='transform' dur='2s' from='0 12 12' repeatCount='indefinite' to='360 12 12' type='rotate'/%3E%3Canimate attributeName='stroke-dasharray' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0,150;42,150;42,150'/%3E%3Canimate attributeName='stroke-dashoffset' dur='1.5s' keyTimes='0;0.475;1' repeatCount='indefinite' values='0;-16;-59'/%3E%3C/circle%3E%3C/svg%3E")}.loading-xs{width:1rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;margin:.5rem 1rem;height:1px}.menu :where(li ul):before{position:absolute;bottom:.75rem;inset-inline-start:0;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;content:""}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;text-wrap:balance}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{cursor:pointer;background-color:var(--fallback-bc,oklch(var(--bc)/.1));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{justify-self:end;display:block;margin-top:-.5rem;height:.5rem;width:.5rem;transform:rotate(45deg);transition-property:transform,margin-top;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);content:"";transform-origin:75% 75%;box-shadow:2px 2px;pointer-events:none}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{transform:rotate(225deg);margin-top:0}.mockup-code:before{content:"";margin-bottom:1rem;display:block;height:.75rem;width:.75rem;border-radius:9999px;opacity:.3;box-shadow:1.4em 0,2.8em 0,4.2em 0}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{overflow:hidden;border-radius:40px;margin-top:-25px}.mockup-browser .mockup-browser-toolbar .input{position:relative;margin-left:auto;margin-right:auto;display:block;height:1.75rem;width:24rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding-left:2rem;direction:ltr}.mockup-browser .mockup-browser-toolbar .input:before{left:.5rem;aspect-ratio:1/1;height:.75rem;--tw-translate-y:-50%;border-radius:9999px;border-width:2px;border-color:currentColor}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));opacity:.6}.mockup-browser .mockup-browser-toolbar .input:after{left:1.25rem;height:.5rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-radius:9999px;border-width:1px;border-color:currentColor}.modal::backdrop,.modal:not(dialog:not(.modal-open)){background-color:#0006;animation:modal-pop .2s ease-out}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);background-color:currentColor}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-size:200%;background-position-x:15%;animation:progress-loading 5s ease-in-out infinite}.progress::-webkit-progress-bar{border-radius:var(--rounded-box,1rem);background-color:transparent}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);background-color:currentColor}.progress:indeterminate::-moz-progress-bar{background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-size:200%;background-position-x:15%;animation:progress-loading 5s ease-in-out infinite}@keyframes progress-loading{50%{background-position-x:-115%}}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-style:solid;border-bottom-width:calc(var(--tab-border, 1px) + 1px)}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-width:0 0 var(--tab-border,1px) 0;border-start-start-radius:var(--tab-radius,.5rem);border-start-end-radius:var(--tab-radius,.5rem);border-bottom-color:var(--tab-border-color);padding-inline-start:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);border-inline-start-color:var(--tab-border-color);border-inline-end-color:var(--tab-border-color);border-top-color:var(--tab-border-color);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-top:0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{z-index:1;content:"";display:block;position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);height:var(--tab-radius,.5rem);bottom:0;background-size:var(--tab-radius,.5rem);background-position:0 0,100% 0;background-repeat:no-repeat;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before,.tabs-lifted>:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled])+.tabs-lifted :is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.table:where([dir=rtl],[dir=rtl] *){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead tr,tbody tr:not(:last-child),tbody tr:first-child:last-child){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){white-space:nowrap;font-size:.75rem;line-height:1rem;font-weight:700;color:var(--fallback-bc,oklch(var(--bc)/.6))}.table :where(tfoot){border-top-width:1px;--tw-border-opacity:1;border-top-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}@keyframes toast-pop{0%{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}.badge-xs{height:.75rem;font-size:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{height:1rem;font-size:.75rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.75rem}.btn-sm{height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem}.btn-square:where(.btn-xs){height:1.5rem;width:1.5rem;padding:0}.btn-square:where(.btn-sm){height:2rem;width:2rem;padding:0}.btn-circle:where(.btn-xs){height:1.5rem;width:1.5rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-sm){height:2rem;width:2rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-md){height:3rem;width:3rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-lg){height:4rem;width:4rem;border-radius:9999px;padding:0}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.input-xs{height:1.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;line-height:1.625}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-start-radius:0;border-end-end-radius:0;border-start-start-radius:inherit;border-start-end-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-start-start-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-end-end-radius:inherit}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0;border-end-end-radius:inherit;border-start-end-radius:inherit}.select-xs{height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:2rem;font-size:.75rem;line-height:1rem;line-height:1.625}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.tabs-md :where(.tab){height:2rem;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){height:3rem;font-size:1.125rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){height:1.5rem;font-size:.875rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){height:1.25rem;font-size:.75rem;line-height:.75rem;--tab-padding:0.5rem}.card-compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{padding:var(--padding-card,2rem);font-size:1rem;line-height:1.5rem}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-vertical>:where(:not(:first-child)):is(.btn){margin-top:calc(var(--border-btn)*-1)}.join.join-horizontal>:where(:not(:first-child)){margin-top:0;margin-bottom:0;margin-inline-start:-1px}.join.join-horizontal>:where(:not(:first-child)):is(.btn){margin-inline-start:calc(var(--border-btn)*-1);margin-top:0}.table-sm :not(thead):not(tfoot) tr{font-size:.875rem;line-height:1.25rem}.table-sm :where(th,td){padding:.5rem .75rem}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-1\/2{left:50%}.left-\[-38px\]{left:-38px}.top-0{top:0}.top-1\/2{top:50%}.top-6{top:1.5rem}.z-40{z-index:40}.z-50,.z-\[50\]{z-index:50}.-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-12{height:3rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-full{height:100%}.max-h-96{max-height:24rem}.min-h-0{min-height:0}.min-h-screen{min-height:100vh}.w-1\/12{width:8.333333%}.w-1\/4{width:25%}.w-1\/5{width:20%}.w-1\/6{width:16.666667%}.w-12{width:3rem}.w-28{width:7rem}.w-36{width:9rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-auto{width:auto}.w-full{width:100%}.max-w-screen-2xl{max-width:1536px}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.-rotate-45{--tw-rotate:-45deg}.-rotate-45,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-nowrap{flex-wrap:nowrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.whitespace-nowrap{white-space:nowrap}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-100\/90{background-color:var(--fallback-b1,oklch(var(--b1)/.9))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-200\/30{background-color:var(--fallback-b2,oklch(var(--b2)/.3))}.bg-base-200\/50{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.stroke-current{stroke:currentColor}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pl-4{padding-left:1rem}.text-center{text-align:center}.align-middle{vertical-align:middle}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tracking-wider{letter-spacing:.05em}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-pink-500{--tw-text-opacity:1;color:rgb(236 72 153/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark\:bg-base-200:is(.dark *){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}@media (min-width:640px){.sm\:left-\[-35px\]{left:-35px}.sm\:top-7{top:1.75rem}.sm\:-mx-8{margin-left:-2rem;margin-right:-2rem}.sm\:mb-12{margin-bottom:3rem}.sm\:inline{display:inline}.sm\:h-16{height:4rem}.sm\:h-24{height:6rem}.sm\:w-72{width:18rem}.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:gap-6{gap:1.5rem}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-12{padding-top:3rem;padding-bottom:3rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file