diff --git a/.deploy.env.example b/.deploy.env.example new file mode 100644 index 0000000..b0c2186 --- /dev/null +++ b/.deploy.env.example @@ -0,0 +1,4 @@ +# Deployment image references used by docker-compose on servers +# Provide full image references including registry and tag +BACKEND_IMAGE= +FRONTEND_IMAGE= diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..67412e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# cache +__pycache__/ +.mypy_cache +.ruff_cache +*.py[cod] +*.pyo +*.pyd +*.db + +# Virtual environments +backend/.venv/ +env/ +venv/ + +# Editor & OS files +.idea/ +.vscode/ + +# Logs +*.log + +# Git +.git/ +.gitignore + +# Secrets & environment files +.env +.env.* diff --git a/.github/workflows/ci-cd-dev.yml b/.github/workflows/ci-cd-dev.yml new file mode 100644 index 0000000..5c930ec --- /dev/null +++ b/.github/workflows/ci-cd-dev.yml @@ -0,0 +1,245 @@ +name: CI/CD Dev + +on: + # Any PR to develop → tests + lint + type-check only (no deploy) + pull_request: + branches: + - develop + # Push to develop branch → CI + build + deploy to dev VM + push: + branches: + - develop + +env: + BACKEND_IMAGE_NAME: snippetly-backend + FRONTEND_IMAGE_NAME: snippetly-frontend + +jobs: + backend-tests: + name: Backend Tests + Ruff + Pyright (Dev) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies (tests + dev tooling) + working-directory: ./backend + run: | + uv sync --group test --group dev + + - name: Ruff lint + working-directory: ./backend + run: | + uv run ruff check . + + - name: Pyright type-check + working-directory: ./backend + run: | + uv run pyright + + frontend-build: + name: Frontend Build (Dev) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ./frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Build + working-directory: ./frontend + run: npm run build + + - name: Run tests + working-directory: ./frontend + run: npm test + + build-push-dev: + name: Build & Push Images (Dev) + runs-on: ubuntu-latest + # Only on push to develop branch (not on PRs) + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + needs: [ backend-tests, frontend-build ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker login to ACR (Dev, Service Principal) + run: | + echo "${{ secrets.DEV_AZURE_SP_PASSWORD }}" | docker login "${{ secrets.DEV_ACR_LOGIN_SERVER }}" \ + -u "${{ secrets.DEV_AZURE_SP_APP_ID }}" \ + --password-stdin + + - name: Build & push backend + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: ${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:${{ github.sha }} + cache-from: type=registry,ref=${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:cache + cache-to: type=registry,ref=${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:cache,mode=max + + - name: Build & push frontend (Nginx) + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: ${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ github.sha }} + build-args: | + VITE_SERVER_BASE_URL=${{ secrets.DEV_PUBLIC_URL || format('http://{0}', secrets.DEV_SSH_HOST) }} + cache-from: type=registry,ref=${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:cache + cache-to: type=registry,ref=${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:cache,mode=max + + deploy-dev: + name: Deploy to Azure VM (Dev) + runs-on: ubuntu-latest + # Only on push to develop branch (not on PRs) + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + needs: [ build-push-dev ] + environment: dev + concurrency: + group: cd-dev + cancel-in-progress: true + steps: + - name: Deploy over SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEV_SSH_HOST }} + username: ${{ secrets.DEV_SSH_USER }} + key: ${{ secrets.DEV_SSH_KEY }} + script_stop: true + script: | + set -euo pipefail + cd /opt/snippetly + + # Rollback function + rollback() { + echo "❌ Deployment failed! Rolling back to previous version..." + if [ -f .deploy.env.bak ]; then + mv .deploy.env.bak .deploy.env + set -a + . ./.deploy.env + set +a + docker compose -f docker-compose.yml -f docker-compose.override.yml pull || true + docker compose -f docker-compose.yml -f docker-compose.override.yml up -d || true + echo "✅ Rollback completed" + else + echo "⚠️ No backup found, cannot rollback" + fi + exit 1 + } + + # Trap errors for rollback + trap 'rollback' ERR + + # Pull latest code from develop branch + echo "Pulling latest code from develop branch..." + git fetch origin + git checkout develop + git pull origin develop + + # Ensure .deploy.env exists + if [ ! -f .deploy.env ]; then + cat > .deploy.env << 'EOF' + BACKEND_IMAGE= + FRONTEND_IMAGE= + EOF + fi + + # Compute image tags for this run (same as in build-push-dev) + BACKEND_IMAGE="${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:${{ github.sha }}" + FRONTEND_IMAGE="${{ secrets.DEV_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ github.sha }}" + + echo "Updating deployment configuration..." + # Update images in .deploy.env (creates .bak file automatically) + sed -i.bak \ + -e "s|^BACKEND_IMAGE=.*$|BACKEND_IMAGE=${BACKEND_IMAGE}|" \ + -e "s|^FRONTEND_IMAGE=.*$|FRONTEND_IMAGE=${FRONTEND_IMAGE}|" \ + .deploy.env + + # Load deployment environment + set -a + # shellcheck source=/dev/null + . ./.deploy.env + set +a + + echo "Using BACKEND_IMAGE=$BACKEND_IMAGE" + echo "Using FRONTEND_IMAGE=$FRONTEND_IMAGE" + + # Login to ACR + echo "Logging into Azure Container Registry..." + echo "${{ secrets.DEV_AZURE_SP_PASSWORD }}" | docker login "${{ secrets.DEV_ACR_LOGIN_SERVER }}" \ + -u "${{ secrets.DEV_AZURE_SP_APP_ID }}" \ + --password-stdin + + # Pull and deploy (Dev: use base compose + override for pgadmin + exposed ports) + echo "Pulling Docker images..." + docker compose -f docker-compose.yml -f docker-compose.override.yml pull + + echo "Starting services..." + docker compose -f docker-compose.yml -f docker-compose.override.yml up -d + + # Wait for services to be healthy + echo "Waiting for services to be healthy..." + sleep 30 + + # Verify deployment - critical services must be running + echo "Verifying deployment..." + docker compose -f docker-compose.yml -f docker-compose.override.yml ps + + # Health check: ensure backend is responding (with retry) + echo "Performing health check..." + MAX_RETRIES=10 + RETRY_COUNT=0 + HEALTH_CHECK_URL="http://${{ secrets.DEV_SSH_HOST }}/api/health" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -f -s "$HEALTH_CHECK_URL" > /dev/null 2>&1; then + echo "✅ Health check passed!" + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then + echo "⏳ Health check attempt $RETRY_COUNT/$MAX_RETRIES failed, retrying in 5s..." + sleep 5 + else + echo "❌ Health check failed after $MAX_RETRIES attempts!" + echo "Backend logs:" + docker logs snippetly-backend --tail 50 || true + exit 1 + fi + done + + echo "✅ Deployment successful! Cleaning up backup..." + rm -f .deploy.env.bak + + # Cleanup old images (keep images from last 24 hours for dev) + echo "Cleaning up old Docker images..." + docker image prune -af --filter "until=24h" || true + + echo "Dev deployment completed successfully!" \ No newline at end of file diff --git a/.github/workflows/ci-cd-prod.yml b/.github/workflows/ci-cd-prod.yml new file mode 100644 index 0000000..08ef0f8 --- /dev/null +++ b/.github/workflows/ci-cd-prod.yml @@ -0,0 +1,252 @@ +name: CI/CD Prod + +on: + # Deploy production on push to main branch + push: + branches: + - main + # Also support tagged releases (must be on main) + # Tags should only be created from main branch + # push: + # tags: + # - 'v*' + +env: + BACKEND_IMAGE_NAME: snippetly-backend + FRONTEND_IMAGE_NAME: snippetly-frontend + +jobs: + backend-tests: + name: Backend Tests + Ruff + Pyright (Prod) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies (tests + dev tooling) + working-directory: ./backend + run: | + uv sync --group test --group dev + + - name: Ruff lint + working-directory: ./backend + run: | + uv run ruff check . + + - name: Pyright type-check + working-directory: ./backend + run: | + uv run pyright + + - name: Run tests + working-directory: ./backend + run: | + uv run pytest -v + + frontend-build: + name: Frontend Build (Prod) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ./frontend/package-lock.json + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Build + working-directory: ./frontend + run: npm run build + + - name: Run tests + working-directory: ./frontend + run: npm test + + build-push-prod: + name: Build & Push Images (Prod) + runs-on: ubuntu-latest + needs: [ backend-tests, frontend-build ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker login to ACR (Prod, Service Principal) + run: | + echo "${{ secrets.PROD_AZURE_SP_PASSWORD }}" | docker login "${{ secrets.PROD_ACR_LOGIN_SERVER }}" \ + -u "${{ secrets.PROD_AZURE_SP_APP_ID }}" \ + --password-stdin + + - name: Build & push backend + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: | + ${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:${{ github.sha }} + ${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:latest + cache-from: type=registry,ref=${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:cache + cache-to: type=registry,ref=${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:cache,mode=max + + - name: Build & push frontend (Nginx) + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile + push: true + tags: | + ${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ github.sha }} + ${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:latest + build-args: | + VITE_SERVER_BASE_URL=${{ secrets.PROD_PUBLIC_URL || 'https://snippetly.codes' }} + cache-from: type=registry,ref=${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:cache + cache-to: type=registry,ref=${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:cache,mode=max + + deploy-prod: + name: Deploy to Azure VM (Prod) + runs-on: ubuntu-latest + needs: [ build-push-prod ] + environment: production + concurrency: + group: cd-prod + cancel-in-progress: false + steps: + - name: Deploy over SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.PROD_SSH_HOST }} + username: ${{ secrets.PROD_SSH_USER }} + key: ${{ secrets.PROD_SSH_KEY }} + script_stop: true + script: | + set -euo pipefail + cd /opt/snippetly + + # Rollback function + rollback() { + echo "❌ Deployment failed! Rolling back to previous version..." + if [ -f .deploy.env.bak ]; then + mv .deploy.env.bak .deploy.env + set -a + . ./.deploy.env + set +a + docker compose -f docker-compose.yml -f docker-compose.prod.yml pull || true + docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d || true + echo "✅ Rollback completed" + else + echo "⚠️ No backup found, cannot rollback" + fi + exit 1 + } + + # Trap errors for rollback + trap 'rollback' ERR + + + # Pull latest code from repository + echo "Pulling latest code from main..." + git fetch origin + git checkout main + git pull origin main + + # Ensure .deploy.env exists + if [ ! -f .deploy.env ]; then + cat > .deploy.env << 'EOF' + BACKEND_IMAGE= + FRONTEND_IMAGE= + EOF + fi + + # Compute image tags based on commit SHA + BACKEND_IMAGE="${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.BACKEND_IMAGE_NAME }}:${{ github.sha }}" + FRONTEND_IMAGE="${{ secrets.PROD_ACR_LOGIN_SERVER }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ github.sha }}" + + echo "Updating deployment configuration..." + # Update images in .deploy.env (creates .bak file automatically) + sed -i.bak \ + -e "s|^BACKEND_IMAGE=.*$|BACKEND_IMAGE=${BACKEND_IMAGE}|" \ + -e "s|^FRONTEND_IMAGE=.*$|FRONTEND_IMAGE=${FRONTEND_IMAGE}|" \ + .deploy.env + + # Load deployment environment + set -a + # shellcheck source=/dev/null + . ./.deploy.env + set +a + + echo "Using BACKEND_IMAGE=$BACKEND_IMAGE" + echo "Using FRONTEND_IMAGE=$FRONTEND_IMAGE" + + # Login to ACR + echo "Logging into Azure Container Registry..." + echo "${{ secrets.PROD_AZURE_SP_PASSWORD }}" | docker login "${{ secrets.PROD_ACR_LOGIN_SERVER }}" \ + -u "${{ secrets.PROD_AZURE_SP_APP_ID }}" \ + --password-stdin + + # Pull and deploy (Prod: base compose + prod overlay for HTTPS) + echo "Pulling Docker images..." + docker compose -f docker-compose.yml -f docker-compose.prod.yml pull + + echo "Starting services..." + docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + + # Wait for services to be healthy + echo "Waiting for services to be healthy..." + sleep 30 + + # Verify deployment - critical services must be running + echo "Verifying deployment..." + docker compose -f docker-compose.yml -f docker-compose.prod.yml ps + + # Health check: ensure backend is responding (with retry) + echo "Performing health check..." + MAX_RETRIES=10 + RETRY_COUNT=0 + HEALTH_CHECK_URL="https://snippetly.codes/api/health" + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -f -s "$HEALTH_CHECK_URL" > /dev/null 2>&1; then + echo "✅ Health check passed!" + break + fi + + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then + echo "⏳ Health check attempt $RETRY_COUNT/$MAX_RETRIES failed, retrying in 5s..." + sleep 5 + else + echo "❌ Health check failed after $MAX_RETRIES attempts!" + echo "Backend logs:" + docker logs snippetly-backend --tail 50 || true + exit 1 + fi + done + + echo "✅ Deployment successful! Cleaning up backup..." + rm -f .deploy.env.bak + + # Cleanup old images (keep images from last 72 hours) + echo "Cleaning up old Docker images..." + docker image prune -af --filter "until=72h" || true + + echo "Deployment completed successfully!" \ No newline at end of file diff --git a/.gitignore b/.gitignore index b7faf40..5f712e8 100644 --- a/.gitignore +++ b/.gitignore @@ -51,79 +51,10 @@ coverage.xml .pytest_cache/ cover/ -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - # IPython profile_default/ ipython_config.py -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -131,11 +62,10 @@ __pypackages__/ celerybeat-schedule celerybeat.pid -# SageMath parsed files -*.sage.py - # Environments .env +.env.prod +.env.test .envrc .venv env/ @@ -169,11 +99,7 @@ dmypy.json cython_debug/ # PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. @@ -182,11 +108,7 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ + .vscode/ # Ruff stuff: .ruff_cache/ @@ -195,9 +117,6 @@ cython_debug/ .pypirc # Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore @@ -205,3 +124,10 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Terraform +infra/terraform/terraform.tfvars +infra/terraform/terraform.tfstate +infra/terraform/terraform.tfstate.backup +infra/terraform/.terraform/ +infra/terraform/.terraform.lock.hcl diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ec6d1b9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,107 @@ +# Pre-commit hooks configuration for Snippetly +# Install: pip install pre-commit && pre-commit install +# Run manually: pre-commit run --all-files +# Update hooks: pre-commit autoupdate + +repos: + # General file checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + exclude: \.md$ + - id: end-of-file-fixer + - id: check-yaml + args: [--unsafe] # Allow custom YAML tags + - id: check-added-large-files + args: [--maxkb=1000] + - id: check-json + - id: check-toml + - id: check-merge-conflict + - id: detect-private-key + - id: mixed-line-ending + + # Backend: Python code quality + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + # Linting + - id: ruff + args: [--fix] + files: ^backend/ + # Formatting + - id: ruff-format + files: ^backend/ + + # Backend: Type checking (optional - can be slow) + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.7.1 + # hooks: + # - id: mypy + # files: ^backend/ + # additional_dependencies: [types-all] + + # Frontend: Prettier formatting + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + files: ^frontend/.*\.(js|jsx|ts|tsx|json|css|scss|md)$ + exclude: package-lock.json + + # Frontend: ESLint (optional - can be slow) + # - repo: https://github.com/pre-commit/mirrors-eslint + # rev: v9.0.0 + # hooks: + # - id: eslint + # files: ^frontend/.*\.(js|jsx|ts|tsx)$ + # args: [--fix] + + # Terraform: Format and validate + - repo: https://github.com/antonbabenko/pre-commit-terraform + rev: v1.92.0 + hooks: + - id: terraform_fmt + args: + - --args=-recursive + - id: terraform_validate + args: + - --hook-config=--retry-once-with-cleanup=true + - id: terraform_tflint + args: + - --args=--config=__GIT_WORKING_DIR__/infra/terraform/.tflint.hcl + + # Docker: Dockerfile linting + - repo: https://github.com/hadolint/hadolint + rev: v2.12.0 + hooks: + - id: hadolint-docker + args: [--ignore, DL3008, --ignore, DL3013] + + # Secrets detection + - repo: https://github.com/Yelp/detect-secrets + rev: v1.4.0 + hooks: + - id: detect-secrets + args: [--baseline, .secrets.baseline] + exclude: package-lock.json + + # Shell scripts + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.9.0.6 + hooks: + - id: shellcheck + files: ^scripts/.*\.sh$ + + # Commit message format (conventional commits) + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.0.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [--force] + +# CI: Use pre-commit.ci service (optional) +# ci: +# autofix_commit_msg: 'ci: auto fixes from pre-commit hooks' +# autoupdate_commit_msg: 'ci: pre-commit autoupdate' diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..8457081 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,595 @@ +# 🏗️ Snippetly Architecture Documentation + +Complete system architecture overview for the Snippetly DevOps project. + +--- + +## 📋 Table of Contents + +1. [High-Level Architecture](#high-level-architecture) +2. [Infrastructure Layer](#infrastructure-layer) +3. [Application Layer](#application-layer) +4. [Data Layer](#data-layer) +5. [Deployment Pipeline](#deployment-pipeline) +6. [Security Architecture](#security-architecture) +7. [Monitoring & Observability](#monitoring--observability) +8. [Network Architecture](#network-architecture) + +--- + +## 🌐 High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Internet │ +└────────────────────────┬────────────────────────────────────────┘ + │ + │ HTTPS (443) + │ + ┌───────────────▼───────────────┐ + │ Azure NSG Firewall │ + │ - Port 22 (SSH - restricted) │ + │ - Port 80 (HTTP) │ + │ - Port 443 (HTTPS) │ + └───────────────┬───────────────┘ + │ + ┌───────────────▼───────────────┐ + │ Azure VM (Standard_B2ms) │ + │ - 2 vCPU, 8GB RAM │ + │ - Ubuntu 22.04 LTS │ + │ - Docker + Docker Compose │ + └───────────────┬───────────────┘ + │ + ┌───────────────▼───────────────┐ + │ Docker Network │ + │ (snippetly-net) │ + └───────────────┬───────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ +┌───▼────┐ ┌──────▼──────┐ ┌─────▼─────┐ +│ nginx │ │ Frontend │ │ Backend │ +│ proxy │───────▶│ (React) │─────▶│ (FastAPI) │ +│ HTTPS │ │ + Nginx │ │ │ +└────────┘ └─────────────┘ └─────┬─────┘ + │ + ┌───────────────────────────┼───────────────┐ + │ │ │ + ┌─────▼─────┐ ┌────────▼────┐ ┌──────▼──────┐ + │ PostgreSQL│ │ Redis │ │ MongoDB │ + │ DB │ │ Cache │ │ Snippets │ + └───────────┘ └─────────────┘ └─────────────┘ +``` + +--- + +## ☁️ Infrastructure Layer + +### Azure Resources (Terraform-managed) + +``` +Azure Subscription +│ +├── Resource Group: rg-snippetly +│ │ +│ ├── Virtual Network: snippetly-vnet (10.0.0.0/16) +│ │ └── Subnet: snippetly-subnet (10.0.1.0/24) +│ │ +│ ├── Network Security Group: snippetly-nsg +│ │ ├── SSH: Port 22 (restricted to allowed_ssh_cidrs) +│ │ ├── HTTP: Port 80 (open) +│ │ └── HTTPS: Port 443 (open) +│ │ +│ ├── VM: snippetly-dev-vm (172.201.5.167) +│ │ ├── OS: Ubuntu 22.04 LTS +│ │ ├── Size: Standard_B2ms +│ │ └── Init: cloud-init.sh +│ │ +│ ├── VM: snippetly-prod-vm (172.201.26.148) +│ │ ├── OS: Ubuntu 22.04 LTS +│ │ ├── Size: Standard_B2ms +│ │ └── Init: cloud-init.sh +│ │ +│ ├── Storage Account: snippetlystor +│ │ ├── Container: media-dev +│ │ ├── Container: media (prod) +│ │ ├── Container: backups-dev +│ │ └── Container: backups (prod) +│ │ +│ └── Container Registry: snippetlyacr +│ ├── Repository: snippetly-backend +│ └── Repository: snippetly-frontend +│ +└── Resource Group: snippetly-tfstate-rg + └── Storage Account: snippetlytfstate + └── Container: tfstate + └── Blob: snippetly.tfstate +``` + +--- + +## 🎯 Application Layer + +### Container Architecture (Production) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Docker Host (VM) │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Docker Network: snippetly-net │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │ +│ │ │ nginx-proxy │ │ certbot │ │ frontend │ │ │ +│ │ │ (HTTPS) │ │ (Let's │ │ (React + │ │ │ +│ │ │ Port: 80,443 │ │ Encrypt) │ │ Nginx) │ │ │ +│ │ │ Limits: │ │ │ │ Limits: │ │ │ +│ │ │ 256M RAM │ │ │ │ 256M RAM │ │ │ +│ │ └──────┬───────┘ └──────────────┘ └────────┬────────┘ │ │ +│ │ │ │ │ │ +│ │ └───────────────────┬───────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌─────────▼──────────┐ │ │ +│ │ │ backend │ │ │ +│ │ │ (FastAPI) │ │ │ +│ │ │ Port: 8000 │ │ │ +│ │ │ Limits: │ │ │ +│ │ │ 1GB RAM │ │ │ +│ │ │ 1.0 CPU │ │ │ +│ │ └─────────┬──────────┘ │ │ +│ │ │ │ │ +│ │ ┌───────────────────┼───────────────────┐ │ │ +│ │ │ │ │ │ │ +│ │ ┌────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ │ +│ │ │celery- │ │celery-beat │ │ migrate │ │ │ +│ │ │worker │ │(scheduler) │ │ (one-shot) │ │ │ +│ │ │Limits: │ │Limits: │ │ │ │ │ +│ │ │ 768M RAM │ │ 512M RAM │ │ │ │ │ +│ │ └────┬─────┘ └──────┬──────┘ └─────────────┘ │ │ +│ │ │ │ │ │ +│ │ └───────────────────┼───────────────────┐ │ │ +│ │ │ │ │ │ +│ │ ┌─────────▼──────┐ ┌──────▼─────┐ │ │ +│ │ │ PostgreSQL │ │ Redis │ │ │ +│ │ │ (Database) │ │ (Cache + │ │ │ +│ │ │ Port: 5432 │ │ Queue) │ │ │ +│ │ │ Limits: │ │ Port: 6379│ │ │ +│ │ │ 512M RAM │ │ Limits: │ │ │ +│ │ └────────────────┘ │ 256M RAM │ │ │ +│ │ └────────────┘ │ │ +│ │ ┌────────────────┐ │ │ +│ │ │ MongoDB │ │ │ +│ │ │ (Snippets DB) │ │ │ +│ │ │ Port: 27017 │ │ │ +│ │ │ Limits: │ │ │ +│ │ │ 512M RAM │ │ │ +│ │ └────────────────┘ │ │ +│ │ │ │ +│ │ Monitoring Stack (Optional): │ │ +│ │ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Prometheus │ │ Grafana │ │ │ +│ │ │ Port: 9090 │ │ Port: 3000 │ │ │ +│ │ │ (localhost) │ │ (localhost) │ │ │ +│ │ │ Limits: │ │ Limits: │ │ │ +│ │ │ 512M RAM │ │ 256M RAM │ │ │ +│ │ └──────────────┘ └──────────────┘ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ Persistent Volumes: │ +│ /opt/app-data/postgres ──▶ PostgreSQL data │ +│ /opt/app-data/redis ──▶ Redis persistence │ +│ /opt/app-data/mongo ──▶ MongoDB data │ +│ /opt/app-data/certbot ──▶ Let's Encrypt certificates │ +│ │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 💾 Data Layer + +### Database Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Data Storage │ +│ │ +│ PostgreSQL (Relational) MongoDB (Document) │ +│ ┌─────────────────────┐ ┌──────────────────┐ │ +│ │ Users │ │ Code Snippets │ │ +│ │ - id │ │ - _id │ │ +│ │ - email │ │ - user_id │ │ +│ │ - password_hash │ │ - title │ │ +│ │ - created_at │ │ - code │ │ +│ │ │ │ - language │ │ +│ │ Auth Tokens │ │ - tags │ │ +│ │ - token │ │ - created_at │ │ +│ │ - user_id │ │ - updated_at │ │ +│ │ - expires_at │ └──────────────────┘ │ +│ │ │ │ +│ │ Sessions │ Redis (Cache) │ +│ │ - session_id │ ┌──────────────────┐ │ +│ │ - user_id │ │ Session Data │ │ +│ │ - data │ │ Cache Keys │ │ +│ └─────────────────────┘ │ Celery Queue │ │ +│ │ Rate Limiting │ │ +│ └──────────────────┘ │ +│ │ +│ Azure Blob Storage │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Media Files: /media-{env}/ │ │ +│ │ Backups: /backups-{env}/ │ │ +│ │ - postgres/pg-backup-*.dump │ │ +│ │ - mongo/mongo-backup-*.archive.gz │ │ +│ │ - redis/redis-backup-*.rdb │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +### Backup Strategy + +``` +Daily Backups (02:00 UTC): +│ +├─▶ PostgreSQL Backup (02:00) +│ ├─ pg_dump → /backups/pg-backup-YYYYMMDD-HHMMSS.dump +│ ├─ Verify: pg_restore --list +│ ├─ Size check: must be > 1KB +│ └─ Upload to: Azure Blob Storage /backups-{env}/postgres/ +│ +├─▶ MongoDB Backup (02:15) +│ ├─ mongodump --archive --gzip → mongo-backup-*.archive.gz +│ ├─ Verify: gzip -t +│ ├─ Size check: must be > 1KB +│ └─ Upload to: Azure Blob Storage /backups-{env}/mongo/ +│ +└─▶ Redis Backup (02:30) + ├─ BGSAVE → dump.rdb + ├─ Verify: RDB magic number "REDIS" + ├─ Size check: must be > 100 bytes + └─ Upload to: Azure Blob Storage /backups-{env}/redis/ + +Retention: 30 days (Azure Blob lifecycle policy) +``` + +--- + +## 🚀 Deployment Pipeline + +### CI/CD Flow + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Developer │ +│ │ │ +│ git push │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ GitHub Repository │ │ +│ └──────────┬───────────┘ │ +│ │ │ +│ ┌──────────────┼──────────────┐ │ +│ │ │ │ │ +│ develop branch main branch tags v* │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ CI/CD Dev │ │ CI/CD Prod │ │ CI/CD Prod │ │ +│ └─────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ GitHub Actions Workflow │ │ +│ │ │ │ +│ │ 1. Backend Tests │ │ +│ │ ├─ Ruff lint │ │ +│ │ ├─ Pyright type-check │ │ +│ │ └─ Pytest (prod only) │ │ +│ │ │ │ +│ │ 2. Frontend Tests │ │ +│ │ ├─ npm run build │ │ +│ │ └─ npm test (Vitest) │ │ +│ │ │ │ +│ │ 3. Build & Push Images │ │ +│ │ ├─ docker build backend │ │ +│ │ ├─ docker build frontend │ │ +│ │ └─ docker push to ACR │ │ +│ │ │ │ +│ │ 4. Deploy to VM │ │ +│ │ ├─ SSH to VM │ │ +│ │ ├─ Backup .deploy.env │ │ +│ │ ├─ Update image tags │ │ +│ │ ├─ docker compose pull │ │ +│ │ ├─ docker compose up -d │ │ +│ │ ├─ Health check │ │ +│ │ ├─ ✅ Success: remove backup │ │ +│ │ └─ ❌ Failure: rollback │ │ +│ │ │ │ +│ └──────────────────┬───────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Dev/Prod VM │ │ +│ │ Running App │ │ +│ └──────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🔒 Security Architecture + +### Multi-Layer Security + +``` +Layer 1: Network Security +│ +├─▶ Azure NSG (Network Security Group) +│ ├─ SSH: Port 22 (restricted to allowed_ssh_cidrs) +│ ├─ HTTP: Port 80 (open - redirects to HTTPS) +│ └─ HTTPS: Port 443 (open) +│ +Layer 2: VM Security +│ +├─▶ fail2ban +│ ├─ SSH brute-force protection +│ ├─ Ban after 3 failed attempts +│ └─ Ban duration: 2 hours +│ +├─▶ Firewall (ufw) +│ └─ Configured by cloud-init +│ +Layer 3: Application Security +│ +├─▶ HTTPS/TLS +│ ├─ Let's Encrypt certificates +│ ├─ TLS 1.2 & 1.3 only +│ ├─ HSTS header +│ └─ Auto-renewal (every 12 hours check) +│ +├─▶ Rate Limiting (nginx) +│ ├─ API: 10 req/s per IP +│ ├─ Auth: 5 req/min per IP +│ └─ Password reset: 2 req/hour per IP +│ +├─▶ Application Security +│ ├─ JWT authentication +│ ├─ OAuth2 (Google) +│ ├─ Password hashing (bcrypt) +│ └─ CORS configuration +│ +Layer 4: Data Security +│ +├─▶ Database Access +│ ├─ No external ports exposed +│ ├─ Credentials in .env (not in git) +│ └─ Network isolation (Docker network) +│ +└─▶ Secrets Management + ├─ GitHub Secrets for CI/CD + ├─ .env files in .gitignore + └─ Terraform variables (not committed) +``` + +--- + +## 📊 Monitoring & Observability + +### Metrics Flow + +``` +┌──────────────────────────────────────────────────────────┐ +│ Application │ +│ │ +│ ┌────────────────┐ │ +│ │ Backend │ │ +│ │ (FastAPI) │ │ +│ │ │ │ +│ │ Middleware: │ │ +│ │ ├─ HTTP req │ │ +│ │ ├─ Latency │ │ +│ │ └─ Errors │ │ +│ └────────┬───────┘ │ +│ │ │ +│ │ /api/metrics │ +│ │ (Prometheus format) │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Prometheus │ │ +│ │ (scrapes │ │ +│ │ every 15s) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ │ PromQL queries │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Grafana │ │ +│ │ Dashboards: │ │ +│ │ ├─ Overview │ │ +│ │ ├─ Requests │ │ +│ │ ├─ Latency │ │ +│ │ └─ Errors │ │ +│ └─────────────────┘ │ +│ │ │ +│ │ SSH tunnel │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ DevOps/Admin │ │ +│ │ (localhost: │ │ +│ │ 3000) │ │ +│ └─────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## 🌐 Network Architecture + +### Production Network Flow + +``` +Internet + │ + │ HTTPS (443) + │ + ▼ +┌──────────────────┐ +│ snippetly.codes │ +│ DNS A Record │ +│ 172.201.26.148 │ +└────────┬─────────┘ + │ + ▼ +┌─────────────────────────┐ +│ nginx-proxy │ +│ - SSL termination │ +│ - Rate limiting │ +│ - Security headers │ +└────────┬────────────────┘ + │ + ├─▶ /.well-known/acme-challenge/ ──▶ certbot + │ + ├─▶ /api/* ──▶ backend:8000 + │ │ + │ ├─▶ PostgreSQL:5432 + │ ├─▶ Redis:6379 + │ └─▶ MongoDB:27017 + │ + └─▶ /* ──▶ frontend:80 (React SPA) + +Internal Network: snippetly-net (Docker) +- All containers on same network +- Service discovery via container names +- No external ports except nginx-proxy +``` + +--- + +## 🔄 Request Flow Example + +### User Creates a Snippet + +``` +1. User (Browser) + │ + │ POST https://snippetly.codes/api/v1/snippets + │ Headers: Authorization: Bearer + │ Body: { title, code, language } + │ + ▼ +2. nginx-proxy + │ + ├─▶ Rate limit check (10 req/s) + ├─▶ HTTPS termination + └─▶ Proxy to backend:8000 + │ + ▼ +3. FastAPI Backend + │ + ├─▶ Prometheus middleware (track request) + ├─▶ JWT authentication + ├─▶ Validate request body + │ + ├─▶ Save metadata to PostgreSQL + │ └─ users, snippets metadata + │ + ├─▶ Save code to MongoDB + │ └─ full snippet document + │ + ├─▶ Cache in Redis + │ └─ recent snippets list + │ + └─▶ Return response + │ + ▼ +4. Frontend + │ + └─▶ Update UI, show new snippet +``` + +--- + +## 📚 Technology Stack Summary + +| Layer | Technology | Purpose | +|-------|-----------|---------| +| **Cloud** | Microsoft Azure | Infrastructure hosting | +| **IaC** | Terraform | Infrastructure provisioning | +| **Compute** | Azure VM | Application hosting | +| **Containers** | Docker + Compose | Application runtime | +| **Backend** | FastAPI + Python 3.13 | REST API | +| **Frontend** | React 19 + Vite | SPA | +| **Databases** | PostgreSQL 16 | User data | +| | MongoDB 7 | Code snippets | +| | Redis 7 | Cache + Queue | +| **Queue** | Celery | Background tasks | +| **Web Server** | Nginx | Reverse proxy, SSL | +| **CI/CD** | GitHub Actions | Automation | +| **Monitoring** | Prometheus + Grafana | Metrics & Dashboards | +| **Security** | Let's Encrypt, fail2ban | SSL, SSH protection | +| **Backups** | Azure Blob Storage | Daily backups | + +--- + +## 📈 Scalability Considerations + +### Current (Single VM) +- **Capacity**: ~1000 concurrent users +- **Bottleneck**: Single VM resources + +### Future Scaling Options + +``` +Option 1: Vertical Scaling +├─ Upgrade VM size (B4ms → D4s_v3) +├─ Cost: Moderate +└─ Limit: Single VM ceiling + +Option 2: Horizontal Scaling +├─ Multiple VMs behind load balancer +├─ Separate DB servers +├─ Redis cluster +└─ Cost: Higher, better reliability + +Option 3: Managed Services +├─ Azure App Service (PaaS) +├─ Azure Database for PostgreSQL +├─ Azure Cache for Redis +└─ Cost: Higher, less management +``` + +--- + +## 🎓 Design Decisions + +### Why Docker Compose (not Kubernetes)? +✅ Simpler for single-VM deployment +✅ Lower resource overhead +✅ Faster iteration +✅ Suitable for pet project scale +❌ Limited horizontal scaling + +### Why Nginx proxy (not Traefik)? +✅ Battle-tested, widely known +✅ Better performance +✅ Simpler configuration +❌ Less dynamic service discovery + +### Why PostgreSQL + MongoDB (not just one)? +✅ PostgreSQL: Relational user data +✅ MongoDB: Flexible snippet storage +✅ Redis: High-speed caching +✅ Right tool for each job + +### Why Azure (not AWS/GCP)? +✅ Good free tier +✅ Simple pricing +✅ Integrated with Azure DevOps +✅ Good for learning + +--- + +**Last Updated**: 2025-12-14 +**Maintained By**: Snippetly Team +**Version**: 1.0 diff --git a/DISASTER-RECOVERY.md b/DISASTER-RECOVERY.md new file mode 100644 index 0000000..890e11a --- /dev/null +++ b/DISASTER-RECOVERY.md @@ -0,0 +1,719 @@ +# 🚨 Disaster Recovery Runbook + +Complete disaster recovery procedures for Snippetly. + +**Target RTO (Recovery Time Objective)**: 2 hours +**Target RPO (Recovery Point Objective)**: 24 hours (daily backups) + +--- + +## 📋 Table of Contents + +1. [Emergency Contacts](#emergency-contacts) +2. [Disaster Scenarios](#disaster-scenarios) +3. [Pre-Recovery Checklist](#pre-recovery-checklist) +4. [Recovery Procedures](#recovery-procedures) +5. [Post-Recovery Verification](#post-recovery-verification) +6. [Prevention Measures](#prevention-measures) + +--- + +## 📞 Emergency Contacts + +| Role | Contact | Availability | +|------|---------|--------------| +| Infrastructure Owner | TBD | 24/7 | +| Backup Admin | TBD | 24/7 | +| Database Admin | TBD | Business hours | +| On-call Engineer | TBD | Rotation | + +**Escalation Path:** +Engineer → Backup Admin → Infrastructure Owner + +--- + +## 💥 Disaster Scenarios + +### Scenario Matrix + +| Scenario | Severity | RTO | RPO | Procedure | +|----------|----------|-----|-----|-----------| +| VM failure | 🔴 Critical | 2h | 24h | [#1 Full VM Recovery](#1-full-vm-recovery) | +| Database corruption | 🔴 Critical | 1h | 24h | [#2 Database Recovery](#2-database-recovery) | +| Application crash | 🟡 High | 15m | 0h | [#3 Application Recovery](#3-application-recovery) | +| Accidental data deletion | 🟡 High | 30m | 24h | [#4 Data Restoration](#4-data-restoration) | +| SSL certificate expired | 🟡 High | 30m | 0h | [#5 SSL Recovery](#5-ssl-certificate-recovery) | +| Storage full | 🟠 Medium | 1h | 0h | [#6 Storage Cleanup](#6-storage-full-recovery) | +| DDoS attack | 🟠 Medium | 1h | 0h | [#7 DDoS Mitigation](#7-ddos-mitigation) | +| Terraform state corruption | 🔴 Critical | 4h | N/A | [#8 State Recovery](#8-terraform-state-recovery) | + +--- + +## ✅ Pre-Recovery Checklist + +Before starting any recovery: + +- [ ] **Assess Impact**: What is broken? What is still working? +- [ ] **Document Current State**: Screenshots, logs, error messages +- [ ] **Notify Stakeholders**: Inform users about downtime +- [ ] **Verify Backups**: Check latest backup date/time +- [ ] **Prepare Rollback**: Plan B if recovery fails +- [ ] **Get Required Access**: SSH keys, Azure portal, GitHub + +--- + +## 🔧 Recovery Procedures + +### #1 Full VM Recovery + +**Scenario**: VM destroyed, corrupted, or unresponsive + +**Prerequisites:** +- Terraform code available +- Azure access configured +- Backups in Azure Blob Storage + +**Steps:** + +#### 1.1 Provision New VM + +```bash +# Navigate to Terraform directory +cd infra/terraform + +# Verify credentials +az account show + +# Initialize Terraform (pulls remote state) +terraform init + +# Plan recovery (check what will be created) +terraform plan -out=recovery.tfplan + +# Apply (create new VM) +terraform apply recovery.tfplan +``` + +**Expected time**: 10-15 minutes + +#### 1.2 Wait for Cloud-Init + +```bash +# SSH to new VM +ssh azureuser@ + +# Check cloud-init status +sudo cloud-init status --wait + +# Verify services are running +docker ps + +# Expected: db, redis, mongodb, backend, celery containers +``` + +**Expected time**: 5-10 minutes + +#### 1.3 Restore Backups + +```bash +# On the new VM +cd /opt/snippetly + +# Download latest backups from Azure Blob +./scripts/restore_all.sh +``` + +**Create restore script** (`scripts/restore_all.sh`): + +```bash +#!/bin/bash +set -euo pipefail + +echo "🔄 Starting full disaster recovery..." + +# Source backend environment +cd /opt/snippetly +source backend/.env + +BACKUP_DATE="${1:-latest}" + +# Download latest backups +echo "📥 Downloading backups from Azure..." +az storage blob download \ + --container-name backups \ + --name "postgres/pg-backup-$BACKUP_DATE.dump" \ + --file /tmp/pg-restore.dump \ + --account-name $AZURE_STORAGE_ACCOUNT_NAME + +az storage blob download \ + --container-name backups \ + --name "mongo/mongo-backup-$BACKUP_DATE.archive.gz" \ + --file /tmp/mongo-restore.archive.gz \ + --account-name $AZURE_STORAGE_ACCOUNT_NAME + +az storage blob download \ + --container-name backups \ + --name "redis/redis-backup-$BACKUP_DATE.rdb" \ + --file /tmp/redis-restore.rdb \ + --account-name $AZURE_STORAGE_ACCOUNT_NAME + +# Restore PostgreSQL +echo "📦 Restoring PostgreSQL..." +docker compose exec -T db pg_restore -U $POSTGRES_USER -d $POSTGRES_DB \ + --clean --if-exists < /tmp/pg-restore.dump + +# Restore MongoDB +echo "📦 Restoring MongoDB..." +docker compose exec -T mongodb mongorestore \ + --archive=/tmp/mongo-restore.archive.gz \ + --gzip \ + --username $MONGO_INITDB_ROOT_USERNAME \ + --password $MONGO_INITDB_ROOT_PASSWORD \ + --authenticationDatabase admin \ + --drop + +# Restore Redis +echo "📦 Restoring Redis..." +docker compose stop redis +cp /tmp/redis-restore.rdb /opt/app-data/redis/dump.rdb +docker compose start redis + +echo "✅ Recovery complete!" +``` + +**Expected time**: 10-15 minutes + +#### 1.4 Verify Application + +```bash +# Check all containers are healthy +docker compose ps + +# Test health endpoint +curl http://localhost/api/health + +# Check logs +docker compose logs backend | tail -50 +``` + +**Expected time**: 5 minutes + +**Total RTO: ~45-60 minutes** + +--- + +### #2 Database Recovery + +**Scenario**: PostgreSQL or MongoDB data corruption + +#### 2.1 PostgreSQL Recovery + +```bash +# Stop backend to prevent writes +docker compose stop backend celery-worker celery-beat + +# List available backups +az storage blob list \ + --container-name backups \ + --prefix "postgres/" \ + --account-name snippetlystor + +# Download specific backup +BACKUP_DATE="20250101-020000" +az storage blob download \ + --container-name backups \ + --name "postgres/pg-backup-$BACKUP_DATE.dump" \ + --file /tmp/pg-restore.dump + +# Restore database +docker compose exec -T db psql -U snippetly -c "DROP DATABASE IF EXISTS snippetly;" +docker compose exec -T db psql -U snippetly -c "CREATE DATABASE snippetly;" +docker compose exec -T db pg_restore \ + -U snippetly -d snippetly \ + --clean --if-exists < /tmp/pg-restore.dump + +# Restart application +docker compose start backend celery-worker celery-beat + +# Verify +docker compose logs backend | grep "Started" +``` + +**RTO: ~20 minutes** + +#### 2.2 MongoDB Recovery + +```bash +# Stop services +docker compose stop backend celery-worker celery-beat + +# Download backup +BACKUP_DATE="20250101-021500" +az storage blob download \ + --container-name backups \ + --name "mongo/mongo-backup-$BACKUP_DATE.archive.gz" \ + --file /tmp/mongo-restore.archive.gz + +# Restore +docker compose exec -T mongodb mongorestore \ + --archive=/tmp/mongo-restore.archive.gz \ + --gzip \ + --username snippetly \ + --password mongodb \ + --authenticationDatabase admin \ + --db snippetly \ + --drop + +# Restart +docker compose start backend celery-worker celery-beat +``` + +**RTO: ~15 minutes** + +--- + +### #3 Application Recovery + +**Scenario**: Backend crashed, container not responding + +#### 3.1 Quick Restart + +```bash +# Check container status +docker compose ps + +# View logs +docker compose logs backend --tail=100 + +# Restart specific service +docker compose restart backend + +# Or restart all +docker compose restart +``` + +**RTO: 2-5 minutes** + +#### 3.2 Rollback Deployment + +```bash +# If recent deployment caused issue +cd /opt/snippetly + +# Restore previous version +if [ -f .deploy.env.bak ]; then + mv .deploy.env.bak .deploy.env + docker compose pull + docker compose up -d +fi +``` + +**RTO: 5-10 minutes** + +--- + +### #4 Data Restoration + +**Scenario**: User accidentally deleted important data + +#### 4.1 Point-in-Time Recovery + +```bash +# List available backups +az storage blob list \ + --container-name backups \ + --prefix "postgres/" \ + | grep "20250114" # Date before deletion + +# Download backup before deletion +az storage blob download \ + --container-name backups \ + --name "postgres/pg-backup-20250114-020000.dump" \ + --file /tmp/recovery.dump + +# Extract specific table/data +docker compose exec -T db pg_restore \ + -U snippetly -d snippetly \ + --table=users \ + --data-only \ + --if-exists < /tmp/recovery.dump + +# Or restore to temp database for data extraction +docker compose exec -T db psql -U snippetly -c "CREATE DATABASE recovery_temp;" +docker compose exec -T db pg_restore \ + -U snippetly -d recovery_temp < /tmp/recovery.dump + +# Extract needed data +docker compose exec -T db psql -U snippetly -d recovery_temp \ + -c "SELECT * FROM users WHERE id = 123;" +``` + +**RTO: 15-30 minutes** + +--- + +### #5 SSL Certificate Recovery + +**Scenario**: Let's Encrypt certificate expired or corrupted + +#### 5.1 Force Certificate Renewal + +```bash +# Stop nginx-proxy to free port 80 +docker compose stop nginx-proxy + +# Run certbot manually +docker compose run --rm certbot certonly \ + --standalone \ + --email admin@snippetly.codes \ + --agree-tos \ + --force-renewal \ + -d snippetly.codes \ + -d www.snippetly.codes + +# Restart nginx +docker compose start nginx-proxy + +# Verify +curl -I https://snippetly.codes +``` + +**RTO: 10 minutes** + +#### 5.2 Use Staging Certificate (Emergency) + +```bash +# If production rate-limited, use staging +docker compose run --rm certbot certonly \ + --standalone \ + --server https://acme-staging-v02.api.letsencrypt.org/directory \ + --email admin@snippetly.codes \ + --agree-tos \ + -d snippetly.codes +``` + +--- + +### #6 Storage Full Recovery + +**Scenario**: Disk space at 100% + +#### 6.1 Emergency Cleanup + +```bash +# Check disk usage +df -h +du -sh /opt/app-data/* +du -sh /var/lib/docker/* + +# Clean Docker +docker system prune -af --volumes +# WARNING: This removes unused volumes! + +# Clean old logs +sudo journalctl --vacuum-time=7d +sudo rm -f /var/log/*.gz + +# Clean old backups (keep last 7 days) +find /opt/snippetly/backups -mtime +7 -delete + +# Clean Docker build cache +docker builder prune -af +``` + +**RTO: 15 minutes** + +--- + +### #7 DDoS Mitigation + +**Scenario**: Application under attack + +#### 7.1 Immediate Response + +```bash +# Check current traffic +docker compose logs nginx-proxy | grep "429 Too Many Requests" + +# Tighten rate limits (infra/nginx/prod-nginx.conf) +# Change: rate=10r/s → rate=5r/s +# Change: rate=5r/m → rate=3r/m + +# Reload nginx +docker compose exec nginx-proxy nginx -s reload + +# Block specific IP +docker compose exec nginx-proxy sh -c \ + 'echo "deny 192.0.2.1;" >> /etc/nginx/conf.d/blocklist.conf' +docker compose exec nginx-proxy nginx -s reload +``` + +#### 7.2 Enable Azure DDoS Protection + +```bash +# Via Terraform +cd infra/terraform + +# Add to network.tf +resource "azurerm_network_ddos_protection_plan" "ddos" { + name = "snippetly-ddos" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name +} + +terraform apply +``` + +**RTO: 30 minutes** + +--- + +### #8 Terraform State Recovery + +**Scenario**: Terraform state corrupted or lost + +#### 8.1 State Recovery from Azure + +```bash +cd infra/terraform + +# Download state from Azure Storage +az storage blob download \ + --account-name snippetlytfstate \ + --container-name tfstate \ + --name snippetly.tfstate \ + --file terraform.tfstate + +# Or pull from backend +terraform init +terraform state pull > terraform.tfstate.backup + +# Verify +terraform plan +``` + +#### 8.2 State Import (If Lost) + +```bash +# Import existing resources +terraform import azurerm_resource_group.rg /subscriptions/.../resourceGroups/rg-snippetly +terraform import azurerm_virtual_network.vnet /subscriptions/.../virtualNetworks/snippetly-vnet +# ... repeat for all resources + +# Verify +terraform plan +# Should show: No changes. Infrastructure is up-to-date. +``` + +**RTO: 2-4 hours** + +--- + +## ✅ Post-Recovery Verification + +After any recovery, verify: + +### Application Checks + +```bash +# 1. Health endpoint +curl http://localhost/api/health +# Expected: {"status": "ok"} + +# 2. Database connectivity +docker compose exec backend python -c "from src.db import engine; print(engine.connect())" + +# 3. All containers running +docker compose ps +# Expected: All containers Up (healthy) + +# 4. Disk space +df -h / +# Expected: < 80% used + +# 5. Logs clean +docker compose logs --tail=50 +# Expected: No errors +``` + +### User-Facing Checks + +```bash +# 1. Homepage loads +curl -I https://snippetly.codes +# Expected: 200 OK + +# 2. Login works +curl -X POST https://snippetly.codes/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@test.com","password":"test123"}' + +# 3. Create snippet +curl -X POST https://snippetly.codes/api/v1/snippets \ + -H "Authorization: Bearer " \ + -d '{"title":"Test","code":"print(1)"}' +``` + +### Monitoring Checks + +```bash +# 1. Prometheus targets +curl http://localhost:9090/api/v1/targets +# Expected: All targets UP + +# 2. Grafana accessible +curl http://localhost:3000/api/health +# Expected: {"commit":"...","database":"ok"} +``` + +--- + +## 🛡️ Prevention Measures + +### Daily + +- ✅ Automated backups (02:00 UTC) +- ✅ Backup verification +- ✅ Health checks + +### Weekly + +- [ ] Review logs for errors +- [ ] Check disk usage +- [ ] Verify SSL certificate validity + +### Monthly + +- [ ] Test backup restoration (dry run) +- [ ] Review and update this runbook +- [ ] Update dependencies +- [ ] Rotate secrets + +### Quarterly + +- [ ] Full DR drill +- [ ] Review RTO/RPO targets +- [ ] Update emergency contacts + +--- + +## 📚 Reference Materials + +### Important Locations + +| Item | Location | +|------|----------| +| Backups | Azure Blob: `/backups-{env}/` | +| Terraform State | Azure Blob: `snippetlytfstate/tfstate/` | +| SSL Certificates | VM: `/opt/app-data/certbot/conf/` | +| Application Data | VM: `/opt/app-data/{postgres,mongo,redis}/` | +| Logs | Docker: `docker compose logs` | + +### Quick Commands + +```bash +# Show all backups +az storage blob list --container-name backups --account-name snippetlystor + +# Download latest backup +az storage blob download \ + --container-name backups \ + --name "postgres/pg-backup-latest.dump" \ + --file /tmp/restore.dump + +# Check service health +docker compose ps +docker compose logs backend --tail=50 + +# Restart everything +docker compose restart + +# Emergency stop +docker compose down +``` + +--- + +## 🚨 Escalation Triggers + +**Immediate escalation if:** + +- ❌ RTO exceeded by >50% +- ❌ Data loss detected (beyond RPO) +- ❌ Security breach suspected +- ❌ Multiple recovery attempts failed +- ❌ Unknown disaster scenario + +**Escalation Process:** + +1. Document incident details +2. Contact Infrastructure Owner +3. Engage Azure Support (if needed) +4. Schedule post-incident review + +--- + +## 📝 Incident Report Template + +After recovery, document: + +```markdown +## Incident Report + +**Date**: YYYY-MM-DD +**Duration**: HH:MM +**Severity**: Critical/High/Medium/Low + +### Summary +What happened? + +### Impact +- Users affected: X +- Services down: Y +- Data lost: Z + +### Root Cause +Why did it happen? + +### Resolution +How was it fixed? + +### Prevention +How to prevent recurrence? + +### Action Items +- [ ] Update monitoring +- [ ] Update runbook +- [ ] Schedule follow-up +``` + +--- + +## 🎓 Training + +**New team members should:** + +1. Read this runbook thoroughly +2. Practice backup restoration (dev environment) +3. Simulate VM recovery (test VM) +4. Review with senior engineer + +**Recommended practice frequency:** +- Individual: Monthly +- Team drill: Quarterly + +--- + +**Last Updated**: 2025-12-14 +**Next Review**: 2025-03-14 +**Owner**: DevOps Team +**Version**: 1.0 + +--- + +## Emergency Hotline + +**Critical Issues**: Call Infrastructure Owner immediately +**Non-critical**: Create GitHub issue, tag @devops + +**Remember**: Stay calm, follow the runbook, document everything. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..1ed831e --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,96 @@ +# Project Overview + +Snippetly is a full-stack platform for creating and sharing code fragments. + +**Architecture:** + +* **Backend:** A Python API built with FastAPI, using PostgreSQL, Redis, and MongoDB for data storage. +* **Frontend:** A React application built with Vite and TypeScript. +* **Containerization:** The entire application is containerized using Docker and managed with `docker-compose`. + +**Key Technologies:** + +* **Backend:** FastAPI, SQLAlchemy, Alembic, Beanie, Redis, Pytest, Ruff, Pyright. +* **Frontend:** React, Vite, TypeScript, SASS, ESLint, Prettier, React Query. +* **DevOps:** Docker, Docker Compose, GitHub Actions. + +# Building and Running + +**Prerequisites:** + +* Docker and Docker Compose +* Node.js and npm (for frontend development) +* Python and uv (for backend development) + +**Running with Docker (Recommended):** + +1. **Copy Environment Variables:** + ```bash + cp backend/config_envs/.env.sample backend/.env + cp frontend/.env.sample frontend/.env + ``` +2. **Start the Application:** + * For production-like environment: + ```bash + docker compose up --build + ``` + * For development (with hot-reloading for the backend): + ```bash + make up-dev + ``` + +**Local Development:** + +* **Frontend:** + ```bash + cd frontend + npm install + npm run dev + ``` +* **Backend:** + ```bash + cd backend + uv sync + # Run the development server (refer to FastAPI documentation) + ``` + +**Makefile Commands:** + +The `Makefile` provides several useful commands for managing the Docker environment: + +* `make up`: Start core services (db, redis). +* `make up-dev`: Start the full development stack. +* `make down`: Stop the services. +* `make logs`: View logs from all services. +* `make psql`: Access the PostgreSQL database. +* `make mongo`: Access the MongoDB shell. + +# Development Conventions + +**Backend:** + +* **Linting:** `ruff` is used for linting. To run the linter: + ```bash + cd backend + uv run ruff check . + ``` +* **Type Checking:** `pyright` is used for static type checking. +* **Database Migrations:** `alembic` is used for managing database schema changes. + +**Frontend:** + +* **Linting:** `eslint` is used for linting. To run the linter: + ```bash + cd frontend + npm run lint + ``` +* **Formatting:** `prettier` is used for code formatting. To format the code: + ```bash + cd frontend + npm run format + ``` + +**CI/CD:** + +The project uses GitHub Actions for CI/CD. The workflow in `.github/workflows/ci.yml` automatically runs linters and +builds Docker images on push and pull request events. diff --git a/MONITORING.md b/MONITORING.md new file mode 100644 index 0000000..1542506 --- /dev/null +++ b/MONITORING.md @@ -0,0 +1,274 @@ +# 📊 Monitoring Guide - Prometheus + Grafana + +## Quick Start + +### 1. Start monitoring stack + +**Development:** +```bash +docker compose -f docker-compose.yml \ + -f docker-compose.override.yml \ + -f docker-compose.monitoring.yml up -d +``` + +**Production:** +```bash +docker compose -f docker-compose.yml \ + -f docker-compose.prod.yml \ + -f docker-compose.monitoring.yml up -d +``` + +### 2. Access dashboards + +**Local development:** +- Grafana: http://localhost:3000 +- Prometheus: http://localhost:9090 + +**Production (via SSH tunnel):** +```bash +# Grafana +ssh -L 3000:localhost:3000 azureuser@ + +# Prometheus +ssh -L 9090:localhost:9090 azureuser@ +``` + +Then open: +- Grafana: http://localhost:3000 +- Prometheus: http://localhost:9090 + +### 3. Login to Grafana + +**Default credentials:** +- Username: `admin` +- Password: `admin123` (change in production!) + +**Change password:** +Set `GRAFANA_ADMIN_PASSWORD` environment variable in `.env` + +--- + +## 📈 Available Metrics + +### HTTP Metrics (Auto-tracked) + +| Metric | Type | Description | +|--------|------|-------------| +| `http_requests_total` | Counter | Total HTTP requests by method, endpoint, status | +| `http_request_duration_seconds` | Histogram | Request duration distribution | +| `http_requests_in_progress` | Gauge | Currently processing requests | + +### Application Metrics (Backend) + +| Metric | Type | Description | +|--------|------|-------------| +| `snippetly_active_users` | Gauge | Users active in last 24h | +| `snippetly_total_snippets` | Gauge | Total code snippets | +| `snippetly_database_connections` | Gauge | Active DB connections | + +--- + +## 🔍 Useful Queries (PromQL) + +### Request Rate +```promql +# Requests per second by endpoint +rate(http_requests_total[5m]) + +# Requests per second by status code +sum by (status_code) (rate(http_requests_total[5m])) +``` + +### Response Time +```promql +# 95th percentile response time +histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) + +# Average response time +rate(http_request_duration_seconds_sum[5m]) / +rate(http_request_duration_seconds_count[5m]) +``` + +### Error Rate +```promql +# 5xx error rate +sum(rate(http_requests_total{status_code=~"5.."}[5m])) / +sum(rate(http_requests_total[5m])) + +# 4xx error rate +sum(rate(http_requests_total{status_code=~"4.."}[5m])) / +sum(rate(http_requests_total[5m])) +``` + +--- + +## 📊 Grafana Dashboards + +### Pre-configured Dashboards + +1. **Snippetly Overview** (auto-loaded) + - HTTP request rate + - 95th percentile response time + - Error rates + - Active users + +### Import Community Dashboards + +Great ready-made dashboards: + +1. **FastAPI Dashboard** + - ID: `14352` + - Import: Dashboards → Import → Enter ID + +2. **PostgreSQL Dashboard** + - ID: `9628` + - Import: Dashboards → Import → Enter ID + +3. **Redis Dashboard** + - ID: `11835` + - Import: Dashboards → Import → Enter ID + +--- + +## 🎯 Custom Metrics (Backend) + +Add custom metrics to your code: + +```python +from src.middleware.prometheus import total_snippets, active_users + +# Increment snippet count +total_snippets.inc() + +# Set active user count +active_users.set(count) + +# Custom counter +from prometheus_client import Counter + +user_signups = Counter('user_signups_total', 'Total user signups') +user_signups.inc() +``` + +--- + +## 🚨 Alerting (Optional) + +### Add Alert Rules + +Create `infra/prometheus/alerts/backend.yml`: + +```yaml +groups: + - name: backend + interval: 30s + rules: + - alert: HighErrorRate + expr: | + sum(rate(http_requests_total{status_code=~"5.."}[5m])) / + sum(rate(http_requests_total[5m])) > 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "High error rate detected" + description: "Error rate is {{ $value | humanizePercentage }}" + + - alert: HighResponseTime + expr: | + histogram_quantile(0.95, + rate(http_request_duration_seconds_bucket[5m]) + ) > 1 + for: 5m + labels: + severity: warning + annotations: + summary: "High response time" + description: "95th percentile is {{ $value }}s" +``` + +Update `prometheus.yml`: +```yaml +rule_files: + - "alerts/*.yml" +``` + +--- + +## 🔧 Maintenance + +### Check Prometheus Targets + +Visit http://localhost:9090/targets + +All targets should be **UP** (green). + +### View Metrics Directly + +Backend metrics endpoint: +```bash +curl http://localhost:8000/api/metrics +``` + +### Retention Policy + +- Prometheus retains data for **30 days** (configurable) +- Grafana stores dashboards permanently in volume + +### Backup Dashboards + +```bash +# Export all dashboards +docker exec snippetly-grafana grafana-cli admin export-dashboard > dashboards.json +``` + +--- + +## 🎓 Learning Resources + +- [Prometheus Basics](https://prometheus.io/docs/prometheus/latest/getting_started/) +- [PromQL Tutorial](https://prometheus.io/docs/prometheus/latest/querying/basics/) +- [Grafana Dashboards](https://grafana.com/grafana/dashboards/) + +--- + +## ⚠️ Production Tips + +1. **Secure Access:** + - ✅ Already bound to localhost + - Access only via SSH tunnel + - Don't expose 9090/3000 to internet + +2. **Resource Limits:** + - ✅ Already configured (512MB Prometheus, 256MB Grafana) + - Monitor memory usage + +3. **Backup:** + - Grafana data in `/var/lib/grafana` volume + - Backup dashboards regularly + +4. **Change Default Password:** + ```bash + # In .env file + GRAFANA_ADMIN_PASSWORD=your-secure-password + ``` + +5. **Disable Anonymous Access:** + Already disabled (`GF_USERS_ALLOW_SIGN_UP=false`) + +--- + +## 🐛 Troubleshooting + +**Metrics not appearing:** +1. Check backend is running: `docker ps | grep backend` +2. Check metrics endpoint: `curl http://localhost:8000/api/metrics` +3. Check Prometheus targets: http://localhost:9090/targets + +**Grafana not loading:** +1. Check container: `docker logs snippetly-grafana` +2. Verify provisioning: `/etc/grafana/provisioning` + +**High memory usage:** +1. Reduce retention: `--storage.tsdb.retention.time=15d` +2. Increase scrape interval: `scrape_interval: 30s` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..18d1ebd --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +PROJECT_NAME ?= snippetly +COMPOSE := docker compose +COMPOSE_DEV := $(COMPOSE) -f docker-compose.yml -f docker-compose.dev.yml +COMPOSE_TEST := $(COMPOSE) -f docker-compose.test.yml --env-file backend/.env.test + +.PHONY: help up up-dev down down-dev logs ps psql prune + +help: + @echo "Available targets:" + @echo " up - Start core services (db, redis) in background" + @echo " up-dev - Start dev stack (db, redis, pgadmin) in background" + @echo " up-test - Start tests" + @echo " down - Stop stack" + @echo " down-dev - Stop dev stack" + @echo " logs - Tail logs" + @echo " ps - List containers" + @echo " psql - Open psql shell in db container" + @echo " prune - Remove volumes (pgdata, redisdata, pgadmin)" + +up: + $(COMPOSE) up -d db redis + +up-dev: + $(COMPOSE_DEV) up -d + +up-test: + $(COMPOSE_TEST) up + +down: + $(COMPOSE) down + +down-dev: + $(COMPOSE_DEV) down + +logs: + $(COMPOSE) logs -f + +ps: + $(COMPOSE) ps + +psql: + docker exec -it snippetly-db psql -U $${POSTGRES_USER:-snippetly} -d $${POSTGRES_DB:-snippetly} + +prune: + $(COMPOSE) down -v + +mongo: + docker exec -it snippetly-mongodb mongosh -u $${MONGO_USER:-snippetly} -p $${MONGO_PASSWORD:-mongodb} --authenticationDatabase admin diff --git a/PRE-COMMIT-SETUP.md b/PRE-COMMIT-SETUP.md new file mode 100644 index 0000000..0bdb61f --- /dev/null +++ b/PRE-COMMIT-SETUP.md @@ -0,0 +1,225 @@ +# 🪝 Pre-commit Hooks Setup + +Automatic code quality checks before every commit. + +## Quick Setup + +### 1. Install pre-commit + +```bash +pip install pre-commit +``` + +Or with uv (recommended): +```bash +uv tool install pre-commit +``` + +### 2. Install hooks + +```bash +# From project root +pre-commit install + +# Install commit-msg hook (for conventional commits) +pre-commit install --hook-type commit-msg +``` + +### 3. Test + +```bash +# Run on all files +pre-commit run --all-files + +# Run on staged files (happens automatically on commit) +git add . +git commit -m "test: pre-commit hooks" +``` + +--- + +## What Gets Checked + +### ✅ All Files +- Trailing whitespace +- End-of-file newline +- Large files (max 1MB) +- Merge conflicts +- Private keys detection +- YAML/JSON/TOML syntax + +### 🐍 Backend (Python) +- **Ruff** - Fast linter + formatter +- Imports sorting +- Code style (PEP 8) +- Unused variables +- Security issues + +### ⚛️ Frontend (JS/TS) +- **Prettier** - Code formatting +- ESLint (optional, disabled by default) + +### 🏗️ Infrastructure +- **Terraform** - Format + validate +- **Dockerfile** - Hadolint linting +- **Shell scripts** - ShellCheck + +### 🔒 Security +- **detect-secrets** - Prevent committing secrets +- Private key detection + +### 📝 Commits +- **Conventional Commits** format validation + +--- + +## Conventional Commits + +Format: `(): ` + +**Types:** +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation +- `style`: Code style (formatting) +- `refactor`: Code refactoring +- `perf`: Performance improvement +- `test`: Tests +- `build`: Build system +- `ci`: CI/CD changes +- `chore`: Maintenance + +**Examples:** +```bash +git commit -m "feat(auth): add OAuth2 login" +git commit -m "fix(api): resolve CORS issue" +git commit -m "docs: update deployment guide" +``` + +--- + +## Skip Hooks (Emergency) + +```bash +# Skip all hooks +git commit --no-verify -m "emergency fix" + +# Skip specific hook +SKIP=ruff git commit -m "fix: urgent patch" +``` + +**Warning:** Only use in emergencies! + +--- + +## Customize Hooks + +### Disable Specific Checks + +Edit `.pre-commit-config.yaml`: + +```yaml +# Disable ESLint (already commented) +# - repo: https://github.com/pre-commit/mirrors-eslint + +# Disable mypy type checking (already commented) +# - repo: https://github.com/pre-commit/mirrors-mypy +``` + +### Add Custom Checks + +```yaml +repos: + - repo: local + hooks: + - id: pytest-fast + name: Run fast tests + entry: pytest tests/unit -v + language: system + pass_filenames: false +``` + +--- + +## Update Hooks + +```bash +# Update to latest versions +pre-commit autoupdate + +# Run updated hooks +pre-commit run --all-files +``` + +--- + +## Troubleshooting + +**Hooks failing:** +```bash +# See what failed +pre-commit run --all-files --verbose + +# Clear cache +pre-commit clean +pre-commit gc +``` + +**Terraform validate fails:** +```bash +# Init terraform first +cd infra/terraform +terraform init +``` + +**detect-secrets fails:** +```bash +# Create baseline (first time) +detect-secrets scan > .secrets.baseline + +# Update baseline +detect-secrets scan --baseline .secrets.baseline +``` + +--- + +## CI/CD Integration + +Pre-commit runs automatically in GitHub Actions via: + +```yaml +# .github/workflows/ci-cd-dev.yml +- name: Ruff lint + run: uv run ruff check . + +- name: Prettier check + run: npm run format:check +``` + +--- + +## Benefits + +✅ Catch issues **before** CI/CD +✅ Consistent code style across team +✅ Prevent committing secrets +✅ Faster code reviews +✅ Auto-fix formatting issues + +--- + +## Performance Tips + +**Slow hooks:** +- mypy (type checking) - disabled by default +- ESLint - disabled by default +- Terraform validate - cached + +**Speed up:** +```bash +# Run only on changed files +git commit # Auto-runs on staged files only + +# Skip slow checks locally (CI still runs them) +SKIP=terraform_validate git commit -m "docs: update README" +``` diff --git a/README.md b/README.md index fc4e08d..8734462 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,87 @@ -# snippetly -Snippetly - platform for creating and sharing code fragments +# Snippetly + +Snippetly is a platform for creating and sharing code snippets. It consists of a FastAPI backend and a React (Vite) frontend, with PostgreSQL, Redis, MongoDB, and Celery (worker/beat). + +This repository includes everything to provision and operate two environments (dev and prod) on Microsoft Azure using a single VM per environment with Docker Compose, HTTP-only (port 80), persistent storage, backups to Azure Blob Storage, and CI/CD via two workflows (ACR with Service Principal). + +--- + +## What you get + +- Two environments (dev and prod) created by a single `terraform apply`. +- Single VM per environment, Docker Compose runtime. +- HTTP-only on port 80 via Nginx serving the SPA and proxying `/api` to the backend. +- Persistent host-path storage on the VM under `/opt/app-data/...`. +- Backups to Azure Blob Storage (per-environment containers) via scripts. +- Automatic DB migrations via cron on each VM (@reboot + periodic). +- Two GitHub Actions workflows for CI + CD (dev via non-main branch push, prod via version tags). + +--- + +## Quickstart (from zero to running) + +1) Clone the repo + +```bash +git clone +cd snippetly +``` + +2) Configure Terraform + +```bash +cd infra/terraform +cp terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars with your Azure region, SSH key, storage account name, ACR name, etc. +terraform init +terraform apply +``` + +Terraform will create: Resource Group, VNet, Subnet, NSG (22/80), two Linux VMs (dev/prod), a Storage Account with containers (media + backups), and an Azure Container Registry (ACR). Both VMs are initialized by cloud-init (Docker, repo checkout, single compose, cron for migrations and backups). + +3) Configure GitHub Actions secrets (dev/prod) + +Set ACR Service Principal secrets and SSH (host/user/key), and optional PUBLIC_URL for smoke tests. See `readme-deploy.md` for the complete list. + +4) Deploy + +- Dev: push to any non-`main` branch to trigger build, push, and deploy to the dev VM. +- Prod: create a tag `vX.Y.Z` to build, push, and deploy to the prod VM. + +After deploy, open: +- Dev: `http:///` +- Prod: `http:///` + +Health endpoint: `http:///api/health` returns `{ "status": "ok" }`. + +--- + +## Migrations and Backups + +- Migrations run automatically on each VM via cron (@reboot and every 10 minutes) using `docker compose run --rm migrate`. +- Backups: two scripts on the VM upload Postgres and MongoDB archives to the configured Object Storage backup bucket. + - `/opt/snippetly/scripts/backup_pg.sh` + - `/opt/snippetly/scripts/backup_mongo.sh` + +Nightly cron entries are installed by cloud-init (can be disabled by removing entries on the VM). + +--- + +## CI/CD + +Two workflows in `.github/workflows/`: + +- `ci-cd-dev.yml` + - CI runs on PRs to `main`/`develop` (backend tests and frontend build). + - CD Dev runs on pushes to any non-main branch. +- `ci-cd-prod.yml` + - CD Prod runs on tags `v*`. + +Each deploy updates `.deploy.env` on the VM, pulls images, runs `docker compose up -d`, then performs smoke tests. Database migrations are applied automatically on the VMs via cron. + +--- + +## More documentation + +- Infrastructure details: `readme-infra.md` +- Deployment, verification, backup/restore: `readme-deploy.md` diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8d48a2a --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.13-slim-bookworm +# Install uv - dependency manager +COPY --from=ghcr.io/astral-sh/uv:0.8.19 /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +ENV PYTHONPATH=/app/src:$PYTHONPATH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev build-essential curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Install dependencies +COPY ./pyproject.toml ./uv.lock ./ +RUN uv sync + +# Copy project code +COPY src /app/src +COPY ops /app/ops + +RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +# Default command runs the API server +CMD ["uv", "run", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/config_envs/.env.prod.sample b/backend/config_envs/.env.prod.sample new file mode 100644 index 0000000..518bae3 --- /dev/null +++ b/backend/config_envs/.env.prod.sample @@ -0,0 +1,45 @@ +#PostgreSQL +POSTGRES_DB=snippetly +POSTGRES_USER=snippetly +POSTGRES_PASSWORD=snippetly +POSTGRES_HOST=db +POSTGRES_PORT=5432 +#Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD=redis +#MongoDB +MONGO_DB=snippetly +MONGO_USER=snippetly +MONGO_PASSWORD=mongodb +MONGODB_HOST=mongodb +MONGODB_PORT=27017 +MONGO_INITDB_ROOT_USERNAME=snippetly +MONGO_INITDB_ROOT_PASSWORD=mongodb +MONGO_INITDB_DATABASE=snippetly +#Security +SECRET_KEY_REFRESH=32b3fef2c10efb1d79cf6357cb443a45b5e811942accda9a639977ec26542883 +SECRET_KEY_ACCESS=2a6ecf7c164c7f0ce72af9cdf2e7e97670efe2daa1ad30436e4279abc94afc91 +#Project Variables +ENVIRONMENT=testing +FRONTEND_URL=http://localhost:5173 +#Email +EMAIL_APP_PASSWORD= +EMAIL_HOST= +EMAIL_PORT= +EMAIL_HOST_USER= +FROM_EMAIL= +USE_TLS=true +#OAuth Google +OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX- +OAUTH_GOOGLE_CLIENT_ID=... +OAUTH_SSL=true +# Azure Blob Storage +AZURE_STORAGE_ACCOUNT_NAME=snippetlystorage12345 +# Provide either connection string or account key + optional custom endpoint +AZURE_STORAGE_CONNECTION_STRING= +AZURE_STORAGE_ACCOUNT_KEY= +AZURE_BLOB_ENDPOINT= +AZURE_MEDIA_CONTAINER=media +AZURE_BACKUP_CONTAINER=backups diff --git a/backend/config_envs/.env.sample b/backend/config_envs/.env.sample new file mode 100644 index 0000000..5e5be0b --- /dev/null +++ b/backend/config_envs/.env.sample @@ -0,0 +1,36 @@ +#PostgreSQL +POSTGRES_DB=snippetly +POSTGRES_USER=snippetly +POSTGRES_PASSWORD=snippetly +POSTGRES_HOST=db +POSTGRES_PORT=5432 +#Redis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD=redis +#MongoDB +MONGO_DB=snippetly +MONGO_USER=snippetly +MONGO_PASSWORD=mongodb +MONGODB_HOST=mongodb +MONGODB_PORT=27017 +MONGO_INITDB_ROOT_USERNAME=snippetly +MONGO_INITDB_ROOT_PASSWORD=mongodb +MONGO_INITDB_DATABASE=snippetly +#Security +SECRET_KEY_REFRESH=32b3fef2c10efb1d79cf6357cb443a45b5e811942accda9a639977ec26542883 +SECRET_KEY_ACCESS=2a6ecf7c164c7f0ce72af9cdf2e7e97670efe2daa1ad30436e4279abc94afc91 +#Project Variables +ENVIRONMENT=testing +FRONTEND_URL=http://localhost:5173 +#Email +EMAIL_HOST=mailhog +EMAIL_PORT=1025 +EMAIL_HOST_USER=testuser +FROM_EMAIL=no-reply@example.com +USE_TLS=false +#OAuth Google +OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX- +OAUTH_GOOGLE_CLIENT_ID=... +OAUTH_SSL=false diff --git a/backend/config_envs/.env.test.sample b/backend/config_envs/.env.test.sample new file mode 100644 index 0000000..cb2be02 --- /dev/null +++ b/backend/config_envs/.env.test.sample @@ -0,0 +1,36 @@ +#PostgreSQL +POSTGRES_DB=snippetly +POSTGRES_USER=snippetly +POSTGRES_PASSWORD=snippetly +POSTGRES_HOST=test_db +POSTGRES_PORT=5432 +#Redis +REDIS_HOST=test_redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_PASSWORD=redis +#MongoDB +MONGO_DB=snippetly +MONGO_USER=snippetly +MONGO_PASSWORD=mongodb +MONGODB_HOST=test_mongodb +MONGODB_PORT=27017 +MONGO_INITDB_ROOT_USERNAME=snippetly +MONGO_INITDB_ROOT_PASSWORD=mongodb +MONGO_INITDB_DATABASE=snippetly +#Security +SECRET_KEY_REFRESH=32b3fef2c10efb1d79cf6357cb443a45b5e811942accda9a639977ec26542883 +SECRET_KEY_ACCESS=2a6ecf7c164c7f0ce72af9cdf2e7e97670efe2daa1ad30436e4279abc94afc91 +#Project Variables +ENVIRONMENT=development +FRONTEND_URL=http://localhost:5173 +#Email +EMAIL_HOST=mailhog +EMAIL_PORT=1025 +EMAIL_HOST_USER=testuser +FROM_EMAIL=no-reply@example.com +USE_TLS=false +#OAuth Google +OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX- +OAUTH_GOOGLE_CLIENT_ID=... +OAUTH_SSL=false diff --git a/backend/docker/test/Dockerfile b/backend/docker/test/Dockerfile new file mode 100644 index 0000000..9133c96 --- /dev/null +++ b/backend/docker/test/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.13-slim-bookworm +COPY --from=ghcr.io/astral-sh/uv:0.8.19 /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app +ENV PYTHONPATH=/app/src:$PYTHONPATH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc libpq-dev build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY ./pyproject.toml ./uv.lock ./ +RUN uv sync --locked --group test + +COPY src /app/src +COPY tests /app/tests + +#RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app +#USER appuser diff --git a/backend/ops/upload_to_azure.py b/backend/ops/upload_to_azure.py new file mode 100644 index 0000000..3faaf58 --- /dev/null +++ b/backend/ops/upload_to_azure.py @@ -0,0 +1,57 @@ +# Upload a local file to Azure Blob Storage using SDK +# Reads Azure credentials from environment variables compatible with +# ProductionSettings +# Usage: python /app/ops/upload_to_azure.py + +import os +import sys +from azure.storage.blob import BlobServiceClient + + +def _build_blob_service() -> BlobServiceClient: + conn = os.environ.get("AZURE_STORAGE_CONNECTION_STRING") + if conn: + return BlobServiceClient.from_connection_string(conn) + + account = os.environ.get("AZURE_STORAGE_ACCOUNT_NAME") + key = os.environ.get("AZURE_STORAGE_ACCOUNT_KEY") + endpoint = os.environ.get("AZURE_BLOB_ENDPOINT") + + if not account: + raise SystemExit("ERROR: Missing AZURE_STORAGE_ACCOUNT_NAME") + + if not endpoint: + endpoint = f"https://{account}.blob.core.windows.net" + + if not key: + raise SystemExit( + "ERROR: Missing AZURE_STORAGE_ACCOUNT_KEY or " + "AZURE_STORAGE_CONNECTION_STRING" + ) + + return BlobServiceClient(account_url=endpoint, credential=key) + + +def main() -> None: + if len(sys.argv) != 3: + print("Usage: upload_to_azure.py ") + sys.exit(1) + + local_path = sys.argv[1] + blob_path = sys.argv[2] + + container = os.environ.get("AZURE_BACKUP_CONTAINER") or os.environ.get( + "AZURE_MEDIA_CONTAINER", "media" + ) + + service = _build_blob_service() + blob_client = service.get_blob_client(container=container, blob=blob_path) + + with open(local_path, "rb") as f: + blob_client.upload_blob(f, overwrite=True) + + print(f"Uploaded {local_path} to container {container} as {blob_path}") + + +if __name__ == "__main__": + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..2a8bf05 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,89 @@ +[project] +name = "snippetly-api" +version = "0.1.0" +description = "Snippetly - platform for creating and sharing code fragments" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "alembic>=1.16.5", + "asyncpg>=0.30.0", + "beanie>=2.0.0", + "fastapi>=0.117.1", + "psycopg>=3.2.10", + "pydantic[email]>=2.11.9", + "pydantic-settings>=2.10.1", + "pyjwt>=2.10.1", + "redis>=6.4.0", + "sqlalchemy>=2.0.43", + "bcrypt>=4.3.0", + "aiosmtplib>=4.0.2", + "aiohttp>=3.12.15", + "pillow>=11.3.0", + "python-multipart>=0.0.20", + "starlette-admin>=0.15.1", + "itsdangerous>=2.2.0", + "celery>=5.5.3", + "slowapi>=0.1.9", + "azure-storage-blob>=12.24.0", + "uvicorn>=0.38.0", + "prometheus-client>=0.21.1", +] + +[dependency-groups] +dev = [ + "pyright>=1.1.406", + "ruff>=0.13.1", +] +test = [ + "faker>=37.12.0", + "httpx>=0.28.1", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", +] + +[tool.ruff] +line-length = 79 + +[tool.ruff.lint] +select = [ + "B", "C", "E", "F", "W", + "B9", "ANN", "Q0", "N8" +] +ignore = ["N807", "F401", "F405", "ANN204"] + +[tool.ruff.lint.per-file-ignores] +"**/{tests}/*" = ["ANN"] + + +[tool.ruff.format] +docstring-code-format = true + + +[tool.alembic] +path_separator = "os" +script_location = "src/adapters/postgres/migrations" +prepend_sys_path = "." +sqlalchemy.url = "driver://user:pass@localhost/dbname" + + +[tool.pyright] +reportAttributeAccessIssue = "none" +include = ["src"] +exclude = ["src/adapters/postgres/migrations", "**/.venv", "**/venv", "**/__pycache__"] +typeCheckingMode = "basic" + +[tool.pytest.ini_options] +testpaths = "tests/" +pythonpath = "src/" +addopts = """ + --tb=short + --strict-markers +""" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +filterwarnings = [ + "ignore::DeprecationWarning" +] diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..bf6b26c --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development dependencies +-r requirements.txt +mypy>=1.18.2 +ruff>=0.13.1 \ No newline at end of file diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/adapters/__init__.py b/backend/src/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/adapters/mongo/__init__.py b/backend/src/adapters/mongo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/adapters/mongo/client.py b/backend/src/adapters/mongo/client.py new file mode 100644 index 0000000..9cb2d02 --- /dev/null +++ b/backend/src/adapters/mongo/client.py @@ -0,0 +1,16 @@ +from beanie import init_beanie +from pymongo import AsyncMongoClient + +from src.core.config import get_settings +from .documents import SnippetDocument + +settings = get_settings() + + +async def init_mongo_client() -> None: + client: AsyncMongoClient = AsyncMongoClient( + settings.mongodb_url, maxPoolSize=10, minPoolSize=1 + ) + await init_beanie( + database=client.snippetly, document_models=[SnippetDocument] + ) diff --git a/backend/src/adapters/mongo/documents.py b/backend/src/adapters/mongo/documents.py new file mode 100644 index 0000000..3ef22e1 --- /dev/null +++ b/backend/src/adapters/mongo/documents.py @@ -0,0 +1,26 @@ +from datetime import datetime, timezone +from typing import Optional + +from beanie import Document, before_event, Insert, Update +from pydantic import Field + + +class SnippetDocument(Document): + content: str = Field(..., min_length=1, max_length=1000) + description: Optional[str] = Field(None, max_length=500) + created_at: datetime = datetime.now(timezone.utc) + updated_at: datetime = datetime.now(timezone.utc) + + @before_event(Insert) + def set_created_at(self) -> None: + if not self.created_at: + self.created_at = datetime.now(timezone.utc) + if not self.updated_at: + self.updated_at = datetime.now(timezone.utc) + + @before_event(Update) + def set_updated_at(self) -> None: + self.updated_at = datetime.now(timezone.utc) + + class Settings: + name = "snippets" diff --git a/backend/src/adapters/mongo/repo.py b/backend/src/adapters/mongo/repo.py new file mode 100644 index 0000000..007875d --- /dev/null +++ b/backend/src/adapters/mongo/repo.py @@ -0,0 +1,101 @@ +from typing import Optional, cast + +from beanie import PydanticObjectId +from pydantic import ValidationError +from pymongo.errors import ( + ConnectionFailure, + ServerSelectionTimeoutError, + PyMongoError, +) + +from .documents import SnippetDocument + +messages = { + "conn": "MongoDB connection failed", + "fail": "MongoDB operation failed", + "invalid": "Invalid document data", +} + + +class SnippetDocumentRepository: + def __init__(self) -> None: + self.document = SnippetDocument + + # --- Create --- + @staticmethod + async def create( + content: str, description: Optional[str] = None + ) -> SnippetDocument: + try: + snippet = SnippetDocument(content=content, description=description) + return cast(SnippetDocument, await snippet.insert()) + except ValidationError as e: + raise ValueError(messages["invalid"]) from e + except (ConnectionFailure, ServerSelectionTimeoutError) as e: + raise ConnectionFailure(messages["conn"]) from e + except PyMongoError as e: + raise PyMongoError(messages["fail"]) from e + + # --- Read --- + async def get_by_id(self, _id: str) -> Optional[SnippetDocument]: + try: + return await self.document.get(PydanticObjectId(_id)) + except (ConnectionFailure, ServerSelectionTimeoutError) as e: + raise ConnectionFailure(messages["conn"]) from e + except PyMongoError as e: + raise PyMongoError(messages["fail"]) from e + + async def get_by_ids(self, _ids: list[str]) -> list[SnippetDocument]: + try: + object_ids = [PydanticObjectId(id_str) for id_str in _ids] + documents = await self.document.find( + {"_id": {"$in": object_ids}} + ).to_list() + except (ConnectionFailure, ServerSelectionTimeoutError) as e: + raise ConnectionFailure(messages["conn"]) from e + except PyMongoError as e: + raise PyMongoError(messages["fail"]) from e + return documents + + # --- Update --- + async def update( + self, + _id: str, + content: Optional[str] = None, + description: Optional[str] = None, + ) -> Optional[SnippetDocument]: + try: + snippet = await self.get_by_id(_id) + if snippet: + if content: + snippet.content = content + if description: + snippet.description = description + await snippet.save() + return snippet + except ValidationError as e: + raise ValueError(messages["invalid"]) from e + except (ConnectionFailure, ServerSelectionTimeoutError) as e: + raise ConnectionFailure(messages["conn"]) from e + except PyMongoError as e: + raise PyMongoError(messages["fail"]) from e + + # --- Delete --- + async def delete(self, _id: str) -> None: + try: + snippet = await self.get_by_id(_id) + if snippet: + await snippet.delete() + except (ConnectionFailure, ServerSelectionTimeoutError) as e: + raise ConnectionFailure(messages["conn"]) from e + except PyMongoError as e: + raise PyMongoError(messages["fail"]) from e + + @staticmethod + async def delete_document(document: SnippetDocument) -> None: + try: + await document.delete() + except (ConnectionFailure, ServerSelectionTimeoutError) as e: + raise ConnectionFailure(messages["conn"]) from e + except PyMongoError as e: + raise PyMongoError(messages["fail"]) from e diff --git a/backend/src/adapters/postgres/__init__.py b/backend/src/adapters/postgres/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/adapters/postgres/async_db.py b/backend/src/adapters/postgres/async_db.py new file mode 100644 index 0000000..4a9e796 --- /dev/null +++ b/backend/src/adapters/postgres/async_db.py @@ -0,0 +1,22 @@ +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + create_async_engine, + async_sessionmaker, + AsyncSession, +) + +from src.core.config import get_settings + +settings = get_settings() + +engine = create_async_engine(settings.database_url) +SessionLocal = async_sessionmaker(autoflush=False, bind=engine) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + db = SessionLocal() + try: + yield db + finally: + await db.close() diff --git a/backend/src/adapters/postgres/migrations/README b/backend/src/adapters/postgres/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/src/adapters/postgres/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/src/adapters/postgres/migrations/env.py b/backend/src/adapters/postgres/migrations/env.py new file mode 100644 index 0000000..91f1821 --- /dev/null +++ b/backend/src/adapters/postgres/migrations/env.py @@ -0,0 +1,49 @@ +from alembic import context +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from src.adapters.postgres.models import * # noqa: F403 +from src.core.config import get_settings + +config = context.config +settings = get_settings() + +postgres_url = settings.database_url_sync +config.set_main_option("sqlalchemy.url", postgres_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/src/adapters/postgres/migrations/script.py.mako b/backend/src/adapters/postgres/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/src/adapters/postgres/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/src/adapters/postgres/migrations/versions/196cd12cd328_init_tables.py b/backend/src/adapters/postgres/migrations/versions/196cd12cd328_init_tables.py new file mode 100644 index 0000000..1d28514 --- /dev/null +++ b/backend/src/adapters/postgres/migrations/versions/196cd12cd328_init_tables.py @@ -0,0 +1,189 @@ +"""init tables + +Revision ID: 196cd12cd328 +Revises: +Create Date: 2025-11-06 14:37:18.371463 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "196cd12cd328" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "tags", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(length=20), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("username", sa.String(length=40), nullable=False), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_admin", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("username"), + ) + op.create_table( + "activation_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("token", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + sa.UniqueConstraint("user_id"), + ) + op.create_table( + "password_reset_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("token", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + sa.UniqueConstraint("user_id"), + ) + op.create_table( + "refresh_tokens", + sa.Column("token", sa.String(length=512), nullable=False), + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token"), + ) + op.create_table( + "snippets", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uuid", sa.UUID(), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column( + "language", + sa.Enum("PYTHON", "JAVASCRIPT", name="languageenum"), + nullable=False, + ), + sa.Column("is_private", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("mongodb_id", sa.String(length=24), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("mongodb_id"), + sa.UniqueConstraint("user_id", "title", name="uq_user_title"), + sa.UniqueConstraint("uuid"), + ) + op.create_table( + "user_profiles", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("first_name", sa.String(length=100), nullable=True), + sa.Column("last_name", sa.String(length=100), nullable=True), + sa.Column( + "gender", + sa.Enum("MALE", "FEMALE", "OTHER", name="genderenum"), + nullable=True, + ), + sa.Column("date_of_birth", sa.Date(), nullable=True), + sa.Column("info", sa.Text(), nullable=True), + sa.Column("avatar_url", sa.Text(), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + ) + op.create_table( + "snippet_favorites", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("snippet_id", sa.Integer(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["snippet_id"], ["snippets.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", "snippet_id", name="uq_user_snippet_favorite" + ), + ) + op.create_table( + "snippets_tags", + sa.Column("snippet_id", sa.Integer(), nullable=False), + sa.Column("tag_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["snippet_id"], ["snippets.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("snippet_id", "tag_id"), + sa.UniqueConstraint("snippet_id", "tag_id", name="uq_snippet_tag"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("snippets_tags") + op.drop_table("snippet_favorites") + op.drop_table("user_profiles") + op.drop_table("snippets") + op.drop_table("refresh_tokens") + op.drop_table("password_reset_tokens") + op.drop_table("activation_tokens") + op.drop_table("users") + op.drop_table("tags") + # ### end Alembic commands ### diff --git a/backend/src/adapters/postgres/models/__init__.py b/backend/src/adapters/postgres/models/__init__.py new file mode 100644 index 0000000..0a79d76 --- /dev/null +++ b/backend/src/adapters/postgres/models/__init__.py @@ -0,0 +1,30 @@ +from .accounts import ( + UserModel, + UserProfileModel, + TokenBaseModel, + RefreshTokenModel, + ActivationTokenModel, + PasswordResetTokenModel, +) +from .base import Base +from .enums import GenderEnum, LanguageEnum +from .snippets import ( + SnippetModel, + SnippetFavoritesModel, + TagModel, +) + +__all__ = [ + "Base", + "UserModel", + "TokenBaseModel", + "RefreshTokenModel", + "UserProfileModel", + "ActivationTokenModel", + "PasswordResetTokenModel", + "GenderEnum", + "LanguageEnum", + "SnippetModel", + "SnippetFavoritesModel", + "TagModel", +] diff --git a/backend/src/adapters/postgres/models/accounts/__init__.py b/backend/src/adapters/postgres/models/accounts/__init__.py new file mode 100644 index 0000000..2b91b3b --- /dev/null +++ b/backend/src/adapters/postgres/models/accounts/__init__.py @@ -0,0 +1,8 @@ +from .profile import UserProfileModel +from .tokens import ( + TokenBaseModel, + ActivationTokenModel, + PasswordResetTokenModel, + RefreshTokenModel, +) +from .user import UserModel diff --git a/backend/src/adapters/postgres/models/accounts/profile.py b/backend/src/adapters/postgres/models/accounts/profile.py new file mode 100644 index 0000000..ba92a8c --- /dev/null +++ b/backend/src/adapters/postgres/models/accounts/profile.py @@ -0,0 +1,42 @@ +from datetime import date +from typing import TYPE_CHECKING + +from sqlalchemy import ( + Integer, + String, + Date, + Enum, + Text, + ForeignKey, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..base import Base +from ..enums import GenderEnum + +if TYPE_CHECKING: + from .user import UserModel + + +class UserProfileModel(Base): + __tablename__ = "user_profiles" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + first_name: Mapped[str | None] = mapped_column(String(100)) + last_name: Mapped[str | None] = mapped_column(String(100)) + gender: Mapped[GenderEnum | None] = mapped_column(Enum(GenderEnum)) + date_of_birth: Mapped[date | None] = mapped_column(Date) + info: Mapped[str | None] = mapped_column(Text) + avatar_url: Mapped[str | None] = mapped_column(Text) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True + ) + + user: Mapped["UserModel"] = relationship( + "UserModel", back_populates="profile", lazy="joined" + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/src/adapters/postgres/models/accounts/tokens.py b/backend/src/adapters/postgres/models/accounts/tokens.py new file mode 100644 index 0000000..05391f0 --- /dev/null +++ b/backend/src/adapters/postgres/models/accounts/tokens.py @@ -0,0 +1,101 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional, TYPE_CHECKING + +from sqlalchemy import ( + Integer, + String, + DateTime, + ForeignKey, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.core.security import ( + generate_secure_token, +) +from ..base import Base + +if TYPE_CHECKING: + from .user import UserModel + + +class TokenBaseModel(Base): + __abstract__ = True + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + token: Mapped[str] = mapped_column( + String(64), unique=True, nullable=False, default=generate_secure_token + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc) + timedelta(days=1), + ) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + + @classmethod + def create( + cls, + user_id: int, + token: Optional[str] = None, + days: int = 1, + ) -> "TokenBaseModel": + return cls( + user_id=user_id, + token=token or generate_secure_token(), + expires_at=datetime.now(timezone.utc) + timedelta(days=days), + ) + + +class ActivationTokenModel(TokenBaseModel): + __tablename__ = "activation_tokens" + + user: Mapped["UserModel"] = relationship( + "UserModel", back_populates="activation_token" + ) + + __table_args__ = (UniqueConstraint("user_id"),) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class PasswordResetTokenModel(TokenBaseModel): + __tablename__ = "password_reset_tokens" + + user: Mapped["UserModel"] = relationship( + "UserModel", back_populates="password_reset_token" + ) + + __table_args__ = (UniqueConstraint("user_id"),) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class RefreshTokenModel(TokenBaseModel): + __tablename__ = "refresh_tokens" + + token: Mapped[str] = mapped_column( + String(512), unique=True, nullable=False, default=generate_secure_token + ) + + user: Mapped["UserModel"] = relationship( + "UserModel", back_populates="refresh_tokens" + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/src/adapters/postgres/models/accounts/user.py b/backend/src/adapters/postgres/models/accounts/user.py new file mode 100644 index 0000000..629ded4 --- /dev/null +++ b/backend/src/adapters/postgres/models/accounts/user.py @@ -0,0 +1,110 @@ +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import ( + Integer, + String, + Boolean, + DateTime, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.core.security import ( + hash_password, + verify_password, +) +from ..base import Base + +if TYPE_CHECKING: + from .profile import UserProfileModel + from .tokens import ( + ActivationTokenModel, + PasswordResetTokenModel, + RefreshTokenModel, + ) + from ..snippets import SnippetModel, SnippetFavoritesModel + + +class UserModel(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + email: Mapped[str] = mapped_column( + String(255), nullable=False, unique=True + ) + username: Mapped[str] = mapped_column( + String(40), nullable=False, unique=True + ) + _hashed_password: Mapped[str] = mapped_column( + "hashed_password", String(255), nullable=False + ) + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + is_admin: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=func.now(), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + profile: Mapped["UserProfileModel"] = relationship( + "UserProfileModel", + back_populates="user", + cascade="all, delete-orphan", + lazy="selectin", + ) + activation_token: Mapped["ActivationTokenModel"] = relationship( + "ActivationTokenModel", + back_populates="user", + cascade="all, delete-orphan", + ) + password_reset_token: Mapped["PasswordResetTokenModel"] = relationship( + "PasswordResetTokenModel", + back_populates="user", + cascade="all, delete-orphan", + ) + refresh_tokens: Mapped[list["RefreshTokenModel"]] = relationship( + "RefreshTokenModel", back_populates="user", cascade="all" + ) + snippets: Mapped[list["SnippetModel"]] = relationship( + "SnippetModel", back_populates="user", cascade="all, delete-orphan" + ) + favorite_snippets: Mapped[list["SnippetFavoritesModel"]] = relationship( + "SnippetFavoritesModel", + back_populates="user", + cascade="all, delete-orphan", + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + @property + def password(self) -> str: + return "Password is write-only." + + @password.setter + def password(self, new_password: str) -> None: + self._hashed_password = hash_password(new_password) + + def verify_password(self, new_password: str) -> bool: + return verify_password(new_password, self._hashed_password) + + @classmethod + def create(cls, email: str, username: str, password: str) -> "UserModel": + user = cls(email=email, username=username) + user.password = password + return user diff --git a/backend/src/adapters/postgres/models/base.py b/backend/src/adapters/postgres/models/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/src/adapters/postgres/models/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/src/adapters/postgres/models/enums.py b/backend/src/adapters/postgres/models/enums.py new file mode 100644 index 0000000..44be757 --- /dev/null +++ b/backend/src/adapters/postgres/models/enums.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class GenderEnum(Enum): + MALE = "male" + FEMALE = "female" + OTHER = "other" + + +class LanguageEnum(Enum): + PYTHON = "python" + JAVASCRIPT = "javascript" diff --git a/backend/src/adapters/postgres/models/snippets.py b/backend/src/adapters/postgres/models/snippets.py new file mode 100644 index 0000000..08cb924 --- /dev/null +++ b/backend/src/adapters/postgres/models/snippets.py @@ -0,0 +1,164 @@ +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import ( + Integer, + UUID, + String, + Enum, + DateTime, + func, + Boolean, + ForeignKey, + Table, + Column, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base +from .enums import LanguageEnum + +if TYPE_CHECKING: + from .accounts import UserModel + +SnippetsTagsTable = Table( + "snippets_tags", + Base.metadata, + Column( + "snippet_id", + Integer, + ForeignKey("snippets.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "tag_id", + Integer, + ForeignKey("tags.id", ondelete="CASCADE"), + primary_key=True, + ), + UniqueConstraint("snippet_id", "tag_id", name="uq_snippet_tag"), +) + + +class SnippetModel(Base): + __tablename__ = "snippets" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + uuid: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), unique=True, nullable=False, default=uuid.uuid4 + ) + + title: Mapped[str] = mapped_column(String(255), nullable=False) + language: Mapped[LanguageEnum] = mapped_column( + Enum(LanguageEnum), nullable=False + ) + is_private: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=func.now(), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + mongodb_id: Mapped[str] = mapped_column( + String(24), nullable=False, unique=True + ) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + + user: Mapped["UserModel"] = relationship( + "UserModel", back_populates="snippets", lazy="selectin" + ) + tags: Mapped[list["TagModel"]] = relationship( + "TagModel", + secondary=SnippetsTagsTable, + back_populates="snippets", + lazy="selectin", + ) + favorited_by: Mapped[list["SnippetFavoritesModel"]] = relationship( + "SnippetFavoritesModel", back_populates="snippet" + ) + + __table_args__ = ( + UniqueConstraint("user_id", "title", name="uq_user_title"), + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class SnippetFavoritesModel(Base): + __tablename__ = "snippet_favorites" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + snippet_id: Mapped[int] = mapped_column( + Integer, ForeignKey("snippets.id", ondelete="CASCADE"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "user_id", "snippet_id", name="uq_user_snippet_favorite" + ), + ) + + user: Mapped["UserModel"] = relationship( + "UserModel", back_populates="favorite_snippets" + ) + snippet: Mapped[SnippetModel] = relationship( + "SnippetModel", back_populates="favorited_by" + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class TagModel(Base): + __tablename__ = "tags" + + id: Mapped[int] = mapped_column( + Integer, primary_key=True, autoincrement=True + ) + + name: Mapped[str] = mapped_column(String(20), nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + snippets: Mapped[list["SnippetModel"]] = relationship( + "SnippetModel", + secondary=SnippetsTagsTable, + back_populates="tags", + lazy="selectin", + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/src/adapters/postgres/repositories/__init__.py b/backend/src/adapters/postgres/repositories/__init__.py new file mode 100644 index 0000000..1d9c1c2 --- /dev/null +++ b/backend/src/adapters/postgres/repositories/__init__.py @@ -0,0 +1,2 @@ +from .accounts import UserRepository, UserProfileRepository, TokenRepository +from .snippets import SnippetRepository, FavoritesRepository diff --git a/backend/src/adapters/postgres/repositories/accounts/__init__.py b/backend/src/adapters/postgres/repositories/accounts/__init__.py new file mode 100644 index 0000000..e9b21b3 --- /dev/null +++ b/backend/src/adapters/postgres/repositories/accounts/__init__.py @@ -0,0 +1,3 @@ +from .profile import UserProfileRepository +from .token import TokenRepository +from .user import UserRepository diff --git a/backend/src/adapters/postgres/repositories/accounts/profile.py b/backend/src/adapters/postgres/repositories/accounts/profile.py new file mode 100644 index 0000000..e10a133 --- /dev/null +++ b/backend/src/adapters/postgres/repositories/accounts/profile.py @@ -0,0 +1,94 @@ +from datetime import date +from typing import Optional, Union + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +import src.core.exceptions as exc +from src.adapters.postgres.models import ( + UserProfileModel, + GenderEnum, + UserModel, +) + + +class UserProfileRepository: + def __init__(self, db: AsyncSession) -> None: + self._db = db + + # --- Create --- + async def create( + self, + user_id: int, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + gender: Optional[GenderEnum] = None, + date_of_birth: Optional[date] = None, + info: Optional[str] = None, + avatar_url: Optional[str] = None, + ) -> UserProfileModel: + profile = UserProfileModel( + user_id=user_id, + first_name=first_name or "", + last_name=last_name or "", + avatar_url=avatar_url or "", + gender=GenderEnum(gender) if gender else None, + date_of_birth=date_of_birth, + info=info or "", + ) + self._db.add(profile) + return profile + + # --- Read --- + async def get_by_user_id(self, user_id: int) -> UserProfileModel: + query = select(UserProfileModel).where( + UserProfileModel.user_id == user_id + ) + result = await self._db.execute(query) + profile = result.scalar_one_or_none() + if profile is None: + raise exc.ProfileNotFoundError( + "Profile with this user ID was not found" + ) + return profile + + async def get_by_username(self, username: str) -> UserProfileModel: + query = ( + select(UserProfileModel) + .join(UserModel) + .where(UserModel.username == username) + ) + + result = await self._db.execute(query) + profile = result.scalar_one_or_none() + if profile is None: + raise exc.ProfileNotFoundError( + "Profile with this username was not found" + ) + return profile + + # --- Update --- + async def update( + self, user_id: int, **kwargs: Union[str, None, GenderEnum, date] + ) -> UserProfileModel: + profile: UserProfileModel = await self.get_by_user_id(user_id) + for key, value in kwargs.items(): + setattr(profile, key, value) + self._db.add(profile) + return profile + + async def update_avatar_url( + self, user_id: int, avatar_url: str + ) -> UserProfileModel: + profile: UserProfileModel = await self.get_by_user_id(user_id) + + profile.avatar_url = avatar_url + self._db.add(profile) + return profile + + # --- Delete --- + async def delete_avatar_url(self, user_id: int) -> None: + profile: UserProfileModel = await self.get_by_user_id(user_id) + + profile.avatar_url = None + self._db.add(profile) diff --git a/backend/src/adapters/postgres/repositories/accounts/token.py b/backend/src/adapters/postgres/repositories/accounts/token.py new file mode 100644 index 0000000..e4471b6 --- /dev/null +++ b/backend/src/adapters/postgres/repositories/accounts/token.py @@ -0,0 +1,65 @@ +from typing import Optional, Tuple, cast, TypeVar, Generic + +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.models import ( + TokenBaseModel, + UserModel, +) + +T = TypeVar("T", bound=TokenBaseModel) + + +class TokenRepository(Generic[T]): + def __init__(self, db: AsyncSession, token_model: type[T]): + self._db = db + self.token_model = token_model + + # --- Create --- + async def create(self, user_id: int, token: str, days: int) -> T: + token_instance = cast(T, self.token_model.create(user_id, token, days)) + self._db.add(token_instance) + return token_instance + + # --- Read --- + async def get_by_token(self, token: str) -> Optional[T]: + query = select(self.token_model).where(self.token_model.token == token) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def get_with_user(self, token: str) -> Optional[Tuple[UserModel, T]]: + query = ( + select(UserModel, self.token_model) + .join(UserModel) + .where(self.token_model.token == token) + ) + result = await self._db.execute(query) + row = result.one_or_none() + return cast(tuple | None, row) + + async def get_by_user(self, user_id: int) -> Optional[T]: + query = select(self.token_model).where( + self.token_model.user_id == user_id + ) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def list_by_user(self, user_id: int) -> list[T]: + query = select(self.token_model).where( + self.token_model.user_id == user_id + ) + result = await self._db.execute(query) + rows = result.scalars().all() + return list(rows) + + # --- Delete --- + async def delete(self, token: str) -> None: + query = delete(self.token_model).where(self.token_model.token == token) + await self._db.execute(query) + + async def delete_by_user_id(self, user_id: int) -> None: + query = delete(self.token_model).where( + self.token_model.user_id == user_id + ) + await self._db.execute(query) diff --git a/backend/src/adapters/postgres/repositories/accounts/user.py b/backend/src/adapters/postgres/repositories/accounts/user.py new file mode 100644 index 0000000..cc521cb --- /dev/null +++ b/backend/src/adapters/postgres/repositories/accounts/user.py @@ -0,0 +1,48 @@ +from typing import Optional + +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.models import UserModel + + +class UserRepository: + def __init__(self, db: AsyncSession): + self._db = db + + # --- Create --- + async def create( + self, email: str, username: str, password: str + ) -> UserModel: + user = UserModel.create( + email=email, username=username, password=password + ) + self._db.add(user) + return user + + # --- Read --- + async def get_by_id(self, user_id: int) -> Optional[UserModel]: + query = select(UserModel).where(UserModel.id == user_id) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def get_by_email(self, email: str) -> Optional[UserModel]: + query = select(UserModel).where(UserModel.email == email) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def get_by_login(self, login: str) -> Optional[UserModel]: + query = select(UserModel).where( + or_(UserModel.email == login, UserModel.username == login) + ) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def get_by_email_or_username( + self, email: str, username: str + ) -> Optional[UserModel]: + query = select(UserModel).where( + or_(UserModel.email == email, UserModel.username == username) + ) + result = await self._db.execute(query) + return result.scalar_one_or_none() diff --git a/backend/src/adapters/postgres/repositories/snippets/__init__.py b/backend/src/adapters/postgres/repositories/snippets/__init__.py new file mode 100644 index 0000000..871a97b --- /dev/null +++ b/backend/src/adapters/postgres/repositories/snippets/__init__.py @@ -0,0 +1,2 @@ +from .favorites import FavoritesRepository +from .snippet import SnippetRepository diff --git a/backend/src/adapters/postgres/repositories/snippets/favorites.py b/backend/src/adapters/postgres/repositories/snippets/favorites.py new file mode 100644 index 0000000..5104028 --- /dev/null +++ b/backend/src/adapters/postgres/repositories/snippets/favorites.py @@ -0,0 +1,119 @@ +from typing import Optional, Sequence, Tuple +from uuid import UUID + +from sqlalchemy import select, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload, joinedload + +import src.core.exceptions as exc +from src.adapters.postgres.models import ( + UserModel, + SnippetFavoritesModel, + LanguageEnum, + SnippetModel, + TagModel, +) +from src.api.v1.schemas.snippets import FavoritesSortingEnum +from .snippet import SnippetRepository + + +class FavoritesRepository: + def __init__(self, db: AsyncSession): + self._db = db + self._snippet_repo = SnippetRepository(db) + + async def add_to_favorites( + self, user: UserModel, snippet_uuid: UUID + ) -> None: + snippet = await self._snippet_repo.get_by_uuid(snippet_uuid) + if snippet is None: + raise exc.SnippetNotFoundError + + query = select(SnippetFavoritesModel).where( + SnippetFavoritesModel.user_id == user.id, + SnippetFavoritesModel.snippet_id == snippet.id, + ) + result = await self._db.execute(query) + favorite = result.scalar_one_or_none() + if favorite: + raise exc.FavoritesAlreadyError + + fav = SnippetFavoritesModel(user_id=user.id, snippet_id=snippet.id) + self._db.add(fav) + + async def remove_from_favorites( + self, user: UserModel, snippet_uuid: UUID + ) -> None: + snippet = await self._snippet_repo.get_by_uuid(snippet_uuid) + if snippet is None: + raise exc.SnippetNotFoundError + + query = ( + delete(SnippetFavoritesModel) + .where( + SnippetFavoritesModel.user_id == user.id, + SnippetFavoritesModel.snippet_id == snippet.id, + ) + .returning(SnippetFavoritesModel.id) + ) + + result = await self._db.execute(query) + deleted = result.scalar_one_or_none() + if not deleted: + raise exc.FavoritesAlreadyError + + async def get_favorites_paginated( + self, + offset: int, + limit: int, + user_id: int, + sort_by: FavoritesSortingEnum, + language: Optional[LanguageEnum] = None, + tags: Optional[list[str]] = None, + username: Optional[str] = None, + ) -> Tuple[Sequence[SnippetModel], int]: + base_query = ( + select(SnippetModel) + .join( + SnippetFavoritesModel, + SnippetFavoritesModel.snippet_id == SnippetModel.id, + ) + .where(SnippetFavoritesModel.user_id == user_id) + .options( + selectinload(SnippetModel.tags), joinedload(SnippetModel.user) + ) + ) + + if language: + base_query = base_query.where(SnippetModel.language == language) + + if tags: + base_query = base_query.join(SnippetModel.tags).where( + TagModel.name.in_(tags) + ) + + if username: + base_query = base_query.join(UserModel).where( + UserModel.username.icontains(username) + ) + + if sort_by == "created_at": + base_query = base_query.order_by( + SnippetFavoritesModel.created_at.desc() + ) + elif sort_by == "snippet_date": + base_query = base_query.order_by(SnippetModel.created_at.desc()) + elif sort_by == "title": + base_query = base_query.order_by(SnippetModel.title.asc()) + else: + base_query = base_query.order_by( + SnippetFavoritesModel.created_at.desc() + ) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await self._db.scalar(count_query) + + data_query = base_query.offset(offset).limit(limit) + result = await self._db.execute(data_query) + + return result.scalars().all(), total # type: ignore diff --git a/backend/src/adapters/postgres/repositories/snippets/snippet.py b/backend/src/adapters/postgres/repositories/snippets/snippet.py new file mode 100644 index 0000000..8307c68 --- /dev/null +++ b/backend/src/adapters/postgres/repositories/snippets/snippet.py @@ -0,0 +1,213 @@ +from datetime import date, timedelta +from typing import Optional, Tuple +from uuid import UUID + +from beanie import PydanticObjectId +from sqlalchemy import select, delete, Sequence, func, or_, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +import src.core.exceptions as exc +from src.adapters.postgres.models import ( + SnippetModel, + LanguageEnum, + TagModel, + UserModel, +) + + +class SnippetRepository: + def __init__(self, db: AsyncSession): + self._db = db + + # --- Create --- + def create( + self, + title: str, + language: LanguageEnum, + is_private: bool, + mongodb_id: PydanticObjectId, + user_id: int, + ) -> SnippetModel: + snippet = SnippetModel( + title=title, + language=language, + is_private=is_private, + mongodb_id=str(mongodb_id), + user_id=user_id, + ) + self._db.add(snippet) + return snippet + + async def create_with_tags( + self, + title: str, + language: LanguageEnum, + is_private: bool, + tag_names: list[str], + mongodb_id: PydanticObjectId, + user_id: int, + ) -> SnippetModel: + snippet = SnippetModel( + title=title, + language=language, + is_private=is_private, + user_id=user_id, + mongodb_id=str(mongodb_id), + ) + + tags: list[TagModel] = [] + for name in tag_names: + stmt = select(TagModel).where(TagModel.name == name) + result = await self._db.execute(stmt) + tag = result.scalar_one_or_none() + + if tag is None: + tag = TagModel(name=name) + self._db.add(tag) + + tags.append(tag) + + snippet.tags = tags + self._db.add(snippet) + return snippet + + # --- Read --- + async def get_snippets_paginated( + self, + offset: int, + limit: int, + current_user_id: int, + visibility: Optional[str] = None, + language: Optional[LanguageEnum] = None, + tags: Optional[list[str]] = None, + created_before: Optional[date] = None, + created_after: Optional[date] = None, + username: Optional[str] = None, + ) -> Tuple[Sequence, int]: + if visibility == "private": + visibility_filter = and_( + SnippetModel.is_private.is_(True), + SnippetModel.user_id == current_user_id, + ) + elif visibility == "public": + visibility_filter = SnippetModel.is_private.is_(False) + else: + visibility_filter = or_( + SnippetModel.is_private.is_(False), + SnippetModel.user_id == current_user_id, + ) + base_query = select(SnippetModel).where(visibility_filter) + + if language: + language_enum_member = LanguageEnum(language) + base_query = base_query.where( + SnippetModel.language == language_enum_member + ) + + if tags: + base_query = ( + base_query.join(SnippetModel.tags) + .where(TagModel.name.in_(tags)) + .distinct() + ) + + if created_before: + end_date = created_before + timedelta(days=1) + base_query = base_query.where(SnippetModel.created_at < end_date) + + if created_after: + base_query = base_query.where( + SnippetModel.created_at >= created_after + ) + + if username: + base_query = base_query.join(UserModel).where( + UserModel.username.icontains(username) + ) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await self._db.scalar(count_query) + + data_query = ( + base_query.options(selectinload(SnippetModel.tags)) + .order_by(SnippetModel.created_at.desc()) + .offset(offset) + .limit(limit) + ) + + result = await self._db.execute(data_query) + return result.scalars().all(), total # type: ignore + + async def get_by_uuid(self, uuid: UUID) -> Optional[SnippetModel]: + query = select(SnippetModel).where(SnippetModel.uuid == uuid) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def get_by_uuid_with_tags( + self, uuid: UUID + ) -> Optional[SnippetModel]: + query = ( + select(SnippetModel) + .where(SnippetModel.uuid == uuid) + .options(selectinload(SnippetModel.tags)) + ) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def get_by_user(self, user_id: int) -> Optional[Sequence]: + query = select(SnippetModel).where(SnippetModel.user_id == user_id) + result = await self._db.execute(query) + return result.scalars().all() # type: ignore + + async def get_by_title( + self, title: str, user_id: int + ) -> Optional[SnippetModel]: + query = select(SnippetModel).where( + SnippetModel.title == title, SnippetModel.user_id == user_id + ) + result = await self._db.execute(query) + return result.scalar_one_or_none() + + async def get_by_title_list( + self, title: str, user_id: int, limit: int = 20 + ) -> Optional[Sequence]: + search_access = and_( + SnippetModel.title.icontains(title), + or_( + SnippetModel.is_private.is_(False), + SnippetModel.user_id == user_id, + ), + ) + query = select(SnippetModel).where(search_access).limit(limit) + result = await self._db.execute(query) + return result.scalars().all() # type: ignore + + # --- Update --- + async def update( + self, + uuid: UUID, + title: str | None = None, + language: LanguageEnum | None = None, + is_private: bool | None = None, + ) -> SnippetModel: + snippet: SnippetModel | None = await self.get_by_uuid_with_tags(uuid) + + if snippet is None: + raise exc.SnippetNotFoundError( + "Snippet with this UUID was not found" + ) + + if title is not None: + snippet.title = title + if language is not None: + snippet.language = language + if is_private is not None: + snippet.is_private = is_private + + return snippet + + # --- Delete --- + async def delete(self, uuid: UUID) -> None: + query = delete(SnippetModel).where(SnippetModel.uuid == uuid) + await self._db.execute(query) diff --git a/backend/src/adapters/postgres/sync_db.py b/backend/src/adapters/postgres/sync_db.py new file mode 100644 index 0000000..43143f5 --- /dev/null +++ b/backend/src/adapters/postgres/sync_db.py @@ -0,0 +1,19 @@ +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session + +from src.core.config import get_settings + +settings = get_settings() + +engine = create_engine(settings.database_url_sync) +SessionLocal = sessionmaker(autoflush=False, bind=engine) + + +def get_db_sync() -> Generator[Session, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/src/adapters/redis/__init__.py b/backend/src/adapters/redis/__init__.py new file mode 100644 index 0000000..c8315c6 --- /dev/null +++ b/backend/src/adapters/redis/__init__.py @@ -0,0 +1 @@ +from .client import get_redis_client diff --git a/backend/src/adapters/redis/blacklist.py b/backend/src/adapters/redis/blacklist.py new file mode 100644 index 0000000..09f24fd --- /dev/null +++ b/backend/src/adapters/redis/blacklist.py @@ -0,0 +1,14 @@ +from datetime import datetime, timezone +from typing import cast + +from redis.asyncio.client import Redis + + +async def add_to_blacklist(redis: Redis, jti: str, exp: int) -> None: + ttl = exp - int(datetime.now(timezone.utc).timestamp()) + if ttl > 0: + await redis.setex(f"bl:{jti}", ttl, "true") + + +async def is_blacklisted(redis: Redis, jti: str) -> bool: + return cast(bool, await redis.exists(f"bl:{jti}") == 1) diff --git a/backend/src/adapters/redis/client.py b/backend/src/adapters/redis/client.py new file mode 100644 index 0000000..56aaac8 --- /dev/null +++ b/backend/src/adapters/redis/client.py @@ -0,0 +1,19 @@ +from typing import Optional + +from redis.asyncio import Redis + +from src.core.config.dbs import RedisSettings + +_redis_client: Optional[Redis] = None + + +def get_redis_client(settings: RedisSettings) -> Redis: + global _redis_client + if _redis_client is None: + _redis_client = Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB, + decode_responses=True, + ) + return _redis_client diff --git a/backend/src/adapters/redis/common.py b/backend/src/adapters/redis/common.py new file mode 100644 index 0000000..b3f701d --- /dev/null +++ b/backend/src/adapters/redis/common.py @@ -0,0 +1,17 @@ +from typing import cast + +from redis.asyncio import Redis + + +async def save_access_token( + redis: Redis, jti: str, user_id: int, ttl: int +) -> None: + await redis.setex(f"access:{jti}", ttl, str(user_id)) + + +async def get_access_token(redis: Redis, jti: str) -> str | None: + return cast(str, await redis.get(f"access:{jti}")) + + +async def delete_access_token(redis: Redis, jti: str) -> None: + await redis.delete(f"access:{jti}") diff --git a/backend/src/adapters/storage/__init__.py b/backend/src/adapters/storage/__init__.py new file mode 100644 index 0000000..35e08de --- /dev/null +++ b/backend/src/adapters/storage/__init__.py @@ -0,0 +1,3 @@ +from .dev_storage import DevStorage +from .interface import StorageInterface +from .stub_storage import StubStorage diff --git a/backend/src/adapters/storage/dev_storage.py b/backend/src/adapters/storage/dev_storage.py new file mode 100644 index 0000000..10419ff --- /dev/null +++ b/backend/src/adapters/storage/dev_storage.py @@ -0,0 +1,44 @@ +import os +from pathlib import Path +from typing import Union +from urllib.parse import urljoin, urlparse + +from .interface import StorageInterface + + +class DevStorage(StorageInterface): + def __init__( + self, base_path: Path, base_url: str = "http://localhost:8000/static/" + ): + self.base_url = base_url.rstrip("/") + "/" + self.storage_dir = base_path / "static" / "avatars" + + @staticmethod + def _extract_filename(file_url: str) -> str: + parsed = urlparse(file_url) + return Path(parsed.path).name + + def upload_file( + self, file_name: str, file_data: Union[bytes, bytearray] + ) -> str: + os.makedirs(self.storage_dir, exist_ok=True) + file_path = self.storage_dir / file_name + + with open(file_path, "wb") as f: + f.write(file_data) + + return self.get_file_url(file_name) + + def get_file_url(self, file_name: str) -> str: + return urljoin(self.base_url + "avatars/", file_name) + + def delete_file(self, file_url: str) -> None: + file_name = self._extract_filename(file_url) + file_path = self.storage_dir / file_name + + if os.path.exists(file_path): + os.remove(file_path) + + def file_exists(self, file_url: str) -> bool: + file_name = self._extract_filename(file_url) + return os.path.exists(self.storage_dir / file_name) diff --git a/backend/src/adapters/storage/interface.py b/backend/src/adapters/storage/interface.py new file mode 100644 index 0000000..809268c --- /dev/null +++ b/backend/src/adapters/storage/interface.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod +from typing import Union + + +class StorageInterface(ABC): + @abstractmethod + def upload_file( + self, file_name: str, file_data: Union[bytes, bytearray] + ) -> str: + """ + Uploads a file to the storage. + + :param file_name: The name of the file to be stored. + :type: str + :param file_data: The file data in bytes. + :type: bytes + :return: URL of the uploaded file. + """ + pass + + @abstractmethod + def get_file_url(self, file_name: str) -> str: + """ + Generate a public URL for a file. + + :param file_name: The name of the file stored in the bucket. + :return: The full URL to access the file. + """ + pass + + @abstractmethod + def delete_file(self, file_url: str) -> None: + """ + Method for file deletion + + :param file_url: The URL of the file to be deleted. + :type: str + :return: None + """ + pass + + @abstractmethod + def file_exists(self, file_url: str) -> bool: + """ + Method that checks if a file exists. + + :param file_url: The URL of the file. + :type: str + :return: Boolean value if the file exists. + :rtype: bool + """ + pass diff --git a/backend/src/adapters/storage/prod_storage.py b/backend/src/adapters/storage/prod_storage.py new file mode 100644 index 0000000..1d90cf8 --- /dev/null +++ b/backend/src/adapters/storage/prod_storage.py @@ -0,0 +1,72 @@ +from typing import Union + +from azure.storage.blob import BlobServiceClient + +from src.core.config import ProductionSettings +from .interface import StorageInterface + + +class ProdStorage(StorageInterface): + def __init__(self, settings: ProductionSettings): + self._settings = settings + + if settings.AZURE_STORAGE_CONNECTION_STRING: + self._blob_service = BlobServiceClient.from_connection_string( + settings.AZURE_STORAGE_CONNECTION_STRING + ) + else: + if not settings.AZURE_BLOB_ENDPOINT: + endpoint = f"https://{settings.AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net" + else: + endpoint = settings.AZURE_BLOB_ENDPOINT + self._blob_service = BlobServiceClient( + account_url=endpoint, + credential=settings.AZURE_STORAGE_ACCOUNT_KEY, + ) + + self._container = settings.AZURE_MEDIA_CONTAINER + + def upload_file( + self, file_name: str, file_data: Union[bytes, bytearray] + ) -> str: + blob_client = self._blob_service.get_blob_client( + container=self._container, blob=file_name + ) + if isinstance(file_data, bytes): + data: bytes = file_data + else: + data = bytes(file_data) + + blob_client.upload_blob(data, overwrite=True) + return self.get_file_url(file_name) + + def get_file_url(self, file_name: str) -> str: + if self._settings.AZURE_BLOB_ENDPOINT: + base = self._settings.AZURE_BLOB_ENDPOINT.rstrip("/") + else: + base = f"https://{self._settings.AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net" + return f"{base}/{self._container}/{file_name}" + + def delete_file(self, file_url: str) -> None: + # Expected URL: https://.blob.core.windows.net// + parts = file_url.split("/") + file_name = parts[-1] + blob_client = self._blob_service.get_blob_client( + container=self._container, blob=file_name + ) + try: + blob_client.delete_blob() + except Exception: + pass + + def file_exists(self, file_url: str) -> bool: + parts = file_url.split("/") + file_name = parts[-1] + blob_client = self._blob_service.get_blob_client( + container=self._container, blob=file_name + ) + try: + blob_client.get_blob_properties() + return True + except Exception: + return False diff --git a/backend/src/adapters/storage/stub_storage.py b/backend/src/adapters/storage/stub_storage.py new file mode 100644 index 0000000..2a44c97 --- /dev/null +++ b/backend/src/adapters/storage/stub_storage.py @@ -0,0 +1,34 @@ +from typing import Union +from urllib.parse import urljoin +from uuid import uuid4 + +from .interface import StorageInterface + + +class StubStorage(StorageInterface): + def __init__(self, base_url: str = "http://fake-cdn.com/"): + self._store = {} # {file_name: file_data} + self.base_url = base_url.rstrip("/") + "/" + + def _extract_filename(self, file_url: str) -> str: + return file_url.split("/")[-1] + + def upload_file( + self, file_name: str, file_data: Union[bytes, bytearray] + ) -> str: + unique_file_name = f"{uuid4()}_{file_name}" + + self._store[unique_file_name] = file_data + return self.get_file_url(unique_file_name) + + def get_file_url(self, file_name: str) -> str: + return urljoin(self.base_url, file_name) + + def delete_file(self, file_url: str) -> None: + file_name = self._extract_filename(file_url) + if file_name in self._store: + del self._store[file_name] + + def file_exists(self, file_url: str) -> bool: + file_name = self._extract_filename(file_url) + return file_name in self._store diff --git a/backend/src/adapters/storage/validation.py b/backend/src/adapters/storage/validation.py new file mode 100644 index 0000000..b479219 --- /dev/null +++ b/backend/src/adapters/storage/validation.py @@ -0,0 +1,47 @@ +from io import BytesIO +from typing import cast + +from PIL import Image +from fastapi import UploadFile + + +def validate_image(avatar: UploadFile) -> BytesIO: + supported_image_formats = {"JPEG", "PNG", "WEBP"} + max_file_size = 2 * 1024 * 1024 + + contents = avatar.file.read() + if len(contents) > max_file_size: + raise ValueError( + "Avatar must be less than 2MB. Please choose a smaller file." + ) + + try: + image = Image.open(BytesIO(contents)) + except IOError as e: + raise ValueError("Invalid image format") from e + + image_format = cast(str, image.format) + + image_format = image_format.upper() + if image_format not in supported_image_formats: + raise ValueError( + f"Unsupported image format: {image_format}. " + f"Use one of: {', '.join(supported_image_formats)}" + ) + + # Crop to 1:1 aspect ratio + width, height = image.size + min_side = min(width, height) + left = (width - min_side) / 2 + top = (height - min_side) / 2 + right = (width + min_side) / 2 + bottom = (height + min_side) / 2 + image = image.crop((left, top, right, bottom)) + + output = BytesIO() + image.save(output, format=image_format) + output.seek(0) + + avatar.file.seek(0) + + return output diff --git a/backend/src/admin/__init__.py b/backend/src/admin/__init__.py new file mode 100644 index 0000000..8c3f1e1 --- /dev/null +++ b/backend/src/admin/__init__.py @@ -0,0 +1 @@ +from .admin import admin diff --git a/backend/src/admin/admin.py b/backend/src/admin/admin.py new file mode 100644 index 0000000..2cef502 --- /dev/null +++ b/backend/src/admin/admin.py @@ -0,0 +1,45 @@ +from starlette_admin.contrib.sqla import Admin, ModelView + +from src.adapters.postgres.async_db import engine +from src.adapters.postgres.models import ( + UserModel, + ActivationTokenModel, + PasswordResetTokenModel, + UserProfileModel, + SnippetModel, + TagModel, + SnippetFavoritesModel, + RefreshTokenModel, +) +from .auth import SnippetlyAuthProvider + +admin = Admin( + engine, + title="Snippetly Admin Dashboard", + auth_provider=SnippetlyAuthProvider(), +) + +admin.add_view(ModelView(UserModel, "fa fa-users", label="Users")) +admin.add_view( + ModelView(UserProfileModel, "fa fa-user-circle", label="User Profiles") +) +admin.add_view( + ModelView( + ActivationTokenModel, "fa fa-certificate", label="Activation Tokens" + ) +) +admin.add_view( + ModelView( + PasswordResetTokenModel, "fa fa-key", label="Password Reset Tokens" + ) +) +admin.add_view( + ModelView(RefreshTokenModel, "fa fa-ticket", label="Refresh Tokens") +) +admin.add_view(ModelView(SnippetModel, "fa fa-code", label="Snippets")) +admin.add_view( + ModelView( + SnippetFavoritesModel, "fa fa-thumbs-up", label="Snippet Favorites" + ) +) +admin.add_view(ModelView(TagModel, "fa fa-tags", label="Tags")) diff --git a/backend/src/admin/auth.py b/backend/src/admin/auth.py new file mode 100644 index 0000000..e490182 --- /dev/null +++ b/backend/src/admin/auth.py @@ -0,0 +1,48 @@ +from sqlalchemy import select +from starlette.requests import Request +from starlette.responses import Response +from starlette_admin.auth import AuthProvider +from starlette_admin.exceptions import LoginFailed + +from src.adapters.postgres.models import UserModel + + +class SnippetlyAuthProvider(AuthProvider): + async def login( + self, + username: str, + password: str, + remember_me: bool, + request: Request, + response: Response, + ) -> Response: + db_session = request.state.session + + stmt = select(UserModel).where(UserModel.username == username) + user = (await db_session.execute(stmt)).scalar_one_or_none() + + if ( + user is None + or not user.is_active + or not user.is_admin + or not user.verify_password(password) + ): + raise LoginFailed("Invalid username or password") + + request.session.update({"identity": user.username, "pk": user.id}) + return response + + async def logout(self, request: Request, response: Response) -> Response: + request.session.clear() + return response + + async def is_authenticated(self, request: Request) -> bool: + if "identity" not in request.session: + return False + + if not hasattr(request.state, "user"): + db_session = request.state.session + user = await db_session.get(UserModel, request.session["pk"]) + request.state.user = user + + return True diff --git a/backend/src/api/__init__.py b/backend/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/docs/__init__.py b/backend/src/api/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/docs/auth_error_examples.py b/backend/src/api/docs/auth_error_examples.py new file mode 100644 index 0000000..a06bc2c --- /dev/null +++ b/backend/src/api/docs/auth_error_examples.py @@ -0,0 +1,13 @@ +UNAUTHORIZED_ERROR_EXAMPLES = { + "header": "Authorization header is missing", + "invalid_header": "Invalid Authorization header format. " + "Expected 'Bearer '", + "expired_token": "Token has expired", + "invalid_token": "Invalid token", + # "miss_jti": "Token missing jti claim", + # "blacklisted": "Token is blacklisted", +} +FORBIDDEN_ERROR_EXAMPLES = {"not_active": "User account is not activated"} +NOT_FOUND_ERRORS_EXAMPLES = { + "user_not_found": "User not found", +} diff --git a/backend/src/api/docs/openapi.py b/backend/src/api/docs/openapi.py new file mode 100644 index 0000000..96f4898 --- /dev/null +++ b/backend/src/api/docs/openapi.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel + + +class DetailErrorResponseSchema(BaseModel): + detail: str + + +class ErrorResponseSchema(BaseModel): + error: str + + +def create_json_examples( + description: str, + examples: dict[str, dict], + model: type = DetailErrorResponseSchema, +) -> dict: + return { + "description": description, + "model": model, + "content": { + "application/json": { + "examples": { + name: { + "summary": name.replace("_", " ").capitalize(), + "value": value, + } + for name, value in examples.items() + } + } + }, + } + + +def create_error_examples( + description: str, + examples: dict[str, str], + model: type = DetailErrorResponseSchema, +) -> dict: + error_examples = { + name: {"detail": detail} for name, detail in examples.items() + } + return create_json_examples(description, error_examples, model) diff --git a/backend/src/api/v1/__init__.py b/backend/src/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/v1/routes/__init__.py b/backend/src/api/v1/routes/__init__.py new file mode 100644 index 0000000..7e53ab8 --- /dev/null +++ b/backend/src/api/v1/routes/__init__.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from .accounts import ( + registration_router, + auth_router, + oauth2_router, + password_router, + profile_router, +) +from .docs import router as docs_router +from .snippets import snippets_router + +v1_router = APIRouter(prefix="/v1") +v1_router.include_router(registration_router) +v1_router.include_router(auth_router) +v1_router.include_router(oauth2_router) +v1_router.include_router(password_router) +v1_router.include_router(snippets_router) +v1_router.include_router(profile_router) diff --git a/backend/src/api/v1/routes/accounts/__init__.py b/backend/src/api/v1/routes/accounts/__init__.py new file mode 100644 index 0000000..ac9daf0 --- /dev/null +++ b/backend/src/api/v1/routes/accounts/__init__.py @@ -0,0 +1,5 @@ +from .auth_login import router as auth_router +from .auth_password import router as password_router +from .auth_registration import router as registration_router +from .oauth2 import router as oauth2_router +from .profile import router as profile_router diff --git a/backend/src/api/v1/routes/accounts/auth_login.py b/backend/src/api/v1/routes/accounts/auth_login.py new file mode 100644 index 0000000..09d3311 --- /dev/null +++ b/backend/src/api/v1/routes/accounts/auth_login.py @@ -0,0 +1,249 @@ +from typing import Annotated + +import jwt +from fastapi import APIRouter, HTTPException, Request, Response, Cookie +from fastapi.params import Depends +from sqlalchemy.exc import SQLAlchemyError + +import src.api.docs.auth_error_examples as exm +import src.core.exceptions as exc +from src.adapters.postgres.models import UserModel +from src.api.docs.openapi import create_error_examples, ErrorResponseSchema +from src.api.v1.schemas.accounts import ( + UserLoginRequestSchema, + UserLoginResponseSchema, + TokenRefreshResponseSchema, +) +from src.api.v1.schemas.common import MessageResponseSchema +from src.core.app.limiter import limiter +from src.core.dependencies.accounts import get_jwt_manager +from src.core.dependencies.accounts import ( + get_token, + get_current_user, + get_auth_service, +) +from src.core.security.jwt_manager import JWTAuthInterface +from src.features.auth import AuthServiceInterface +from .utils import set_refresh_token + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post( + "/login", + summary="Log in via username or email", + status_code=200, + description="Authenticate a user and return access/refresh tokens", + responses={ + 404: create_error_examples( + description="Not Found", + examples={ + "not_found": "User with such email or username not registered." + }, + ), + 401: create_error_examples( + description="Unauthorized", + examples={ + "unauthorized": "Entered Invalid password! Check your " + "keyboard layout or Caps Lock. Forgot your password?" + }, + ), + 403: create_error_examples( + description="Forbidden", + examples={"forbidden": "User account is not activated"}, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 5 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Something went wrong during " + "refresh token creation" + }, + ), + }, +) +@limiter.limit("5/minute") +async def login_user( + request: Request, + response: Response, + data: UserLoginRequestSchema, + service: Annotated[AuthServiceInterface, Depends(get_auth_service)], +) -> UserLoginResponseSchema: + try: + result = await service.login_user(data.login, data.password) + except exc.UserNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except exc.InvalidPasswordError as e: + raise HTTPException(status_code=401, detail=str(e)) from e + except exc.UserNotActiveError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something went wrong during refresh token creation", + ) from e + + set_refresh_token(response, result["refresh_token"]) + return UserLoginResponseSchema(access_token=result["access_token"]) + + +@router.post( + "/refresh", + summary="Refresh token", + status_code=200, + description="Refresh an access token using a valid refresh token", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples={"auth_error": "Invalid refresh token"}, + ), + 404: create_error_examples( + description="Not Found", + examples={"not_found": "User was not found"}, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 30 per 1 minute"}, + model=ErrorResponseSchema, + ), + }, +) +@limiter.limit("30/minute") +async def refresh( + request: Request, + response: Response, + service: Annotated[AuthServiceInterface, Depends(get_auth_service)], + refresh_token: Annotated[str | None, Cookie()] = None, +) -> TokenRefreshResponseSchema: + if refresh_token is None: + raise HTTPException( + status_code=401, detail="Refresh token not found in cookies" + ) + + try: + result = await service.refresh_tokens(refresh_token) + except exc.AuthenticationError as e: + raise HTTPException(status_code=401, detail=str(e)) from e + except exc.UserNotFoundError as e: + raise HTTPException( + status_code=404, detail="User was not found" + ) from e + return TokenRefreshResponseSchema(**result) + + +@router.post( + "/revoke-all-tokens", + summary="Logout from all sessions", + status_code=200, + description="Revoke all tokens of the current user, " + "logging out from every session", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples=exm.NOT_FOUND_ERRORS_EXAMPLES, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Failed to log out from every " + "sessions. Try again" + }, + ), + }, +) +async def revoke_all_tokens( + service: Annotated[AuthServiceInterface, Depends(get_auth_service)], + current_user: Annotated[UserModel, Depends(get_current_user)], +) -> MessageResponseSchema: + try: + await service.logout_from_all_sessions(current_user) + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Failed to log out from every sessions. Try again", + ) from e + return MessageResponseSchema( + message="Logged out from every session successfully" + ) + + +@router.post( + "/logout", + response_model=MessageResponseSchema, + status_code=200, + summary="User Logout", + description="Logout a user by revoking their refresh and access tokens", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples=exm.NOT_FOUND_ERRORS_EXAMPLES, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 30 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Failed to log out. Try again"}, + ), + }, +) +@limiter.limit("5/minute") +async def logout_user( + request: Request, + response: Response, + service: Annotated[AuthServiceInterface, Depends(get_auth_service)], + access_token: Annotated[str, Depends(get_token)], + current_user: Annotated[UserModel, Depends(get_current_user)], # noqa + refresh_token: Annotated[str | None, Cookie()] = None, +) -> MessageResponseSchema: + try: + await service.logout_user(refresh_token, access_token) + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, detail="Failed to log out. Try again" + ) from e + + response.delete_cookie("refresh_token") + return MessageResponseSchema(message="Logged out successfully") + + +@router.get("/test-access-token/") +async def test_access_token( + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthInterface, Depends(get_jwt_manager)], +) -> MessageResponseSchema: + """ + Protected endpoint to check if access token is valid + and not blacklisted. + """ + try: + await jwt_manager.verify_token(token, is_refresh=False) + except jwt.InvalidTokenError as e: + raise HTTPException( + status_code=401, + detail="Invalid or blacklisted token", + ) from e + + return MessageResponseSchema(message="Your token is valid!") diff --git a/backend/src/api/v1/routes/accounts/auth_password.py b/backend/src/api/v1/routes/accounts/auth_password.py new file mode 100644 index 0000000..e337cd9 --- /dev/null +++ b/backend/src/api/v1/routes/accounts/auth_password.py @@ -0,0 +1,193 @@ +from typing import Annotated + +from fastapi import ( + APIRouter, + HTTPException, + BackgroundTasks, + Request, + Response, +) +from fastapi.params import Depends +from sqlalchemy.exc import SQLAlchemyError + +import src.api.docs.auth_error_examples as exm +import src.core.exceptions as exc +from src.adapters.postgres.models import UserModel +from src.api.docs.openapi import create_error_examples, ErrorResponseSchema +from src.api.v1.schemas.accounts import ( + PasswordResetCompletionSchema, + PasswordResetRequestSchema, + ChangePasswordRequestSchema, +) +from src.api.v1.schemas.common import MessageResponseSchema +from src.core.app.limiter import limiter, key_func_per_user +from src.core.dependencies.accounts import ( + get_user_service, + get_current_user, +) +from src.core.dependencies.infrastructure import get_email_sender +from src.core.email import EmailSenderInterface +from src.features.auth import UserServiceInterface + +router = APIRouter(prefix="/auth", tags=["Password Reset"]) + + +@router.post( + "/reset-password/complete", + status_code=200, + summary="Reset Password Completion", + description="Change password using password reset token", + responses={ + 404: create_error_examples( + description="Not Found", + examples={ + "not_found": "Password reset token was not found", + "expired": "This password reset link has expired " + "or is invalid. Please request a new reset link.", + }, + ), + 400: create_error_examples( + description="Bad Request", + examples={"bad_request": "Password reset token has expired"}, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Something went wrong during password reset" + }, + ), + }, +) +async def reset_password_complete( + data: PasswordResetCompletionSchema, + service: Annotated[UserServiceInterface, Depends(get_user_service)], +) -> MessageResponseSchema: + try: + await service.reset_password_complete( + data.email, data.password, data.password_reset_token + ) + except exc.TokenNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except exc.TokenExpiredError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something went wrong during password reset", + ) from e + + return MessageResponseSchema( + message="Password has been successfully changed" + ) + + +@router.post( + "/reset-password/request", + status_code=202, + summary="Request Password Reset", + description="Reset password request, an email will be sent", + responses={ + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 5 per 1 hour"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + "Internal Server Error", + examples={ + "internal_server": "Something went wrong during " + "request processing" + }, + ), + }, +) +@limiter.limit("5/hour") +async def reset_password_request( + request: Request, + response: Response, + data: PasswordResetRequestSchema, + service: Annotated[UserServiceInterface, Depends(get_user_service)], + email_sender: Annotated[EmailSenderInterface, Depends(get_email_sender)], + background_tasks: BackgroundTasks, +) -> MessageResponseSchema: + message = ( + "If an account with that email exists, " + "we've sent password reset instructions. " + "Please check your inbox" + ) + try: + user, reset_token = await service.reset_password_token(data.email) + except exc.UserNotFoundError: + return MessageResponseSchema(message=message) + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something went wrong during request processing", + ) from e + else: + background_tasks.add_task( + email_sender.send_password_reset_email, data.email, reset_token + ) + return MessageResponseSchema(message=message) + + +@router.post( + "/change-password", + summary="Change Password", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples={ + **exm.FORBIDDEN_ERROR_EXAMPLES, + "password_match": "New password cannot be the " + "same as old password!", + "invalid_password": "Entered Invalid password! " + "Check your keyboard layout " + "or Caps Lock. Forgot your password?", + }, + ), + 404: create_error_examples( + description="Not Found", + examples=exm.NOT_FOUND_ERRORS_EXAMPLES, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Something went wrong during " + "saving new password" + }, + ), + }, +) +@limiter.limit("10/minute", key_func=key_func_per_user) +async def change_password( + request: Request, + response: Response, + data: ChangePasswordRequestSchema, + user: Annotated[UserModel, Depends(get_current_user)], + user_service: Annotated[UserServiceInterface, Depends(get_user_service)], +) -> MessageResponseSchema: + message = MessageResponseSchema( + message="Password has been successfully changed" + ) + try: + await user_service.change_password( + user, data.old_password, data.new_password + ) + except exc.InvalidPasswordError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something wen wrong during saving new password", + ) from e + return message diff --git a/backend/src/api/v1/routes/accounts/auth_registration.py b/backend/src/api/v1/routes/accounts/auth_registration.py new file mode 100644 index 0000000..c58c524 --- /dev/null +++ b/backend/src/api/v1/routes/accounts/auth_registration.py @@ -0,0 +1,181 @@ +from typing import Annotated + +from fastapi import ( + APIRouter, + HTTPException, + BackgroundTasks, + Response, + Request, +) +from fastapi.params import Depends +from sqlalchemy.exc import SQLAlchemyError + +import src.core.exceptions as exc +from src.api.docs.openapi import ( + create_error_examples, + create_json_examples, + ErrorResponseSchema, +) +from src.api.v1.schemas.accounts import ( + UserRegistrationRequestSchema, + UserRegistrationResponseSchema, + ActivationRequestSchema, + EmailBaseSchema, +) +from src.api.v1.schemas.common import MessageResponseSchema +from src.core.app.limiter import limiter +from src.core.dependencies.accounts import get_user_service +from src.core.dependencies.infrastructure import get_email_sender +from src.core.email import EmailSenderInterface +from src.features.auth import UserServiceInterface + +router = APIRouter(prefix="/auth", tags=["Registration"]) + + +@router.post( + "/register", + summary="Register New User", + status_code=201, + description="Register a new user with email, username, and password", + responses={ + 409: create_json_examples( + description="Conflict", + examples={ + "email_taken": {"email": "This email is taken."}, + "username_taken": {"username": "This username is taken."}, + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 5 per 1 hour"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Error occurred during user registration." + }, + ), + }, +) +@limiter.limit("5/hour") +async def register( + request: Request, + response: Response, + data: UserRegistrationRequestSchema, + service: Annotated[UserServiceInterface, Depends(get_user_service)], + email_sender: Annotated[EmailSenderInterface, Depends(get_email_sender)], + background_tasks: BackgroundTasks, +) -> UserRegistrationResponseSchema: + try: + user, token = await service.register_user( + data.email, data.username, data.password + ) + except exc.UserAlreadyExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, detail="Error occurred during user registration." + ) from e + else: + background_tasks.add_task( + email_sender.send_activation_email, user.email, token + ) + + return UserRegistrationResponseSchema.model_validate(user) + + +@router.post( + "/activate", + status_code=200, + summary="Activate user's account", + description="Activates user account using activation token, " + "that was given in email", + responses={ + 404: create_error_examples( + description="Not Found", + examples={"not_found": "Activation token was not found"}, + ), + 400: create_error_examples( + description="Bad Request", + examples={"expired": "Activation token has expired"}, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 hour"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Something went wrong " + "during account activation" + }, + ), + }, +) +@limiter.limit("10/hour") +async def activate_account( + request: Request, + response: Response, + service: Annotated[UserServiceInterface, Depends(get_user_service)], + data: ActivationRequestSchema, +) -> MessageResponseSchema: + try: + await service.activate_account(data.activation_token) + except exc.TokenNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except exc.TokenExpiredError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something went wrong during account activation", + ) from e + return MessageResponseSchema( + message="Account has been activated successfully" + ) + + +@router.post( + "/resend-activation", + summary="Resend activation email", + description="Endpoint for resending an activation email", + responses={ + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 5 per 1 hour"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Something went wrong"}, + ), + }, +) +@limiter.limit("5/hour") +async def resend_activation( + request: Request, + response: Response, + service: Annotated[UserServiceInterface, Depends(get_user_service)], + email_sender: Annotated[EmailSenderInterface, Depends(get_email_sender)], + data: EmailBaseSchema, + background_tasks: BackgroundTasks, +) -> MessageResponseSchema: + message = MessageResponseSchema( + message="If an inactive account exists for this " + "email, an activation email has been sent." + ) + try: + token = await service.new_activation_token(data.email) + except (exc.UserNotFoundError, ValueError): + return message + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, detail="Something went wrong" + ) from e + + background_tasks.add_task( + email_sender.send_activation_email, data.email, token.token + ) + return message diff --git a/backend/src/api/v1/routes/accounts/oauth2.py b/backend/src/api/v1/routes/accounts/oauth2.py new file mode 100644 index 0000000..32bc203 --- /dev/null +++ b/backend/src/api/v1/routes/accounts/oauth2.py @@ -0,0 +1,68 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Body, HTTPException, Request, Response +from fastapi.responses import RedirectResponse +from sqlalchemy.exc import SQLAlchemyError + +from src.api.docs.openapi import create_error_examples +from src.api.v1.routes.accounts.utils import set_refresh_token +from src.api.v1.schemas.accounts import UserLoginResponseSchema +from src.core.app.limiter import limiter +from src.core.dependencies.accounts import get_oauth_manager, get_oauth_service +from src.core.security.oauth2 import OAuth2ManagerInterface +from src.features.auth import OAuth2ServiceInterface + +router = APIRouter(prefix="/auth", tags=["OAuth2"]) + + +@router.get( + "/google/url", + summary="Get Google OAuth redirect URL", + description="Returns the URL to redirect users to " + "Google OAuth authentication page", +) +@limiter.limit("30/hour") +def get_google_oauth_redirect_url( + request: Request, + response: Response, + oauth_service: Annotated[ + OAuth2ManagerInterface, Depends(get_oauth_manager) + ], +) -> RedirectResponse: + uri = oauth_service.generate_google_oauth_redirect_uri() + return RedirectResponse(url=uri) + + +@router.post( + "/google/callback", + summary="Endpoint where google is redirecting after account confirmation", + description="Endpoint for getting Refresh & Access tokens", + responses={ + 500: create_error_examples( + description="Internal Server Error", + examples={ + "user": "Error occurred during user creation", + "refresh_token": "Error occurred during " + "refresh token creation", + }, + ) + }, +) +@limiter.limit("30/hour") +async def google_oauth_callback( + request: Request, + response: Response, + oauth_service: Annotated[ + OAuth2ServiceInterface, Depends(get_oauth_service) + ], + code: Annotated[str, Body(embed=True)], +) -> UserLoginResponseSchema: + try: + result = await oauth_service.login_user_via_oauth(code) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + set_refresh_token(response, result["refresh_token"]) + return UserLoginResponseSchema(access_token=result["access_token"]) diff --git a/backend/src/api/v1/routes/accounts/profile.py b/backend/src/api/v1/routes/accounts/profile.py new file mode 100644 index 0000000..13b3c43 --- /dev/null +++ b/backend/src/api/v1/routes/accounts/profile.py @@ -0,0 +1,285 @@ +from typing import Annotated + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + UploadFile, + Request, + Response, +) +from fastapi.params import File +from sqlalchemy.exc import SQLAlchemyError + +import src.api.docs.auth_error_examples as exm +import src.core.exceptions as exc +from src.adapters.postgres.models import UserModel +from src.api.docs.openapi import create_error_examples, ErrorResponseSchema +from src.api.v1.schemas.accounts import ( + ProfileResponseSchema, + ProfileUpdateRequestSchema, +) +from src.api.v1.schemas.common import MessageResponseSchema +from src.core.app.limiter import limiter, key_func_per_user +from src.core.dependencies.accounts import ( + get_current_user, + get_profile_service, +) +from src.features.profile import ProfileServiceInterface + +router = APIRouter(prefix="/profile", tags=["Profile Management"]) + + +@router.get( + "/", + summary="Get user's profile details", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "profile_not_found": "Profile with this user ID was not found", + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 20 per 1 minute"}, + model=ErrorResponseSchema, + ), + }, +) +@limiter.limit("20/minute", key_func=key_func_per_user) +async def get_profile_details( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ProfileServiceInterface, Depends(get_profile_service)], +) -> ProfileResponseSchema: + try: + profile = await service.get_profile(user.id) + except exc.ProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + combined_data = {**profile.__dict__, "username": user.username} + return ProfileResponseSchema.model_construct(**combined_data) + + +@router.get( + "/{username}", + dependencies=[Depends(get_current_user)], + summary="Get specific user's profile details", + description="Endpoint for getting specific user's profile " + "details by username", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "profile_not_found": "Profile with this username " + "was not found", + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 minute"}, + model=ErrorResponseSchema, + ), + }, +) +@limiter.limit("10/minute") +async def get_specific_user_profile( + request: Request, + response: Response, + username: str, + service: Annotated[ProfileServiceInterface, Depends(get_profile_service)], +) -> ProfileResponseSchema: + try: + profile = await service.get_specific_user_profile(username) + except exc.ProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + combined_data = {**profile.__dict__, "username": username} + return ProfileResponseSchema.model_construct(**combined_data) + + +@router.patch( + "/", + summary="Update user's profile details", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "profile_not_found": "Profile with this user ID was not found", + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Something went wrong during profile update" + }, + ), + }, +) +@limiter.limit("10/minute", key_func=key_func_per_user) +async def update_profile_details( + request: Request, + response: Response, + data: ProfileUpdateRequestSchema, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ProfileServiceInterface, Depends(get_profile_service)], +) -> ProfileResponseSchema: + try: + profile = await service.update_profile(user.id, data) + except exc.ProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something went wrong during profile update", + ) from e + combined_data = {**profile.__dict__, "username": user.username} + return ProfileResponseSchema.model_construct(**combined_data) + + +@router.delete( + "/avatar", + summary="Delete user's profile avatar", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "profile_not_found": "Profile with this user ID was not found", + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={ + "internal_server": "Something went wrong during " + "avatar deletion" + }, + ), + }, +) +@limiter.limit("10/minute", key_func=key_func_per_user) +async def delete_profile_avatar( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ProfileServiceInterface, Depends(get_profile_service)], +) -> MessageResponseSchema: + try: + await service.delete_profile_avatar(user.id) + except exc.ProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something went wrong during avatar deletion", + ) from e + return MessageResponseSchema( + message="Profile avatar has been deleted successfully" + ) + + +@router.post( + "/avatar", + summary="Set user's profile avatar", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "profile_not_found": "Profile with this user ID was not found", + }, + ), + 422: create_error_examples( + description="Unprocessable Entity", + examples={ + "size": "Image size exceeds 2 MB limit", + "error": "Invalid image format", + "invalid_format": "Unsupported image format: " + "{image_format}. Use one of: JPEG, PNG, " + "WEBP", + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Something went wrong"}, + ), + }, +) +@limiter.limit("10/minute", key_func=key_func_per_user) +async def set_profile_avatar( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ProfileServiceInterface, Depends(get_profile_service)], + avatar: Annotated[UploadFile, File(...)], +) -> MessageResponseSchema: + try: + await service.set_profile_avatar(user.id, avatar) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + except exc.ProfileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, + detail="Something went wrong", + ) from e + return MessageResponseSchema(message="Profile picture updated") diff --git a/backend/src/api/v1/routes/accounts/utils.py b/backend/src/api/v1/routes/accounts/utils.py new file mode 100644 index 0000000..ed3a7d4 --- /dev/null +++ b/backend/src/api/v1/routes/accounts/utils.py @@ -0,0 +1,24 @@ +from fastapi import Response + +from src.core.config import get_settings + +settings = get_settings() + + +def set_refresh_token(response: Response, refresh_token: str) -> None: + if settings.ENVIRONMENT == "development": + samesite = "lax" + secure_flag = False + else: + samesite = "none" + secure_flag = True + + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + samesite=samesite, + secure=secure_flag, + max_age=settings.REFRESH_TOKEN_LIFE * 24 * 60 * 60, + path="/api/v1/auth", + ) diff --git a/backend/src/api/v1/routes/docs.py b/backend/src/api/v1/routes/docs.py new file mode 100644 index 0000000..0d9491c --- /dev/null +++ b/backend/src/api/v1/routes/docs.py @@ -0,0 +1,47 @@ +from typing import Dict, Any + +from fastapi import APIRouter, Depends +from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html +from fastapi.openapi.utils import get_openapi +from fastapi.responses import HTMLResponse + +from src.core.config import get_settings +from src.core.dependencies.accounts import is_admin + +router = APIRouter() + +settings = get_settings() + + +@router.get( + settings.DOCS_URL, + include_in_schema=False, + dependencies=[Depends(is_admin)], +) +def custom_swagger_ui() -> HTMLResponse: + return get_swagger_ui_html(openapi_url="/api/openapi.json", title="Docs") + + +@router.get( + settings.REDOC_URL, + include_in_schema=False, + dependencies=[Depends(is_admin)], +) +def custom_redoc_html() -> HTMLResponse: + return get_redoc_html(openapi_url="/api/openapi.json", title="Redoc") + + +@router.get( + settings.OPENAPI_URL, + include_in_schema=False, + dependencies=[Depends(is_admin)], +) +def custom_openapi() -> Dict[str, Any]: + from src.main import app + + return get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) diff --git a/backend/src/api/v1/routes/snippets/__init__.py b/backend/src/api/v1/routes/snippets/__init__.py new file mode 100644 index 0000000..6697105 --- /dev/null +++ b/backend/src/api/v1/routes/snippets/__init__.py @@ -0,0 +1,6 @@ +from .favorites import router as favorites_router +from .search import router as search_router +from .snippets import router as snippets_router + +snippets_router.include_router(favorites_router) +snippets_router.include_router(search_router) diff --git a/backend/src/api/v1/routes/snippets/favorites.py b/backend/src/api/v1/routes/snippets/favorites.py new file mode 100644 index 0000000..a5e798d --- /dev/null +++ b/backend/src/api/v1/routes/snippets/favorites.py @@ -0,0 +1,205 @@ +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Response, Request +from fastapi.params import Query +from sqlalchemy.exc import SQLAlchemyError + +import src.api.docs.auth_error_examples as exm +import src.core.exceptions as exc +from src.adapters.postgres.models import UserModel, LanguageEnum +from src.api.docs.openapi import create_error_examples, ErrorResponseSchema +from src.api.v1.schemas.common import MessageResponseSchema +from src.api.v1.schemas.snippets import ( + FavoritesSchema, + FavoritesSortingEnum, + GetSnippetsResponseSchema, +) +from src.core.app.limiter import limiter, key_func_per_user +from src.core.dependencies.accounts import get_current_user +from src.core.dependencies.snippets import get_favorites_service +from src.features.snippets import FavoritesServiceInterface + +router = APIRouter(prefix="/favorites", tags=["Favorite Snippets"]) + + +@router.post( + "/", + summary="Add snippet to favorites", + status_code=201, + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "not_found": "Snippet with this UUID was not found", + }, + ), + 409: create_error_examples( + description="Conflict", + examples={"conflict": "Snippet with this UUID already favorited"}, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Something went wrong"}, + ), + }, +) +@limiter.limit("10/minute", key_func=key_func_per_user) +async def add_snippet( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ + FavoritesServiceInterface, Depends(get_favorites_service) + ], + data: FavoritesSchema, +) -> MessageResponseSchema: + try: + await service.add_to_favorites(user, data.uuid) + except exc.SnippetNotFoundError as e: + raise HTTPException( + status_code=404, detail="Snippet with this UUID was not found" + ) from e + except exc.FavoritesAlreadyError as e: + raise HTTPException( + status_code=409, detail="Snippet with this UUID already favorited" + ) from e + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, detail="Something went wrong" + ) from e + return MessageResponseSchema(message="Snippet added to favorites") + + +@router.delete( + "/{uuid}", + summary="Remove snippet from favorites", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "not_found": "Snippet with this UUID was not found", + }, + ), + 409: create_error_examples( + description="Conflict", + examples={ + "conflict": "Snippet with this UUID already not favorited" + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 10 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Something went wrong"}, + ), + }, +) +@limiter.limit("10/minute", key_func=key_func_per_user) +async def remove_snippet( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ + FavoritesServiceInterface, Depends(get_favorites_service) + ], + uuid: UUID, +) -> MessageResponseSchema: + message = MessageResponseSchema(message="Snippet removed from favorites") + try: + await service.remove_from_favorites(user, uuid) + except exc.SnippetNotFoundError as e: + raise HTTPException( + status_code=404, detail="Snippet with this UUID was not found" + ) from e + except exc.FavoritesAlreadyError: + return message + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, detail="Something went wrong" + ) from e + return message + + +@router.get( + "/", + summary="Get favorites", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples=exm.NOT_FOUND_ERRORS_EXAMPLES, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 30 per 1 minute"}, + model=ErrorResponseSchema, + ), + }, +) +@limiter.limit("30/minute", key_func=key_func_per_user) +async def get_favorites( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ + FavoritesServiceInterface, Depends(get_favorites_service) + ], + sort_by: Annotated[ + FavoritesSortingEnum, Query() + ] = FavoritesSortingEnum.DATE_ADDED, + language: Annotated[Optional[LanguageEnum], Query()] = None, + tags: Annotated[ + Optional[list[str]], Query(description="Filter snippets by tags") + ] = None, + username: Annotated[Optional[str], Query()] = None, + page: Annotated[ + int, Query(ge=1, description="Page number (1-based index)") + ] = 1, + per_page: Annotated[ + int, Query(ge=1, le=20, description="Number of items per page") + ] = 10, +) -> GetSnippetsResponseSchema: + return await service.get_favorites( + request, + page, + per_page, + user.id, + sort_by=sort_by, + language=language, + tags=tags, + username=username, + ) diff --git a/backend/src/api/v1/routes/snippets/search.py b/backend/src/api/v1/routes/snippets/search.py new file mode 100644 index 0000000..033ee0d --- /dev/null +++ b/backend/src/api/v1/routes/snippets/search.py @@ -0,0 +1,40 @@ +from typing import Annotated + +from fastapi import APIRouter, Request, Response +from fastapi.params import Depends + +from src.adapters.postgres.models import UserModel +from src.api.docs.openapi import ErrorResponseSchema, create_error_examples +from src.api.v1.schemas.snippets import SnippetSearchResponseSchema +from src.core.app.limiter import limiter +from src.core.dependencies.accounts import get_current_user +from src.core.dependencies.snippets import get_search_service +from src.features.snippets.search.interface import ( + SnippetSearchServiceInterface, +) + +router = APIRouter(prefix="/search", tags=["Snippets Search"]) + + +@router.get( + "/{title}", + summary="Search snippets by title", + responses={ + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 100 per 1 minute"}, + model=ErrorResponseSchema, + ), + }, +) +@limiter.limit("100/minute") +async def search( + request: Request, + response: Response, + title: str, + user: Annotated[UserModel, Depends(get_current_user)], + service: Annotated[ + SnippetSearchServiceInterface, Depends(get_search_service) + ], +) -> SnippetSearchResponseSchema: + return await service.search_by_title(title, user.id, 20) diff --git a/backend/src/api/v1/routes/snippets/snippets.py b/backend/src/api/v1/routes/snippets/snippets.py new file mode 100644 index 0000000..258fe2a --- /dev/null +++ b/backend/src/api/v1/routes/snippets/snippets.py @@ -0,0 +1,361 @@ +from datetime import date +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, HTTPException, Response +from fastapi.requests import Request +from pydantic import ValidationError +from pymongo.errors import PyMongoError +from sqlalchemy.exc import SQLAlchemyError + +import src.api.docs.auth_error_examples as exm +import src.core.exceptions as exc +from src.adapters.postgres.models import UserModel, LanguageEnum +from src.api.docs.openapi import create_error_examples, ErrorResponseSchema +from src.api.v1.schemas.common import MessageResponseSchema +from src.api.v1.schemas.snippets import ( + BaseSnippetSchema, + SnippetCreateSchema, + GetSnippetsResponseSchema, + SnippetResponseSchema, + SnippetUpdateRequestSchema, + VisibilityFilterEnum, +) +from src.core.app.limiter import limiter, key_func_per_user +from src.core.dependencies.accounts import get_current_user +from src.core.dependencies.snippets import get_snippet_service +from src.core.utils.logger import logger +from src.features.snippets import SnippetServiceInterface + +router = APIRouter( + prefix="/snippets", + tags=["Snippet Management"], +) + + +@router.post( + "/", + summary="Create new Snippet", + description="Create new Snippet", + status_code=201, + dependencies=[Depends(get_current_user)], + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples=exm.NOT_FOUND_ERRORS_EXAMPLES, + ), + 409: create_error_examples( + description="Conflict", + examples={ + "already_exists": "You already have a snippet with this " + "title. Please choose a different name." + }, + ), + 422: create_error_examples( + description="Validation Error", + examples={"validation_error": "Invalid input data"}, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 5 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"snippet_creation": "Failed to create snippet"}, + ), + }, +) +@limiter.limit("5/minute", key_func=key_func_per_user) +async def create_snippet( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + data: BaseSnippetSchema, + snippet_service: Annotated[ + SnippetServiceInterface, Depends(get_snippet_service) + ], +) -> SnippetResponseSchema: + data = SnippetCreateSchema(**data.model_dump(), user_id=user.id) + try: + return await snippet_service.create_snippet(data) + except exc.SnippetAlreadyExistsError as e: + raise HTTPException( + status_code=409, + detail="You already have a snippet with this title. " + "Please choose a different name.", + ) from e + except ValidationError as e: + logger.error(f"Validation error: {e}") + raise HTTPException( + status_code=422, detail="Invalid input data" + ) from e + except (PyMongoError, SQLAlchemyError) as e: + logger.error(f"Database Error: {e}") + raise HTTPException( + status_code=500, detail="Failed to create snippet" + ) from e + + +@router.get( + "/", + summary="Get all snippets", + description="Get all snippets except of other user's private snippets, " + "if access token provided", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples=exm.FORBIDDEN_ERROR_EXAMPLES, + ), + 404: create_error_examples( + description="Not Found", + examples=exm.NOT_FOUND_ERRORS_EXAMPLES, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 30 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Something went wrong"}, + ), + }, +) +@limiter.limit("30/minute", key_func=key_func_per_user) +async def get_all_snippets( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + snippet_service: Annotated[ + SnippetServiceInterface, Depends(get_snippet_service) + ], + language: Annotated[ + Optional[LanguageEnum], + Query(description="Filter snippets by language"), + ] = None, + created_before: Annotated[ + Optional[date], + Query(description="Created before date snippets filter"), + ] = None, + created_after: Annotated[ + Optional[date], + Query( + description="Created after date snippets filter included", + ), + ] = None, + username: Annotated[ + Optional[str], + Query(description="Filter snippets by username"), + ] = None, + visibility: Annotated[ + Optional[VisibilityFilterEnum], + Query(description="Filter snippets by private flag"), + ] = None, + tags: Annotated[ + Optional[list[str]], + Query(description="Filter snippets by tags"), + ] = None, + page: Annotated[ + int, Query(ge=1, description="Page number (1-based index)") + ] = 1, + per_page: Annotated[ + int, + Query(ge=1, le=20, description="Number of items per page"), + ] = 10, +) -> GetSnippetsResponseSchema: + try: + return await snippet_service.get_snippets( + request=request, + page=page, + per_page=per_page, + current_user_id=user.id, + visibility=visibility, + language=language, + tags=tags, + created_before=created_before, + created_after=created_after, + username=username, + ) + except SQLAlchemyError as e: + raise HTTPException( + status_code=500, detail="Something went wrong" + ) from e + + +@router.get( + "/{uuid}", + summary="Get Snippet details", + description="Get Snippet by UUID", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples={ + **exm.FORBIDDEN_ERROR_EXAMPLES, + "no_permission": "User have no permission to get snippet", + }, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "snippet_not_found": "Snippet with this UUID was not found", + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 30 per 1 minute"}, + model=ErrorResponseSchema, + ), + }, +) +@limiter.limit("30/minute", key_func=key_func_per_user) +async def get_snippet( + request: Request, + response: Response, + user: Annotated[UserModel, Depends(get_current_user)], + uuid: UUID, + snippet_service: Annotated[ + SnippetServiceInterface, Depends(get_snippet_service) + ], +) -> SnippetResponseSchema: + try: + return await snippet_service.get_snippet_by_uuid(uuid, user) + except exc.SnippetNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except exc.NoPermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + + +@router.patch( + "/{uuid}", + summary="Update Snippet details", + description="Update Snippet by UUID", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples={ + **exm.FORBIDDEN_ERROR_EXAMPLES, + "no_permission": "User have no permission to update snippet", + }, + ), + 404: create_error_examples( + description="Not Found", + examples={ + **exm.NOT_FOUND_ERRORS_EXAMPLES, + "snippet_not_found": "Snippet with this UUID was not found", + }, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 5 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Failed to update snippet"}, + ), + }, +) +@limiter.limit("5/minute", key_func=key_func_per_user) +async def update_snippet( + request: Request, + response: Response, + uuid: UUID, + data: SnippetUpdateRequestSchema, + user: Annotated[UserModel, Depends(get_current_user)], + snippet_service: Annotated[ + SnippetServiceInterface, Depends(get_snippet_service) + ], +) -> SnippetResponseSchema: + try: + return await snippet_service.update_snippet(uuid, data, user) + except exc.SnippetNotFoundError as e: + raise HTTPException( + status_code=404, detail="Snippet with this UUID was not found" + ) from e + except exc.NoPermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except (SQLAlchemyError, PyMongoError) as e: + logger.error(f"Database error: {e}") + raise HTTPException( + status_code=500, detail="Failed to update snippet" + ) from e + + +@router.delete( + "/{uuid}", + summary="Delete Snippet", + description="Delete Snippet by UUID", + responses={ + 401: create_error_examples( + description="Unauthorized", + examples=exm.UNAUTHORIZED_ERROR_EXAMPLES, + ), + 403: create_error_examples( + description="Forbidden", + examples={ + **exm.FORBIDDEN_ERROR_EXAMPLES, + "no_permission": "User have no permission to delete snippet", + }, + ), + 404: create_error_examples( + description="Not Found", + examples=exm.NOT_FOUND_ERRORS_EXAMPLES, + ), + 429: create_error_examples( + description="Too many requests", + examples={"error": "Rate limit exceeded: 5 per 1 minute"}, + model=ErrorResponseSchema, + ), + 500: create_error_examples( + description="Internal Server Error", + examples={"internal_server": "Failed to delete snippet"}, + ), + }, +) +@limiter.limit("5/minute", key_func=key_func_per_user) +async def delete_snippet( + request: Request, + response: Response, + uuid: UUID, + user: Annotated[UserModel, Depends(get_current_user)], + snippet_service: Annotated[ + SnippetServiceInterface, Depends(get_snippet_service) + ], +) -> MessageResponseSchema: + message = MessageResponseSchema( + message="Snippet has been deleted successfully" + ) + try: + await snippet_service.delete_snippet(uuid, user) + except exc.SnippetNotFoundError: + return message + except exc.NoPermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except (SQLAlchemyError, PyMongoError) as e: + logger.error(f"Database error: {e}") + raise HTTPException( + status_code=500, detail="Failed to delete snippet" + ) from e + return message diff --git a/backend/src/api/v1/schemas/__init__.py b/backend/src/api/v1/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/api/v1/schemas/accounts/__init__.py b/backend/src/api/v1/schemas/accounts/__init__.py new file mode 100644 index 0000000..07ff74d --- /dev/null +++ b/backend/src/api/v1/schemas/accounts/__init__.py @@ -0,0 +1,18 @@ +from .auth import ( + ActivationRequestSchema, + ChangePasswordRequestSchema, + EmailBaseSchema, + PasswordResetCompletionSchema, + PasswordResetRequestSchema, + TokenRefreshResponseSchema, + UserLoginRequestSchema, + UserLoginResponseSchema, + UserRegistrationRequestSchema, + UserRegistrationResponseSchema, +) + +from .profiles import ( + BaseProfileSchema, + ProfileResponseSchema, + ProfileUpdateRequestSchema, +) diff --git a/backend/src/api/v1/schemas/accounts/auth.py b/backend/src/api/v1/schemas/accounts/auth.py new file mode 100644 index 0000000..003f45c --- /dev/null +++ b/backend/src/api/v1/schemas/accounts/auth.py @@ -0,0 +1,95 @@ +from pydantic import ( + BaseModel, + EmailStr, + field_serializer, + Field, + field_validator, + ConfigDict, +) + +from src.core.security.validation import ( + password_validation, + username_validation, +) + + +class EmailBaseSchema(BaseModel): + email: EmailStr = Field(..., max_length=255) + + @field_serializer("email") + def serialize_email(self, value: str) -> str: + return value.lower() + + +class PasswordMixin(BaseModel): + password: str = Field(min_length=8, max_length=30) + + @field_validator("password") + @classmethod + def validate_password(cls, value: str) -> str: + return password_validation(value) + + +class UsernameMixin(BaseModel): + username: str = Field(min_length=3, max_length=40) + + @field_validator("username") + @classmethod + def validate_username(cls, value: str) -> str: + return username_validation(value) + + +# --- Request --- +class UserRegistrationRequestSchema( + EmailBaseSchema, UsernameMixin, PasswordMixin, BaseModel +): + pass + + +class PasswordResetRequestSchema(EmailBaseSchema, BaseModel): + pass + + +class PasswordResetCompletionSchema(EmailBaseSchema, PasswordMixin, BaseModel): + password_reset_token: str = Field(...) + + +class UserLoginRequestSchema(PasswordMixin, BaseModel): + login: str = Field(..., min_length=3, max_length=40) + + @field_validator("login") + @classmethod + def validate_login(cls, value: str) -> str: + return value.strip() + + +class ActivationRequestSchema(BaseModel): + activation_token: str = Field(..., max_length=64) + + +class ChangePasswordRequestSchema(BaseModel): + old_password: str = Field(..., min_length=8, max_length=30) + new_password: str = Field(..., min_length=8, max_length=30) + + @field_validator("new_password") + @classmethod + def validate_password(cls, value: str) -> str: + return password_validation(value) + + +# --- Response --- +class UserRegistrationResponseSchema( + EmailBaseSchema, UsernameMixin, BaseModel +): + id: int + model_config = ConfigDict(from_attributes=True) + + +class UserLoginResponseSchema(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenRefreshResponseSchema(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/backend/src/api/v1/schemas/accounts/profiles.py b/backend/src/api/v1/schemas/accounts/profiles.py new file mode 100644 index 0000000..79840e8 --- /dev/null +++ b/backend/src/api/v1/schemas/accounts/profiles.py @@ -0,0 +1,35 @@ +from datetime import date +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from src.adapters.postgres.models import GenderEnum + + +# --- Base --- +class BaseProfileSchema(BaseModel): + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + gender: Optional[GenderEnum] = Field(None) + date_of_birth: Optional[date] = Field(None) + info: Optional[str] = Field(None) + + model_config = ConfigDict(from_attributes=True) + + @field_validator("date_of_birth") + @classmethod + def validate_data_of_birth(cls, v: date) -> date: + if v and v > date.today(): + raise ValueError("Date of birth cannot be in the future") + return v + + +# --- Requests --- +class ProfileUpdateRequestSchema(BaseProfileSchema): + pass + + +# --- Responses --- +class ProfileResponseSchema(BaseProfileSchema): + avatar_url: Optional[str] = Field(None) + username: str = Field(..., min_length=3, max_length=40) diff --git a/backend/src/api/v1/schemas/common.py b/backend/src/api/v1/schemas/common.py new file mode 100644 index 0000000..5af40a5 --- /dev/null +++ b/backend/src/api/v1/schemas/common.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class MessageResponseSchema(BaseModel): + message: str + + +class BaseListSchema(BaseModel): + page: int + per_page: int + total_items: int + total_pages: int + prev_page: Optional[str] = Field(None) + next_page: Optional[str] = Field(None) diff --git a/backend/src/api/v1/schemas/snippets/__init__.py b/backend/src/api/v1/schemas/snippets/__init__.py new file mode 100644 index 0000000..a19d5c9 --- /dev/null +++ b/backend/src/api/v1/schemas/snippets/__init__.py @@ -0,0 +1,14 @@ +from .favorites import ( + FavoritesSchema, + FavoritesSortingEnum, +) +from .search import SnippetSearchItemSchema, SnippetSearchResponseSchema +from .snippets import ( + BaseSnippetSchema, + GetSnippetsResponseSchema, + SnippetCreateSchema, + SnippetListItemSchema, + SnippetResponseSchema, + SnippetUpdateRequestSchema, + VisibilityFilterEnum, +) diff --git a/backend/src/api/v1/schemas/snippets/favorites.py b/backend/src/api/v1/schemas/snippets/favorites.py new file mode 100644 index 0000000..d8a297f --- /dev/null +++ b/backend/src/api/v1/schemas/snippets/favorites.py @@ -0,0 +1,14 @@ +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel + + +class FavoritesSortingEnum(str, Enum): + DATE_ADDED = "date_added" + SNIPPET_DATE = "snippet_date" + TITLE = "title" + + +class FavoritesSchema(BaseModel): + uuid: UUID diff --git a/backend/src/api/v1/schemas/snippets/search.py b/backend/src/api/v1/schemas/snippets/search.py new file mode 100644 index 0000000..26fa08d --- /dev/null +++ b/backend/src/api/v1/schemas/snippets/search.py @@ -0,0 +1,15 @@ +from uuid import UUID + +from pydantic import BaseModel + +from src.adapters.postgres.models import LanguageEnum + + +class SnippetSearchItemSchema(BaseModel): + uuid: UUID + title: str + language: LanguageEnum + + +class SnippetSearchResponseSchema(BaseModel): + results: list[SnippetSearchItemSchema] diff --git a/backend/src/api/v1/schemas/snippets/snippets.py b/backend/src/api/v1/schemas/snippets/snippets.py new file mode 100644 index 0000000..f860d90 --- /dev/null +++ b/backend/src/api/v1/schemas/snippets/snippets.py @@ -0,0 +1,79 @@ +from datetime import datetime +from enum import Enum +from typing import List, Optional, Annotated +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator, StringConstraints + +from src.adapters.postgres.models import LanguageEnum +from ..common import BaseListSchema + + +def serialize_tags(tags: list[str]) -> list: + return [item.strip().lower().replace(" ", "") for item in tags] + + +TagStr = Annotated[ + str, + StringConstraints( + min_length=2, max_length=20, pattern=r"^[A-Za-z0-9_-]+$" + ), +] + + +# --- Requests --- +class BaseSnippetSchema(BaseModel): + title: str = Field(..., min_length=3, max_length=100) + language: LanguageEnum = Field(...) + is_private: bool = Field(...) + content: str = Field(..., min_length=1, max_length=1000) + description: str = Field(default="", max_length=500) + tags: List[TagStr] = Field( + default_factory=list, min_length=0, max_length=10 + ) + + @field_validator("tags", mode="before") + @classmethod + def normalize_tags(cls, v: list[str]) -> list: + return serialize_tags(tags=v) + + +class SnippetCreateSchema(BaseSnippetSchema): + user_id: int + + +class SnippetUpdateRequestSchema(BaseModel): + title: Optional[str] = Field(None, min_length=3, max_length=100) + language: Optional[LanguageEnum] = None + is_private: Optional[bool] = None + content: Optional[str] = Field(None, min_length=1, max_length=1000) + description: Optional[str] = Field(None, max_length=500) + tags: List[TagStr] = Field( + default_factory=list, min_length=0, max_length=10 + ) + + @field_validator("tags", mode="before") + @classmethod + def normalize_tags(cls, v: list[str]) -> list: + return serialize_tags(tags=v) + + +# --- Responses --- +class SnippetListItemSchema(BaseSnippetSchema): + uuid: UUID + + +class GetSnippetsResponseSchema(BaseListSchema): + snippets: List[SnippetListItemSchema] + + +class SnippetResponseSchema(BaseSnippetSchema): + username: str + uuid: UUID + created_at: datetime + updated_at: datetime + + +class VisibilityFilterEnum(str, Enum): + PRIVATE = "private" + PUBLIC = "public" diff --git a/backend/src/core/__init__.py b/backend/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/core/app/__init__.py b/backend/src/core/app/__init__.py new file mode 100644 index 0000000..819ccf0 --- /dev/null +++ b/backend/src/core/app/__init__.py @@ -0,0 +1 @@ +from .app import create_app diff --git a/backend/src/core/app/app.py b/backend/src/core/app/app.py new file mode 100644 index 0000000..76fe05d --- /dev/null +++ b/backend/src/core/app/app.py @@ -0,0 +1,43 @@ +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from src.admin import admin +from src.api.v1.routes import v1_router, docs_router +from src.core.config import get_settings +from .lifespan import lifespan +from .limiter import setup_limiter +from .middleware import setup_middlewares + + +def create_app() -> FastAPI: + settings = get_settings() + + app = FastAPI( + title="Snippetly - API", + version="1.0.0", + debug=settings.DEBUG, + lifespan=lifespan, + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + + setup_middlewares(app, settings) + setup_limiter(app, settings) + + admin.mount_to(app) + + app.mount( + "/static", + StaticFiles(directory=settings.PROJECT_ROOT / "static"), + name="static", + ) + + app.include_router(docs_router, prefix="/api") + app.include_router(v1_router, prefix="/api") + + @app.get("/api/health") + def health() -> dict: + return {"status": "ok"} + + return app diff --git a/backend/src/core/app/lifespan.py b/backend/src/core/app/lifespan.py new file mode 100644 index 0000000..51a2042 --- /dev/null +++ b/backend/src/core/app/lifespan.py @@ -0,0 +1,18 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import FastAPI + +from src.adapters.mongo.client import init_mongo_client +from src.core.utils.logger import setup_logger, logger + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + setup_logger() + logger.info("Logger Initialized") + + await init_mongo_client() + logger.info("MongoDB Initialized") + + yield diff --git a/backend/src/core/app/limiter.py b/backend/src/core/app/limiter.py new file mode 100644 index 0000000..916dfc3 --- /dev/null +++ b/backend/src/core/app/limiter.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI +from fastapi.requests import Request +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from src.core.config import Settings + +limiter = Limiter(key_func=get_remote_address, headers_enabled=True) + + +def setup_limiter(app: FastAPI, settings: Settings) -> None: + limiter._storage_uri = settings.redis_url + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore + + +def key_func_per_user(request: Request) -> str: + user = getattr(request, "current_user", None) + if user: + return str(user.id) + return get_remote_address(request) diff --git a/backend/src/core/app/middleware.py b/backend/src/core/app/middleware.py new file mode 100644 index 0000000..40b0f83 --- /dev/null +++ b/backend/src/core/app/middleware.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware + +from src.core.config import Settings + + +def setup_middlewares(app: FastAPI, settings: Settings) -> None: + app.add_middleware( + CORSMiddleware, + allow_origins=[settings.FRONTEND_URL], + allow_methods=["*"], + allow_credentials=True, + allow_headers=["*"], + ) + app.add_middleware( + SessionMiddleware, + secret_key=settings.SECRET_KEY_ACCESS.get_secret_value(), + ) diff --git a/backend/src/core/config/__init__.py b/backend/src/core/config/__init__.py new file mode 100644 index 0000000..02b9a04 --- /dev/null +++ b/backend/src/core/config/__init__.py @@ -0,0 +1,24 @@ +import os +from functools import lru_cache + +from .settings import ( + Settings, + DevelopmentSettings, + ProductionSettings, + TestingSettings, +) + + +def _build_settings() -> Settings: + env = os.getenv("ENVIRONMENT", "development") + + if env == "production": + return ProductionSettings() # type: ignore[reportCallIssue] + if env == "testing": + return TestingSettings() + return DevelopmentSettings() + + +@lru_cache() +def get_settings() -> Settings: + return _build_settings() diff --git a/backend/src/core/config/base.py b/backend/src/core/config/base.py new file mode 100644 index 0000000..ba2ae04 --- /dev/null +++ b/backend/src/core/config/base.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from dotenv import load_dotenv +from pydantic_settings import BaseSettings, SettingsConfigDict + +load_dotenv() + + +class BaseAppSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + ENVIRONMENT: str = "development" + + PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.parent.resolve() + + DEBUG: bool = False diff --git a/backend/src/core/config/components.py b/backend/src/core/config/components.py new file mode 100644 index 0000000..65e9873 --- /dev/null +++ b/backend/src/core/config/components.py @@ -0,0 +1,60 @@ +import secrets + +from pydantic import SecretStr + +from .base import BaseAppSettings + + +class APISettings(BaseAppSettings): + FRONTEND_URL: str = "http://localhost:5173" + BACKEND_URL: str = "http://localhost:8000" + + DOCS_URL: str = "/docs" + REDOC_URL: str = "/redoc" + OPENAPI_URL: str = "/openapi.json" + + +class EmailSettings(BaseAppSettings): + EMAIL_APP_PASSWORD: SecretStr | None = None + EMAIL_HOST: str = "localhost" + EMAIL_PORT: int = 1111 + EMAIL_HOST_USER: str = "" + FROM_EMAIL: str = "no-reply@example.com" + USE_TLS: bool = False + + +class SecuritySettings(BaseAppSettings): + SECRET_KEY_REFRESH: SecretStr = SecretStr(secrets.token_urlsafe(32)) + SECRET_KEY_ACCESS: SecretStr = SecretStr(secrets.token_urlsafe(32)) + ALGORITHM: str = "HS256" + + REFRESH_TOKEN_LIFE: int = 7 + ACCESS_TOKEN_LIFE_MINUTES: int = 15 + ACTIVATION_TOKEN_LIFE: int = 1 + PASSWORD_RESET_TOKEN_LIFE: int = 1 + + +class OAuthSettings(APISettings, BaseAppSettings): + OAUTH_GOOGLE_CLIENT_SECRET: SecretStr = SecretStr("") + OAUTH_GOOGLE_CLIENT_ID: str = "" + OAUTH_SSL: bool = False + + OAUTH_GOOGLE_SCOPES: list = ["openid", "profile", "email"] + + BASE_GOOGLE_OAUTH_URL: str = "https://accounts.google.com/o/oauth2/v2/auth" + GOOGLE_TOKEN_URL: str = "https://oauth2.googleapis.com/token" + + REDIRECT_URI: str = "" + + def model_post_init(self, context: dict, /) -> None: + if self.REDIRECT_URI == "": + self.REDIRECT_URI = f"{self.FRONTEND_URL}/auth/google" + + +class AzureStorageSettings(BaseAppSettings): + AZURE_STORAGE_ACCOUNT_NAME: str + AZURE_STORAGE_ACCOUNT_KEY: str | None = None + AZURE_STORAGE_CONNECTION_STRING: str | None = None + AZURE_MEDIA_CONTAINER: str = "media" + AZURE_BACKUP_CONTAINER: str = "backups" + AZURE_BLOB_ENDPOINT: str | None = None diff --git a/backend/src/core/config/dbs.py b/backend/src/core/config/dbs.py new file mode 100644 index 0000000..c04f728 --- /dev/null +++ b/backend/src/core/config/dbs.py @@ -0,0 +1,78 @@ +from pydantic import PostgresDsn, MongoDsn, RedisDsn + +from .base import BaseAppSettings + + +class PostgresSQLSettings(BaseAppSettings): + POSTGRES_DB: str = "app_db" + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_HOST: str = "db" + POSTGRES_PORT: int = 5432 + + @property + def database_url(self) -> str: + return str( + PostgresDsn.build( + scheme="postgresql+asyncpg", + path=self.POSTGRES_DB, + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + port=self.POSTGRES_PORT, + host=self.POSTGRES_HOST, + ) + ) + + @property + def database_url_sync(self) -> str: + return str( + PostgresDsn.build( + scheme="postgresql+psycopg", + host=self.POSTGRES_HOST, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + ) + ) + + +class MongoDBSettings(BaseAppSettings): + MONGO_DB: str = "app_db" + MONGO_USER: str = "mongodb" + MONGO_PASSWORD: str = "mongodb" + MONGODB_HOST: str = "localhost" + MONGODB_PORT: int = 27017 + + @property + def mongodb_url(self) -> str: + return str( + MongoDsn.build( + scheme="mongodb", + username=self.MONGO_USER, + password=self.MONGO_PASSWORD, + host=self.MONGODB_HOST, + port=self.MONGODB_PORT, + path=self.MONGO_DB, + query="authSource=admin", + ) + ) + + +class RedisSettings(BaseAppSettings): + REDIS_HOST: str = "redis" + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + REDIS_PASSWORD: str = "" + + @property + def redis_url(self) -> str: + return str( + RedisDsn.build( + scheme="redis", + host=self.REDIS_HOST, + port=self.REDIS_PORT, + # password=self.REDIS_PASSWORD, + path=f"/{self.REDIS_DB}", + ) + ) diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py new file mode 100644 index 0000000..d868aa4 --- /dev/null +++ b/backend/src/core/config/settings.py @@ -0,0 +1,52 @@ +from pydantic_settings import SettingsConfigDict + +from .components import ( + APISettings, + EmailSettings, + SecuritySettings, + OAuthSettings, + AzureStorageSettings, +) +from .dbs import MongoDBSettings, PostgresSQLSettings, RedisSettings + + +class Settings( + PostgresSQLSettings, + SecuritySettings, + RedisSettings, + EmailSettings, + OAuthSettings, + APISettings, + MongoDBSettings, +): + pass + + +class DevelopmentSettings(Settings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + DEBUG: bool = True + + +class ProductionSettings(Settings, AzureStorageSettings): + model_config = SettingsConfigDict( + env_file=".env.prod", + env_file_encoding="utf-8", + extra="ignore", + ) + + DEBUG: bool = False + + +class TestingSettings(Settings): + model_config = SettingsConfigDict( + env_file=".env.test", + env_file_encoding="utf-8", + extra="ignore", + ) + + DEBUG: bool = False diff --git a/backend/src/core/dependencies/__init__.py b/backend/src/core/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/core/dependencies/accounts/__init__.py b/backend/src/core/dependencies/accounts/__init__.py new file mode 100644 index 0000000..b78ba0f --- /dev/null +++ b/backend/src/core/dependencies/accounts/__init__.py @@ -0,0 +1,10 @@ +from .auth import ( + get_token, + get_current_user, + is_admin, + get_auth_service, + get_user_service, +) +from .oauth import get_oauth_manager, get_oauth_service +from .profile import get_profile_service +from .token_manager import get_jwt_manager diff --git a/backend/src/core/dependencies/accounts/auth.py b/backend/src/core/dependencies/accounts/auth.py new file mode 100644 index 0000000..9c49487 --- /dev/null +++ b/backend/src/core/dependencies/accounts/auth.py @@ -0,0 +1,116 @@ +from typing import Optional, Annotated + +from fastapi import Request, HTTPException, Depends +from jwt import PyJWTError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.async_db import get_db +from src.adapters.postgres.models import UserModel +from src.adapters.postgres.repositories import UserRepository, TokenRepository +from src.core.config import Settings, get_settings +from src.core.security.jwt_manager import JWTAuthInterface +from src.features.auth import ( + AuthService, + AuthServiceInterface, + UserServiceInterface, + UserService, +) +from .repositories import ( + get_user_repo, + get_refresh_token_repo, + get_activation_token_repo, + get_password_reset_token_repo, +) +from .token_manager import get_jwt_manager + + +def get_token(request: Request) -> str: + authorization: Optional[str] = request.headers.get("Authorization") + if not authorization: + raise HTTPException( + status_code=401, + detail="Authorization header is missing", + ) + + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=401, + detail="Invalid Authorization header format. " + "Expected 'Bearer '", + ) + + return token + + +async def get_current_user( + request: Request, + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthInterface, Depends(get_jwt_manager)], + db: Annotated[AsyncSession, Depends(get_db)], +) -> UserModel: + try: + payload = await jwt_manager.verify_token(token, is_refresh=False) + except PyJWTError as e: + raise HTTPException(status_code=401, detail=str(e)) from e + + user = await db.get(UserModel, payload.get("user_id")) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException( + status_code=403, detail="User account is not activated" + ) + + request.state.current_user = user + return user + + +async def is_admin( + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthInterface, Depends(get_jwt_manager)], +) -> None: + try: + payload = await jwt_manager.verify_token(token, is_refresh=False) + except PyJWTError as e: + raise HTTPException(status_code=401, detail=str(e)) from e + + if payload["is_admin"] is False: + raise HTTPException( + status_code=403, + detail="Access denied. Admin privileges required.", + ) + + +def get_auth_service( + db: Annotated[AsyncSession, Depends(get_db)], + jwt_manager: Annotated[JWTAuthInterface, Depends(get_jwt_manager)], + settings: Annotated[Settings, Depends(get_settings)], + user_repo: Annotated[UserRepository, Depends(get_user_repo)], + refresh_token_repo: Annotated[ + TokenRepository, Depends(get_refresh_token_repo) + ], +) -> AuthServiceInterface: + return AuthService( + db, jwt_manager, settings, user_repo, refresh_token_repo + ) + + +def get_user_service( + db: Annotated[AsyncSession, Depends(get_db)], + settings: Annotated[Settings, Depends(get_settings)], + user_repo: Annotated[UserRepository, Depends(get_user_repo)], + activation_token_repo: Annotated[ + TokenRepository, Depends(get_activation_token_repo) + ], + password_reset_token_repo: Annotated[ + TokenRepository, Depends(get_password_reset_token_repo) + ], +) -> UserServiceInterface: + return UserService( + db, + settings, + user_repo, + activation_token_repo, + password_reset_token_repo, + ) diff --git a/backend/src/core/dependencies/accounts/oauth.py b/backend/src/core/dependencies/accounts/oauth.py new file mode 100644 index 0000000..1d0615d --- /dev/null +++ b/backend/src/core/dependencies/accounts/oauth.py @@ -0,0 +1,51 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.async_db import get_db +from src.adapters.postgres.repositories import ( + UserRepository, + TokenRepository, + UserProfileRepository, +) +from src.core.config import Settings, get_settings +from src.core.security.jwt_manager import JWTAuthInterface +from src.core.security.oauth2 import OAuth2Manager, OAuth2ManagerInterface +from src.features.auth import OAuth2ServiceInterface, OAuth2Service +from .repositories import ( + get_user_repo, + get_refresh_token_repo, + get_profile_repo, +) +from .token_manager import get_jwt_manager + + +def get_oauth_manager( + settings: Annotated[Settings, Depends(get_settings)], +) -> OAuth2ManagerInterface: + return OAuth2Manager(settings=settings) + + +def get_oauth_service( + db: Annotated[AsyncSession, Depends(get_db)], + oauth_manager: Annotated[ + OAuth2ManagerInterface, Depends(get_oauth_manager) + ], + jwt_manager: Annotated[JWTAuthInterface, Depends(get_jwt_manager)], + settings: Annotated[Settings, Depends(get_settings)], + user_repo: Annotated[UserRepository, Depends(get_user_repo)], + profile_repo: Annotated[UserProfileRepository, Depends(get_profile_repo)], + refresh_token_repo: Annotated[ + TokenRepository, Depends(get_refresh_token_repo) + ], +) -> OAuth2ServiceInterface: + return OAuth2Service( + db, + oauth_manager, + jwt_manager, + settings, + user_repo, + profile_repo, + refresh_token_repo, + ) diff --git a/backend/src/core/dependencies/accounts/profile.py b/backend/src/core/dependencies/accounts/profile.py new file mode 100644 index 0000000..dd25ddb --- /dev/null +++ b/backend/src/core/dependencies/accounts/profile.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.async_db import get_db +from src.adapters.postgres.repositories import UserProfileRepository +from src.adapters.storage import StorageInterface +from src.features.profile import ProfileServiceInterface, ProfileService +from .repositories import get_profile_repo +from ..infrastructure import get_storage + + +def get_profile_service( + db: Annotated[AsyncSession, Depends(get_db)], + storage: Annotated[StorageInterface, Depends(get_storage)], + profile_repo: Annotated[UserProfileRepository, Depends(get_profile_repo)], +) -> ProfileServiceInterface: + return ProfileService(db, storage, profile_repo) diff --git a/backend/src/core/dependencies/accounts/repositories.py b/backend/src/core/dependencies/accounts/repositories.py new file mode 100644 index 0000000..89c0302 --- /dev/null +++ b/backend/src/core/dependencies/accounts/repositories.py @@ -0,0 +1,44 @@ +from typing import Annotated + +from fastapi.params import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.async_db import get_db +from src.adapters.postgres.models import ( + ActivationTokenModel, + PasswordResetTokenModel, + RefreshTokenModel, +) +from src.adapters.postgres.repositories import ( + UserRepository, + UserProfileRepository, + TokenRepository, +) + +db_param = Annotated[AsyncSession, Depends(get_db)] + + +def get_user_repo(db: db_param) -> UserRepository: + return UserRepository(db) + + +def get_profile_repo(db: db_param) -> UserProfileRepository: + return UserProfileRepository(db) + + +async def get_activation_token_repo( + db: db_param, +) -> TokenRepository[ActivationTokenModel]: + return TokenRepository(db, ActivationTokenModel) + + +async def get_password_reset_token_repo( + db: db_param, +) -> TokenRepository[PasswordResetTokenModel]: + return TokenRepository(db, PasswordResetTokenModel) + + +async def get_refresh_token_repo( + db: db_param, +) -> TokenRepository[RefreshTokenModel]: + return TokenRepository(db, RefreshTokenModel) diff --git a/backend/src/core/dependencies/accounts/token_manager.py b/backend/src/core/dependencies/accounts/token_manager.py new file mode 100644 index 0000000..05f2c78 --- /dev/null +++ b/backend/src/core/dependencies/accounts/token_manager.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from fastapi.params import Depends +from redis.asyncio.client import Redis + +from src.core.config import Settings, get_settings +from src.core.dependencies.infrastructure import get_redis_client +from src.core.security.jwt_manager import JWTAuthInterface, JWTAuthManager + + +async def get_jwt_manager( + settings: Annotated[Settings, Depends(get_settings)], + redis_client: Annotated[Redis, Depends(get_redis_client)], +) -> JWTAuthInterface: + return JWTAuthManager( + redis_client=redis_client, + secret_key_access=settings.SECRET_KEY_ACCESS, + secret_key_refresh=settings.SECRET_KEY_REFRESH, + algorithm=settings.ALGORITHM, + refresh_token_life=settings.REFRESH_TOKEN_LIFE, + access_token_life=settings.ACCESS_TOKEN_LIFE_MINUTES, + ) diff --git a/backend/src/core/dependencies/infrastructure/__init__.py b/backend/src/core/dependencies/infrastructure/__init__.py new file mode 100644 index 0000000..93e9c40 --- /dev/null +++ b/backend/src/core/dependencies/infrastructure/__init__.py @@ -0,0 +1,3 @@ +from .email import get_email_sender +from .redis import get_redis_client +from .storage import get_storage diff --git a/backend/src/core/dependencies/infrastructure/email.py b/backend/src/core/dependencies/infrastructure/email.py new file mode 100644 index 0000000..eb6834b --- /dev/null +++ b/backend/src/core/dependencies/infrastructure/email.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi.params import Depends + +from src.core.config import Settings, get_settings +from src.core.email import EmailSenderInterface, EmailSenderManager + + +async def get_email_sender( + settings: Annotated[Settings, Depends(get_settings)], +) -> EmailSenderInterface: + return EmailSenderManager( + email_host=settings.EMAIL_HOST, + email_port=settings.EMAIL_PORT, + email_host_user=settings.EMAIL_HOST_USER, + from_email=settings.FROM_EMAIL, + email_app_password=settings.EMAIL_APP_PASSWORD, + use_tls=settings.USE_TLS, + app_url=settings.FRONTEND_URL, + ) diff --git a/backend/src/core/dependencies/infrastructure/redis.py b/backend/src/core/dependencies/infrastructure/redis.py new file mode 100644 index 0000000..fbdbf74 --- /dev/null +++ b/backend/src/core/dependencies/infrastructure/redis.py @@ -0,0 +1,14 @@ +from typing import Annotated + +from fastapi.params import Depends +from redis.asyncio import Redis + +from src.adapters.redis import get_redis_client as get_redis_adapter +from src.core.config import Settings, get_settings + + +async def get_redis_client( + settings: Annotated[Settings, Depends(get_settings)], +) -> Redis: + """FastAPI dependency for async Redis client""" + return get_redis_adapter(settings) diff --git a/backend/src/core/dependencies/infrastructure/storage.py b/backend/src/core/dependencies/infrastructure/storage.py new file mode 100644 index 0000000..660b255 --- /dev/null +++ b/backend/src/core/dependencies/infrastructure/storage.py @@ -0,0 +1,18 @@ +import os +from typing import cast + +from src.adapters.storage.dev_storage import DevStorage +from src.adapters.storage.interface import StorageInterface +from src.adapters.storage.prod_storage import ProdStorage +from src.core.config import get_settings +from src.core.config.settings import ProductionSettings + +settings = get_settings() + + +def get_storage() -> StorageInterface: + env = os.getenv("ENVIRONMENT", "development") + + if env == "production": + return ProdStorage(cast(ProductionSettings, settings)) + return DevStorage(base_path=settings.PROJECT_ROOT) diff --git a/backend/src/core/dependencies/snippets/__init__.py b/backend/src/core/dependencies/snippets/__init__.py new file mode 100644 index 0000000..699b146 --- /dev/null +++ b/backend/src/core/dependencies/snippets/__init__.py @@ -0,0 +1,5 @@ +from .snippets import ( + get_snippet_service, + get_search_service, + get_favorites_service, +) diff --git a/backend/src/core/dependencies/snippets/repositories.py b/backend/src/core/dependencies/snippets/repositories.py new file mode 100644 index 0000000..4e166ae --- /dev/null +++ b/backend/src/core/dependencies/snippets/repositories.py @@ -0,0 +1,25 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.mongo.repo import SnippetDocumentRepository +from src.adapters.postgres.async_db import get_db +from src.adapters.postgres.repositories import ( + SnippetRepository, + FavoritesRepository, +) + +db_param = Annotated[AsyncSession, Depends(get_db)] + + +def get_snippet_repo(db: db_param) -> SnippetRepository: + return SnippetRepository(db) + + +def get_snippet_doc_repo() -> SnippetDocumentRepository: + return SnippetDocumentRepository() + + +def get_favorites_repo(db: db_param) -> FavoritesRepository: + return FavoritesRepository(db) diff --git a/backend/src/core/dependencies/snippets/snippets.py b/backend/src/core/dependencies/snippets/snippets.py new file mode 100644 index 0000000..cb22588 --- /dev/null +++ b/backend/src/core/dependencies/snippets/snippets.py @@ -0,0 +1,54 @@ +from typing import Annotated + +from fastapi.params import Depends +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.mongo.repo import SnippetDocumentRepository +from src.adapters.postgres.async_db import get_db +from src.adapters.postgres.repositories import ( + SnippetRepository, + FavoritesRepository, +) +from src.features.snippets import ( + SnippetServiceInterface, + SnippetService, + FavoritesServiceInterface, + FavoritesService, + SnippetSearchService, + SnippetSearchServiceInterface, +) +from .repositories import ( + get_snippet_repo, + get_snippet_doc_repo, + get_favorites_repo, +) +from ..infrastructure import get_redis_client + + +def get_snippet_service( + db: Annotated[AsyncSession, Depends(get_db)], + model_repo: Annotated[SnippetRepository, Depends(get_snippet_repo)], + doc_repo: Annotated[ + SnippetDocumentRepository, Depends(get_snippet_doc_repo) + ], +) -> SnippetServiceInterface: + return SnippetService(db, model_repo, doc_repo) + + +def get_favorites_service( + db: Annotated[AsyncSession, Depends(get_db)], + repo: Annotated[FavoritesRepository, Depends(get_favorites_repo)], + doc_repo: Annotated[ + SnippetDocumentRepository, Depends(get_snippet_doc_repo) + ], +) -> FavoritesServiceInterface: + return FavoritesService(db, repo, doc_repo) + + +def get_search_service( + db: Annotated[AsyncSession, Depends(get_db)], + redis_client: Annotated[Redis, Depends(get_redis_client)], + repo: Annotated[SnippetRepository, Depends(get_snippet_repo)], +) -> SnippetSearchServiceInterface: + return SnippetSearchService(db, redis_client, repo) diff --git a/backend/src/core/email/__init__.py b/backend/src/core/email/__init__.py new file mode 100644 index 0000000..a32661e --- /dev/null +++ b/backend/src/core/email/__init__.py @@ -0,0 +1,3 @@ +from .email_sender import EmailSenderManager +from .interface import EmailSenderInterface +from .stub_sender import StubEmailSender diff --git a/backend/src/core/email/email_sender.py b/backend/src/core/email/email_sender.py new file mode 100644 index 0000000..c87fbad --- /dev/null +++ b/backend/src/core/email/email_sender.py @@ -0,0 +1,80 @@ +from email.message import EmailMessage +from typing import Optional + +import aiosmtplib +from pydantic import SecretStr + +from src.core.utils.logger import logger +from .interface import EmailSenderInterface + + +class EmailSenderManager(EmailSenderInterface): + def __init__( + self, + email_host: str, + email_port: int, + email_host_user: str, + from_email: str, + use_tls: bool, + app_url: str, + email_app_password: Optional[SecretStr] = None, + ): + self._email_host = email_host + self._email_port = email_port + self._email_user = email_host_user + self._from_email = from_email + self._app_password = ( + email_app_password.get_secret_value() + if email_app_password + else None + ) + self._use_tls = use_tls + self._app_url = app_url + + async def _send_email( + self, to_email: str, subject: str, body: str + ) -> None: + message = EmailMessage() + message["From"] = self._from_email + message["To"] = to_email + message["Subject"] = subject + message.set_content(body) + + try: + await aiosmtplib.send( + message, + hostname=self._email_host, + port=self._email_port, + start_tls=self._use_tls, + username=self._email_user, + password=self._app_password, + timeout=10.0, + ) + except aiosmtplib.SMTPAuthenticationError as e: + logger.error(f"SMTP auth failed: {e}") + raise + except aiosmtplib.SMTPRecipientsRefused as e: + logger.warning(f"Recipient refused: {e.recipients}") + raise + except (aiosmtplib.SMTPConnectError, OSError, TimeoutError) as e: + logger.error(f"SMTP connection error: {e}") + raise + except aiosmtplib.SMTPException as e: + logger.error(f"SMTP general error: {e}") + raise + + async def send_activation_email(self, email: str, token: str) -> None: + subject = "Activate your account" + body = ( + f"Please click the link to activate your " + f"account: {self._app_url}/activate-account/{token}" + ) + await self._send_email(email, subject, body) + + async def send_password_reset_email(self, email: str, token: str) -> None: + subject = "Password Reset" + body = ( + f"Please click the link to reset your " + f"password: {self._app_url}/reset-password/{token}" + ) + await self._send_email(email, subject, body) diff --git a/backend/src/core/email/interface.py b/backend/src/core/email/interface.py new file mode 100644 index 0000000..4523352 --- /dev/null +++ b/backend/src/core/email/interface.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod + + +class EmailSenderInterface(ABC): + @abstractmethod + async def send_activation_email(self, email: str, token: str) -> None: + """ + Method for sending an Account Activation link + + :param email: Email string where mail will be sent + :type: EmailStr + :param token: Activation token + :type: str + :return: None + """ + pass + + @abstractmethod + async def send_password_reset_email(self, email: str, token: str) -> None: + """ + Method for sending a Reset Password link + + :param email: Email string where mail will be sent + :type: EmailStr + :param token: Reset Password Token + :type: str + :return: None + """ diff --git a/backend/src/core/email/stub_sender.py b/backend/src/core/email/stub_sender.py new file mode 100644 index 0000000..90cdb51 --- /dev/null +++ b/backend/src/core/email/stub_sender.py @@ -0,0 +1,36 @@ +from pydantic import EmailStr + +from .interface import EmailSenderInterface + + +class StubEmailSender(EmailSenderInterface): + def __init__(self): + self.sent_emails = [] + + async def send_activation_email(self, email: str, token: str) -> None: + self.sent_emails.append( + { + "type": "activation", + "to": email, + "token": token, + } + ) + + async def send_password_reset_email( + self, email: EmailStr, token: str + ) -> None: + self.sent_emails.append( + { + "type": "password_reset", + "to": email, + "token": token, + } + ) + + def get_sent_emails(self, email: str | None = None) -> list: + if email: + return [e for e in self.sent_emails if e["to"] == email] + return self.sent_emails + + def clear(self) -> None: + self.sent_emails.clear() diff --git a/backend/src/core/exceptions/__init__.py b/backend/src/core/exceptions/__init__.py new file mode 100644 index 0000000..e46c49a --- /dev/null +++ b/backend/src/core/exceptions/__init__.py @@ -0,0 +1,14 @@ +from .exceptions import ( + UserNotActiveError, + UserNotFoundError, + UserAlreadyExistsError, + AuthenticationError, + TokenNotFoundError, + TokenExpiredError, + InvalidPasswordError, + SnippetNotFoundError, + SnippetAlreadyExistsError, + NoPermissionError, + ProfileNotFoundError, + FavoritesAlreadyError, +) diff --git a/backend/src/core/exceptions/exceptions.py b/backend/src/core/exceptions/exceptions.py new file mode 100644 index 0000000..c2eeb85 --- /dev/null +++ b/backend/src/core/exceptions/exceptions.py @@ -0,0 +1,46 @@ +class UserNotFoundError(Exception): + pass + + +class AuthenticationError(Exception): + pass + + +class UserAlreadyExistsError(Exception): + pass + + +class UserNotActiveError(Exception): + pass + + +class TokenNotFoundError(Exception): + pass + + +class TokenExpiredError(Exception): + pass + + +class InvalidPasswordError(Exception): + pass + + +class SnippetNotFoundError(Exception): + pass + + +class SnippetAlreadyExistsError(Exception): + pass + + +class NoPermissionError(Exception): + pass + + +class ProfileNotFoundError(Exception): + pass + + +class FavoritesAlreadyError(Exception): + pass diff --git a/backend/src/core/security/__init__.py b/backend/src/core/security/__init__.py new file mode 100644 index 0000000..e6e08a8 --- /dev/null +++ b/backend/src/core/security/__init__.py @@ -0,0 +1,2 @@ +from .password import hash_password, verify_password +from .utils import generate_secure_token diff --git a/backend/src/core/security/jwt_manager/__init__.py b/backend/src/core/security/jwt_manager/__init__.py new file mode 100644 index 0000000..7b6cae3 --- /dev/null +++ b/backend/src/core/security/jwt_manager/__init__.py @@ -0,0 +1,2 @@ +from .interface import JWTAuthInterface +from .jwt_manager import JWTAuthManager diff --git a/backend/src/core/security/jwt_manager/interface.py b/backend/src/core/security/jwt_manager/interface.py new file mode 100644 index 0000000..547a77f --- /dev/null +++ b/backend/src/core/security/jwt_manager/interface.py @@ -0,0 +1,114 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + + +class JWTAuthInterface(ABC): + @abstractmethod + async def create_access_token(self, user_data: dict) -> str: + """ + Generate a JWT access token for a user. + + :param user_data: Dictionary containing user information + (id, username, email, is_admin) + :type user_data: dict + :return: JWT access token string + :rtype: str + """ + pass + + @abstractmethod + def create_refresh_token(self, user_data: dict) -> str: + """ + Generate a JWT refresh token for a user. + + :param user_data: Dictionary containing user + information (id, username, email, is_admin) + :type user_data: dict + :return: JWT refresh token string + :rtype: str + :raises: None + """ + pass + + @abstractmethod + async def verify_token(self, token: str, is_refresh: bool = False) -> dict: + """ + Verify a JWT token's validity, signature, expiration, + and blacklist status. + + :param token: JWT token string to verify + :type token: str + :param is_refresh: Flag indicating if the token + is a refresh token (default: False) + :type is_refresh: bool + :return: Decoded payload as a dictionary if valid + :rtype: dict + :raises jwt.InvalidTokenError: if token is expired, + invalid, missing jti, or blacklisted + """ + pass + + @abstractmethod + async def refresh_tokens( + self, db: AsyncSession, refresh_token: str + ) -> dict: + """ + Create a new access token using a valid refresh token. + + :param db: Async database session for querying user + :type db: AsyncSession + :param refresh_token: Refresh token string to validate + :type refresh_token: str + :return: Dictionary containing new access token + {"access_token": str} if successful + :rtype: dict + :raises AuthenticationError: If refresh token is invalid + UserNotFoundError: if user does not exist in database + """ + pass + + @abstractmethod + async def revoke_all_user_tokens( + self, db: AsyncSession, user_id: int + ) -> None: + """ + Revoke all refresh tokens and add all + active access tokens to blacklist for a user. + + :param db: Async database session for deleting refresh tokens + :type db: AsyncSession + :param user_id: ID of the user whose tokens should be revoked + :type user_id: int + :return: None + :rtype: None + :raises SQLAlchemyError: if database operation fails + """ + pass + + @abstractmethod + async def add_to_blacklist(self, jti: str, exp: int) -> None: + """ + Add an access token JTI to the Redis blacklist until its expiration. + + :param jti: JWT token's unique identifier (jti claim) + :type jti: str + :param exp: Unix timestamp when token expires + :type exp: int + :return: None + :rtype: None + """ + pass + + @abstractmethod + def decode_token(self, token: str) -> Optional[dict]: + """ + Decode a JWT token without verification to extract payload. + + :param token: JWT token string + :type token: str + :return: Decoded payload as a dictionary or None if decoding fails + :rtype: dict or None + """ + pass diff --git a/backend/src/core/security/jwt_manager/jwt_manager.py b/backend/src/core/security/jwt_manager/jwt_manager.py new file mode 100644 index 0000000..5bc6494 --- /dev/null +++ b/backend/src/core/security/jwt_manager/jwt_manager.py @@ -0,0 +1,183 @@ +import secrets +from datetime import timedelta, datetime, timezone +from typing import Optional, cast + +import jwt +from pydantic import SecretStr +from redis import RedisError +from redis.asyncio.client import Redis +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +import src.core.exceptions as exc +from src.adapters.postgres.models import RefreshTokenModel +from src.adapters.postgres.repositories import UserRepository, TokenRepository +from src.adapters.redis import blacklist as redis_blacklist +from src.adapters.redis import common as redis_common +from src.core.utils.logger import logger +from .interface import JWTAuthInterface + + +class JWTAuthManager(JWTAuthInterface): + def __init__( + self, + redis_client: Redis, + secret_key_access: SecretStr, + secret_key_refresh: SecretStr, + algorithm: str, + refresh_token_life: int, + access_token_life: int, + ): + self._redis_client = redis_client + self._secret_key_access = secret_key_access.get_secret_value() + self._secret_key_refresh = secret_key_refresh.get_secret_value() + self._algorithm = algorithm + self._refresh_token_life = timedelta(days=refresh_token_life) + self._access_token_life = timedelta(minutes=access_token_life) + + def __create_token(self, data: dict, secret_key: str) -> str: + encoded_jwt = jwt.encode(data, secret_key, algorithm=self._algorithm) + return encoded_jwt + + def __parse_user_data(self, user_data: dict, exp_delta: timedelta) -> dict: + return { + "sub": str(user_data["id"]), + "user_id": user_data["id"], + "username": user_data["username"], + "email": user_data["email"], + "is_admin": user_data["is_admin"], + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + exp_delta, + "jti": self.__generate_jti(), + } + + @staticmethod + def __generate_jti(length: int = 16) -> str: + return secrets.token_hex(length) + + async def is_blacklisted(self, jti: str) -> bool: + return await redis_blacklist.is_blacklisted(self._redis_client, jti) + + def decode_token(self, token: str) -> Optional[dict]: + try: + payload = jwt.decode( + token, + options={"verify_signature": False, "verify_exp": False}, + algorithms=[self._algorithm], + ) + return cast(dict, payload) + except jwt.PyJWTError: + return None + + async def add_to_blacklist(self, jti: str, exp: int) -> None: + await redis_blacklist.add_to_blacklist(self._redis_client, jti, exp) + + async def create_access_token(self, user_data: dict) -> str: + payload = self.__parse_user_data(user_data, self._access_token_life) + token = self.__create_token(payload, self._secret_key_access) + ttl = int(self._access_token_life.total_seconds()) + jti = payload["jti"] + await redis_common.save_access_token( + self._redis_client, jti, user_data["id"], ttl + ) + return token + + def create_refresh_token(self, user_data: dict) -> str: + payload = self.__parse_user_data(user_data, self._refresh_token_life) + return self.__create_token(payload, self._secret_key_refresh) + + async def verify_token(self, token: str, is_refresh: bool = False) -> dict: + key = ( + self._secret_key_refresh if is_refresh else self._secret_key_access + ) + try: + payload = jwt.decode(token, key=key, algorithms=[self._algorithm]) + except jwt.ExpiredSignatureError as e: + raise jwt.InvalidTokenError("Token has expired") from e + except jwt.InvalidTokenError as e: + raise jwt.InvalidTokenError("Invalid token") from e + + jti = payload.get("jti") + if not jti: + logger.error("Token missing jti claim") + raise jwt.InvalidTokenError("Invalid token") + + if await self.is_blacklisted(jti): + logger.error("Token is blacklisted") + raise jwt.InvalidTokenError("Invalid token") + + return cast(dict, payload) + + async def refresh_tokens( + self, db: AsyncSession, refresh_token: str + ) -> dict: + user_repo = UserRepository(db) + try: + payload = await self.verify_token( + token=refresh_token, is_refresh=True + ) + except jwt.InvalidTokenError as e: + raise exc.AuthenticationError("Invalid refresh token") from e + + user = await user_repo.get_by_id(cast(int, payload.get("user_id"))) + if not user: + raise exc.UserNotFoundError + + user_data = { + "id": user.id, + "username": user.username, + "email": user.email, + "is_admin": user.is_admin, + } + + new_access_token = await self.create_access_token(user_data) + + return {"access_token": new_access_token} + + async def revoke_all_user_tokens( + self, db: AsyncSession, user_id: int + ) -> None: + refresh_repo = TokenRepository(db, RefreshTokenModel) + + tokens = await refresh_repo.list_by_user(user_id) + + for t in tokens: + payload = self.decode_token(t.token) + if ( + payload + and payload.get("jti") + and payload.get("exp") is not None + ): + try: + await self.add_to_blacklist( + payload["jti"], int(payload["exp"]) + ) + except RedisError: + pass + + try: + await refresh_repo.delete_by_user_id(user_id) + await db.commit() + except SQLAlchemyError: + await db.rollback() + raise + + keys = await self._redis_client.keys("access:*") + for key in keys: + jti = key.split("access:")[1] + + token_user_id = await redis_common.get_access_token( + self._redis_client, jti + ) + if token_user_id is not None: + token_user_id = ( + token_user_id.decode() + if isinstance(token_user_id, bytes) + else token_user_id + ) + + if token_user_id == str(user_id): + ttl = await self._redis_client.ttl(key) + if ttl > 0: + exp = int(datetime.now(timezone.utc).timestamp()) + ttl + await self.add_to_blacklist(jti, exp) diff --git a/backend/src/core/security/oauth2/__init__.py b/backend/src/core/security/oauth2/__init__.py new file mode 100644 index 0000000..a440eed --- /dev/null +++ b/backend/src/core/security/oauth2/__init__.py @@ -0,0 +1,2 @@ +from .interface import OAuth2ManagerInterface +from .oauth_manager import OAuth2Manager diff --git a/backend/src/core/security/oauth2/interface.py b/backend/src/core/security/oauth2/interface.py new file mode 100644 index 0000000..d4c055d --- /dev/null +++ b/backend/src/core/security/oauth2/interface.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod + + +class OAuth2ManagerInterface(ABC): + @abstractmethod + def generate_google_oauth_redirect_uri(self) -> str: + """Method for generating OAuth2 google auth redirect URI.""" + pass + + @abstractmethod + async def handle_google_code(self, code: str) -> dict: + """ + Method for handling OAuth2 google auth code. + + :param code: Google auth code + :type code: str + :return: dict with user data + :rtype: dict with keys: + "email", "first_name", "last_name", "avatar_url" + :raises ValueError + """ + pass diff --git a/backend/src/core/security/oauth2/oauth_manager.py b/backend/src/core/security/oauth2/oauth_manager.py new file mode 100644 index 0000000..78e227b --- /dev/null +++ b/backend/src/core/security/oauth2/oauth_manager.py @@ -0,0 +1,80 @@ +import urllib.parse + +import aiohttp +import jwt + +from src.core.config import Settings +from src.core.security.oauth2 import OAuth2ManagerInterface + + +class OAuth2Manager(OAuth2ManagerInterface): + def __init__(self, settings: Settings): + self.settings = settings + + @staticmethod + def _generate_oauth_redirect_uri( + client_id: str, redirect_uri: str, scopes: list, base_url: str + ) -> str: + query_params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join(scopes), + "access_type": "offline", + } + query = urllib.parse.urlencode( + query_params, quote_via=urllib.parse.quote + ) + return f"{base_url}?{query}" + + def generate_google_oauth_redirect_uri(self) -> str: + uri = self._generate_oauth_redirect_uri( + self.settings.OAUTH_GOOGLE_CLIENT_ID, + self.settings.REDIRECT_URI, + self.settings.OAUTH_GOOGLE_SCOPES, + self.settings.BASE_GOOGLE_OAUTH_URL, + ) + return uri + + async def handle_google_code(self, code: str) -> dict: + try: + if not code: + raise ValueError("Google code is required") + + async with aiohttp.ClientSession() as session: + async with session.post( + url=self.settings.GOOGLE_TOKEN_URL, + data={ + "client_id": self.settings.OAUTH_GOOGLE_CLIENT_ID, + "client_secret": ( + self.settings.OAUTH_GOOGLE_CLIENT_SECRET.get_secret_value() + ), + "redirect_uri": self.settings.REDIRECT_URI, + "grant_type": "authorization_code", + "code": code, + }, + ssl=self.settings.OAUTH_SSL, + ) as response: + res = await response.json() + + if "id_token" not in res: + raise ValueError("No id_token in Google OAuth response") + + id_token = res["id_token"] + user_data = jwt.decode( + id_token, + algorithms=["RS256"], + options={"verify_signature": False}, + ) + + return { + "email": user_data["email"], + "first_name": user_data.get("given_name", ""), + "last_name": user_data.get("family_name", ""), + "avatar_url": user_data.get("picture", ""), + } + + except jwt.PyJWTError as e: + raise ValueError(f"JWT token decoding error: {e}") from e + except ValueError as e: + raise ValueError(str(e)) from e diff --git a/backend/src/core/security/password.py b/backend/src/core/security/password.py new file mode 100644 index 0000000..9b3bc04 --- /dev/null +++ b/backend/src/core/security/password.py @@ -0,0 +1,14 @@ +import bcrypt + + +def hash_password(password: str) -> str: + password_bytes = password.encode("utf-8") + salt = bcrypt.gensalt() + hashed_bytes = bcrypt.hashpw(password_bytes, salt) + return hashed_bytes.decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + plain_password_bytes = plain_password.encode("utf-8") + hashed_password_bytes = hashed_password.encode("utf-8") + return bcrypt.checkpw(plain_password_bytes, hashed_password_bytes) diff --git a/backend/src/core/security/utils.py b/backend/src/core/security/utils.py new file mode 100644 index 0000000..ec2e3f8 --- /dev/null +++ b/backend/src/core/security/utils.py @@ -0,0 +1,5 @@ +import secrets + + +def generate_secure_token(length: int = 32) -> str: + return secrets.token_urlsafe(length) diff --git a/backend/src/core/security/validation.py b/backend/src/core/security/validation.py new file mode 100644 index 0000000..7fe9043 --- /dev/null +++ b/backend/src/core/security/validation.py @@ -0,0 +1,35 @@ +def password_validation(value: str) -> str: + message = ( + "Password can’t be blank. Password should " + "contain at least one capital letter, " + "one number, and one special character." + ) + + if ( + not value + or value.strip() == "" + or len(value) < 8 + or len(value) > 30 + or any(c.isspace() or not c.isprintable() for c in value) + or not any(c.isupper() for c in value) + or not any(c.isdigit() for c in value) + or not any(c in "@$!%*?&" for c in value) + ): + raise ValueError(message) + + return value + + +def username_validation(value: str) -> str: + if ( + len(value) < 3 + or len(value) > 40 + or not value[0].isalpha() + or any(not c.isalnum() for c in value) + or any(c.isspace() or not c.isprintable() for c in value) + ): + raise ValueError( + "Username must start with a letter, have " + "no spaces, and be 3 - 40 characters." + ) + return value diff --git a/backend/src/core/utils/__init__.py b/backend/src/core/utils/__init__.py new file mode 100644 index 0000000..5d803c5 --- /dev/null +++ b/backend/src/core/utils/__init__.py @@ -0,0 +1,2 @@ +from .logger import logger +from .paginator import Paginator diff --git a/backend/src/core/utils/logger.py b/backend/src/core/utils/logger.py new file mode 100644 index 0000000..e508b08 --- /dev/null +++ b/backend/src/core/utils/logger.py @@ -0,0 +1,14 @@ +import logging + + +def setup_logger() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s\t| %(message)s\t| %(asctime)s", + handlers=[ + logging.StreamHandler(), + ], + ) + + +logger = logging.getLogger("snippetly") diff --git a/backend/src/core/utils/paginator.py b/backend/src/core/utils/paginator.py new file mode 100644 index 0000000..38a899c --- /dev/null +++ b/backend/src/core/utils/paginator.py @@ -0,0 +1,32 @@ +from typing import Optional + +from fastapi.requests import Request + + +class Paginator: + @staticmethod + def calculate_offset(page: int, per_page: int) -> int: + return (page - 1) * per_page + + @staticmethod + def build_links( + request: Request, page: int, per_page: int, total: int + ) -> tuple[Optional[str], Optional[str]]: + params = dict(request.query_params) + params["per_page"] = str(per_page) + + prev_page = None + if page > 1: + params["page"] = str(page - 1) + prev_page = str(request.url.replace_query_params(**params)) + + next_page = None + if (page * per_page) < total: + params["page"] = str(page + 1) + next_page = str(request.url.replace_query_params(**params)) + + return prev_page, next_page + + @staticmethod + def total_pages(total: int, per_page: int) -> int: + return (total + per_page - 1) // per_page diff --git a/backend/src/features/__init__.py b/backend/src/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/features/auth/__init__.py b/backend/src/features/auth/__init__.py new file mode 100644 index 0000000..20fe99e --- /dev/null +++ b/backend/src/features/auth/__init__.py @@ -0,0 +1,3 @@ +from .auth_service import AuthServiceInterface, AuthService +from .oauth_service import OAuth2ServiceInterface, OAuth2Service +from .user_service import UserServiceInterface, UserService diff --git a/backend/src/features/auth/auth_service/__init__.py b/backend/src/features/auth/auth_service/__init__.py new file mode 100644 index 0000000..f3f3b2d --- /dev/null +++ b/backend/src/features/auth/auth_service/__init__.py @@ -0,0 +1,2 @@ +from .interface import AuthServiceInterface +from .service import AuthService diff --git a/backend/src/features/auth/auth_service/interface.py b/backend/src/features/auth/auth_service/interface.py new file mode 100644 index 0000000..6431341 --- /dev/null +++ b/backend/src/features/auth/auth_service/interface.py @@ -0,0 +1,63 @@ +from abc import ABC, abstractmethod + +from src.adapters.postgres.models import UserModel + + +class AuthServiceInterface(ABC): + @abstractmethod + async def login_user(self, login: str, password: str) -> dict: + """ + Authenticate user and return access and refresh tokens. + + :param login: User's email or username + :type login: str + :param password: User's password + :type password: str + :return: Dictionary with access_token, refresh_token, and token_type + :rtype: dict + :raises UserNotFoundError: if user doesn't exist + InvalidPasswordError: If password is incorrect + UserNotActiveError: if is_active is False + """ + pass + + @abstractmethod + async def refresh_tokens(self, refresh_token: str) -> dict: + """ + Refresh access and refresh tokens using a valid refresh token. + + :param refresh_token: Current refresh token + :type refresh_token: str + :return: Dictionary with new access_token, refresh_token, token_type + :rtype: dict + :raises UserNotFoundError: if refresh token is invalid + """ + pass + + @abstractmethod + async def logout_user( + self, refresh_token: str | None, access_token: str + ) -> None: + """ + Log out a user from the current session by invalidating tokens. + + :param refresh_token: Refresh token of the session + :type refresh_token: str | None + :param access_token: Access token of the session + :type access_token: str + :return: None + :raises SQLAlchemyError: if error occurred during + refresh token deletion + """ + pass + + @abstractmethod + async def logout_from_all_sessions(self, user: UserModel) -> None: + """ + Revoke all active sessions for a given user. + + :param user: UserModel instance to log out from all sessions + :type user: UserModel + :return: None + """ + pass diff --git a/backend/src/features/auth/auth_service/service.py b/backend/src/features/auth/auth_service/service.py new file mode 100644 index 0000000..11a75fb --- /dev/null +++ b/backend/src/features/auth/auth_service/service.py @@ -0,0 +1,111 @@ +from redis import RedisError +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +import src.core.exceptions as exc +from src.adapters.postgres.models import ( + UserModel, +) +from src.adapters.postgres.repositories import UserRepository, TokenRepository +from src.core.config import Settings +from src.core.security.jwt_manager import JWTAuthInterface +from .interface import AuthServiceInterface + + +class AuthService(AuthServiceInterface): + def __init__( + self, + db: AsyncSession, + jwt_manager: JWTAuthInterface, + settings: Settings, + user_repo: UserRepository, + refresh_token_repo: TokenRepository, + ): + self._db = db + self._jwt_manager = jwt_manager + self._settings = settings + self._user_repo = user_repo + self._refresh_token_repo = refresh_token_repo + + async def login_user(self, login: str, password: str) -> dict: + user = await self._user_repo.get_by_login(login) + + if not user: + raise exc.UserNotFoundError( + "User with such email or username not registered." + ) + + if not user.verify_password(password): + raise exc.InvalidPasswordError( + "Entered Invalid password! Check your keyboard " + "layout or Caps Lock. Forgot your password?" + ) + + if not user.is_active: + raise exc.UserNotActiveError("User account is not activated") + + user_data = { + "id": user.id, + "username": user.username, + "email": user.email, + "is_admin": user.is_admin, + } + + refresh_token = self._jwt_manager.create_refresh_token(user_data) + + try: + await self._refresh_token_repo.create( + user.id, refresh_token, self._settings.REFRESH_TOKEN_LIFE + ) + await self._db.commit() + except SQLAlchemyError: + await self._db.rollback() + raise + + access_token = await self._jwt_manager.create_access_token(user_data) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + + async def refresh_tokens(self, refresh_token: str) -> dict: + return await self._jwt_manager.refresh_tokens(self._db, refresh_token) + + async def logout_user( + self, refresh_token: str | None, access_token: str + ) -> None: + access_payload = self._jwt_manager.decode_token(access_token) + if ( + access_payload + and access_payload.get("jti") + and access_payload.get("exp") + ): + jti = access_payload["jti"] + exp = access_payload["exp"] + await self._jwt_manager.add_to_blacklist(jti, exp) + + if refresh_token: + payload = self._jwt_manager.decode_token(refresh_token) + if ( + payload + and payload.get("jti") + and payload.get("exp") is not None + ): + try: + await self._jwt_manager.add_to_blacklist( + payload["jti"], + int(payload["exp"]), # type: ignore[arg-type] + ) + except RedisError: + pass + try: + await self._refresh_token_repo.delete(refresh_token) + await self._db.commit() + except SQLAlchemyError: + await self._db.rollback() + raise + + async def logout_from_all_sessions(self, user: UserModel) -> None: + await self._jwt_manager.revoke_all_user_tokens(self._db, user.id) diff --git a/backend/src/features/auth/oauth_service/__init__.py b/backend/src/features/auth/oauth_service/__init__.py new file mode 100644 index 0000000..1de2c6d --- /dev/null +++ b/backend/src/features/auth/oauth_service/__init__.py @@ -0,0 +1,2 @@ +from .interface import OAuth2ServiceInterface +from .service import OAuth2Service diff --git a/backend/src/features/auth/oauth_service/interface.py b/backend/src/features/auth/oauth_service/interface.py new file mode 100644 index 0000000..13c53d8 --- /dev/null +++ b/backend/src/features/auth/oauth_service/interface.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod + + +class OAuth2ServiceInterface(ABC): + @abstractmethod + async def login_user_via_oauth(self, code: str) -> dict: + """ + Generates Refresh, Access tokens for user provided in data + + :param code: code from Google callback endpoint + :type: str + :return: Refresh, Access tokens + :rtype: dict with keys: + "refresh_token", "access_token", "token_type" + :raises ValueError + SQLAlchemyError: If error occurred during: + - User creation + - Refresh token creation + """ + pass diff --git a/backend/src/features/auth/oauth_service/service.py b/backend/src/features/auth/oauth_service/service.py new file mode 100644 index 0000000..6e37cef --- /dev/null +++ b/backend/src/features/auth/oauth_service/service.py @@ -0,0 +1,147 @@ +import re +import secrets +import string + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.models import ( + UserModel, + UserProfileModel, +) +from src.adapters.postgres.repositories import ( + UserRepository, + UserProfileRepository, + TokenRepository, +) +from src.core.config import Settings +from src.core.security.jwt_manager import JWTAuthInterface +from src.core.security.oauth2 import OAuth2ManagerInterface +from .interface import OAuth2ServiceInterface + + +class OAuth2Service(OAuth2ServiceInterface): + def __init__( + self, + db: AsyncSession, + oauth_manager: OAuth2ManagerInterface, + jwt_manager: JWTAuthInterface, + settings: Settings, + user_repo: UserRepository, + profile_repo: UserProfileRepository, + refresh_token_repo: TokenRepository, + ): + self._db = db + self._oauth_manager = oauth_manager + self._jwt_manager = jwt_manager + self._settings = settings + self._user_repo = user_repo + self._profile_repo = profile_repo + self._refresh_token_repo = refresh_token_repo + + @staticmethod + def _generate_username(email: str) -> str: + username = email.split("@")[0] + username = re.sub(r"[^a-zA-Z]", "", username) + username = username.lower() + return username[:40] + + @staticmethod + def _generate_password(length: int = 30) -> str: + alphabet = string.ascii_letters + string.digits + string.punctuation + password = "".join(secrets.choice(alphabet) for _ in range(length)) + return password + + async def _create_user(self, email: str, username: str) -> UserModel: + password = self._generate_password() + user = await self._user_repo.create(email, username, password=password) + return user + + async def _create_profile( + self, + user_id: int, + first_name: str, + last_name: str, + avatar_url: str, + ) -> UserProfileModel: + profile = await self._profile_repo.create( + user_id=user_id, + first_name=first_name, + last_name=last_name, + avatar_url=avatar_url, + ) + return profile + + async def login_user_via_oauth(self, code: str) -> dict: + try: + data = await self._oauth_manager.handle_google_code(code) + + email = data.get("email") + first_name = data.get("first_name", "") + last_name = data.get("last_name", "") + avatar_url = data.get("avatar_url", "") + + if not email: + raise ValueError("Email is required for OAuth login") + + user = await self._user_repo.get_by_email(email) + + if not user: + username = self._generate_username(email) + user = await self._create_user(email=email, username=username) + + await self._db.flush() + + await self._create_profile( + user_id=user.id, + first_name=first_name, + last_name=last_name, + avatar_url=avatar_url, + ) + + user.is_active = True + + try: + await self._db.commit() + await self._db.refresh(user) + except SQLAlchemyError as e: + await self._db.rollback() + raise SQLAlchemyError( + "Error occurred during user creation" + ) from e + + if not user.is_active: + raise ValueError("User account is not activated") + + user_data = { + "id": user.id, + "username": user.username, + "email": email, + "is_admin": user.is_admin, + } + refresh_token = self._jwt_manager.create_refresh_token(user_data) + access_token = await self._jwt_manager.create_access_token( + user_data + ) + + try: + await self._refresh_token_repo.create( + user.id, refresh_token, self._settings.REFRESH_TOKEN_LIFE + ) + await self._db.commit() + except SQLAlchemyError as e: + await self._db.rollback() + raise SQLAlchemyError( + "Error occurred during refresh token creation" + ) from e + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + + except ValueError as e: + raise ValueError(str(e)) from e + except SQLAlchemyError as e: + raise SQLAlchemyError(str(e)) from e diff --git a/backend/src/features/auth/user_service/__init__.py b/backend/src/features/auth/user_service/__init__.py new file mode 100644 index 0000000..5983c81 --- /dev/null +++ b/backend/src/features/auth/user_service/__init__.py @@ -0,0 +1,2 @@ +from .interface import UserServiceInterface +from .service import UserService diff --git a/backend/src/features/auth/user_service/interface.py b/backend/src/features/auth/user_service/interface.py new file mode 100644 index 0000000..401fafb --- /dev/null +++ b/backend/src/features/auth/user_service/interface.py @@ -0,0 +1,109 @@ +from abc import ABC, abstractmethod +from typing import Tuple + +from src.adapters.postgres.models import UserModel, ActivationTokenModel + + +class UserServiceInterface(ABC): + @abstractmethod + async def register_user( + self, email: str, username: str, password: str + ) -> Tuple[UserModel, str]: + """ + Register a new user and create an activation token. + + :param email: User's email address + :type email: str + :param username: Desired username + :type username: str + :param password: User's password + :type password: str + :return: Tuple of created UserModel and activation token + :rtype: Tuple[UserModel, str] + :raises UserAlreadyExistsError: if user with email or username + already exists + SQLAlchemyError: if database error happened + """ + pass + + @abstractmethod + async def activate_account(self, token: str) -> None: + """ + Activate new user's account by token + + :param token: Activation Token + :type: str + :return: None + :raises TokenNotFoundError: If token was not found + TokenExpiredError: If token is expired + SQLAlchemyError: If user activation went wrong + """ + pass + + @abstractmethod + async def reset_password_token(self, email: str) -> Tuple[UserModel, str]: + """ + + :param email: User's email address + :type: str + :return: Tuple of created UserModel and Reset Password Token + :rtype: Tuple[UserModel, str] + :raises UserNotFoundError: If user was not found + SQLAlchemyError: If database error occurred + """ + pass + + # TODO: check is_active where needed + @abstractmethod + async def reset_password_complete( + self, email: str, password: str, token: str + ) -> None: + """ + + :param email: User's email address + :type: str + :param password: New User's password + :type: str + :param token: Password Reset Token + :type: str + :return: None + :raises TokenNotFoundError If token was not found + TokenExpiredError: If token has expired + SQLAlchemyError: If database error occurred + """ + pass + + @abstractmethod + async def new_activation_token(self, email: str) -> ActivationTokenModel: + """ + Method for creating a new activation token and deleting old one + + :param email: User's email address + :type: str + :return: Activation Token + :rtype: ActivationTokenModel + :raises UserNotFoundError: If user was not found + ValueError: If user is activated + SQLAlchemyError: If error occurred during deletion + or creation new token + """ + + @abstractmethod + async def change_password( + self, user: UserModel, old_password: str, new_password: str + ) -> None: + """ + Method for changing User's password if + old password provided and doesn't match the new password + + :param user: User requesting change + :type: UserModel + :param old_password: Old user's password + :type: str + :param new_password: New user's password + :type: str + :return: None + :raises InvalidPasswordError: If old password is incorrect + and new password the same as old one + SQLAlchemyError: If error occurred during new password save + """ diff --git a/backend/src/features/auth/user_service/service.py b/backend/src/features/auth/user_service/service.py new file mode 100644 index 0000000..44104d5 --- /dev/null +++ b/backend/src/features/auth/user_service/service.py @@ -0,0 +1,184 @@ +from datetime import datetime, timezone +from typing import Tuple + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +import src.core.exceptions as exc +from src.adapters.postgres.models import ( + UserModel, + ActivationTokenModel, +) +from src.adapters.postgres.repositories import ( + UserRepository, + TokenRepository, + UserProfileRepository, +) +from src.core.config import Settings +from src.core.security import generate_secure_token +from .interface import UserServiceInterface + + +class UserService(UserServiceInterface): + def __init__( + self, + db: AsyncSession, + settings: Settings, + user_repo: UserRepository, + activation_token_repo: TokenRepository, + password_reset_token_repo: TokenRepository, + ): + self._db = db + self._settings = settings + + self._user_repo = user_repo + self._activation_token_repo = activation_token_repo + self._password_reset_token_repo = password_reset_token_repo + + async def register_user( + self, email: str, username: str, password: str + ) -> Tuple[UserModel, str]: + existing_user = await self._user_repo.get_by_email_or_username( + email, username + ) + if existing_user: + if existing_user.email == email: + raise exc.UserAlreadyExistsError("This email is taken.") + if existing_user.username == username: + raise exc.UserAlreadyExistsError("This username is taken.") + + profile_repo = UserProfileRepository(self._db) + token = generate_secure_token() + + user = await self._user_repo.create(email, username, password) + await self._db.flush() + await profile_repo.create(user.id) + activation_token = await self._activation_token_repo.create( + user.id, token, self._settings.ACTIVATION_TOKEN_LIFE + ) + + try: + await self._db.commit() + await self._db.refresh(user) + await self._db.refresh(activation_token) + return user, token + except SQLAlchemyError: + await self._db.rollback() + raise + + async def new_activation_token(self, email: str) -> ActivationTokenModel: + user = await self._user_repo.get_by_email(email) + if not user: + raise exc.UserNotFoundError + + if user.is_active: + raise ValueError + + existing_token = await self._activation_token_repo.get_by_user(user.id) + if existing_token: + await self._activation_token_repo.delete(existing_token.token) + await self._db.flush() + + token = generate_secure_token() + new_token = await self._activation_token_repo.create( + user.id, token, self._settings.ACTIVATION_TOKEN_LIFE + ) + + try: + await self._db.commit() + await self._db.refresh(new_token) + return new_token + except SQLAlchemyError: + await self._db.rollback() + raise + + async def activate_account(self, token: str) -> None: + result = await self._activation_token_repo.get_with_user(token) + if not result: + raise exc.TokenNotFoundError("Activation token was not found") + + user, token_model = result + + if token_model.expires_at < datetime.now(timezone.utc): + try: + await self._activation_token_repo.delete(token) + await self._db.commit() + raise exc.TokenExpiredError("Activation token has expired") + except SQLAlchemyError: + await self._db.rollback() + raise + + user.is_active = True + + await self._activation_token_repo.delete(token) + + try: + await self._db.commit() + await self._db.refresh(user) + except SQLAlchemyError: + await self._db.rollback() + raise + + async def reset_password_token(self, email: str) -> Tuple[UserModel, str]: + user = await self._user_repo.get_by_email(email) + if not user: + raise exc.UserNotFoundError + + token = generate_secure_token() + password_reset_token = await self._password_reset_token_repo.create( + user.id, token, self._settings.PASSWORD_RESET_TOKEN_LIFE + ) + + try: + await self._db.commit() + await self._db.refresh(password_reset_token) + except SQLAlchemyError: + await self._db.rollback() + raise + return user, token + + async def reset_password_complete( + self, email: str, password: str, token: str + ) -> None: + result = await self._password_reset_token_repo.get_with_user(token) + if not result: + raise exc.TokenNotFoundError("Password reset token was not found") + + user, token_model = result + + if token_model.expires_at < datetime.now(timezone.utc): + raise exc.TokenExpiredError( + "This password reset link has expired or is invalid. " + "Please request a new reset link." + ) + + user.password = password + await self._password_reset_token_repo.delete(token) + + try: + await self._db.commit() + await self._db.refresh(user) + except SQLAlchemyError: + await self._db.rollback() + raise + + async def change_password( + self, user: UserModel, old_password: str, new_password: str + ) -> None: + if not user.verify_password(old_password): + raise exc.InvalidPasswordError( + "Entered Invalid password! Check your keyboard " + "layout or Caps Lock. Forgot your password?" + ) + + if old_password == new_password: + raise exc.InvalidPasswordError( + "New password cannot be the same as old password!" + ) + + user.password = new_password + try: + await self._db.commit() + except SQLAlchemyError: + await self._db.rollback() + raise diff --git a/backend/src/features/profile/__init__.py b/backend/src/features/profile/__init__.py new file mode 100644 index 0000000..4940845 --- /dev/null +++ b/backend/src/features/profile/__init__.py @@ -0,0 +1,2 @@ +from .interface import ProfileServiceInterface +from .service import ProfileService diff --git a/backend/src/features/profile/interface.py b/backend/src/features/profile/interface.py new file mode 100644 index 0000000..fe3662e --- /dev/null +++ b/backend/src/features/profile/interface.py @@ -0,0 +1,81 @@ +from abc import ABC, abstractmethod + +from fastapi import UploadFile + +from src.adapters.postgres.models import UserProfileModel +from src.api.v1.schemas.accounts import ProfileUpdateRequestSchema + + +class ProfileServiceInterface(ABC): + @abstractmethod + async def get_profile(self, user_id: int) -> UserProfileModel: + """ + Method for getting user's profile + + :param user_id: User's, requesting profile, id + :type: int + :return: User profile record + :rtype: UserProfileModel + :raises: ProfileNotFoundError: If profile doesn't exist + """ + pass + + @abstractmethod + async def get_specific_user_profile( + self, username: str + ) -> UserProfileModel: + """ + Method for getting specific user's profile by username + + :param username: Specific user's username + :type: str + :return: User profile record + :rtype: UserProfileModel + :raises: ProfileNotFoundError: If profile doesn't exist + """ + + @abstractmethod + async def update_profile( + self, user_id: int, data: ProfileUpdateRequestSchema + ) -> UserProfileModel: + """ + Method for updating user's profile + + :param user_id: User's, requesting profile update, id + :type: int + :param data: schema with profile update data + :type: ProfileUpdateRequestSchema + :return: Updated user profile record + :rtype: UserProfileModel + :raises SQLAlchemyError: If error occurred during profile update + ProfileNotFoundError: If profile doesn't exist + """ + pass + + @abstractmethod + async def delete_profile_avatar(self, user_id: int) -> None: + """ + Method for deleting user's profile avatar + + :param user_id: User's, requesting profile update, id + :return: None + :raises SQLAlchemyError: If error occurred during profile avatar delete + ProfileNotFoundError: If profile doesn't exist + """ + pass + + @abstractmethod + async def set_profile_avatar( + self, user_id: int, avatar: UploadFile + ) -> None: + """ + Method for setting user's profile avatar + + :param user_id: User's, requesting avatar setting, id + :param avatar: Uploaded profile avatar + :type: fastapi.UploadFile + :return: None + :raises SQLAlchemyError: If error occurred during profile avatar set + ProfileNotFoundError: If profile doesn't exist + """ + pass diff --git a/backend/src/features/profile/service.py b/backend/src/features/profile/service.py new file mode 100644 index 0000000..b05086a --- /dev/null +++ b/backend/src/features/profile/service.py @@ -0,0 +1,115 @@ +import asyncio + +from fastapi import UploadFile +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.models import UserProfileModel +from src.adapters.postgres.repositories import UserProfileRepository +from src.adapters.storage import StorageInterface +from src.adapters.storage.validation import validate_image +from src.api.v1.schemas.accounts import ProfileUpdateRequestSchema +from src.core.utils.logger import logger +from .interface import ProfileServiceInterface + + +class ProfileService(ProfileServiceInterface): + def __init__( + self, + db: AsyncSession, + storage: StorageInterface, + profile_repo: UserProfileRepository, + ): + self._db = db + self._storage = storage + self._repo = profile_repo + + async def get_profile(self, user_id: int) -> UserProfileModel: + return await self._repo.get_by_user_id(user_id) + + async def get_specific_user_profile( + self, username: str + ) -> UserProfileModel: + return await self._repo.get_by_username(username) + + async def update_profile( + self, user_id: int, data: ProfileUpdateRequestSchema + ) -> UserProfileModel: + profile = await self._repo.update( + user_id=user_id, **data.model_dump(exclude_unset=True) + ) + + try: + await self._db.commit() + await self._db.refresh(profile) + except SQLAlchemyError: + await self._db.rollback() + raise + return profile + + async def delete_profile_avatar(self, user_id: int) -> None: + profile = await self._repo.get_by_user_id(user_id) + + if not profile.avatar_url: + return + + file_url = profile.avatar_url + try: + await self._repo.delete_avatar_url(user_id) + await self._db.commit() + except SQLAlchemyError: + await self._db.rollback() + logger.error(f"Failed to delete avatar URL for user {user_id}") + raise + await self._delete_from_storage_with_retry(file_url) + + async def _delete_from_storage_with_retry( + self, file_url: str, retries: int = 3, delay: float = 2.0 + ) -> None: + for attempt in range(1, retries + 1): + try: + if self._storage.file_exists(file_url): + self._storage.delete_file(file_url) + return + except Exception as e: + logger.warning( + f"Attempt {attempt}/{retries} failed to " + f"delete {file_url}: {e}" + ) + if attempt < retries: + await asyncio.sleep(delay) + logger.error( + f"Failed to delete file {file_url} after {retries} attempts" + ) + + async def set_profile_avatar( + self, user_id: int, avatar: UploadFile + ) -> None: + profile = await self._repo.get_by_user_id(user_id) + old_url = profile.avatar_url if profile else None + + processed_image_io = validate_image(avatar) + processed_image_bytes = processed_image_io.read() + + from uuid import uuid4 + from pathlib import Path + + filename = avatar.filename if avatar.filename is not None else "" + + ext = Path(filename).suffix or ".png" + file_name = f"{uuid4()}{ext}" + + avatar_url = self._storage.upload_file( + file_name, processed_image_bytes + ) + + try: + await self._repo.update_avatar_url(user_id, avatar_url) + await self._db.commit() + except SQLAlchemyError: + await self._db.rollback() + logger.error(f"Failed to update avatar for user {user_id}") + raise + + if old_url: + asyncio.create_task(self._delete_from_storage_with_retry(old_url)) diff --git a/backend/src/features/snippets/__init__.py b/backend/src/features/snippets/__init__.py new file mode 100644 index 0000000..4ba5b4c --- /dev/null +++ b/backend/src/features/snippets/__init__.py @@ -0,0 +1,3 @@ +from .favorites import FavoritesServiceInterface, FavoritesService +from .search import SnippetSearchServiceInterface, SnippetSearchService +from .snippets import SnippetServiceInterface, SnippetService diff --git a/backend/src/features/snippets/favorites/__init__.py b/backend/src/features/snippets/favorites/__init__.py new file mode 100644 index 0000000..13469f7 --- /dev/null +++ b/backend/src/features/snippets/favorites/__init__.py @@ -0,0 +1,2 @@ +from .interface import FavoritesServiceInterface +from .service import FavoritesService diff --git a/backend/src/features/snippets/favorites/interface.py b/backend/src/features/snippets/favorites/interface.py new file mode 100644 index 0000000..e494c8a --- /dev/null +++ b/backend/src/features/snippets/favorites/interface.py @@ -0,0 +1,78 @@ +from abc import ABC, abstractmethod +from typing import Optional +from uuid import UUID + +from fastapi.requests import Request + +from src.adapters.postgres.models import UserModel, LanguageEnum +from src.api.v1.schemas.snippets import ( + GetSnippetsResponseSchema, + FavoritesSortingEnum, +) + + +class FavoritesServiceInterface(ABC): + @abstractmethod + async def add_to_favorites(self, user: UserModel, uuid: UUID) -> None: + """ + Method for adding Snippet to user's favorites + + :param user: User requesting addition + :type: UserModel + :param uuid: UUID of SnippetModel + :type: UUID + :return: None + :raises SnippetNotFoundError: If snippet was not found by UUID + FavoritesAlreadyError: If snippet was already favorited + SQLAlchemyError: If database error occurred + """ + + @abstractmethod + async def remove_from_favorites(self, user: UserModel, uuid: UUID) -> None: + """ + Method for removing Snippet from user's favorites + + :param user: User requesting removal + :type: UserModel + :param uuid: UUID of SnippetModel + :type: UUID + :return: None + :raises SnippetNotFoundError: If snippet was not found by UUID + or snippet not in user's favorites + SQLAlchemyError: If database error occurred + """ + + @abstractmethod + async def get_favorites( + self, + request: Request, + page: int, + per_page: int, + user_id: int, + sort_by: FavoritesSortingEnum, + language: Optional[LanguageEnum] = None, + tags: Optional[list[str]] = None, + username: Optional[str] = None, + ) -> GetSnippetsResponseSchema: + """ + Method for getting favorite Snippets with pagination + + :param request: Request that will be used to create pagination links + :type: fastapi.requests.Request + :param page: Current page number + :type: int + :param per_page: Number of items per page + :type: int + :param user_id: Current user id + :type: int + :param sort_by: what snippets to sort by + :type: FavoritesSortingEnum + :param language: Optional param programing language + :type: LanguageEnum | None + :param tags: Optional param tags + :type: list[str] | None + :param username: Optional param snippet's author username + :type: str | None + :return: Schema of favorite snippets with pagination + :rtype: GetSnippetsResponseSchema + """ diff --git a/backend/src/features/snippets/favorites/service.py b/backend/src/features/snippets/favorites/service.py new file mode 100644 index 0000000..138e294 --- /dev/null +++ b/backend/src/features/snippets/favorites/service.py @@ -0,0 +1,87 @@ +from typing import Optional +from uuid import UUID + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.requests import Request + +import src.core.exceptions as exc +from src.adapters.mongo.repo import SnippetDocumentRepository +from src.adapters.postgres.models import UserModel, LanguageEnum +from src.adapters.postgres.repositories import FavoritesRepository +from src.api.v1.schemas.snippets import ( + FavoritesSortingEnum, + GetSnippetsResponseSchema, +) +from src.core.utils import Paginator +from .interface import FavoritesServiceInterface +from ..merger import SnippetDataMerger + + +class FavoritesService(FavoritesServiceInterface): + def __init__( + self, + db: AsyncSession, + repo: FavoritesRepository, + doc_repo: SnippetDocumentRepository, + ): + self._db = db + self._repo = repo + self._doc_repo = doc_repo + + self._paginator = Paginator + + async def add_to_favorites(self, user: UserModel, uuid: UUID) -> None: + try: + await self._repo.add_to_favorites(user, uuid) + await self._db.commit() + except (exc.SnippetNotFoundError, exc.FavoritesAlreadyError): + raise + except SQLAlchemyError: + await self._db.rollback() + raise + + async def remove_from_favorites(self, user: UserModel, uuid: UUID) -> None: + try: + await self._repo.remove_from_favorites(user, uuid) + await self._db.commit() + except (exc.SnippetNotFoundError, exc.FavoritesAlreadyError): + raise + except SQLAlchemyError: + await self._db.rollback() + raise + + async def get_favorites( + self, + request: Request, + page: int, + per_page: int, + user_id: int, + sort_by: FavoritesSortingEnum, + language: Optional[LanguageEnum] = None, + tags: Optional[list[str]] = None, + username: Optional[str] = None, + ) -> GetSnippetsResponseSchema: + offset = self._paginator.calculate_offset(page, per_page) + + favorites, total = await self._repo.get_favorites_paginated( + offset, per_page, user_id, sort_by, language, tags, username + ) + + prev_page, next_page = self._paginator.build_links( + request, page, per_page, total + ) + + snippet_list = await SnippetDataMerger.merge_with_documents( + favorites, self._doc_repo + ) + + return GetSnippetsResponseSchema( + page=page, + per_page=per_page, + prev_page=prev_page, + next_page=next_page, + total_items=total, + snippets=snippet_list, + total_pages=self._paginator.total_pages(total, per_page), + ) diff --git a/backend/src/features/snippets/merger.py b/backend/src/features/snippets/merger.py new file mode 100644 index 0000000..b70c620 --- /dev/null +++ b/backend/src/features/snippets/merger.py @@ -0,0 +1,38 @@ +from typing import Sequence, Any + +from src.adapters.mongo.documents import SnippetDocument +from src.adapters.mongo.repo import SnippetDocumentRepository +from src.api.v1.schemas.snippets import SnippetListItemSchema + + +class SnippetDataMerger: + @staticmethod + async def merge_with_documents( + snippets: Sequence[Any], doc_repo: SnippetDocumentRepository + ) -> list[SnippetListItemSchema]: + mongo_ids = [ + snippet.mongodb_id for snippet in snippets if snippet.mongodb_id + ] + documents = await doc_repo.get_by_ids(mongo_ids) + documents_map = {str(doc.id): doc for doc in documents} + + merged: list[SnippetListItemSchema] = [] + for snippet in snippets: + document: SnippetDocument | None = documents_map.get( + snippet.mongodb_id + ) + content = document.content if document else "" + description = document.description if document else "" + + merged.append( + SnippetListItemSchema( + title=snippet.title, + language=snippet.language, + is_private=snippet.is_private, + content=content, + description=description, # type: ignore + uuid=snippet.uuid, + tags=[tag.name for tag in snippet.tags], + ) + ) + return merged diff --git a/backend/src/features/snippets/search/__init__.py b/backend/src/features/snippets/search/__init__.py new file mode 100644 index 0000000..f643176 --- /dev/null +++ b/backend/src/features/snippets/search/__init__.py @@ -0,0 +1,2 @@ +from .interface import SnippetSearchServiceInterface +from .service import SnippetSearchService diff --git a/backend/src/features/snippets/search/interface.py b/backend/src/features/snippets/search/interface.py new file mode 100644 index 0000000..501b89d --- /dev/null +++ b/backend/src/features/snippets/search/interface.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod + +from src.api.v1.schemas.snippets import SnippetSearchResponseSchema + + +class SnippetSearchServiceInterface(ABC): + @abstractmethod + async def search_by_title( + self, title: str, user_id: int, limit: int + ) -> SnippetSearchResponseSchema: + """ + Method to return a search result by title + Checks if title already is in cache and if it is uses cached result + If it is not - saves it in cache for 1m + + :param title: SnippetModel title + :type: str + :param user_id: ID of User requesting + :type: int + :param limit: Number of results to return + :type: int + :return: Search result + :rtype: SnippetSearchResponseSchema + """ diff --git a/backend/src/features/snippets/search/service.py b/backend/src/features/snippets/search/service.py new file mode 100644 index 0000000..f8179c9 --- /dev/null +++ b/backend/src/features/snippets/search/service.py @@ -0,0 +1,60 @@ +import json +from typing import Optional, Dict, Any + +from redis.asyncio.client import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.postgres.repositories import SnippetRepository +from src.api.v1.schemas.snippets import ( + SnippetSearchResponseSchema, + SnippetSearchItemSchema, +) +from .interface import SnippetSearchServiceInterface + + +class SnippetSearchService(SnippetSearchServiceInterface): + def __init__( + self, db: AsyncSession, redis_client: Redis, repo: SnippetRepository + ): + self._db = db + self._redis_client = redis_client + self._repo = repo + + async def search_by_title( + self, title: str, user_id: int, limit: int + ) -> SnippetSearchResponseSchema: + cached = await self._get_cached(title, user_id) + if cached: + return SnippetSearchResponseSchema(**cached) + + snippets = await self._repo.get_by_title_list(title, user_id, limit) + + snippet_list = [] + for snippet in snippets: # type: ignore + snippet_list.append( + SnippetSearchItemSchema( + uuid=snippet.uuid, + title=snippet.title, + language=snippet.language, + ) + ) + + response = SnippetSearchResponseSchema(results=snippet_list) + await self._cache_result(title, user_id, response.model_dump_json()) + + return response + + async def _get_cached( + self, title: str, user_id: int + ) -> Optional[Dict[str, Any]]: + cache_key = f"search:{user_id}:{title.lower()}" + cached = await self._redis_client.get(cache_key) + if cached is None: + return None + return json.loads(cached) + + async def _cache_result( + self, title: str, user_id: int, result: str + ) -> None: + cache_key = f"search:{user_id}:{title.lower()}" + await self._redis_client.setex(cache_key, 60, result) diff --git a/backend/src/features/snippets/snippets/__init__.py b/backend/src/features/snippets/snippets/__init__.py new file mode 100644 index 0000000..56d99a5 --- /dev/null +++ b/backend/src/features/snippets/snippets/__init__.py @@ -0,0 +1,2 @@ +from .interface import SnippetServiceInterface +from .service import SnippetService diff --git a/backend/src/features/snippets/snippets/interface.py b/backend/src/features/snippets/snippets/interface.py new file mode 100644 index 0000000..ec98ce9 --- /dev/null +++ b/backend/src/features/snippets/snippets/interface.py @@ -0,0 +1,139 @@ +from abc import ABC, abstractmethod +from datetime import date +from typing import Optional +from uuid import UUID + +from fastapi.requests import Request + +from src.adapters.postgres.models import UserModel, LanguageEnum +from src.api.v1.schemas.snippets import ( + SnippetCreateSchema, + SnippetResponseSchema, + GetSnippetsResponseSchema, + SnippetUpdateRequestSchema, +) + + +class SnippetServiceInterface(ABC): + @abstractmethod + async def create_snippet( + self, data: SnippetCreateSchema + ) -> SnippetResponseSchema: + """ + Method that creates snippet in PostgreSQL & MongoDB + using data from payload + + :param data: + :type: SnippetCreateSchema + :return: SnippetSchema + :rtype: SnippetResponseSchema + :raises SQLAlchemyError: If error occurred during SnippetModel creation + PyMongoError: If error occurred during SnippetDocument creation + ValidationError: If during document creation + validation error occurred + SnippetAlreadyExistsError: If snippet with same title + already exists + """ + pass + + @abstractmethod + async def get_snippets( + self, + request: Request, + page: int, + per_page: int, + current_user_id: int, + visibility: Optional[str], + language: Optional[LanguageEnum], + tags: Optional[list[str]], + created_before: Optional[date], + created_after: Optional[date], + username: Optional[str], + ) -> GetSnippetsResponseSchema: + """ + Method that gets data from PostgreSQL & MongoDB and returns + list of Snippets with pagination. Optional params used for filtering + + :param request: Request that will be used to create pagination links + :type: fastapi.requests.Request + :param page: Current page number + :type: int + :param per_page: Number of items per page + :type: int + :param current_user_id: Current user id + :type: int + :param visibility: visibility param flag + :type: str + :param language: Optional param - language + :type: LanguageEnum | None + :param tags: Optional param - list of tag names + :type: list[str] | None + :param created_before: Optional param - created before date + :type: date | None + :param created_after: Optional param - created after date + :type: date | None + :param username: Optional param - username + :type: str | None + :return: Snippets with pagination + :rtype: GetSnippetsResponseSchema + :raises SQLAlchemyError: If error occurred during SnippetModel get + """ + pass + + # TODO: total favorites + @abstractmethod + async def get_snippet_by_uuid( + self, uuid: UUID, user: UserModel + ) -> SnippetResponseSchema: + """ + Method that gets data from PostgreSQL & MongoDB and returns + single Snippet by UUID + + :param uuid: identifier of Snippet + :type: UUID + :param user: User requesting snippet details + :type: UserModel + :return: Snippet Pydantic Model + :rtype: SnippetResponseSchema + :raises SnippetNotFound: If Snippet was not found in db + NoPermissionError: If user is not an admin or a snippet owner + """ + pass + + @abstractmethod + async def update_snippet( + self, uuid: UUID, data: SnippetUpdateRequestSchema, user: UserModel + ) -> SnippetResponseSchema: + """ + Method that updates Snippet by UUID using data dict + + :param uuid: UUID of Snippet + :type: UUID + :param data: Snippet data + :type: SnippetUpdateRequestSchema + :param user: User that expected to be Snippet owner or admin + :type: int + :return: SnippetResponseSchema + :raises: SnippetNotFoundError: If Snippet was not found in db + NoPermissionError: If user is not an admin or a snippet owner + SQLAlchemyError: If error occurred during model update + PymongoError: If error occurred during document update + """ + pass + + @abstractmethod + async def delete_snippet(self, uuid: UUID, user: UserModel) -> None: + """ + Method that delete Snippet by UUID in PostgreSQL & MongoDB + + :param uuid: UUID of Snippet + :type: UUID + :param user: User that expected to be Snippet owner or admin + :type: UserModel + :return: None + :raises SnippetNotFound: If Snippet was not found in db + NoPermissionError: If user is not an admin or a snippet owner + SQLAlchemyError: If error occurred during model deletion + PyMongoError: If error occurred during document deletion + """ + pass diff --git a/backend/src/features/snippets/snippets/service.py b/backend/src/features/snippets/snippets/service.py new file mode 100644 index 0000000..5a4fa54 --- /dev/null +++ b/backend/src/features/snippets/snippets/service.py @@ -0,0 +1,279 @@ +from datetime import date +from typing import Optional, cast +from uuid import UUID + +from fastapi.requests import Request +from pymongo.errors import PyMongoError +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +import src.core.exceptions as exc +from src.adapters.mongo.documents import SnippetDocument +from src.adapters.mongo.repo import SnippetDocumentRepository +from src.adapters.postgres.models import ( + SnippetModel, + UserModel, + TagModel, + LanguageEnum, +) +from src.adapters.postgres.repositories import SnippetRepository +from src.api.v1.schemas.snippets import ( + SnippetCreateSchema, + SnippetResponseSchema, + GetSnippetsResponseSchema, + SnippetUpdateRequestSchema, +) +from src.core.utils import Paginator +from .interface import SnippetServiceInterface +from ..merger import SnippetDataMerger + + +class SnippetService(SnippetServiceInterface): + def __init__( + self, + db: AsyncSession, + model_repo: SnippetRepository, + doc_repo: SnippetDocumentRepository, + ): + self._db = db + self._doc_repo = doc_repo + self._model_repo = model_repo + + self._paginator = Paginator + + @staticmethod + def _build_snippet_response( + snippet: SnippetModel, document: SnippetDocument + ) -> SnippetResponseSchema: + description = document.description if document else None + content = document.content if document else None + + return SnippetResponseSchema( + uuid=cast(UUID, snippet.uuid), + username=snippet.user.username, + title=snippet.title, + language=snippet.language, + is_private=snippet.is_private, + content=content if content is not None else "", + description=description if description is not None else "", + created_at=snippet.created_at, + updated_at=snippet.updated_at, + tags=[tag.name for tag in snippet.tags], + ) + + async def _sync_tags(self, tag_names: list[str]) -> list[TagModel]: + stmt = select(TagModel).where(TagModel.name.in_(tag_names)) + result = await self._db.execute(stmt) + existing = {t.name: t for t in result.scalars().all()} + + tags_to_return: list[TagModel] = [] + for name in tag_names: + tag = existing.get(name) + if tag is None: + tag = TagModel(name=name) + self._db.add(tag) + tags_to_return.append(tag) + + return tags_to_return + + # TODO: remove + async def _update_sql_snippet( + self, snippet: SnippetModel, data: SnippetUpdateRequestSchema + ) -> None: + payload = data.model_dump(exclude_unset=True) + + try: + if "tags" in payload: + tag_names = payload.pop("tags") or [] + tag_models = await self._sync_tags(tag_names) + snippet.tags = tag_models + + for field, value in payload.items(): + if hasattr(snippet, field) and value is not None: + setattr(snippet, field, value) + + await self._db.flush() + await self._db.commit() + await self._db.refresh(snippet) + except SQLAlchemyError: + await self._db.rollback() + raise + + async def _update_mongo_document( + self, snippet: SnippetModel, data: SnippetUpdateRequestSchema + ) -> SnippetDocument: + document = await self._doc_repo.get_by_id(snippet.mongodb_id) + if not document: + raise exc.SnippetNotFoundError("Snippet document not found") + + update_data = {} + if data.content is not None: + update_data["content"] = data.content + if data.description is not None: + update_data["description"] = data.description + + try: + if update_data: + await self._doc_repo.update(snippet.mongodb_id, **update_data) + except (ValueError, PyMongoError): + raise + + updated_doc = await self._doc_repo.get_by_id(snippet.mongodb_id) + + if not updated_doc: + raise exc.SnippetNotFoundError("Snippet document not found") + + return updated_doc + + async def create_snippet( + self, data: SnippetCreateSchema + ) -> SnippetResponseSchema: + snippet_record = await self._model_repo.get_by_title( + data.title, data.user_id + ) + if snippet_record: + raise exc.SnippetAlreadyExistsError + + try: + document = await self._doc_repo.create( + data.content, data.description + ) + except (ValueError, PyMongoError): + raise + + assert document.id is not None + + try: + snippet_model = await self._model_repo.create_with_tags( + data.title, + data.language, + data.is_private, + tag_names=data.tags, + user_id=data.user_id, + mongodb_id=document.id, + ) + await self._db.commit() + await self._db.refresh(snippet_model) + except SQLAlchemyError: + await self._db.rollback() + await self._doc_repo.delete_document(document) + raise + + return self._build_snippet_response(snippet_model, document) + + async def get_snippets( + self, + request: Request, + page: int, + per_page: int, + current_user_id: int, + visibility: Optional[str], + language: Optional[LanguageEnum], + tags: Optional[list[str]], + created_before: Optional[date], + created_after: Optional[date], + username: Optional[str], + ) -> GetSnippetsResponseSchema: + try: + offset = self._paginator.calculate_offset(page, per_page) + snippets, total = await self._model_repo.get_snippets_paginated( + offset, + per_page, + current_user_id, + visibility, + language, + tags, + created_before, + created_after, + username, + ) + + prev_page, next_page = self._paginator.build_links( + request, page, per_page, total + ) + + snippet_list = await SnippetDataMerger.merge_with_documents( + snippets, # type: ignore + self._doc_repo, + ) + except SQLAlchemyError: + raise + + return GetSnippetsResponseSchema( + page=page, + per_page=per_page, + prev_page=prev_page, + next_page=next_page, + total_items=total, + snippets=snippet_list, + total_pages=self._paginator.total_pages(total, per_page), + ) + + async def get_snippet_by_uuid( + self, uuid: UUID, user: UserModel + ) -> SnippetResponseSchema: + snippet = await self._model_repo.get_by_uuid_with_tags(uuid) + if not snippet: + raise exc.SnippetNotFoundError( + "Snippet with this UUID was not found" + ) + if ( + snippet.is_private + and snippet.user_id != user.id + and not user.is_admin + ): + raise exc.NoPermissionError( + "User have no permission to get snippet" + ) + + document = await self._doc_repo.get_by_id(snippet.mongodb_id) + + if document is None: + raise exc.SnippetNotFoundError( + "Snippet with this UUID was not found" + ) + + return self._build_snippet_response(snippet, document) + + async def update_snippet( + self, uuid: UUID, data: SnippetUpdateRequestSchema, user: UserModel + ) -> SnippetResponseSchema: + snippet = await self._model_repo.get_by_uuid_with_tags(uuid) + if not snippet: + raise exc.SnippetNotFoundError( + "Snippet with this UUID was not found" + ) + if snippet.user_id != user.id and not user.is_admin: + raise exc.NoPermissionError( + "User have no permission to update snippet" + ) + + await self._update_sql_snippet(snippet, data) + document = await self._update_mongo_document(snippet, data) + + return self._build_snippet_response(snippet, document) + + async def delete_snippet(self, uuid: UUID, user: UserModel) -> None: + snippet = await self._model_repo.get_by_uuid(uuid) + if not snippet: + raise exc.SnippetNotFoundError( + "Snippet with this UUID was not found" + ) + if snippet.user_id != user.id and not user.is_admin: + raise exc.NoPermissionError( + "User have no permission to delete snippet" + ) + + doc_id = snippet.mongodb_id + try: + await self._model_repo.delete(uuid) + await self._db.commit() + except SQLAlchemyError: + await self._db.rollback() + raise + + try: + await self._doc_repo.delete(doc_id) + except PyMongoError: + raise diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..f5fa9f0 --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,7 @@ +import faulthandler + +from src.core.app import create_app + +faulthandler.enable() + +app = create_app() diff --git a/backend/src/middleware/prometheus.py b/backend/src/middleware/prometheus.py new file mode 100644 index 0000000..82e5f25 --- /dev/null +++ b/backend/src/middleware/prometheus.py @@ -0,0 +1,170 @@ +""" +Prometheus metrics middleware for FastAPI + +Tracks HTTP requests, response times, and custom application metrics. +""" + +from collections.abc import Callable +from time import time +import logging + +from prometheus_client import ( + Counter, + Histogram, + Gauge, + generate_latest, + CONTENT_TYPE_LATEST, +) +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +logger = logging.getLogger(__name__) + +# HTTP Metrics +http_requests_total = Counter( + "http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status_code"], +) + +http_request_duration_seconds = Histogram( + "http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], + buckets=( + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 7.5, + 10.0, + ), +) + +http_requests_in_progress = Gauge( + "http_requests_in_progress", + "HTTP requests currently being processed", + ["method", "endpoint"], +) + +# Application Metrics +active_users = Gauge( + "snippetly_active_users", + "Number of active users (logged in last 24h)", +) + +total_snippets = Gauge( + "snippetly_total_snippets", + "Total number of code snippets", +) + +database_connections = Gauge( + "snippetly_database_connections", + "Number of active database connections", + ["database"], +) + + +class PrometheusMiddleware(BaseHTTPMiddleware): + """ + Middleware to track HTTP requests with Prometheus metrics. + + Tracks: + - Request count by method, endpoint, and status code + - Request duration histogram + - Requests in progress gauge + """ + + async def dispatch( + self, request: Request, call_next: Callable + ) -> Response: + # Skip metrics endpoint itself to avoid recursion + if request.url.path == "/api/metrics": + return await call_next(request) + + # Extract endpoint pattern (remove IDs and dynamic parts) + endpoint = self._get_endpoint_pattern(request) + method = request.method + + # Track request in progress + http_requests_in_progress.labels( + method=method, endpoint=endpoint + ).inc() + + # Start timer + start_time = time() + + try: + # Process request + response = await call_next(request) + + # Record metrics + duration = time() - start_time + status_code = response.status_code + + http_requests_total.labels( + method=method, endpoint=endpoint, status_code=status_code + ).inc() + + http_request_duration_seconds.labels( + method=method, endpoint=endpoint + ).observe(duration) + + return response + + except Exception as e: + # Record error + http_requests_total.labels( + method=method, endpoint=endpoint, status_code=500 + ).inc() + + logger.error(f"Request failed: {method} {endpoint} - {str(e)}") + raise + + finally: + # Decrement in-progress counter + http_requests_in_progress.labels( + method=method, endpoint=endpoint + ).dec() + + def _get_endpoint_pattern(self, request: Request) -> str: + """ + Extract endpoint pattern from request path. + + Examples: + /api/v1/snippets/123 -> /api/v1/snippets/{id} + /api/v1/users/abc-def -> /api/v1/users/{id} + """ + path = request.url.path + + # Common patterns + patterns = [ + ("/api/v1/snippets/", "/api/v1/snippets/{id}"), + ("/api/v1/users/", "/api/v1/users/{id}"), + ("/api/v1/auth/verify/", "/api/v1/auth/verify/{token}"), + ] + + for prefix, pattern in patterns: + if path.startswith(prefix) and len(path) > len(prefix): + return pattern + + return path + + +def metrics_endpoint() -> Response: + """ + Prometheus metrics endpoint. + + Returns metrics in Prometheus format for scraping. + """ + return Response( + content=generate_latest(), media_type=CONTENT_TYPE_LATEST + ) diff --git a/backend/src/worker/__init__.py b/backend/src/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/worker/app.py b/backend/src/worker/app.py new file mode 100644 index 0000000..64b6c73 --- /dev/null +++ b/backend/src/worker/app.py @@ -0,0 +1,31 @@ +from celery import Celery +from celery.schedules import crontab + +from src.core.config import get_settings + +settings = get_settings() + +app = Celery("snippetly") +app.conf.update( + broker_url=settings.redis_url, + result_backend=settings.redis_url, + timezone="UTC", + result_expires=3600, +) + +app.conf.beat_schedule = { + "cleanup_expired_activation_password_tokens": { + "task": "tokens.delete_expired_activation_reset", + "schedule": crontab(minute=0, hour="*/6"), + }, + "cleanup_expired_refresh_tokens": { + "task": "tokens.delete_expired_refresh", + "schedule": crontab(minute=0, hour="*/6"), + }, + "cleanup_unused_tags": { + "task": "tags.delete_unused_tags", + "schedule": crontab(minute=0, hour=0), + }, +} + +from .tasks import tags, tokens # noqa diff --git a/backend/src/worker/tasks/__init__.py b/backend/src/worker/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/worker/tasks/tags.py b/backend/src/worker/tasks/tags.py new file mode 100644 index 0000000..e17db67 --- /dev/null +++ b/backend/src/worker/tasks/tags.py @@ -0,0 +1,31 @@ +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from src.adapters.postgres.sync_db import get_db_sync +from src.core.utils import logger +from ..app import app + + +@app.task( + name="tags.delete_unused_tags", + autoretry_for=(SQLAlchemyError,), + retry_kwargs={"max_retries": 3, "countdown": 10}, + retry_backoff=True, +) +def delete_unused_tags() -> None: + for session in get_db_sync(): + session.execute( + text( + "DELETE FROM tags " + "WHERE NOT EXISTS (" + "SELECT 1 FROM snippets_tags st " + "WHERE st.tag_id = tags.id)" + ) + ) + try: + session.commit() + logger.info("Tags has been deleted successfully") + except SQLAlchemyError as e: + session.rollback() + logger.error(f"Database error occurred during tags cleanup: {e}") + raise e diff --git a/backend/src/worker/tasks/tokens.py b/backend/src/worker/tasks/tokens.py new file mode 100644 index 0000000..970fb48 --- /dev/null +++ b/backend/src/worker/tasks/tokens.py @@ -0,0 +1,54 @@ +from datetime import datetime + +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from src.adapters.postgres.sync_db import get_db_sync +from src.core.utils import logger +from ..app import app + + +def _delete_tokens(token: str, db: Session) -> None: + now = datetime.now() + db.execute( + text(f"DELETE FROM {token} WHERE expires_at < :now"), + {"now": now}, + ) + + +@app.task( + name="tokens.delete_expired_activation_reset", + autoretry_for=(SQLAlchemyError,), + retry_kwargs={"max_retries": 3, "countdown": 10}, + retry_backoff=True, +) +def delete_expired_activation_reset_tokens() -> None: + for session in get_db_sync(): + _delete_tokens("password_reset_tokens", session) + _delete_tokens("activation_tokens", session) + try: + session.commit() + logger.info("Expired tokens successfully deleted") + except SQLAlchemyError as e: + session.rollback() + logger.error(f"Database error occurred during token cleanup: {e}") + raise e + + +@app.task( + name="tokens.delete_expired_refresh", + autoretry_for=(SQLAlchemyError,), + retry_kwargs={"max_retries": 3, "countdown": 10}, + retry_backoff=True, +) +def delete_expired_refresh_tokens() -> None: + for session in get_db_sync(): + _delete_tokens("refresh_tokens", session) + try: + session.commit() + logger.info("Expired tokens successfully deleted") + except SQLAlchemyError as e: + session.rollback() + logger.error(f"Database error occurred during token cleanup: {e}") + raise e diff --git a/backend/static/avatars/.gitignore b/backend/static/avatars/.gitignore new file mode 100644 index 0000000..df6153b --- /dev/null +++ b/backend/static/avatars/.gitignore @@ -0,0 +1,4 @@ +*.jpg +*.png +*jpeg +*.webp \ No newline at end of file diff --git a/backend/static/avatars/.gitkeep b/backend/static/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c7e4fd7 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,46 @@ +import pytest +import pytest_asyncio +from faker import Faker +from httpx import AsyncClient, ASGITransport + +from src.adapters.mongo.client import init_mongo_client +from src.adapters.redis import get_redis_client +from src.core.config import get_settings +from src.core.dependencies.infrastructure import get_email_sender +from src.main import app +from .fixtures import * # noqa + +app.state.limiter.enabled = False + + +@pytest.fixture(scope="session") +def settings(): + return get_settings() + + +@pytest.fixture(scope="session") +def redis_client(settings): + return get_redis_client(settings) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def mongo_client(): + return await init_mongo_client() + + +@pytest.fixture(scope="session") +def faker(): + return Faker() + + +@pytest.fixture +async def client(email_sender_mock): + app.dependency_overrides[get_email_sender] = lambda: email_sender_mock + + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://testserver" + ) as ac: + yield ac + + app.dependency_overrides.clear() diff --git a/backend/tests/e2e/__init__.py b/backend/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/e2e/accounts/__init__.py b/backend/tests/e2e/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/e2e/accounts/helpers.py b/backend/tests/e2e/accounts/helpers.py new file mode 100644 index 0000000..9047e17 --- /dev/null +++ b/backend/tests/e2e/accounts/helpers.py @@ -0,0 +1,8 @@ +def extract_refresh_token_from_set_cookie( + set_cookie_header: str, +) -> str | None: + prefix = "refresh_token=" + if prefix not in set_cookie_header: + return None + after = set_cookie_header.split(prefix, 1)[1] + return after.split(";", 1)[0] diff --git a/backend/tests/e2e/accounts/routes.py b/backend/tests/e2e/accounts/routes.py new file mode 100644 index 0000000..f34d643 --- /dev/null +++ b/backend/tests/e2e/accounts/routes.py @@ -0,0 +1,21 @@ +# Authentication routes +route_prefix = "/api/v1/auth/" + +# Registration +register_url = f"{route_prefix}register" +activate_url = f"{route_prefix}activate" +resend_activation_url = f"{route_prefix}resend-activation" + +# Login & Authentication +login_url = f"{route_prefix}login" +logout_url = f"{route_prefix}logout" +refresh_url = f"{route_prefix}refresh" +revoke_all_tokens_url = f"{route_prefix}revoke-all-tokens" + +# Password Management +reset_password_request_url = f"{route_prefix}reset-password/request" +reset_password_complete_url = f"{route_prefix}reset-password/complete" +change_password_url = f"{route_prefix}change-password" + +profile_url = "/api/v1/profile/" +avatar_url = f"{profile_url}avatar" diff --git a/backend/tests/e2e/accounts/test_activation.py b/backend/tests/e2e/accounts/test_activation.py new file mode 100644 index 0000000..ffbad3e --- /dev/null +++ b/backend/tests/e2e/accounts/test_activation.py @@ -0,0 +1,102 @@ +from .routes import activate_url, resend_activation_url + + +async def test_activate_account_success(db, user_factory, client): + user, token = await user_factory.create_with_activation_token( + db, "token1234" + ) + await db.commit() + + response = await client.post( + activate_url, json={"activation_token": token} + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Account has been activated successfully" + + await db.refresh(user) + assert user.is_active is True + + +async def test_activate_account_invalid_token(client): + response = await client.post( + activate_url, json={"activation_token": "invalid_token_12345"} + ) + + assert response.status_code == 404 + + +async def test_activate_account_expired_token( + db, user_factory, activation_token_repo, client +): + user = await user_factory.create(db) + token = await activation_token_repo.create(user.id, "token1234", -1) + await db.commit() + + response = await client.post( + activate_url, json={"activation_token": token.token} + ) + + assert response.status_code == 400 + + +message = ( + "If an inactive account exists " + "for this email, an activation email has been sent." +) + + +async def test_resend_activation_inactive_user_sends_email( + db, user_factory, client, email_sender_mock +): + user = await user_factory.create(db, is_active=False) + await db.commit() + + response = await client.post( + resend_activation_url, + json={"email": user.email}, + ) + + assert response.status_code == 200 + assert response.json()["message"] == message + + sent_emails = email_sender_mock.get_sent_emails(user.email) + assert len(sent_emails) == 1 + assert sent_emails[0]["type"] == "activation" + assert "token" in sent_emails[0] + + +async def test_resend_activation_nonexistent_email_noop( + client, faker, email_sender_mock +): + email = faker.unique.email() + + response = await client.post( + resend_activation_url, + json={"email": email}, + ) + + assert response.status_code == 200 + assert response.json()["message"] == message + + sent_emails = email_sender_mock.get_sent_emails(email) + assert len(sent_emails) == 0 + + +async def test_resend_activation_active_user_noop( + db, user_factory, client, email_sender_mock +): + user = await user_factory.create(db, is_active=True) + await db.commit() + + response = await client.post( + resend_activation_url, + json={"email": user.email}, + ) + + assert response.status_code == 200 + assert response.json()["message"] == message + + sent_emails = email_sender_mock.get_sent_emails(user.email) + assert len(sent_emails) == 0 diff --git a/backend/tests/e2e/accounts/test_auth.py b/backend/tests/e2e/accounts/test_auth.py new file mode 100644 index 0000000..05bb223 --- /dev/null +++ b/backend/tests/e2e/accounts/test_auth.py @@ -0,0 +1,166 @@ +from .helpers import extract_refresh_token_from_set_cookie +from .routes import login_url, refresh_url, logout_url, revoke_all_tokens_url + + +async def test_login_success_with_email(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + response = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data and isinstance(data["access_token"], str) + assert data["token_type"] == "bearer" + + set_cookie_header = response.headers.get("set-cookie", "") + assert "refresh_token=" in set_cookie_header + + +async def test_login_success_with_username(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + response = await client.post( + login_url, + json={"login": user.username, "password": "Test1234!"}, + ) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +async def test_login_user_not_found(client): + response = await client.post( + login_url, + json={"login": "unknown_user", "password": "SomePass123!"}, + ) + assert response.status_code == 404 + + +async def test_login_invalid_password(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + response = await client.post( + login_url, + json={"login": user.email, "password": "WrongPass999!"}, + ) + assert response.status_code == 401 + + +async def test_login_user_not_active(db, user_factory, client): + user = await user_factory.create(db, is_active=False) + await db.commit() + + response = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert response.status_code == 403 + + +async def test_refresh_success_via_cookie_after_login( + db, user_factory, client +): + user = await user_factory.create(db, is_active=True) + await db.commit() + + login_resp = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_resp.status_code == 200 + refresh_cookie = login_resp.headers.get("set-cookie", "") + token = extract_refresh_token_from_set_cookie(refresh_cookie) + assert token + + refresh_resp = await client.post( + refresh_url, cookies={"refresh_token": token} + ) + + assert refresh_resp.status_code == 200 + data = refresh_resp.json() + assert "access_token" in data and isinstance(data["access_token"], str) + assert data["token_type"] == "bearer" + + +async def test_refresh_missing_cookie(client): + response = await client.post(refresh_url) + assert response.status_code == 401 + assert response.json()["detail"] == "Refresh token not found in cookies" + + +async def test_refresh_invalid_token(client): + response = await client.post( + refresh_url, + cookies={"refresh_token": "invalid.refresh.token"}, + ) + assert response.status_code == 401 + + +async def test_revoke_all_tokens_success(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + login_resp = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_resp.status_code == 200 + access_token = login_resp.json()["access_token"] + + resp = await client.post( + revoke_all_tokens_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert resp.status_code == 200 + assert ( + resp.json()["message"] == "Logged out from every session successfully" + ) + + +async def test_revoke_all_tokens_unauthorized(client): + resp = await client.post(revoke_all_tokens_url) + assert resp.status_code == 401 + + +async def test_logout_success_clears_cookie(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + login_resp = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_resp.status_code == 200 + + set_cookie_header = login_resp.headers.get("set-cookie", "") + refresh_token = extract_refresh_token_from_set_cookie(set_cookie_header) + assert refresh_token + + access_token = login_resp.json()["access_token"] + + resp = await client.post( + logout_url, + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + ) + + assert resp.status_code == 200 + assert resp.json()["message"] == "Logged out successfully" + + clear_cookie_header = resp.headers.get("set-cookie", "") + assert "refresh_token=" in clear_cookie_header + assert "Max-Age=0" in clear_cookie_header + + +async def test_logout_unauthorized(client): + resp = await client.post(logout_url) + assert resp.status_code == 401 diff --git a/backend/tests/e2e/accounts/test_flow.py b/backend/tests/e2e/accounts/test_flow.py new file mode 100644 index 0000000..de08adb --- /dev/null +++ b/backend/tests/e2e/accounts/test_flow.py @@ -0,0 +1,159 @@ +from tests.utils.user import create_user_data +from .helpers import extract_refresh_token_from_set_cookie +from .routes import ( + register_url, + activate_url, + login_url, + refresh_url, + logout_url, + revoke_all_tokens_url, + profile_url, + avatar_url, +) + + +async def test_register_activate_login(client, email_sender_mock, faker): + user_data = create_user_data(faker) + response = await client.post(register_url, json=user_data) + + assert response.status_code == 201 + + sent_emails = email_sender_mock.get_sent_emails(user_data["email"]) + token = sent_emails[0]["token"] + + activate_response = await client.post( + activate_url, json={"activation_token": token} + ) + assert activate_response.status_code == 200 + + login_response = await client.post( + login_url, + json={"login": user_data["email"], "password": user_data["password"]}, + ) + assert login_response.status_code == 200 + + +async def test_login_refresh_logout_flow(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + login_resp = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_resp.status_code == 200 + access_token = login_resp.json()["access_token"] + set_cookie_header = login_resp.headers.get("set-cookie", "") + refresh_token = extract_refresh_token_from_set_cookie(set_cookie_header) + assert refresh_token + + refresh_ok = await client.post( + refresh_url, + cookies={"refresh_token": refresh_token}, + ) + assert refresh_ok.status_code == 200 + + logout_resp = await client.post( + logout_url, + headers={"Authorization": f"Bearer {access_token}"}, + cookies={"refresh_token": refresh_token}, + ) + assert logout_resp.status_code == 200 + + refresh_after_logout = await client.post( + refresh_url, + cookies={"refresh_token": refresh_token}, + ) + assert refresh_after_logout.status_code == 401 + + +async def test_revoke_all_tokens_invalidates_all_sessions( + db, user_factory, client +): + user = await user_factory.create(db, is_active=True) + await db.commit() + + login_a = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_a.status_code == 200 + access_token_a = login_a.json()["access_token"] + rt_cookie_a = extract_refresh_token_from_set_cookie( + login_a.headers.get("set-cookie", "") + ) + assert rt_cookie_a + + login_b = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_b.status_code == 200 + rt_cookie_b = extract_refresh_token_from_set_cookie( + login_b.headers.get("set-cookie", "") + ) + assert rt_cookie_b + + revoke_resp = await client.post( + revoke_all_tokens_url, + headers={"Authorization": f"Bearer {access_token_a}"}, + ) + assert revoke_resp.status_code == 200 + + refresh_a = await client.post( + refresh_url, + cookies={"refresh_token": rt_cookie_a}, + ) + refresh_b = await client.post( + refresh_url, + cookies={"refresh_token": rt_cookie_b}, + ) + + assert refresh_a.status_code == 401 + assert refresh_b.status_code == 401 + + +async def test_profile_e2e_flow(auth_client, avatar_file): + client, user = auth_client + + profile_resp = await client.get(profile_url) + assert profile_resp.status_code == 200 + profile_data = profile_resp.json() + assert profile_data["username"] == user.username + assert "email" not in profile_data + + profile_specific = await client.get(f"{profile_url}{user.username}") + assert profile_specific.status_code == 200 + data = profile_specific.json() + assert data["username"] == user.username + + update_data = { + "first_name": "John", + "last_name": "Doe", + "info": "QA engineer", + } + patch_resp = await client.patch(profile_url, json=update_data) + assert patch_resp.status_code == 200 + patched = patch_resp.json() + assert patched["first_name"] == "John" + assert patched["info"] == "QA engineer" + + avatar_resp = await client.post( + avatar_url, + files={"avatar": avatar_file}, + ) + assert avatar_resp.status_code == 200 + assert avatar_resp.json()["message"] == "Profile picture updated" + + delete_resp = await client.delete(avatar_url) + assert delete_resp.status_code == 200 + assert ( + delete_resp.json()["message"] + == "Profile avatar has been deleted successfully" + ) + + +async def test_profile_not_found_returns_404(auth_client): + client, _ = auth_client + resp = await client.get(f"{profile_url}/nonexistent_user") + assert resp.status_code == 404 diff --git a/backend/tests/e2e/accounts/test_password.py b/backend/tests/e2e/accounts/test_password.py new file mode 100644 index 0000000..1239436 --- /dev/null +++ b/backend/tests/e2e/accounts/test_password.py @@ -0,0 +1,142 @@ +from .routes import ( + reset_password_request_url, + reset_password_complete_url, + change_password_url, + login_url, +) + +message_request = ( + "If an account with that email exists, " + "we've sent password reset instructions. Please check your inbox" +) + + +async def test_reset_password_request_existing_email_sends_email( + db, user_factory, client, email_sender_mock +): + user = await user_factory.create(db, is_active=True) + await db.commit() + + resp = await client.post( + reset_password_request_url, + json={"email": user.email}, + ) + + assert resp.status_code == 202 + assert resp.json()["message"] == message_request + + sent = email_sender_mock.get_sent_emails(user.email) + assert len(sent) == 1 + assert sent[0]["type"] == "password_reset" + assert "token" in sent[0] and isinstance(sent[0]["token"], str) + + +async def test_reset_password_request_nonexistent_email_noop( + client, faker, email_sender_mock +): + email = faker.unique.email() + + resp = await client.post( + reset_password_request_url, + json={"email": email}, + ) + + assert resp.status_code == 202 + assert resp.json()["message"] == message_request + + sent = email_sender_mock.get_sent_emails(email) + assert len(sent) == 0 + + +async def test_reset_password_complete_success(db, user_factory, client): + user, token = await user_factory.create_with_reset_token( + db, "token_rst_123" + ) + await db.commit() + + new_password = "NewPass123!" + + resp = await client.post( + reset_password_complete_url, + json={ + "email": user.email, + "password": new_password, + "password_reset_token": token, + }, + ) + + assert resp.status_code == 200 + assert resp.json()["message"] == "Password has been successfully changed" + + login_resp = await client.post( + login_url, + json={"login": user.email, "password": new_password}, + ) + assert login_resp.status_code == 200 + + bad_login = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert bad_login.status_code == 401 + + +async def test_change_password_success(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + login_resp = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_resp.status_code == 200 + access_token = login_resp.json()["access_token"] + + new_password = "BrandNew1!" + + resp = await client.post( + change_password_url, + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "old_password": "Test1234!", + "new_password": new_password, + }, + ) + + assert resp.status_code == 200 + assert resp.json()["message"] == "Password has been successfully changed" + + good_login = await client.post( + login_url, + json={"login": user.email, "password": new_password}, + ) + assert good_login.status_code == 200 + + bad_login = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert bad_login.status_code == 401 + + +async def test_change_password_invalid_old_password(db, user_factory, client): + user = await user_factory.create(db, is_active=True) + await db.commit() + + login_resp = await client.post( + login_url, + json={"login": user.email, "password": "Test1234!"}, + ) + assert login_resp.status_code == 200 + access_token = login_resp.json()["access_token"] + + resp = await client.post( + change_password_url, + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "old_password": "WrongPass123!", + "new_password": "AnotherNew1!", + }, + ) + + assert resp.status_code == 403 diff --git a/backend/tests/e2e/accounts/test_profile.py b/backend/tests/e2e/accounts/test_profile.py new file mode 100644 index 0000000..20f66d0 --- /dev/null +++ b/backend/tests/e2e/accounts/test_profile.py @@ -0,0 +1,123 @@ +import pytest_asyncio + +from .routes import profile_url, avatar_url + + +@pytest_asyncio.fixture +async def another_user_with_profile(db, user_factory, profile_repo): + user, profile = await user_factory.create_with_profile(db, profile_repo) + await db.commit() + return user, profile + + +async def test_get_profile_success(auth_client): + client, user = auth_client + response = await client.get(profile_url) + + assert response.status_code == 200 + data = response.json() + assert data["username"] == user.username + assert data["first_name"] == user.profile.first_name + assert data["last_name"] == user.profile.last_name + assert data["info"] == user.profile.info + + +async def test_get_profile_unauthorized(client): + response = await client.get(profile_url) + assert response.status_code == 401 + + +async def test_get_specific_user_profile_success( + auth_client, another_user_with_profile +): + client, _ = auth_client + user, profile = another_user_with_profile + response = await client.get(f"{profile_url}{user.username}") + + assert response.status_code == 200 + data = response.json() + assert data["username"] == user.username + assert data["first_name"] == profile.first_name + assert data["last_name"] == profile.last_name + assert data["info"] == profile.info + + +async def test_get_specific_user_profile_not_found(auth_client): + client, _ = auth_client + response = await client.get(f"{profile_url}non_existent_user") + assert response.status_code == 404 + + +async def test_get_specific_user_profile_unauthorized(client): + response = await client.get(f"{profile_url}some_user") + assert response.status_code == 401 + + +async def test_update_profile_invalid_data(auth_client): + client, _ = auth_client + profile_data = { + "first_name": 123, + "last_name": "Doe", + "info": "Test info", + } + response = await client.patch(profile_url, json=profile_data) + assert response.status_code == 422 + + +async def test_update_profile_unauthorized(client): + profile_data = { + "first_name": "John", + "last_name": "Doe", + "info": "Test info", + } + response = await client.patch(profile_url, json=profile_data) + assert response.status_code == 401 + + +async def test_update_profile_success(auth_client): + client, user = auth_client + profile_data = { + "first_name": "John", + "last_name": "Doe", + "info": "Test info", + } + response = await client.patch(profile_url, json=profile_data) + assert response.status_code == 200 + data = response.json() + assert data["first_name"] == profile_data["first_name"] + assert data["last_name"] == profile_data["last_name"] + assert data["info"] == profile_data["info"] + + +async def test_delete_profile_avatar_success(auth_client, avatar_file): + client, _ = auth_client + await client.post(avatar_url, files={"avatar": avatar_file}) + response = await client.delete(avatar_url) + assert response.status_code == 200 + assert response.json() == { + "message": "Profile avatar has been deleted successfully" + } + + +async def test_delete_profile_avatar_unauthorized(client): + response = await client.delete(avatar_url) + assert response.status_code == 401 + + +async def test_set_profile_avatar_invalid_format(auth_client): + client, _ = auth_client + with open("tests/invalid_avatar.txt", "rb") as f: + response = await client.post(avatar_url, files={"avatar": f}) + assert response.status_code == 422 + + +async def test_set_profile_avatar_unauthorized(client, avatar_file): + response = await client.post(avatar_url, files={"avatar": avatar_file}) + assert response.status_code == 401 + + +async def test_set_profile_avatar_success(auth_client, avatar_file): + client, _ = auth_client + response = await client.post(avatar_url, files={"avatar": avatar_file}) + assert response.status_code == 200 + assert response.json() == {"message": "Profile picture updated"} diff --git a/backend/tests/e2e/accounts/test_register.py b/backend/tests/e2e/accounts/test_register.py new file mode 100644 index 0000000..3d00525 --- /dev/null +++ b/backend/tests/e2e/accounts/test_register.py @@ -0,0 +1,75 @@ +from tests.utils.user import create_user_data +from .routes import register_url + + +async def test_register_new_user(client, email_sender_mock, faker): + user_data = create_user_data(faker) + response = await client.post(register_url, json=user_data) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == user_data["email"] + assert data["username"] == user_data["username"] + + sent_emails = email_sender_mock.get_sent_emails(user_data["email"]) + assert len(sent_emails) == 1 + assert sent_emails[0]["type"] == "activation" + assert "token" in sent_emails[0] + + +async def test_register_duplicate_email(db, user_factory, client): + user = await user_factory.create(db) + await db.commit() + + response = await client.post( + register_url, + json={ + "email": user.email, + "password": "Test123!", + "username": "newuser", + }, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "This email is taken." + + +async def test_register_duplicate_username(db, user_factory, client): + user = await user_factory.create(db) + await db.commit() + + response = await client.post( + register_url, + json={ + "email": "uniq@test.com", + "password": "Test123!", + "username": user.username, + }, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "This username is taken." + + +async def test_register_invalid_email(client, faker): + response = await client.post( + register_url, + json={ + "email": "invalid-email", + "password": "SecurePass123!", + "username": faker.unique.user_name(), + }, + ) + assert response.status_code == 422 + + +async def test_register_weak_password(client, faker): + response = await client.post( + register_url, + json={ + "email": "user@example.com", + "password": "123", + "username": faker.unique.user_name(), + }, + ) + assert response.status_code == 422 diff --git a/backend/tests/e2e/snippets/__init__.py b/backend/tests/e2e/snippets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/e2e/snippets/routes.py b/backend/tests/e2e/snippets/routes.py new file mode 100644 index 0000000..9e1dc81 --- /dev/null +++ b/backend/tests/e2e/snippets/routes.py @@ -0,0 +1,5 @@ +route_prefix = "/api/v1/snippets" + +snippet_url = f"{route_prefix}/" +favorites_url = f"{route_prefix}/favorites/" +search_url = f"{route_prefix}/search/" diff --git a/backend/tests/e2e/snippets/test_favorites.py b/backend/tests/e2e/snippets/test_favorites.py new file mode 100644 index 0000000..4ae0c6d --- /dev/null +++ b/backend/tests/e2e/snippets/test_favorites.py @@ -0,0 +1,233 @@ +from uuid import uuid4 + +import pytest + +from src.adapters.postgres.models import LanguageEnum +from src.core.exceptions import FavoritesAlreadyError +from .routes import favorites_url + + +async def test_add_favorite_success( + auth_client, setup_snippets, favorites_repo, db +): + client, user = auth_client + + target_snippet = setup_snippets["u1_public_py"] + + resp = await client.post( + favorites_url, json={"uuid": str(target_snippet.uuid)} + ) + assert resp.status_code == 201 + + body = resp.json() + assert body["message"] == "Snippet added to favorites" + + with pytest.raises(FavoritesAlreadyError): + await favorites_repo.add_to_favorites(user, target_snippet.uuid) + await db.rollback() + + +async def test_add_favorite_unauthorized(client, setup_snippets): + target_snippet = setup_snippets["u1_public_py"] + + resp = await client.post( + favorites_url, json={"uuid": str(target_snippet.uuid)} + ) + assert resp.status_code == 401 + assert "detail" in resp.json() + + +async def test_add_favorite_not_found(auth_client): + client, _ = auth_client + + resp = await client.post(favorites_url, json={"uuid": str(uuid4())}) + assert resp.status_code == 404 + assert resp.json().get("detail") == "Snippet with this UUID was not found" + + +async def test_add_favorite_conflict(auth_client, setup_snippets): + client, _ = auth_client + target_snippet = setup_snippets["u1_public_py"] + + resp1 = await client.post( + favorites_url, json={"uuid": str(target_snippet.uuid)} + ) + assert resp1.status_code == 201 + + resp2 = await client.post( + favorites_url, json={"uuid": str(target_snippet.uuid)} + ) + assert resp2.status_code == 409 + assert ( + resp2.json().get("detail") + == "Snippet with this UUID already favorited" + ) + + +async def _add_favorites_for(auth_client, setup_snippets): + client, user = auth_client + targets = [ + setup_snippets["u1_public_py"], + setup_snippets["u1_public_js"], + setup_snippets["u2_public_py"], + ] + for sn in targets: + resp = await client.post(favorites_url, json={"uuid": str(sn.uuid)}) + assert resp.status_code in (201, 409) + return targets + + +async def test_get_favorites_basic(auth_client, setup_snippets): + client, _ = auth_client + targets = await _add_favorites_for(auth_client, setup_snippets) + + resp = await client.get(favorites_url) + assert resp.status_code == 200 + + body = resp.json() + assert body["page"] == 1 + assert body["per_page"] == 10 + assert body["total_items"] == len(targets) + assert body["total_pages"] == 1 + + items = body["snippets"] + assert len(items) == len(targets) + target_uuids = {str(t.uuid) for t in targets} + assert target_uuids.issuperset({it["uuid"] for it in items}) + + +async def test_get_favorites_unauthorized(client): + resp = await client.get(favorites_url) + assert resp.status_code == 401 + assert "detail" in resp.json() + + +async def test_get_favorites_filter_language(auth_client, setup_snippets): + client, _ = auth_client + await _add_favorites_for(auth_client, setup_snippets) + + resp = await client.get( + favorites_url, params={"language": LanguageEnum.PYTHON.value} + ) + assert resp.status_code == 200 + data = resp.json() + + assert data["total_items"] >= 1 + assert all( + it["language"] == LanguageEnum.PYTHON + or it["language"] == LanguageEnum.PYTHON.value + for it in data["snippets"] + ) + + +async def test_get_favorites_filter_tag(auth_client, setup_snippets): + client, _ = auth_client + await _add_favorites_for(auth_client, setup_snippets) + + resp = await client.get(favorites_url, params=[("tags", "test")]) + assert resp.status_code == 200 + data = resp.json() + + assert ( + all("test" in it.get("tags", []) for it in data["snippets"]) + or data["total_items"] == 0 + ) + + +async def test_get_favorites_filter_username(auth_client, setup_snippets): + client, _ = auth_client + await _add_favorites_for(auth_client, setup_snippets) + + owner_username = setup_snippets["user2"].username + resp = await client.get(favorites_url, params={"username": owner_username}) + assert resp.status_code == 200 + data = resp.json() + assert ( + all(it["username"] == owner_username for it in data["snippets"]) + or data["total_items"] == 0 + ) + + +async def test_get_favorites_sorting_title(auth_client, setup_snippets): + client, _ = auth_client + await _add_favorites_for(auth_client, setup_snippets) + + resp = await client.get(favorites_url, params={"sort_by": "title"}) + assert resp.status_code == 200 + titles = [it["title"] for it in resp.json()["snippets"]] + assert titles == sorted(titles, key=str.lower) + + +async def test_get_favorites_pagination(auth_client, setup_snippets): + client, _ = auth_client + await _add_favorites_for(auth_client, setup_snippets) + + resp1 = await client.get(favorites_url, params={"per_page": 2, "page": 1}) + resp2 = await client.get(favorites_url, params={"per_page": 2, "page": 2}) + + assert resp1.status_code == 200 and resp2.status_code == 200 + b1, b2 = resp1.json(), resp2.json() + + assert b1["total_items"] in (2, 3) + assert b1["per_page"] == 2 + assert b2["per_page"] == 2 + + ids1 = {it["uuid"] for it in b1["snippets"]} + ids2 = {it["uuid"] for it in b2["snippets"]} + assert not ids1.intersection(ids2) + + +async def test_get_favorites_empty(auth_client): + client, _ = auth_client + resp = await client.get(favorites_url) + assert resp.status_code == 200 + data = resp.json() + assert data["total_items"] == 0 + assert data["snippets"] == [] + + +async def test_delete_favorite_success( + auth_client, setup_snippets, favorites_repo, db +): + client, user = auth_client + target = setup_snippets["u1_public_py"] + + resp_add = await client.post( + favorites_url, json={"uuid": str(target.uuid)} + ) + assert resp_add.status_code in (201, 409) + + resp_del = await client.delete(f"{favorites_url}{target.uuid}") + assert resp_del.status_code == 200 + assert resp_del.json().get("message") == "Snippet removed from favorites" + + with pytest.raises(FavoritesAlreadyError): + await favorites_repo.remove_from_favorites(user, target.uuid) + await db.rollback() + + +async def test_delete_favorite_idempotent(auth_client, setup_snippets): + client, _ = auth_client + target = setup_snippets["u1_public_js"] + + resp1 = await client.delete(f"{favorites_url}{target.uuid}") + resp2 = await client.delete(f"{favorites_url}{target.uuid}") + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + assert resp1.json().get("message") == "Snippet removed from favorites" + assert resp2.json().get("message") == "Snippet removed from favorites" + + +async def test_delete_favorite_not_found(auth_client): + client, _ = auth_client + resp = await client.delete(f"{favorites_url}{uuid4()}") + assert resp.status_code == 404 + assert resp.json().get("detail") == "Snippet with this UUID was not found" + + +async def test_delete_favorite_unauthorized(client, setup_snippets): + target = setup_snippets["u2_public_py"] + resp = await client.delete(f"{favorites_url}{target.uuid}") + assert resp.status_code == 401 + assert "detail" in resp.json() diff --git a/backend/tests/e2e/snippets/test_flow.py b/backend/tests/e2e/snippets/test_flow.py new file mode 100644 index 0000000..91ecf83 --- /dev/null +++ b/backend/tests/e2e/snippets/test_flow.py @@ -0,0 +1,353 @@ +from src.adapters.postgres.models import LanguageEnum +from .routes import snippet_url, favorites_url, search_url + + +async def test_full_crud_flow(auth_client): + client, _ = auth_client + + create_payload = { + "title": "Flow One", + "language": LanguageEnum.PYTHON.value, + "content": "print('v1')", + "description": "d1", + "is_private": False, + "tags": ["One_Tag", " another"], + } + c = await client.post(snippet_url, json=create_payload) + assert c.status_code == 201 + created = c.json() + uuid = created["uuid"] + + g1 = await client.get(f"{snippet_url}{uuid}") + assert g1.status_code == 200 + + p = await client.patch( + f"{snippet_url}{uuid}", + json={"title": "Flow One Updated", "tags": ["tAg_1", " two "]}, + ) + assert p.status_code == 200 + pdata = p.json() + assert pdata["title"] == "Flow One Updated" + assert sorted(pdata.get("tags", [])) == sorted(["tag_1", "two"]) or sorted( + pdata.get("tags", []) + ) == sorted(["tag_1", "two"]) + + g2 = await client.get(f"{snippet_url}{uuid}") + assert g2.status_code == 200 + g2d = g2.json() + assert g2d["title"] == "Flow One Updated" + + full = await client.patch( + f"{snippet_url}{uuid}", + json={ + "title": "Flow One Final", + "language": LanguageEnum.JAVASCRIPT.value, + "is_private": True, + "content": "console.log('final')", + "description": "d2", + "tags": ["JS", "final tag"], + }, + ) + assert full.status_code == 200 + fdata = full.json() + assert fdata["title"] == "Flow One Final" + assert fdata["is_private"] is True + assert fdata["content"].startswith("console.log") + + lst = await client.get( + snippet_url, + params={ + "language": LanguageEnum.JAVASCRIPT.value, + "visibility": "private", + "tags": "finaltag", + }, + ) + assert lst.status_code == 200 + ldata = lst.json() + assert any(item["uuid"] == uuid for item in ldata["snippets"]) + + d = await client.delete(f"{snippet_url}{uuid}") + assert d.status_code == 200 + g3 = await client.get(f"{snippet_url}{uuid}") + assert g3.status_code == 404 + + +async def test_permission_flow_two_users( + db, client, auth_service, user_factory +): + user_a = await user_factory.create_active(db) + tokens_a = await auth_service.login_user(user_a.email, "Test1234!") + client_a = client + client_a.headers["Authorization"] = f"Bearer {tokens_a['access_token']}" + + c_priv = await client_a.post( + snippet_url, + json={ + "title": "Priv A", + "language": LanguageEnum.PYTHON.value, + "content": "print('a')", + "is_private": True, + }, + ) + assert c_priv.status_code == 201 + priv_uuid = c_priv.json()["uuid"] + + c_pub = await client_a.post( + snippet_url, + json={ + "title": "Pub A", + "language": LanguageEnum.PYTHON.value, + "content": "print('a2')", + "is_private": False, + }, + ) + assert c_pub.status_code == 201 + pub_uuid = c_pub.json()["uuid"] + + user_b = await user_factory.create_active(db) + tokens_b = await auth_service.login_user(user_b.email, "Test1234!") + client_b = client + client_b.headers["Authorization"] = f"Bearer {tokens_b['access_token']}" + + assert (await client_b.get(f"{snippet_url}{pub_uuid}")).status_code == 200 + assert (await client_b.get(f"{snippet_url}{priv_uuid}")).status_code == 403 + + assert ( + await client_b.patch(f"{snippet_url}{pub_uuid}", json={"title": "xxx"}) + ).status_code == 403 + assert ( + await client_b.delete(f"{snippet_url}{pub_uuid}") + ).status_code == 403 + + client_a.headers["Authorization"] = f"Bearer {tokens_a['access_token']}" + assert ( + await client_a.delete(f"{snippet_url}{priv_uuid}") + ).status_code == 200 + + +async def test_visibility_and_pagination_flow(auth_client, setup_snippets): + client, user = auth_client + + base = await client.get(snippet_url) + assert base.status_code == 200 + b = base.json() + assert b["page"] == 1 and b["per_page"] > 0 + assert b["total_items"] >= 3 + + pub = await client.get(snippet_url, params={"visibility": "public"}) + assert pub.status_code == 200 + pdata = pub.json() + assert all(not it["is_private"] for it in pdata["snippets"]) + + priv = await client.get(snippet_url, params={"visibility": "private"}) + assert priv.status_code == 200 + pr = priv.json() + assert all(it["is_private"] for it in pr["snippets"]) or pr[ + "total_items" + ] in (0, 1) + + p1 = await client.get(snippet_url, params={"per_page": 2, "page": 1}) + p2 = await client.get(snippet_url, params={"per_page": 2, "page": 2}) + assert p1.status_code == p2.status_code == 200 + d1, d2 = p1.json(), p2.json() + assert d1["total_pages"] >= 1 + uuids_p1 = {it["uuid"] for it in d1["snippets"]} + uuids_p2 = {it["uuid"] for it in d2["snippets"]} + assert not uuids_p1.intersection(uuids_p2) + + +async def test_favorites_full_flow(auth_client, setup_snippets): + client, user = auth_client + + targets = [ + setup_snippets["u1_public_py"], + setup_snippets["u1_public_js"], + setup_snippets["u2_public_py"], + ] + + added_uuids = [] + for snippet in targets: + resp = await client.post( + favorites_url, json={"uuid": str(snippet.uuid)} + ) + assert resp.status_code in (201, 409) + if resp.status_code == 201: + added_uuids.append(str(snippet.uuid)) + + resp = await client.get(favorites_url) + assert resp.status_code == 200 + favorites_data = resp.json() + + favorite_uuids = {item["uuid"] for item in favorites_data["snippets"]} + assert all(uuid in favorite_uuids for uuid in added_uuids) + + python_favorites = await client.get( + favorites_url, params={"language": LanguageEnum.PYTHON.value} + ) + assert python_favorites.status_code == 200 + python_data = python_favorites.json() + + assert all( + item["language"] in (LanguageEnum.PYTHON, LanguageEnum.PYTHON.value) + for item in python_data["snippets"] + ) + + tagged_favorites = await client.get(favorites_url, params={"tags": "test"}) + assert tagged_favorites.status_code == 200 + + paginated_favorites = await client.get( + favorites_url, params={"per_page": 2, "page": 1} + ) + assert paginated_favorites.status_code == 200 + paginated_data = paginated_favorites.json() + + assert paginated_data["per_page"] == 2 + assert len(paginated_data["snippets"]) <= 2 + + target_to_remove = targets[0] + delete_resp = await client.delete( + f"{favorites_url}{target_to_remove.uuid}" + ) + assert delete_resp.status_code == 200 + + after_delete_resp = await client.get(favorites_url) + assert after_delete_resp.status_code == 200 + after_delete_data = after_delete_resp.json() + + remaining_uuids = {item["uuid"] for item in after_delete_data["snippets"]} + assert str(target_to_remove.uuid) not in remaining_uuids + + +async def test_favorites_and_search_integration_flow( + auth_client, setup_snippets +): + client, user = auth_client + + test_snippets = [ + { + "title": "Integration Test Python", + "language": LanguageEnum.PYTHON.value, + "content": "print('integration')", + "is_private": False, + "tags": ["integration", "test"], + }, + { + "title": "Integration Test JS", + "language": LanguageEnum.JAVASCRIPT.value, + "content": "console.log('integration')", + "is_private": False, + "tags": ["integration", "javascript"], + }, + ] + + created_uuids = [] + for snippet_data in test_snippets: + resp = await client.post(snippet_url, json=snippet_data) + assert resp.status_code == 201 + created_uuids.append(resp.json()["uuid"]) + + search_resp = await client.get(f"{search_url}Integration") + assert search_resp.status_code == 200 + search_results = search_resp.json()["results"] + + integration_uuids = { + item["uuid"] + for item in search_results + if "Integration" in item["title"] + } + + for uuid in integration_uuids: + fav_resp = await client.post(favorites_url, json={"uuid": uuid}) + assert fav_resp.status_code in (201, 409) + + favorites_resp = await client.get(favorites_url) + assert favorites_resp.status_code == 200 + favorites_data = favorites_resp.json() + + favorite_uuids = {item["uuid"] for item in favorites_data["snippets"]} + assert integration_uuids.issubset(favorite_uuids) + + python_favs = await client.get( + favorites_url, + params={"language": LanguageEnum.PYTHON.value, "tags": "integration"}, + ) + assert python_favs.status_code == 200 + python_favs_data = python_favs.json() + + python_integration_found = any( + item["language"] in (LanguageEnum.PYTHON, LanguageEnum.PYTHON.value) + and "integration" in [tag.lower() for tag in item.get("tags", [])] + for item in python_favs_data["snippets"] + ) + assert python_integration_found + + for uuid in created_uuids: + await client.delete(f"{snippet_url}{uuid}") + for uuid in integration_uuids: + await client.delete(f"{favorites_url}{uuid}") + + +async def test_favorites_pagination_search_flow(auth_client): + client, user = auth_client + + base_title = "Pagination Test" + snippets_to_create = 15 + + created_uuids = [] + for i in range(snippets_to_create): + snippet_data = { + "title": f"{base_title} {i:02d}", + "language": LanguageEnum.PYTHON.value, + "content": f"print('pagination {i}')", + "is_private": False, + "tags": ["pagination", f"test{i}"], + } + resp = await client.post(snippet_url, json=snippet_data) + assert resp.status_code == 201 + created_uuids.append(resp.json()["uuid"]) + + for uuid in created_uuids: + fav_resp = await client.post(favorites_url, json={"uuid": uuid}) + assert fav_resp.status_code in (201, 409) + + page1_resp = await client.get( + favorites_url, params={"per_page": 5, "page": 1, "tags": "pagination"} + ) + assert page1_resp.status_code == 200 + page1_data = page1_resp.json() + + assert page1_data["page"] == 1 + assert page1_data["per_page"] == 5 + assert len(page1_data["snippets"]) == 5 + + page2_resp = await client.get( + favorites_url, params={"per_page": 5, "page": 2, "tags": "pagination"} + ) + assert page2_resp.status_code == 200 + page2_data = page2_resp.json() + + assert page2_data["page"] == 2 + assert len(page2_data["snippets"]) == 5 + + page1_uuids = {item["uuid"] for item in page1_data["snippets"]} + page2_uuids = {item["uuid"] for item in page2_data["snippets"]} + assert not page1_uuids.intersection(page2_uuids) + + search_resp = await client.get(f"{search_url}Pagination Test 07") + assert search_resp.status_code == 200 + search_data = search_resp.json() + + assert len(search_data["results"]) == 1 + searched_uuid = search_data["results"][0]["uuid"] + + all_favorites = await client.get(favorites_url) + all_favorites_data = all_favorites.json() + + all_favorite_uuids = { + item["uuid"] for item in all_favorites_data["snippets"] + } + assert searched_uuid in all_favorite_uuids + + for uuid in created_uuids: + await client.delete(f"{snippet_url}{uuid}") + await client.delete(f"{favorites_url}{uuid}") diff --git a/backend/tests/e2e/snippets/test_search.py b/backend/tests/e2e/snippets/test_search.py new file mode 100644 index 0000000..4655fb5 --- /dev/null +++ b/backend/tests/e2e/snippets/test_search.py @@ -0,0 +1,82 @@ +from urllib.parse import quote + +from src.adapters.postgres.models import LanguageEnum +from .routes import search_url, snippet_url + + +async def test_search_unauthorized(client, setup_snippets): + target_title = setup_snippets["u1_public_py"].title + resp = await client.get(f"{search_url}{quote(target_title)}") + assert resp.status_code == 401 + assert "detail" in resp.json() + + +async def test_search_basic_http_success(auth_client, setup_snippets): + client, _ = auth_client + target_snippet = setup_snippets["u1_public_js"] + + resp = await client.get(f"{search_url}{quote(target_snippet.title)}") + assert resp.status_code == 200 + data = resp.json() + + assert isinstance(data.get("results"), list) + assert len(data["results"]) <= 20 + + uuids = {item["uuid"] for item in data["results"]} + assert str(target_snippet.uuid) in uuids + + for item in data["results"]: + assert {"uuid", "title", "language"}.issubset(item.keys()) + + +async def test_search_case_insensitive_and_url_encoding(auth_client): + client, user = auth_client + + title = "My Fancy Snippet v1" + payload = { + "title": title, + "description": "E2E search encoding test", + "language": LanguageEnum.PYTHON.value, + "content": "print('encoding')", + "is_private": False, + "tags": ["search", "e2e"], + } + create_resp = await client.post(snippet_url, json=payload) + assert create_resp.status_code == 201 + + query = title.lower() + search_resp = await client.get(f"{search_url}{quote(query)}") + assert search_resp.status_code == 200 + items = search_resp.json()["results"] + assert any(item["title"].lower() == query for item in items) + + +async def test_search_limit_enforced(auth_client): + client, user = auth_client + + prefix = "Batch Item" + for i in range(25): + payload = { + "title": f"{prefix} {i}", + "language": LanguageEnum.PYTHON.value, + "content": f"print({i})", + "is_private": False, + } + resp = await client.post(snippet_url, json=payload) + assert resp.status_code == 201 + + resp = await client.get(f"{search_url}{quote(prefix)}") + assert resp.status_code == 200 + results = resp.json()["results"] + assert len(results) <= 20 + assert all(prefix.lower() in item["title"].lower() for item in results) + + +async def test_search_excludes_others_private(auth_client, setup_snippets): + client, _ = auth_client + other_private = setup_snippets["u2_private_js"] + + resp = await client.get(f"{search_url}{quote(other_private.title)}") + assert resp.status_code == 200 + items = resp.json()["results"] + assert all(item["uuid"] != str(other_private.uuid) for item in items) diff --git a/backend/tests/e2e/snippets/test_snippets.py b/backend/tests/e2e/snippets/test_snippets.py new file mode 100644 index 0000000..1f02bc8 --- /dev/null +++ b/backend/tests/e2e/snippets/test_snippets.py @@ -0,0 +1,443 @@ +from datetime import datetime, timezone, timedelta + +from src.adapters.postgres.models import LanguageEnum +from .routes import snippet_url + + +async def test_create_snippet_success(auth_client, db, snippet_model_repo): + client, user = auth_client + + data = { + "title": "New Snippet", + "description": "Simple test snippet", + "language": LanguageEnum.PYTHON.value, + "content": "print('Hello, World!')", + "is_private": False, + "tags": ["test", "example"], + } + + response = await client.post(snippet_url, json=data) + assert response.status_code == 201 + + body = response.json() + assert body["title"] == data["title"] + assert body["language"] == data["language"] + assert body["username"] == user.username + + snippet = await snippet_model_repo.get_by_title(data["title"], user.id) + assert snippet is not None + + +async def test_create_snippet_unauthorized(client): + data = { + "title": "Unauthorized Snippet", + "description": "Should fail", + "language": LanguageEnum.PYTHON.value, + "content": "print('No auth')", + "is_private": False, + } + + response = await client.post(snippet_url, json=data) + assert response.status_code == 401 + assert "detail" in response.json() + + +async def test_create_snippet_duplicate_title(auth_client, db): + client, user = auth_client + + data = { + "title": "Duplicate Snippet", + "language": LanguageEnum.PYTHON.value, + "content": "print('dup')", + "is_private": False, + } + + response1 = await client.post(snippet_url, json=data) + assert response1.status_code == 201 + + response2 = await client.post(snippet_url, json=data) + assert response2.status_code == 409 + + +async def test_create_snippet_validation_error(auth_client): + client, _ = auth_client + + invalid_data = { + "title": "", + "language": "INVALID_LANG", + "content": "", + } + + response = await client.post(snippet_url, json=invalid_data) + assert response.status_code == 422 + + +async def test_get_all_snippets_basic(auth_client, setup_snippets): + client, _ = auth_client + + response = await client.get(snippet_url) + assert response.status_code == 200 + + body = response.json() + assert body["page"] == 1 + assert body["per_page"] == 10 + assert body["total_items"] == 3 + assert body["total_pages"] == 1 + + items = body["snippets"] + assert len(items) == 3 + assert all(item["is_private"] is False for item in items) + + +async def test_get_all_snippets_filter_language(auth_client, setup_snippets): + client, _ = auth_client + + response = await client.get( + snippet_url, params={"language": LanguageEnum.PYTHON.value} + ) + assert response.status_code == 200 + data = response.json() + + assert data["total_items"] == 2 + assert all( + item["language"] == LanguageEnum.PYTHON for item in data["snippets"] + ) or all( + item["language"] == LanguageEnum.PYTHON.value + for item in data["snippets"] + ) + + +async def test_get_all_snippets_filter_username(auth_client, setup_snippets): + client, _ = auth_client + user1 = setup_snippets["user1"] + + response = await client.get( + snippet_url, params={"username": user1.username} + ) + assert response.status_code == 200 + data = response.json() + + assert data["total_items"] == 2 + assert len(data["snippets"]) == 2 + + +async def test_get_all_snippets_filter_tags(auth_client, setup_snippets): + client, _ = auth_client + + response = await client.get(snippet_url, params={"tags": "test"}) + assert response.status_code == 200 + data = response.json() + + assert data["total_items"] == 2 + assert len(data["snippets"]) == 2 + assert all("test" in item["tags"] for item in data["snippets"]) + + +async def test_get_all_snippets_filter_created_before( + auth_client, setup_snippets +): + client, _ = auth_client + + created_before = ( + (datetime.now(timezone.utc) - timedelta(days=3)).date().isoformat() + ) + + response = await client.get( + snippet_url, params={"created_before": created_before} + ) + assert response.status_code == 200 + data = response.json() + + assert data["total_items"] == 1 + assert len(data["snippets"]) == 1 + + +async def test_get_all_snippets_pagination(auth_client, setup_snippets): + client, _ = auth_client + + resp1 = await client.get(snippet_url, params={"per_page": 2, "page": 1}) + assert resp1.status_code == 200 + d1 = resp1.json() + assert d1["per_page"] == 2 + assert d1["page"] == 1 + assert d1["total_items"] == 3 + assert d1["total_pages"] == 2 + assert len(d1["snippets"]) == 2 + assert d1["next_page"] + assert d1["prev_page"] is None + + resp2 = await client.get(snippet_url, params={"per_page": 2, "page": 2}) + assert resp2.status_code == 200 + d2 = resp2.json() + assert d2["page"] == 2 + assert len(d2["snippets"]) == 1 + assert d2["prev_page"] + + +async def test_get_snippet_public_success(auth_client, setup_snippets): + client, _ = auth_client + public_snippet = setup_snippets["u1_public_py"] + + response = await client.get(f"{snippet_url}{public_snippet.uuid}") + assert response.status_code == 200 + data = response.json() + + assert data["uuid"] == str(public_snippet.uuid) + assert data["title"] == public_snippet.title + assert isinstance(data["language"], str) + assert "username" in data + assert "created_at" in data and "updated_at" in data + assert data["is_private"] is False + assert isinstance(data.get("tags", []), list) + + +async def test_get_snippet_private_other_user_forbidden( + auth_client, setup_snippets +): + client, _ = auth_client + private_snippet = setup_snippets["u1_private_py"] + + response = await client.get(f"{snippet_url}{private_snippet.uuid}") + assert response.status_code == 403 + body = response.json() + assert "detail" in body + + +async def test_get_snippet_private_owner_success(auth_client): + client, _ = auth_client + create_payload = { + "title": "My Private", + "language": LanguageEnum.PYTHON.value, + "content": "print('secret')", + "is_private": True, + "tags": ["secret"], + } + create_resp = await client.post(snippet_url, json=create_payload) + assert create_resp.status_code == 201 + created = create_resp.json() + + get_resp = await client.get(f"{snippet_url}{created['uuid']}") + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["uuid"] == created["uuid"] + assert data["is_private"] is True + assert data["title"] == create_payload["title"] + + +async def test_get_snippet_not_found(auth_client): + client, _ = auth_client + from uuid import uuid4 + + nonexistent_uuid = uuid4() + response = await client.get(f"{snippet_url}{nonexistent_uuid}") + assert response.status_code == 404 + body = response.json() + assert "detail" in body + + +async def test_update_snippet_owner_partial_update_success(auth_client): + client, _ = auth_client + + create_payload = { + "title": "Patch Me", + "language": LanguageEnum.PYTHON.value, + "content": "print('v1')", + "is_private": False, + "description": "desc v1", + "tags": ["FirstTag", "second tag"], + } + create_resp = await client.post(snippet_url, json=create_payload) + assert create_resp.status_code == 201 + created = create_resp.json() + + patch_payload = { + "title": "Patched Title", + "tags": ["New_Tag", " Another "], + } + patch_resp = await client.patch( + f"{snippet_url}{created['uuid']}", json=patch_payload + ) + assert patch_resp.status_code == 200 + data = patch_resp.json() + + assert data["uuid"] == created["uuid"] + assert data["title"] == patch_payload["title"] + assert sorted(data.get("tags", [])) == sorted(["new_tag", "another"]) + assert isinstance(data["language"], str) + assert data["content"] == create_payload["content"] + assert data["description"] == create_payload["description"] + assert data["is_private"] is False + + get_resp = await client.get(f"{snippet_url}{created['uuid']}") + assert get_resp.status_code == 200 + got = get_resp.json() + assert got["title"] == patch_payload["title"] + assert sorted(got.get("tags", [])) == sorted(["new_tag", "another"]) + + +async def test_update_snippet_owner_full_update_success(auth_client): + client, _ = auth_client + + create_payload = { + "title": "Full Patch", + "language": LanguageEnum.PYTHON.value, + "content": "print('old')", + "is_private": False, + "description": "old desc", + "tags": ["old", "t1"], + } + c = await client.post(snippet_url, json=create_payload) + assert c.status_code == 201 + created = c.json() + + update_payload = { + "title": "Full Patched", + "language": LanguageEnum.JAVASCRIPT.value, + "is_private": True, + "content": "console.log('new')", + "description": "new desc", + "tags": ["JS", "new tag"], + } + + u = await client.patch( + f"{snippet_url}{created['uuid']}", json=update_payload + ) + assert u.status_code == 200 + updated = u.json() + + assert updated["uuid"] == created["uuid"] + assert updated["title"] == update_payload["title"] + assert isinstance(updated["language"], str) + assert updated["language"] in ( + LanguageEnum.JAVASCRIPT.value, + str(LanguageEnum.JAVASCRIPT), + ) + assert updated["is_private"] is True + assert updated["content"] == update_payload["content"] + assert updated["description"] == update_payload["description"] + assert sorted(updated.get("tags", [])) == sorted( + ["js", "newtag"] + ) or sorted(updated.get("tags", [])) == sorted(["js", "new_tag"]) + + g = await client.get(f"{snippet_url}{created['uuid']}") + assert g.status_code == 200 + gdata = g.json() + assert gdata["title"] == update_payload["title"] + assert gdata["is_private"] is True + + +async def test_update_snippet_other_user_forbidden( + auth_client, setup_snippets +): + client, _ = auth_client + target = setup_snippets["u1_public_py"] + + resp = await client.patch( + f"{snippet_url}{target.uuid}", json={"title": "nope"} + ) + assert resp.status_code == 403 + body = resp.json() + assert "detail" in body + + +async def test_update_snippet_not_found(auth_client): + client, _ = auth_client + from uuid import uuid4 + + random_uuid = uuid4() + resp = await client.patch( + f"{snippet_url}{random_uuid}", json={"title": "x" * 5} + ) + assert resp.status_code == 404 + body = resp.json() + assert "detail" in body + + +async def test_update_snippet_validation_error(auth_client): + client, _ = auth_client + + create_payload = { + "title": "Valid Before", + "language": LanguageEnum.PYTHON.value, + "content": "print('ok')", + "is_private": False, + "tags": ["ok"], + } + create_resp = await client.post(snippet_url, json=create_payload) + assert create_resp.status_code == 201 + created = create_resp.json() + + invalid_patch = {"language": "INVALID_LANG", "title": "ab"} + resp = await client.patch( + f"{snippet_url}{created['uuid']}", json=invalid_patch + ) + assert resp.status_code == 422 + + +async def test_delete_snippet_owner_public_success(auth_client): + client, _ = auth_client + + create_payload = { + "title": "Delete Public", + "language": LanguageEnum.PYTHON.value, + "content": "print('bye')", + "is_private": False, + "tags": ["cleanup"], + } + c = await client.post(snippet_url, json=create_payload) + assert c.status_code == 201 + created = c.json() + + d = await client.delete(f"{snippet_url}{created['uuid']}") + assert d.status_code == 200 + body = d.json() + assert body["message"] == "Snippet has been deleted successfully" + + g = await client.get(f"{snippet_url}{created['uuid']}") + assert g.status_code == 404 + + +async def test_delete_snippet_owner_private_success(auth_client): + client, _ = auth_client + + create_payload = { + "title": "Delete Private", + "language": LanguageEnum.PYTHON.value, + "content": "print('secret bye')", + "is_private": True, + "tags": ["secret"], + } + c = await client.post(snippet_url, json=create_payload) + assert c.status_code == 201 + created = c.json() + + d = await client.delete(f"{snippet_url}{created['uuid']}") + assert d.status_code == 200 + body = d.json() + assert body["message"] == "Snippet has been deleted successfully" + + g = await client.get(f"{snippet_url}{created['uuid']}") + assert g.status_code == 404 + + +async def test_delete_snippet_other_user_forbidden( + auth_client, setup_snippets +): + client, _ = auth_client + foreign_snippet = setup_snippets["u1_public_py"] + + resp = await client.delete(f"{snippet_url}{foreign_snippet.uuid}") + assert resp.status_code == 403 + data = resp.json() + assert "detail" in data + + +async def test_delete_snippet_nonexistent_returns_success_message(auth_client): + client, _ = auth_client + from uuid import uuid4 + + random_uuid = uuid4() + resp = await client.delete(f"{snippet_url}{random_uuid}") + assert resp.status_code == 200 + data = resp.json() + assert data["message"] == "Snippet has been deleted successfully" diff --git a/backend/tests/factories/__init__.py b/backend/tests/factories/__init__.py new file mode 100644 index 0000000..286212a --- /dev/null +++ b/backend/tests/factories/__init__.py @@ -0,0 +1,2 @@ +from .snippet import SnippetFactory +from .user import UserFactory diff --git a/backend/tests/factories/snippet.py b/backend/tests/factories/snippet.py new file mode 100644 index 0000000..bf9d991 --- /dev/null +++ b/backend/tests/factories/snippet.py @@ -0,0 +1,97 @@ +from typing import Optional + +from faker import Faker +from sqlalchemy.ext.asyncio import AsyncSession + +from src.adapters.mongo.documents import SnippetDocument +from src.adapters.mongo.repo import SnippetDocumentRepository +from src.adapters.postgres.models import SnippetModel, UserModel, LanguageEnum +from src.adapters.postgres.repositories import SnippetRepository + + +class SnippetFactory: + def __init__( + self, + db: AsyncSession, + model_repo: SnippetRepository, + doc_repo: SnippetDocumentRepository, + faker: Faker, + ): + self.db = db + self.model_repo = model_repo + self.doc_repo = doc_repo + self.fake = faker + + async def create_document( + self, + content: Optional[str] = None, + description: Optional[str] = None, + ) -> SnippetDocument: + return await self.doc_repo.create( + content=content or self.fake.text(), + description=description or self.fake.sentence(), + ) + + def build_model( + self, + user_id: int, + mongodb_id: str, + title: Optional[str] = None, + language: Optional[LanguageEnum] = None, + is_private: bool = False, + ) -> SnippetModel: + return SnippetModel( + title=title or self.fake.sentence(nb_words=3), + language=language + or self.fake.random_element(elements=list(LanguageEnum)), + is_private=is_private, + user_id=user_id, + mongodb_id=mongodb_id, + ) + + async def create_model( + self, + user: UserModel, + document: SnippetDocument, + title: Optional[str] = None, + language: Optional[LanguageEnum] = None, + is_private: bool = False, + tags: Optional[list[str]] = None, + ) -> SnippetModel: + if tags: + snippet = await self.model_repo.create_with_tags( + title=title or self.fake.sentence(nb_words=3), + language=language + or self.fake.random_element(elements=list(LanguageEnum)), + is_private=is_private, + tag_names=tags, + mongodb_id=document.id, + user_id=user.id, + ) + else: + snippet = self.model_repo.create( + title=title or self.fake.sentence(nb_words=3), + language=language + or self.fake.random_element(elements=list(LanguageEnum)), + is_private=is_private, + mongodb_id=document.id, + user_id=user.id, + ) + await self.db.flush() + return snippet + + async def create( + self, + user: UserModel, + title: Optional[str] = None, + language: Optional[LanguageEnum] = None, + is_private: bool = False, + content: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> tuple[SnippetModel, SnippetDocument]: + document = await self.create_document(content, description) + model = await self.create_model( + user, document, title, language, is_private, tags + ) + return model, document diff --git a/backend/tests/factories/user.py b/backend/tests/factories/user.py new file mode 100644 index 0000000..2c67690 --- /dev/null +++ b/backend/tests/factories/user.py @@ -0,0 +1,104 @@ +from faker import Faker + +from src.adapters.postgres.models import UserModel, UserProfileModel + +fake = Faker() + + +class UserFactory: + DEFAULT_PASSWORD = "Test1234!" + + @staticmethod + def build( + email: str | None = None, + username: str | None = None, + password: str | None = None, + ) -> UserModel: + user = UserModel.create( + email=email or fake.unique.email(), + username=username or fake.unique.user_name(), + password=password or UserFactory.DEFAULT_PASSWORD, + ) + return user + + @staticmethod + async def create( + db, + email: str | None = None, + username: str | None = None, + password: str | None = None, + is_active: bool = False, + ) -> UserModel: + user = UserFactory.build( + email=email, username=username, password=password + ) + + if is_active: + user.is_active = is_active + + db.add(user) + await db.flush() + return user + + @staticmethod + async def create_active( + db, + email: str | None = None, + username: str | None = None, + password: str | None = None, + ) -> UserModel: + return await UserFactory.create( + db, + email=email, + username=username, + password=password, + is_active=True, + ) + + @staticmethod + async def create_with_activation_token( + db, + token: str, + ) -> tuple[UserModel, str]: + from src.adapters.postgres.models import ActivationTokenModel + + user = await UserFactory.create(db) + token_model = ActivationTokenModel.create(user.id, token) + db.add(token_model) + await db.flush() + return user, token + + @staticmethod + async def create_with_reset_token( + db, + token: str, + ) -> tuple[UserModel, str]: + from src.adapters.postgres.models import PasswordResetTokenModel + + user = await UserFactory.create(db, is_active=True) + token_model = PasswordResetTokenModel.create(user.id, token) + db.add(token_model) + await db.flush() + return user, token + + @staticmethod + async def create_with_refresh_token( + db, + token: str, + ) -> tuple[UserModel, str]: + from src.adapters.postgres.models import RefreshTokenModel + + user = await UserFactory.create(db, is_active=True) + token_model = RefreshTokenModel.create(user.id, token) + db.add(token_model) + await db.flush() + return user, token + + @staticmethod + async def create_with_profile( + db, profile_repo + ) -> tuple[UserModel, UserProfileModel]: + user = await UserFactory.create(db, is_active=True) + profile = await profile_repo.create(user.id) + await db.flush() + return user, profile diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py new file mode 100644 index 0000000..34004bc --- /dev/null +++ b/backend/tests/fixtures/__init__.py @@ -0,0 +1,72 @@ +from .auth import ( + auth_service, + jwt_manager, + logged_in_tokens, + activation_token_repo, + password_reset_token_repo, + refresh_token_repo, +) +from .client import auth_client +from .database import db, _session_local, _engine, reset_db +from .email import email_sender_stub, email_sender_mock +from .profile import ( + storage_stub, + profile_repo, + profile_service, + mock_upload_file, + avatar_file, +) +from .snippet import ( + snippet_model_repo, + snippet_doc_repo, + snippet_factory, + snippet_service, + favorites_repo, + favorites_service, + search_service, +) +from .snippet_data import setup_snippets, setup_favorites +from .user import ( + user_factory, + user_repo, + user_service, + active_user, + inactive_user, + user_with_profile, +) + +__all__ = [ + "auth_client", + "auth_service", + "jwt_manager", + "logged_in_tokens", + "activation_token_repo", + "password_reset_token_repo", + "refresh_token_repo", + "db", + "_session_local", + "_engine", + "reset_db", + "email_sender_stub", + "email_sender_mock", + "storage_stub", + "profile_repo", + "profile_service", + "mock_upload_file", + "avatar_file", + "user_factory", + "user_repo", + "user_service", + "active_user", + "inactive_user", + "user_with_profile", + "snippet_model_repo", + "snippet_doc_repo", + "snippet_factory", + "snippet_service", + "setup_snippets", + "setup_favorites", + "favorites_repo", + "favorites_service", + "search_service", +] diff --git a/backend/tests/fixtures/auth.py b/backend/tests/fixtures/auth.py new file mode 100644 index 0000000..5f2dc14 --- /dev/null +++ b/backend/tests/fixtures/auth.py @@ -0,0 +1,51 @@ +import pytest_asyncio + +from src.adapters.postgres.models import ( + ActivationTokenModel, + PasswordResetTokenModel, + RefreshTokenModel, +) +from src.adapters.postgres.repositories import TokenRepository +from src.core.security.jwt_manager import JWTAuthManager +from src.features.auth import AuthService + + +@pytest_asyncio.fixture +async def jwt_manager(settings, redis_client): + return JWTAuthManager( + redis_client, + settings.SECRET_KEY_ACCESS, + settings.SECRET_KEY_REFRESH, + settings.ALGORITHM, + settings.REFRESH_TOKEN_LIFE, + settings.ACCESS_TOKEN_LIFE_MINUTES, + ) + + +@pytest_asyncio.fixture +async def activation_token_repo(db): + return TokenRepository(db, ActivationTokenModel) + + +@pytest_asyncio.fixture +async def password_reset_token_repo(db): + return TokenRepository(db, PasswordResetTokenModel) + + +@pytest_asyncio.fixture +async def refresh_token_repo(db): + return TokenRepository(db, RefreshTokenModel) + + +@pytest_asyncio.fixture +async def auth_service( + db, jwt_manager, settings, user_repo, refresh_token_repo +): + return AuthService( + db, jwt_manager, settings, user_repo, refresh_token_repo + ) + + +@pytest_asyncio.fixture +async def logged_in_tokens(db, active_user, auth_service): + return await auth_service.login_user(active_user.email, "Test1234!") diff --git a/backend/tests/fixtures/client.py b/backend/tests/fixtures/client.py new file mode 100644 index 0000000..44e4929 --- /dev/null +++ b/backend/tests/fixtures/client.py @@ -0,0 +1,15 @@ +import pytest_asyncio +from httpx import AsyncClient + +from src.adapters.postgres.models import UserModel + + +@pytest_asyncio.fixture +async def auth_client( + client, auth_service, user_with_profile +) -> tuple[AsyncClient, UserModel]: + user = user_with_profile[0] + tokens = await auth_service.login_user(user.email, "Test1234!") + access_token = tokens["access_token"] + client.headers["Authorization"] = f"Bearer {access_token}" + return client, user diff --git a/backend/tests/fixtures/database.py b/backend/tests/fixtures/database.py new file mode 100644 index 0000000..3cd362c --- /dev/null +++ b/backend/tests/fixtures/database.py @@ -0,0 +1,41 @@ +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import ( + create_async_engine, + async_sessionmaker, + AsyncSession, +) + +from src.adapters.postgres.models import Base + + +@pytest_asyncio.fixture(scope="session") +async def _engine(settings): + engine = create_async_engine(settings.database_url) + yield engine + await engine.dispose() + + +@pytest.fixture(scope="session") +def _session_local(_engine): + return async_sessionmaker( + autoflush=False, bind=_engine, expire_on_commit=False + ) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def reset_db(_engine): + async with _engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + +@pytest_asyncio.fixture(scope="function") +async def db(_session_local: async_sessionmaker[AsyncSession]): + session: AsyncSession = _session_local() + await session.begin() + try: + yield session + finally: + await session.rollback() + await session.close() diff --git a/backend/tests/fixtures/email.py b/backend/tests/fixtures/email.py new file mode 100644 index 0000000..ada11a4 --- /dev/null +++ b/backend/tests/fixtures/email.py @@ -0,0 +1,22 @@ +import pytest_asyncio + +from src.core.email import EmailSenderManager, StubEmailSender + + +@pytest_asyncio.fixture +async def email_sender_stub(): + return EmailSenderManager( + email_host="smtp.example.com", + email_port=587, + email_host_user="noreply@example.com", + from_email="noreply@example.com", + use_tls=True, + app_url="https://myapp.com", + ) + + +@pytest_asyncio.fixture +async def email_sender_mock(settings, redis_client): + sender = StubEmailSender() + yield sender + sender.clear() diff --git a/backend/tests/fixtures/profile.py b/backend/tests/fixtures/profile.py new file mode 100644 index 0000000..6203fce --- /dev/null +++ b/backend/tests/fixtures/profile.py @@ -0,0 +1,52 @@ +from io import BytesIO +from unittest.mock import MagicMock + +import pytest +import pytest_asyncio +from PIL import Image +from fastapi import UploadFile + +from src.adapters.postgres.repositories import UserProfileRepository +from src.adapters.storage import StubStorage +from src.features.profile import ProfileService + + +@pytest_asyncio.fixture +async def storage_stub(): + return StubStorage() + + +@pytest_asyncio.fixture +async def profile_repo(db): + return UserProfileRepository(db) + + +@pytest_asyncio.fixture +async def profile_service(db, storage_stub, profile_repo): + return ProfileService(db, storage_stub, profile_repo) + + +@pytest.fixture +def mock_upload_file(mocker): + mock_image_data = b"fake_image_bytes" + mock_processed_io = BytesIO(mock_image_data) + + mocker.patch( + "src.features.profile.service.validate_image", + return_value=mock_processed_io, + ) + + mock_file = MagicMock(spec=UploadFile) + mock_file.filename = "test_avatar.jpg" + + return mock_file + + +@pytest_asyncio.fixture +async def avatar_file(): + image = Image.new("RGB", (100, 100), color="red") + file = BytesIO() + image.save(file, "PNG") + file.name = "test_avatar.png" + file.seek(0) + return file diff --git a/backend/tests/fixtures/snippet.py b/backend/tests/fixtures/snippet.py new file mode 100644 index 0000000..0863adf --- /dev/null +++ b/backend/tests/fixtures/snippet.py @@ -0,0 +1,48 @@ +import pytest_asyncio + +from src.adapters.mongo.repo import SnippetDocumentRepository +from src.adapters.postgres.repositories import ( + SnippetRepository, + FavoritesRepository, +) +from src.features.snippets import ( + SnippetService, + FavoritesService, + SnippetSearchService, +) +from tests.factories import SnippetFactory + + +@pytest_asyncio.fixture +async def snippet_model_repo(db): + return SnippetRepository(db) + + +@pytest_asyncio.fixture +async def snippet_doc_repo(): + return SnippetDocumentRepository() + + +@pytest_asyncio.fixture +async def favorites_repo(db): + return FavoritesRepository(db) + + +@pytest_asyncio.fixture +async def favorites_service(db, favorites_repo, snippet_doc_repo): + return FavoritesService(db, favorites_repo, snippet_doc_repo) + + +@pytest_asyncio.fixture +async def search_service(db, redis_client, snippet_model_repo): + return SnippetSearchService(db, redis_client, snippet_model_repo) + + +@pytest_asyncio.fixture +async def snippet_factory(db, snippet_model_repo, snippet_doc_repo, faker): + return SnippetFactory(db, snippet_model_repo, snippet_doc_repo, faker) + + +@pytest_asyncio.fixture +async def snippet_service(db, snippet_model_repo, snippet_doc_repo): + return SnippetService(db, snippet_model_repo, snippet_doc_repo) diff --git a/backend/tests/fixtures/snippet_data.py b/backend/tests/fixtures/snippet_data.py new file mode 100644 index 0000000..4569d78 --- /dev/null +++ b/backend/tests/fixtures/snippet_data.py @@ -0,0 +1,85 @@ +from datetime import datetime, timedelta, timezone + +import pytest_asyncio +from sqlalchemy import update, delete + +from src.adapters.postgres.models import SnippetModel, LanguageEnum + + +@pytest_asyncio.fixture +async def setup_snippets( + db, snippet_factory, snippet_model_repo, snippet_doc_repo, user_factory +): + await db.execute(delete(SnippetModel)) + await db.flush() + user1 = await user_factory.create_active(db) + user2 = await user_factory.create_active(db) + + u1_pub_py, _ = await snippet_factory.create( + user1, + language=LanguageEnum.PYTHON, + is_private=False, + tags=["test", "python"], + ) + u1_priv_py, _ = await snippet_factory.create( + user1, + language=LanguageEnum.PYTHON, + is_private=True, + tags=["private"], + ) + u1_pub_js, _ = await snippet_factory.create( + user1, + language=LanguageEnum.JAVASCRIPT, + is_private=False, + ) + + u2_pub_py, _ = await snippet_factory.create( + user2, + language=LanguageEnum.PYTHON, + is_private=False, + tags=["test"], + ) + u2_priv_js, _ = await snippet_factory.create( + user2, + language=LanguageEnum.JAVASCRIPT, + is_private=True, + tags=["private"], + ) + + old_snippet_stmt = ( + update(SnippetModel) + .where(SnippetModel.id == u1_pub_js.id) + .values(created_at=datetime.now(timezone.utc) - timedelta(days=5)) + ) + await db.execute(old_snippet_stmt) + await db.commit() + + return { + "user1": user1, + "user2": user2, + "u1_public_py": u1_pub_py, + "u1_private_py": u1_priv_py, + "u1_public_js": u1_pub_js, + "u2_public_py": u2_pub_py, + "u2_private_js": u2_priv_js, + } + + +@pytest_asyncio.fixture +async def setup_favorites(db, favorites_repo, setup_snippets): + user1 = setup_snippets["user1"] + user2 = setup_snippets["user2"] + + snippets = [ + setup_snippets["u1_public_py"], + setup_snippets["u1_public_js"], + setup_snippets["u2_public_py"], + ] + + favorites = [] + for snippet in snippets: + await favorites_repo.add_to_favorites(user1, snippet.uuid) + favorites.append(snippet) + await db.commit() + + return user1, user2, favorites diff --git a/backend/tests/fixtures/user.py b/backend/tests/fixtures/user.py new file mode 100644 index 0000000..5e0f90b --- /dev/null +++ b/backend/tests/fixtures/user.py @@ -0,0 +1,43 @@ +import pytest_asyncio + +from src.adapters.postgres.repositories import UserRepository +from src.features.auth import UserService +from tests.factories import UserFactory + + +@pytest_asyncio.fixture +async def user_factory(): + return UserFactory + + +@pytest_asyncio.fixture +async def user_repo(db): + return UserRepository(db) + + +@pytest_asyncio.fixture +async def user_service( + db, settings, user_repo, activation_token_repo, password_reset_token_repo +): + return UserService( + db, + settings, + user_repo, + activation_token_repo, + password_reset_token_repo, + ) + + +@pytest_asyncio.fixture +async def active_user(db, user_factory): + return await user_factory.create(db, is_active=True) + + +@pytest_asyncio.fixture +async def inactive_user(db, user_factory): + return await user_factory.create(db, is_active=False) + + +@pytest_asyncio.fixture +async def user_with_profile(db, user_factory, profile_repo): + return await user_factory.create_with_profile(db, profile_repo) diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/adapters/__init__.py b/backend/tests/integration/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/adapters/test_dev_storage.py b/backend/tests/integration/adapters/test_dev_storage.py new file mode 100644 index 0000000..f5c21e9 --- /dev/null +++ b/backend/tests/integration/adapters/test_dev_storage.py @@ -0,0 +1,75 @@ +from pathlib import Path + +import pytest + +from src.adapters.storage import DevStorage + +TEST_BASE_URL = "http://test-server.com/static/" +TEST_FILE_CONTENT = b"This is test file content." + + +@pytest.fixture +def dev_storage_instance(tmp_path: Path): + return DevStorage(base_path=tmp_path, base_url=TEST_BASE_URL) + + +def test_upload_file_and_get_url_success( + dev_storage_instance: DevStorage, tmp_path: Path +): + test_file_name = "test_upload.txt" + + file_url = dev_storage_instance.upload_file( + test_file_name, TEST_FILE_CONTENT + ) + + expected_path = tmp_path / "static" / "avatars" / test_file_name + + expected_url = TEST_BASE_URL + "avatars/" + test_file_name + + assert file_url == expected_url + assert expected_path.exists() + + with open(expected_path, "rb") as f: + content = f.read() + assert content == TEST_FILE_CONTENT + + +def test_file_exists_and_delete_file_success( + dev_storage_instance: DevStorage, tmp_path: Path +): + test_file_name = "test_delete.bin" + + file_url = dev_storage_instance.upload_file( + test_file_name, TEST_FILE_CONTENT + ) + expected_path = tmp_path / "static" / "avatars" / test_file_name + + assert expected_path.exists() + assert dev_storage_instance.file_exists(file_url) is True + + dev_storage_instance.delete_file(file_url) + + assert not expected_path.exists() + assert dev_storage_instance.file_exists(file_url) is False + + +def test_file_exists_for_non_existent_file(dev_storage_instance: DevStorage): + non_existent_url = TEST_BASE_URL + "avatars/not_here.jpg" + assert dev_storage_instance.file_exists(non_existent_url) is False + + +def test_delete_non_existent_file_no_error(dev_storage_instance: DevStorage): + non_existent_url = TEST_BASE_URL + "avatars/non_exist.png" + + try: + dev_storage_instance.delete_file(non_existent_url) + except Exception as e: + pytest.fail(f"delete_file raised an unexpected exception: {e}") + + +def test_get_file_url(dev_storage_instance: DevStorage): + test_file_name = "image_001.jpg" + expected_url = TEST_BASE_URL + "avatars/" + test_file_name + + url = dev_storage_instance.get_file_url(test_file_name) + assert url == expected_url diff --git a/backend/tests/integration/core/__init__.py b/backend/tests/integration/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/core/test_jwt.py b/backend/tests/integration/core/test_jwt.py new file mode 100644 index 0000000..9372b2b --- /dev/null +++ b/backend/tests/integration/core/test_jwt.py @@ -0,0 +1,260 @@ +from datetime import timezone, datetime, timedelta + +import jwt +import pytest +from sqlalchemy.exc import SQLAlchemyError + +import src.core.exceptions as exc +from src.adapters.postgres.models import UserModel +from src.adapters.redis.common import get_access_token + +user_data = { + "id": 1, + "email": "test@test.com", + "username": "test", + "is_admin": False, +} + + +def parse_user_data(user: UserModel) -> dict: + return { + "id": user.id, + "username": user.username, + "email": user.email, + "is_admin": user.is_admin, + } + + +async def test_create_access_token(db, jwt_manager, redis_client): + token = await jwt_manager.create_access_token(user_data) + assert token is not None + + payload = jwt.decode(token, options={"verify_signature": False}) + jti = payload["jti"] + + stored_user_id = await get_access_token(redis_client, jti) + assert stored_user_id is not None + assert int(stored_user_id) == user_data.get("id") + + +async def test_create_refresh_token(db, jwt_manager): + token = jwt_manager.create_refresh_token(user_data) + assert token is not None + + +async def test_add_to_blacklist(jwt_manager): + token = await jwt_manager.create_access_token(user_data) + payload = jwt.decode(token, options={"verify_signature": False}) + jti = payload["jti"] + + exp = int(datetime.now(timezone.utc).timestamp()) + 5 + await jwt_manager.add_to_blacklist(jti, exp) + + assert await jwt_manager.is_blacklisted(jti) is True + + +async def test_decode_token(jwt_manager): + token = jwt_manager.create_refresh_token(user_data) + payload = jwt_manager.decode_token(token) + + assert payload["user_id"] == user_data["id"] + assert payload["username"] == user_data["username"] + assert payload["email"] == user_data["email"] + assert payload["is_admin"] == user_data["is_admin"] + assert "jti" in payload + assert "exp" in payload + assert "iat" in payload + + +async def test_decode_token_error(jwt_manager): + token = "not.a.valid.token" + payload = jwt_manager.decode_token(token) + assert payload is None + + +async def test_verify_token_valid(db, jwt_manager, redis_client, user_factory): + user = await user_factory.create(db) + user_data = { + "id": user.id, + "username": user.username, + "email": user.email, + "is_admin": user.is_admin, + } + + token = await jwt_manager.create_access_token(user_data) + + payload = await jwt_manager.verify_token(token) + assert payload["user_id"] == user.id + assert payload["jti"] is not None + + +async def test_verify_token_missing_jti(jwt_manager): + payload = { + "sub": "1", + "user_id": 1, + "username": "test", + "email": "test@example.com", + "is_admin": False, + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + } + token = jwt.encode( + payload, + jwt_manager._secret_key_access, + algorithm=jwt_manager._algorithm, + ) + + with pytest.raises(jwt.InvalidTokenError) as e: + await jwt_manager.verify_token(token) + assert str(e.value) == "Invalid token" + + +async def test_verify_token_expired(jwt_manager): + payload = { + **user_data, + "sub": "1", + "iat": datetime.now(timezone.utc) - timedelta(minutes=10), + "exp": datetime.now(timezone.utc) - timedelta(minutes=5), + "jti": "testjti", + } + token = jwt.encode( + payload, + jwt_manager._secret_key_access, + algorithm=jwt_manager._algorithm, + ) + + with pytest.raises(jwt.InvalidTokenError) as e: + await jwt_manager.verify_token(token) + assert str(e.value) == "Token has expired" + + +async def test_verify_token_blacklisted(jwt_manager, redis_client): + token = await jwt_manager.create_access_token(user_data) + payload = jwt.decode(token, options={"verify_signature": False}) + jti = payload["jti"] + + await jwt_manager.add_to_blacklist( + jti, + exp=int( + (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp() + ), + ) + + with pytest.raises(jwt.InvalidTokenError) as e: + await jwt_manager.verify_token(token) + assert str(e.value) == "Invalid token" + + +async def test_refresh_tokens_valid(db, jwt_manager, user_factory): + user = await user_factory.create(db) + user_data = parse_user_data(user) + refresh_token = jwt_manager.create_refresh_token(user_data) + result = await jwt_manager.refresh_tokens(db, refresh_token) + + assert "access_token" in result + access_token = result["access_token"] + assert isinstance(access_token, str) + + payload = jwt.decode(access_token, options={"verify_signature": False}) + assert payload["user_id"] == user.id + assert payload["username"] == user.username + assert payload["email"] == user.email + assert payload["is_admin"] == user.is_admin + assert "jti" in payload + assert "exp" in payload + assert "iat" in payload + assert payload["iat"] <= datetime.now(timezone.utc).timestamp() + + +async def test_refresh_tokens_invalid_token(db, jwt_manager): + invalid_token = "not.a.valid.token" + + with pytest.raises(exc.AuthenticationError) as e: + await jwt_manager.refresh_tokens(db, invalid_token) + assert "Invalid refresh token" in str(e.value) + + +async def test_refresh_tokens_expired_token(db, jwt_manager, user_factory): + user = await user_factory.create(db) + parse_user_data(user) + + past = datetime.now(timezone.utc) - timedelta(days=1) + payload = { + "user_id": user.id, + "username": user.username, + "email": user.email, + "is_admin": user.is_admin, + "jti": "expiredtoken", + "iat": past.timestamp(), + "exp": past.timestamp(), + } + expired_token = jwt.encode( + payload, + jwt_manager._secret_key_refresh, + algorithm=jwt_manager._algorithm, + ) + + with pytest.raises(exc.AuthenticationError) as e: + await jwt_manager.refresh_tokens(db, expired_token) + assert "Invalid refresh token" in str(e.value) + + +async def test_refresh_tokens_user_not_found(db, jwt_manager): + payload = { + "user_id": 9999, + "username": "ghost", + "email": "ghost@test.com", + "is_admin": False, + "jti": "token9999", + "iat": datetime.now(timezone.utc).timestamp(), + "exp": (datetime.now(timezone.utc) + timedelta(days=1)).timestamp(), + } + token = jwt.encode( + payload, + jwt_manager._secret_key_refresh, + algorithm=jwt_manager._algorithm, + ) + + with pytest.raises(exc.UserNotFoundError): + await jwt_manager.refresh_tokens(db, token) + + +async def test_revoke_all_user_tokens( + db, jwt_manager, user_factory, refresh_token_repo +): + user = await user_factory.create(db) + user_data = parse_user_data(user) + token1 = await jwt_manager.create_access_token(user_data) + token2 = await jwt_manager.create_access_token(user_data) + + payload1 = jwt_manager.decode_token(token1) + payload2 = jwt_manager.decode_token(token2) + + await refresh_token_repo.create( + user.id, jwt_manager.create_refresh_token(user_data), 7 + ) + await db.commit() + + await jwt_manager.revoke_all_user_tokens(db, user.id) + assert await jwt_manager.is_blacklisted(payload1["jti"]) is True + assert await jwt_manager.is_blacklisted(payload2["jti"]) is True + assert await refresh_token_repo.get_by_user(user.id) is None + + +async def test_revoke_all_user_tokens_db_error( + db, jwt_manager, user_factory, mocker +): + user = await user_factory.create(db) + user_data = parse_user_data(user) + await jwt_manager.create_access_token(user_data) + + mock_delete = mocker.patch( + "src.core.security.jwt_manager.jwt_manager" + ".JWTAuthManager.revoke_all_user_tokens", + side_effect=SQLAlchemyError, + ) + + with pytest.raises(SQLAlchemyError): + await jwt_manager.revoke_all_user_tokens(db, user.id) + + mock_delete.assert_called_once() diff --git a/backend/tests/integration/repositories/__init__.py b/backend/tests/integration/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/repositories/test_favorites_repo.py b/backend/tests/integration/repositories/test_favorites_repo.py new file mode 100644 index 0000000..a0e7870 --- /dev/null +++ b/backend/tests/integration/repositories/test_favorites_repo.py @@ -0,0 +1,187 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from src.adapters.postgres.models import ( + SnippetFavoritesModel, + SnippetModel, + LanguageEnum, +) +from src.api.v1.schemas.snippets import FavoritesSortingEnum +from src.core import exceptions as exc + + +async def test_add_to_favorites_success( + db, + favorites_repo, + snippet_factory, + snippet_model_repo, + snippet_doc_repo, + active_user, +): + snippet, _ = await snippet_factory.create(active_user) + await favorites_repo.add_to_favorites(active_user, snippet.uuid) + await db.commit() + + query = select(SnippetFavoritesModel).where( + SnippetFavoritesModel.user_id == active_user.id, + SnippetFavoritesModel.snippet_id == snippet.id, + ) + result = await db.execute(query) + favorite = result.scalar_one_or_none() + + assert favorite is not None + assert favorite.user_id == active_user.id + assert favorite.snippet_id == snippet.id + + +async def test_add_to_favorites_already_exists( + db, + favorites_repo, + snippet_factory, + snippet_model_repo, + snippet_doc_repo, + active_user, +): + snippet, _ = await snippet_factory.create(active_user) + await favorites_repo.add_to_favorites(active_user, snippet.uuid) + await db.commit() + + with pytest.raises(exc.FavoritesAlreadyError): + await favorites_repo.add_to_favorites(active_user, snippet.uuid) + + +async def test_add_to_favorites_snippet_not_found(favorites_repo, active_user): + with pytest.raises(exc.SnippetNotFoundError): + await favorites_repo.add_to_favorites(active_user, uuid4()) + + +async def test_remove_from_favorites_success( + db, + favorites_repo, + snippet_factory, + snippet_model_repo, + snippet_doc_repo, + active_user, +): + snippet, _ = await snippet_factory.create(active_user) + await favorites_repo.add_to_favorites(active_user, snippet.uuid) + await db.commit() + + await favorites_repo.remove_from_favorites(active_user, snippet.uuid) + await db.commit() + + query = select(SnippetFavoritesModel).where( + SnippetFavoritesModel.user_id == active_user.id, + SnippetFavoritesModel.snippet_id == snippet.id, + ) + result = await db.execute(query) + favorite = result.scalar_one_or_none() + + assert favorite is None + + +async def test_remove_from_favorites_not_exists( + db, + favorites_repo, + snippet_factory, + snippet_model_repo, + snippet_doc_repo, + active_user, +): + snippet, _ = await snippet_factory.create(active_user) + with pytest.raises(exc.FavoritesAlreadyError): + await favorites_repo.remove_from_favorites(active_user, snippet.uuid) + + +async def test_remove_from_favorites_snippet_not_found( + favorites_repo, active_user +): + with pytest.raises(exc.SnippetNotFoundError): + await favorites_repo.remove_from_favorites(active_user, uuid4()) + + +async def test_get_favorites_paginated_basic(favorites_repo, setup_favorites): + user1, _, _ = setup_favorites + + favorites, total = await favorites_repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + ) + + assert total == len(favorites) + assert all(isinstance(s, SnippetModel) for s in favorites) + assert all( + f.is_private is False or f.user_id == user1.id for f in favorites + ) + + +async def test_get_favorites_paginated_filter_by_language( + favorites_repo, setup_favorites +): + user1, _, _ = setup_favorites + + favorites, total = await favorites_repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + language=LanguageEnum.PYTHON, + ) + + assert total > 0 + assert all(f.language == LanguageEnum.PYTHON for f in favorites) + + +async def test_get_favorites_paginated_filter_by_tag( + favorites_repo, setup_favorites +): + user1, _, _ = setup_favorites + + favorites, total = await favorites_repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + tags=["test"], + ) + + assert total > 0 + assert all(any(tag.name == "test" for tag in f.tags) for f in favorites) + + +async def test_get_favorites_paginated_sorting_snippet_date( + favorites_repo, setup_favorites +): + user1, _, _ = setup_favorites + + favorites, _ = await favorites_repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.SNIPPET_DATE, + ) + + assert all( + favorites[i].created_at >= favorites[i + 1].created_at + for i in range(len(favorites) - 1) + ) + + +async def test_get_favorites_paginated_sorting_title( + favorites_repo, setup_favorites +): + user1, _, _ = setup_favorites + + favorites, _ = await favorites_repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.TITLE, + ) + + titles = [f.title for f in favorites] + assert titles == sorted(titles, key=str.lower) diff --git a/backend/tests/integration/repositories/test_profile_repo.py b/backend/tests/integration/repositories/test_profile_repo.py new file mode 100644 index 0000000..497a10b --- /dev/null +++ b/backend/tests/integration/repositories/test_profile_repo.py @@ -0,0 +1,108 @@ +from datetime import date + +import pytest + +import src.core.exceptions as exc +from src.adapters.postgres.models import GenderEnum + +profile_data = { + "first_name": "John", + "last_name": "Doe", + "avatar_url": "https://example.com/avatar.jpg", + "gender": GenderEnum.MALE, + "date_of_birth": date(1990, 1, 1), + "info": "Some info", +} + + +async def test_create_profile_empty(db, profile_repo, active_user): + profile = await profile_repo.create(active_user.id) + await db.flush() + + assert profile is not None + assert profile.user_id == active_user.id + assert profile.first_name == "" + assert profile.last_name == "" + assert profile.avatar_url == "" + assert profile.info == "" + assert profile.gender is None + assert profile.date_of_birth is None + + +async def test_create_profile(db, profile_repo, active_user): + profile = await profile_repo.create(active_user.id, **profile_data) + await db.flush() + + assert profile is not None + assert profile.user_id == active_user.id + assert profile.first_name == profile_data["first_name"] + assert profile.last_name == profile_data["last_name"] + assert profile.avatar_url == profile_data["avatar_url"] + assert profile.gender == profile_data["gender"] + assert profile.date_of_birth == profile_data["date_of_birth"] + assert profile.info == profile_data["info"] + + +async def test_get_by_user_id_success(db, profile_repo, user_factory): + user, profile = await user_factory.create_with_profile(db, profile_repo) + + profile_record = await profile_repo.get_by_user_id(user.id) + assert profile_record is not None + + +async def test_get_by_user_id_not_found(db, profile_repo, active_user): + with pytest.raises(exc.ProfileNotFoundError) as e: + await profile_repo.get_by_user_id(active_user.id) + + assert str(e.value) == "Profile with this user ID was not found" + + +async def test_get_by_username_success(db, profile_repo, user_factory): + user, profile = await user_factory.create_with_profile(db, profile_repo) + + profile_record = await profile_repo.get_by_username(user.username) + assert profile_record is not None + + +async def test_get_by_username_not_found(db, profile_repo, active_user): + with pytest.raises(exc.ProfileNotFoundError) as e: + await profile_repo.get_by_username(active_user.username) + + assert str(e.value) == "Profile with this username was not found" + + +async def test_update_profile(db, profile_repo, user_factory): + user, profile = await user_factory.create_with_profile(db, profile_repo) + + await profile_repo.update(user.id, **profile_data) + await db.flush() + await db.refresh(profile) + + assert profile.first_name == profile_data["first_name"] + assert profile.last_name == profile_data["last_name"] + assert profile.avatar_url == profile_data["avatar_url"] + assert profile.gender == profile_data["gender"] + assert profile.date_of_birth == profile_data["date_of_birth"] + assert profile.info == profile_data["info"] + + +async def test_update_avatar_url(db, profile_repo, user_factory): + user, profile = await user_factory.create_with_profile(db, profile_repo) + + await profile_repo.update_avatar_url(user.id, profile_data["avatar_url"]) + await db.flush() + await db.refresh(profile) + + assert profile.avatar_url == profile_data["avatar_url"] + + +async def test_delete_avatar_url(db, profile_repo, user_factory): + user, profile = await user_factory.create_with_profile(db, profile_repo) + + assert profile.avatar_url is not None + + await profile_repo.delete_avatar_url(user.id) + await db.flush() + await db.refresh(profile) + + assert profile.avatar_url is None diff --git a/backend/tests/integration/repositories/test_snippet_doc_repo.py b/backend/tests/integration/repositories/test_snippet_doc_repo.py new file mode 100644 index 0000000..a20c870 --- /dev/null +++ b/backend/tests/integration/repositories/test_snippet_doc_repo.py @@ -0,0 +1,148 @@ +import pytest +import pytest_asyncio +from beanie import PydanticObjectId +from pymongo.errors import PyMongoError + +doc_data = {"content": "Test content", "description": "Test description"} + + +@pytest_asyncio.fixture +async def snippet_doc(snippet_doc_repo): + return await snippet_doc_repo.create(content="Some content") + + +async def test_create_snippet_success(snippet_doc_repo): + snippet = await snippet_doc_repo.create(**doc_data) + assert snippet.id is not None + assert snippet.content == doc_data["content"] + assert snippet.description == doc_data["description"] + assert snippet.created_at <= snippet.updated_at + + +async def test_create_snippet_value_error(snippet_doc_repo): + with pytest.raises(ValueError): + await snippet_doc_repo.create(content="") + + +async def test_create_pymongo_error(mocker, snippet_doc_repo): + mock = mocker.patch( + "src.adapters.mongo.repo.SnippetDocument.insert", + side_effect=PyMongoError, + ) + + with pytest.raises(PyMongoError): + await snippet_doc_repo.create(**doc_data) + + mock.assert_called_once() + + +async def test_get_by_id_success(snippet_doc_repo, snippet_doc): + result = await snippet_doc_repo.get_by_id(str(snippet_doc.id)) + assert result is not None + assert result.id == snippet_doc.id + + +async def test_get_by_id_not_found(snippet_doc_repo): + result = await snippet_doc_repo.get_by_id(str(PydanticObjectId())) + assert result is None + + +async def test_get_by_id_pymongo_error(mocker, snippet_doc_repo): + mock = mocker.patch( + "src.adapters.mongo.repo.SnippetDocument.get", + side_effect=PyMongoError, + ) + with pytest.raises(PyMongoError): + await snippet_doc_repo.get_by_id(str(PydanticObjectId())) + mock.assert_called_once() + + +# --- get_by_ids --- +async def test_get_by_ids_success(snippet_doc_repo, snippet_doc): + ids = [str(snippet_doc.id)] + results = await snippet_doc_repo.get_by_ids(ids) + assert len(results) == 1 + assert results[0].id == snippet_doc.id + + +async def test_get_by_ids_empty(snippet_doc_repo): + results = await snippet_doc_repo.get_by_ids([]) + assert results == [] + + +async def test_get_by_ids_pymongo_error(mocker, snippet_doc_repo): + mock = mocker.patch( + "src.adapters.mongo.repo.SnippetDocument.find", + side_effect=PyMongoError, + ) + with pytest.raises(PyMongoError): + await snippet_doc_repo.get_by_ids([str(PydanticObjectId())]) + mock.assert_called_once() + + +async def test_update_success(snippet_doc_repo, snippet_doc): + updated = await snippet_doc_repo.update( + str(snippet_doc.id), content="New content" + ) + assert updated is not None + assert updated.content == "New content" + + +async def test_update_not_found(snippet_doc_repo): + result = await snippet_doc_repo.update( + str(PydanticObjectId()), content="x" + ) + assert result is None + + +async def test_update_validation_error(snippet_doc_repo, snippet_doc): + with pytest.raises(ValueError): + await snippet_doc_repo.update(str(snippet_doc.id), content=1) + + +async def test_update_pymongo_error(mocker, snippet_doc_repo, snippet_doc): + mock = mocker.patch( + "src.adapters.mongo.repo.SnippetDocument.save", + side_effect=PyMongoError, + ) + with pytest.raises(PyMongoError): + await snippet_doc_repo.update(str(snippet_doc.id), content="boom") + mock.assert_called_once() + + +async def test_delete_success(snippet_doc_repo, snippet_doc): + await snippet_doc_repo.delete(str(snippet_doc.id)) + result = await snippet_doc_repo.get_by_id(str(snippet_doc.id)) + assert result is None + + +async def test_delete_not_found(snippet_doc_repo): + await snippet_doc_repo.delete(str(PydanticObjectId())) + + +async def test_delete_pymongo_error(mocker, snippet_doc_repo, snippet_doc): + mock = mocker.patch( + "src.adapters.mongo.repo.SnippetDocument.delete", + side_effect=PyMongoError, + ) + with pytest.raises(PyMongoError): + await snippet_doc_repo.delete(str(snippet_doc.id)) + mock.assert_called_once() + + +async def test_delete_document_success(snippet_doc_repo, snippet_doc): + await snippet_doc_repo.delete_document(snippet_doc) + result = await snippet_doc_repo.get_by_id(str(snippet_doc.id)) + assert result is None + + +async def test_delete_document_pymongo_error( + mocker, snippet_doc_repo, snippet_doc +): + mock = mocker.patch( + "src.adapters.mongo.repo.SnippetDocument.delete", + side_effect=PyMongoError, + ) + with pytest.raises(PyMongoError): + await snippet_doc_repo.delete_document(snippet_doc) + mock.assert_called_once() diff --git a/backend/tests/integration/repositories/test_snippet_model_repo.py b/backend/tests/integration/repositories/test_snippet_model_repo.py new file mode 100644 index 0000000..5f72f20 --- /dev/null +++ b/backend/tests/integration/repositories/test_snippet_model_repo.py @@ -0,0 +1,381 @@ +from datetime import date, timedelta +from uuid import uuid4 + +import pytest +from beanie import PydanticObjectId +from sqlalchemy import select + +import src.core.exceptions as exc +from src.adapters.postgres.models import LanguageEnum, TagModel + +snippet_data = { + "title": "Test Snippet with Tags", + "language": LanguageEnum.PYTHON, + "is_private": False, +} + + +@pytest.fixture +def mongodb_id(): + return PydanticObjectId() + + +async def test_create_with_new_tags( + db, snippet_model_repo, active_user, mongodb_id, faker +): + tag_names = [faker.unique.word() for _ in range(3)] + + snippet = await snippet_model_repo.create_with_tags( + **snippet_data, + tag_names=tag_names, + mongodb_id=mongodb_id, + user_id=active_user.id, + ) + await db.flush() + + assert snippet.id is not None + assert snippet.title == snippet_data["title"] + assert len(snippet.tags) == 3 + assert {tag.name for tag in snippet.tags} == set(tag_names) + + for tag_name in tag_names: + stmt = select(TagModel).where(TagModel.name == tag_name) + result = await db.execute(stmt) + tag = result.scalar_one() + assert tag is not None + + +async def test_create_with_existing_tags( + db, snippet_model_repo, active_user, mongodb_id, faker +): + existing_tags = [TagModel(name=faker.unique.word()) for _ in range(2)] + db.add_all(existing_tags) + await db.flush() + tag_names = [tag.name for tag in existing_tags] + + snippet = await snippet_model_repo.create_with_tags( + **snippet_data, + tag_names=tag_names, + mongodb_id=mongodb_id, + user_id=active_user.id, + ) + await db.flush() + + assert snippet.id is not None + assert len(snippet.tags) == 2 + assert {tag.name for tag in snippet.tags} == set(tag_names) + + stmt = select(TagModel).where(TagModel.name.in_(tag_names)) + result = await db.execute(stmt) + tags_from_db = result.scalars().all() + assert len(tags_from_db) == 2 + assert {tag.id for tag in tags_from_db} == { + tag.id for tag in existing_tags + } + + +async def test_create_with_mixed_tags( + db, snippet_model_repo, active_user, mongodb_id, faker +): + existing_tag = TagModel(name=faker.unique.word()) + db.add(existing_tag) + await db.flush() + + new_tag_name = faker.unique.word() + tag_names = [existing_tag.name, new_tag_name] + + snippet = await snippet_model_repo.create_with_tags( + **snippet_data, + tag_names=tag_names, + mongodb_id=mongodb_id, + user_id=active_user.id, + ) + await db.flush() + + assert snippet.id is not None + assert len(snippet.tags) == 2 + assert {tag.name for tag in snippet.tags} == set(tag_names) + + stmt = select(TagModel).where(TagModel.name == new_tag_name) + result = await db.execute(stmt) + new_tag_from_db = result.scalar_one() + assert new_tag_from_db is not None + assert new_tag_from_db.id is not None + + assert existing_tag in snippet.tags + + +async def test_create_with_no_tags( + db, snippet_model_repo, active_user, mongodb_id +): + snippet = await snippet_model_repo.create_with_tags( + **snippet_data, + tag_names=[], + mongodb_id=mongodb_id, + user_id=active_user.id, + ) + await db.flush() + + assert snippet.id is not None + assert len(snippet.tags) == 0 + + +async def test_get_paginated_default_visibility( + snippet_model_repo, setup_snippets +): + user1 = setup_snippets["user1"] + + snippets, total = await snippet_model_repo.get_snippets_paginated( + offset=0, limit=10, current_user_id=user1.id + ) + + assert total == 4 + + user1_ids = { + setup_snippets["u1_public_py"].id, + setup_snippets["u1_private_py"].id, + setup_snippets["u1_public_js"].id, + } + user2_public_id = setup_snippets["u2_public_py"].id + + assert any(s.id in user1_ids for s in snippets) + assert all(not s.is_private or s.user_id == user1.id for s in snippets) + assert all( + s.language in [LanguageEnum.PYTHON, LanguageEnum.JAVASCRIPT] + for s in snippets + ) + assert any(s.id == user2_public_id for s in snippets) + + +async def test_get_paginated_public_only(snippet_model_repo, setup_snippets): + user1 = setup_snippets["user1"] + + snippets, total = await snippet_model_repo.get_snippets_paginated( + offset=0, limit=10, current_user_id=user1.id, visibility="public" + ) + + assert total == 3 + assert all(not s.is_private for s in snippets) + + +async def test_get_paginated_private_only(snippet_model_repo, setup_snippets): + user1 = setup_snippets["user1"] + + snippets, total = await snippet_model_repo.get_snippets_paginated( + offset=0, limit=10, current_user_id=user1.id, visibility="private" + ) + assert total == 1 + assert snippets[0].is_private + assert snippets[0].user_id == user1.id + + +async def test_get_paginated_filter_by_language( + snippet_model_repo, setup_snippets +): + user1 = setup_snippets["user1"] + + snippets, total = await snippet_model_repo.get_snippets_paginated( + offset=0, + limit=10, + current_user_id=user1.id, + language=LanguageEnum.PYTHON, + ) + assert total == 3 + assert all(s.language == LanguageEnum.PYTHON for s in snippets) + + +async def test_get_paginated_filter_by_tag(snippet_model_repo, setup_snippets): + user1 = setup_snippets["user1"] + + snippets, total = await snippet_model_repo.get_snippets_paginated( + offset=0, limit=10, current_user_id=user1.id, tags=["test"] + ) + + assert total == 2 + assert all("test" in {t.name for t in s.tags} for s in snippets) + + +async def test_get_paginated_filter_by_username( + snippet_model_repo, setup_snippets +): + user1 = setup_snippets["user1"] + user2 = setup_snippets["user2"] + + snippets, total = await snippet_model_repo.get_snippets_paginated( + offset=0, limit=10, current_user_id=user1.id, username=user2.username + ) + + assert total == 1 + assert snippets[0].user_id == user2.id + assert not snippets[0].is_private + + +async def test_get_paginated_pagination(snippet_model_repo, setup_snippets): + user1 = setup_snippets["user1"] + + snippets_p1, total = await snippet_model_repo.get_snippets_paginated( + offset=0, limit=2, current_user_id=user1.id + ) + snippets_p2, total_p2 = await snippet_model_repo.get_snippets_paginated( + offset=2, limit=2, current_user_id=user1.id + ) + + assert total == total_p2 == 4 + assert len(snippets_p1) == 2 + assert len(snippets_p2) == 2 + assert not {s.id for s in snippets_p1}.intersection( + {s.id for s in snippets_p2} + ) + + +async def test_get_paginated_filter_by_date( + snippet_model_repo, setup_snippets +): + user1 = setup_snippets["user1"] + + ( + recent_snippets, + total_recent, + ) = await snippet_model_repo.get_snippets_paginated( + offset=0, + limit=10, + current_user_id=user1.id, + created_after=date.today() - timedelta(days=2), + ) + old_snippets, total_old = await snippet_model_repo.get_snippets_paginated( + offset=0, + limit=10, + current_user_id=user1.id, + created_before=date.today() - timedelta(days=3), + ) + + assert total_recent == 3 + assert total_old == 1 + assert all( + s.created_at.date() < (date.today() - timedelta(days=3)) + for s in old_snippets + ) + + +async def test_get_by_uuid(snippet_model_repo, setup_snippets): + snippet = setup_snippets["u1_public_py"] + + found = await snippet_model_repo.get_by_uuid(snippet.uuid) + assert found is not None + assert found.id == snippet.id + + not_found = await snippet_model_repo.get_by_uuid(uuid4()) + assert not_found is None + + +async def test_get_by_user(snippet_model_repo, setup_snippets): + user1 = setup_snippets["user1"] + user2 = setup_snippets["user2"] + + user1_snippets = await snippet_model_repo.get_by_user(user1.id) + user2_snippets = await snippet_model_repo.get_by_user(user2.id) + + assert len(user1_snippets) == 3 + assert len(user2_snippets) == 2 + + no_snippets = await snippet_model_repo.get_by_user(999) + assert no_snippets == [] + + +async def test_get_by_title(snippet_model_repo, setup_snippets): + snippet = setup_snippets["u1_public_py"] + user1 = setup_snippets["user1"] + user2 = setup_snippets["user2"] + + found_snippet = await snippet_model_repo.get_by_title( + snippet.title, user1.id + ) + assert found_snippet is not None + assert found_snippet.title == snippet.title + assert found_snippet.user_id == user1.id + + not_found = await snippet_model_repo.get_by_title(snippet.title, user2.id) + assert not_found is None + + not_found = await snippet_model_repo.get_by_title( + "DefinitelyNotExist", user1.id + ) + assert not_found is None + + +async def test_get_by_title_list(snippet_model_repo, setup_snippets): + user1 = setup_snippets["user1"] + user2 = setup_snippets["user2"] + sample_snippet = setup_snippets["u1_public_py"] + + keyword = sample_snippet.title.split()[0] + found = await snippet_model_repo.get_by_title_list(keyword, user1.id) + + assert isinstance(found, list) + assert any(s.id == sample_snippet.id for s in found) + assert all(isinstance(s.title, str) for s in found) + + limited = await snippet_model_repo.get_by_title_list( + keyword, user1.id, limit=1 + ) + assert len(limited) == 1 + + found_for_user2 = await snippet_model_repo.get_by_title_list( + keyword, user2.id + ) + assert all( + s.user_id == user2.id or not s.is_private for s in found_for_user2 + ) + + +async def test_delete(db, snippet_model_repo, setup_snippets): + snippet_to_delete = setup_snippets["u1_public_py"] + uuid_to_delete = snippet_to_delete.uuid + + await snippet_model_repo.delete(uuid_to_delete) + await db.flush() + + deleted = await snippet_model_repo.get_by_uuid(uuid_to_delete) + assert deleted is None + + try: + await snippet_model_repo.delete(uuid4()) + except Exception as e: + pytest.fail(f"Unexpected exception: {e}") + + +async def test_update_success(db, snippet_model_repo, setup_snippets): + snippet_to_update = setup_snippets["u1_public_py"] + uuid_to_update = snippet_to_update.uuid + + update_data = { + "title": "Updated Title", + "language": LanguageEnum.JAVASCRIPT, + "is_private": True, + } + + updated = await snippet_model_repo.update(uuid_to_update, **update_data) + await db.flush() + await db.refresh(updated) + + assert updated.title == "Updated Title" + assert updated.language == LanguageEnum.JAVASCRIPT + assert updated.is_private is True + + +async def test_update_partial(db, snippet_model_repo, setup_snippets): + snippet_to_update = setup_snippets["u1_public_py"] + uuid_to_update = snippet_to_update.uuid + original_lang = snippet_to_update.language + + updated = await snippet_model_repo.update( + uuid_to_update, title="Partial Title" + ) + + assert updated.title == "Partial Title" + assert updated.language == original_lang + + +async def test_update_not_found(snippet_model_repo): + with pytest.raises(exc.SnippetNotFoundError): + await snippet_model_repo.update(uuid4(), title="Won't work") diff --git a/backend/tests/integration/repositories/test_token_repos.py b/backend/tests/integration/repositories/test_token_repos.py new file mode 100644 index 0000000..6d3da39 --- /dev/null +++ b/backend/tests/integration/repositories/test_token_repos.py @@ -0,0 +1,133 @@ +import pytest + +from src.adapters.postgres.models import ( + ActivationTokenModel, + PasswordResetTokenModel, + RefreshTokenModel, +) +from src.adapters.postgres.repositories import TokenRepository +from src.core.security import generate_secure_token + + +@pytest.mark.parametrize( + "token_model", + [ + ActivationTokenModel, + PasswordResetTokenModel, + RefreshTokenModel, + ], +) +async def test_create_and_get_token(db, user_factory, token_model): + repo = TokenRepository(db, token_model) + + user = await user_factory.create(db) + token = generate_secure_token() + await repo.create(user.id, token, days=2) + await db.flush() + + fetched = await repo.get_by_token(token) + + assert fetched is not None + assert fetched.token == token + assert fetched.user_id == user.id + + +@pytest.mark.parametrize( + "token_model, token_value, factory_method_name", + [ + ( + ActivationTokenModel, + "activation_token", + "create_with_activation_token", + ), + (PasswordResetTokenModel, "reset_token", "create_with_reset_token"), + (RefreshTokenModel, "refresh_token", "create_with_refresh_token"), + ], +) +async def test_get_with_user( + db, user_factory, token_model, token_value, factory_method_name +): + factory_method = getattr(user_factory, factory_method_name) + user, _ = await factory_method(db, token_value) + + repo = TokenRepository(db, token_model) + + fetched_user, token = await repo.get_with_user(token_value) + + assert fetched_user.id == user.id + assert token.user_id == user.id + + +@pytest.mark.parametrize( + "token_model, token_value, factory_method_name", + [ + ( + ActivationTokenModel, + "activation_token", + "create_with_activation_token", + ), + (PasswordResetTokenModel, "reset_token", "create_with_reset_token"), + (RefreshTokenModel, "refresh_token", "create_with_refresh_token"), + ], +) +async def test_get_by_user( + db, user_factory, token_model, token_value, factory_method_name +): + factory_method = getattr(user_factory, factory_method_name) + user, _ = await factory_method(db, token_value) + + repo = TokenRepository(db, token_model) + + token = await repo.get_by_user(user.id) + assert token is not None + assert token.token == token_value + + +@pytest.mark.parametrize( + "token_model, token_value, factory_method_name", + [ + ( + ActivationTokenModel, + "activation_token", + "create_with_activation_token", + ), + (PasswordResetTokenModel, "reset_token", "create_with_reset_token"), + (RefreshTokenModel, "refresh_token", "create_with_refresh_token"), + ], +) +async def test_delete_by_token( + db, user_factory, token_model, token_value, factory_method_name +): + factory_method = getattr(user_factory, factory_method_name) + await factory_method(db, token_value) + + repo = TokenRepository(db, token_model) + + await repo.delete(token_value) + await db.commit() + token = await repo.get_by_token(token_value) + assert token is None + + +@pytest.mark.parametrize( + "token_model, factory_method_name", + [ + (ActivationTokenModel, "create_with_activation_token"), + (PasswordResetTokenModel, "create_with_reset_token"), + (RefreshTokenModel, "create_with_refresh_token"), + ], +) +async def test_delete_by_user_id( + db, user_factory, token_model, factory_method_name +): + token_value = generate_secure_token() + + factory_method = getattr(user_factory, factory_method_name) + user, _ = await factory_method(db, token_value) + + repo = TokenRepository(db, token_model) + + await repo.delete_by_user_id(user.id) + await db.commit() + token = await repo.get_by_token(token_value) + assert token is None diff --git a/backend/tests/integration/repositories/test_user_repo.py b/backend/tests/integration/repositories/test_user_repo.py new file mode 100644 index 0000000..bc09682 --- /dev/null +++ b/backend/tests/integration/repositories/test_user_repo.py @@ -0,0 +1,56 @@ +test_user = { + "email": "test@email.com", + "password": "Test1234!", + "username": "test", +} + + +async def test_create_user(db, user_repo): + user = await user_repo.create(**test_user) + + await db.flush() + assert user.id is not None + assert user.email == test_user["email"] + assert user.username == test_user["username"] + assert user._hashed_password != test_user["password"] + + +async def test_get_user_by_id(db, user_repo, user_factory): + user = await user_factory.create(db) + + fetched = await user_repo.get_by_id(user.id) + assert fetched is not None + assert fetched.email == user.email + + +async def test_get_user_by_email(db, user_repo, user_factory): + user = await user_factory.create(db) + + fetched = await user_repo.get_by_email(user.email) + assert fetched is not None + assert fetched.id == user.id + + +async def test_get_user_by_login_username(db, user_repo, user_factory): + user = await user_factory.create(db) + + fetched = await user_repo.get_by_login(user.username) + assert fetched is not None + assert fetched.email == user.email + + +async def test_get_user_by_login_email(db, user_repo, user_factory): + user = await user_factory.create(db) + + fetched = await user_repo.get_by_login(user.email) + assert fetched is not None + assert fetched.username == user.username + + +async def test_get_by_email_or_username(db, user_repo, user_factory): + user = await user_factory.create(db) + + fetched = await user_repo.get_by_email_or_username( + user.email, user.username + ) + assert fetched is not None diff --git a/backend/tests/integration/services/__init__.py b/backend/tests/integration/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/services/accounts/__init__.py b/backend/tests/integration/services/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/services/accounts/test_auth_service.py b/backend/tests/integration/services/accounts/test_auth_service.py new file mode 100644 index 0000000..a39bf9b --- /dev/null +++ b/backend/tests/integration/services/accounts/test_auth_service.py @@ -0,0 +1,75 @@ +import pytest +from sqlalchemy.exc import SQLAlchemyError + +import src.core.exceptions as exc + + +async def test_login_user_success(active_user, auth_service): + result = await auth_service.login_user(active_user.email, "Test1234!") + assert "refresh_token" in result + assert "access_token" in result + assert "token_type" in result + + +async def test_login_user_wrong_password(active_user, auth_service): + message = ( + "Entered Invalid password! Check your keyboard " + "layout or Caps Lock. Forgot your password?" + ) + + with pytest.raises(exc.InvalidPasswordError) as e: + await auth_service.login_user(active_user.email, "WrongPassword") + assert str(e.value) == message + + +async def test_login_user_user_not_active(inactive_user, auth_service): + message = "User account is not activated" + + with pytest.raises(exc.UserNotActiveError) as e: + await auth_service.login_user(inactive_user.email, "Test1234!") + + assert str(e.value) == message + + +async def test_login_user_not_found(auth_service): + message = "User with such email or username not registered." + with pytest.raises(exc.UserNotFoundError) as e: + await auth_service.login_user("test1@example.com", "Test1234!") + assert str(e.value) == message + + +async def test_login_user_db_error( + active_user, auth_service, mocker, refresh_token_repo +): + mock = mocker.patch( + "src.features.auth.auth_service.service.TokenRepository.create", + side_effect=SQLAlchemyError, + ) + + with pytest.raises(SQLAlchemyError): + await auth_service.login_user(active_user.email, "Test1234!") + + mock.assert_called_once() + assert await refresh_token_repo.get_by_user(active_user.id) is None + + +async def test_logout_user_success(logged_in_tokens, auth_service): + await auth_service.logout_user( + logged_in_tokens["refresh_token"], logged_in_tokens["access_token"] + ) + + +async def test_logout_user_db_error( + logged_in_tokens, active_user, auth_service, refresh_token_repo, mocker +): + mock = mocker.patch( + "src.features.auth.auth_service.service.TokenRepository.delete", + side_effect=SQLAlchemyError, + ) + with pytest.raises(SQLAlchemyError): + await auth_service.logout_user( + logged_in_tokens["refresh_token"], logged_in_tokens["access_token"] + ) + + mock.assert_called_once() + assert await refresh_token_repo.get_by_user(active_user.id) is not None diff --git a/backend/tests/integration/services/accounts/test_profile_service.py b/backend/tests/integration/services/accounts/test_profile_service.py new file mode 100644 index 0000000..dc9d363 --- /dev/null +++ b/backend/tests/integration/services/accounts/test_profile_service.py @@ -0,0 +1,145 @@ +import pytest +from sqlalchemy.exc import SQLAlchemyError + +import src.core.exceptions as exc +from src.adapters.postgres.models import GenderEnum +from src.api.v1.schemas.accounts import ProfileUpdateRequestSchema + +profile_data = ProfileUpdateRequestSchema( + first_name="John", + last_name="Doe", + gender=GenderEnum.MALE, + info="Some info", +) + + +async def test_update_profile_success(profile_service, user_with_profile): + user = user_with_profile[0] + + profile = await profile_service.update_profile(user.id, profile_data) + + assert profile.first_name == profile_data.first_name + assert profile.last_name == profile_data.last_name + assert profile.gender == profile_data.gender + assert profile.info == profile_data.info + + +async def test_update_profile_not_found(profile_service, active_user): + with pytest.raises(exc.ProfileNotFoundError): + await profile_service.update_profile(active_user.id, profile_data) + + +async def test_update_profile_db_error( + profile_service, user_with_profile, profile_repo, mocker +): + mock = mocker.patch( + "src.features.profile.service.UserProfileRepository.update", + side_effect=SQLAlchemyError, + ) + user, profile = user_with_profile[0], user_with_profile[1] + + with pytest.raises(SQLAlchemyError): + await profile_service.update_profile(user.id, profile_data) + + mock.assert_called_once() + profile_record = await profile_repo.get_by_user_id(user.id) + assert profile_record == profile + + +async def test_set_profile_avatar_success_no_old_avatar( + profile_service, + user_with_profile, + mock_upload_file, + profile_repo, + storage_stub, +): + user, profile = user_with_profile[0], user_with_profile[1] + + assert profile.avatar_url == "" + + await profile_service.set_profile_avatar(user.id, mock_upload_file) + + updated_profile = await profile_repo.get_by_user_id(user.id) + assert updated_profile.avatar_url != "" + assert updated_profile.avatar_url.startswith(storage_stub.base_url) + + assert storage_stub.file_exists(updated_profile.avatar_url) + + +async def test_set_profile_avatar_success_with_old_avatar( + profile_service, + user_with_profile, + mock_upload_file, + profile_repo, + storage_stub, +): + user, profile = user_with_profile[0], user_with_profile[1] + + old_url = storage_stub.upload_file("old_avatar.png", b"old_data") + await profile_repo.update_avatar_url(user.id, old_url) + await profile_repo._db.commit() + await profile_repo._db.refresh(profile) + assert profile.avatar_url == old_url + assert storage_stub.file_exists(old_url) + + await profile_service.set_profile_avatar(user.id, mock_upload_file) + + updated_profile = await profile_repo.get_by_user_id(user.id) + new_url = updated_profile.avatar_url + assert new_url != "" + assert new_url != old_url + + assert storage_stub.file_exists(new_url) + + assert not storage_stub.file_exists(old_url) + + +async def test_set_profile_avatar_db_error_rollback_storage( + profile_service, user_with_profile, mock_upload_file, mocker, storage_stub +): + user, profile = user_with_profile[0], user_with_profile[1] + + mock = mocker.patch( + "src.features.profile.service.UserProfileRepository.update_avatar_url", + side_effect=SQLAlchemyError, + ) + assert profile.avatar_url == "" + + with pytest.raises(SQLAlchemyError): + await profile_service.set_profile_avatar(user.id, mock_upload_file) + + mock.assert_called_once() + assert profile.avatar_url == "" + + +async def test_delete_profile_avatar_success( + profile_service, user_with_profile, profile_repo, storage_stub +): + user, profile = user_with_profile[0], user_with_profile[1] + + avatar_url = storage_stub.upload_file("test_delete.png", b"file_data") + await profile_repo.update_avatar_url(user.id, avatar_url) + await profile_repo._db.commit() + await profile_repo._db.refresh(profile) + assert profile.avatar_url == avatar_url + assert storage_stub.file_exists(avatar_url) + + await profile_service.delete_profile_avatar(user.id) + + updated_profile = await profile_repo.get_by_user_id(user.id) + assert updated_profile.avatar_url is None + + assert not storage_stub.file_exists(avatar_url) + + +async def test_delete_profile_avatar_no_avatar( + profile_service, user_with_profile, storage_stub, mocker +): + user, profile = user_with_profile[0], user_with_profile[1] + assert profile.avatar_url == "" + + mock_storage_delete = mocker.spy(storage_stub, "delete_file") + + await profile_service.delete_profile_avatar(user.id) + + mock_storage_delete.assert_not_called() diff --git a/backend/tests/integration/services/accounts/test_user_service.py b/backend/tests/integration/services/accounts/test_user_service.py new file mode 100644 index 0000000..659ae1a --- /dev/null +++ b/backend/tests/integration/services/accounts/test_user_service.py @@ -0,0 +1,267 @@ +import pytest +from sqlalchemy.exc import SQLAlchemyError + +import src.core.exceptions as exc +from tests.utils.user import create_user_data + + +# TODO: check if user's profile created +async def test_register_user_success( + db, user_service, activation_token_repo, faker +): + user_data = create_user_data(faker) + user, token = await user_service.register_user(**user_data) + assert user is not None + assert token is not None + assert user.email == user_data["email"] + assert user.username == user_data["username"] + + token_record = await activation_token_repo.get_by_token(token) + assert token_record is not None + assert token_record.user_id == user.id + + +async def test_register_user_already_exists(db, user_service, faker): + user_data = create_user_data(faker) + await user_service.register_user(**user_data) + + user_data_username = user_data.copy() + user_data_username["username"] = "test100" + + with pytest.raises(exc.UserAlreadyExistsError) as e: + await user_service.register_user(**user_data_username) + assert str(e.value) == "This email is taken." + + user_data_email = user_data.copy() + user_data_email["email"] = "test100@example.com" + + with pytest.raises(exc.UserAlreadyExistsError) as e: + await user_service.register_user(**user_data_email) + assert str(e.value) == "This username is taken." + + +async def test_register_user_db_error( + user_repo, + user_service, + mocker, + faker, +): + mock = mocker.patch( + "src.features.auth.user_service.service.UserRepository.create", + side_effect=SQLAlchemyError, + ) + + user_data = create_user_data(faker) + with pytest.raises(SQLAlchemyError): + await user_service.register_user(**user_data) + + mock.assert_called_once() + assert await user_repo.get_by_email(user_data["email"]) is None + + +async def test_activate_user_success( + db, user_service, user_factory, activation_token_repo +): + user, token = await user_factory.create_with_activation_token( + db, "token123" + ) + + await user_service.activate_account(token) + + await db.refresh(user) + assert user.is_active is True + assert await activation_token_repo.get_by_token(token) is None + + +async def test_activate_user_token_expired( + db, user_service, inactive_user, activation_token_repo +): + await activation_token_repo.create(inactive_user.id, "token123", -1) + await db.flush() + + with pytest.raises(exc.TokenExpiredError) as e: + await user_service.activate_account("token123") + + await db.refresh(inactive_user) + assert inactive_user.is_active is False + assert await activation_token_repo.get_by_token("token123") is None + assert str(e.value) == "Activation token has expired" + + +async def test_activate_user_db_error( + db, user_service, activation_token_repo, mocker, faker +): + mock = mocker.patch( + "src.features.auth.user_service.service.TokenRepository.delete", + side_effect=SQLAlchemyError, + ) + user_data = create_user_data(faker) + user, token = await user_service.register_user(**user_data) + + with pytest.raises(SQLAlchemyError): + await user_service.activate_account(token) + + mock.assert_called_once() + await db.refresh(user) + assert user.is_active is False + + +async def test_new_activation_token_success( + user_service, inactive_user, activation_token_repo +): + token = await user_service.new_activation_token(inactive_user.email) + + assert token is not None + assert token.user_id == inactive_user.id + assert await activation_token_repo.get_by_token(token.token) is not None + + +async def test_new_activation_token_user_not_found(user_service): + with pytest.raises(exc.UserNotFoundError): + await user_service.new_activation_token("notfound@example.com") + + +async def test_new_activation_token_user_already_active( + user_service, active_user +): + with pytest.raises(ValueError): + await user_service.new_activation_token(active_user.email) + + +async def test_new_activation_token_db_error( + db, user_service, inactive_user, mocker +): + mock = mocker.patch.object(db, "commit", side_effect=SQLAlchemyError) + + with pytest.raises(SQLAlchemyError): + await user_service.new_activation_token(inactive_user.email) + + mock.assert_called_once() + + +async def test_reset_password_token_success( + user_service, inactive_user, password_reset_token_repo +): + user_returned, token = await user_service.reset_password_token( + inactive_user.email + ) + + assert user_returned is not None + assert token is not None + assert user_returned.id == inactive_user.id + assert await password_reset_token_repo.get_by_token(token) is not None + + +async def test_reset_password_token_not_found(user_service): + with pytest.raises(exc.UserNotFoundError): + await user_service.reset_password_token("notfound@example.com") + + +async def test_reset_password_token_db_error( + user_service, inactive_user, password_reset_token_repo, mocker +): + mock = mocker.patch( + "src.features.auth.user_service.service.TokenRepository.create", + side_effect=SQLAlchemyError, + ) + with pytest.raises(SQLAlchemyError): + await user_service.reset_password_token(inactive_user.email) + + mock.assert_called_once() + assert ( + await password_reset_token_repo.get_by_user(inactive_user.id) is None + ) + + +async def test_reset_password_complete_success( + db, user_service, user_factory, password_reset_token_repo +): + user, token = await user_factory.create_with_reset_token(db, "token") + + await user_service.reset_password_complete(user.email, "Test123!", token) + + assert user.verify_password("Test123!") is True + assert await password_reset_token_repo.get_by_token(token) is None + + +async def test_reset_password_complete_expired( + db, user_service, inactive_user, password_reset_token_repo +): + await password_reset_token_repo.create(inactive_user.id, "token123", -1) + await db.flush() + + with pytest.raises(exc.TokenExpiredError) as e: + await user_service.reset_password_complete( + inactive_user.email, "Test123!", "token123" + ) + assert str(e.value) == ( + "This password reset link has expired or is invalid. " + "Please request a new reset link." + ) + + await db.refresh(inactive_user) + assert inactive_user.verify_password("Test123!") is False + + +async def test_reset_password_complete_db_error( + db, user_service, user_factory, mocker +): + mock = mocker.patch( + "src.features.auth.user_service.service.TokenRepository.delete", + side_effect=SQLAlchemyError, + ) + user, token = await user_factory.create_with_reset_token(db, "token5") + with pytest.raises(SQLAlchemyError): + await user_service.reset_password_complete( + user.email, "Test123!", token + ) + + mock.assert_called_once() + await db.refresh(user) + assert user.verify_password("Test123!") is False + + +async def test_change_password_success( + db, + user_service, + user_factory, +): + user = await user_factory.create(db, password="OldPass123!") + await user_service.change_password(user, "OldPass123!", "NewPass123!") + assert user.verify_password("NewPass123!") + + +async def test_change_password_invalid_old_password( + db, + user_service, + user_factory, +): + user = await user_factory.create(db, password="Correct123!") + with pytest.raises(exc.InvalidPasswordError): + await user_service.change_password(user, "WrongOld!", "NewPass123!") + + +async def test_change_password_same_as_old( + db, + user_service, + user_factory, +): + user = await user_factory.create(db, password="SamePass123!") + with pytest.raises(exc.InvalidPasswordError): + await user_service.change_password( + user, "SamePass123!", "SamePass123!" + ) + + +async def test_change_password_db_error( + db, user_service, user_factory, mocker +): + user = await user_factory.create(db, password="OldPass123!") + mock_commit = mocker.patch.object( + db, "commit", side_effect=SQLAlchemyError + ) + + with pytest.raises(SQLAlchemyError): + await user_service.change_password(user, "OldPass123!", "NewPass123!") + + mock_commit.assert_called_once() diff --git a/backend/tests/integration/services/snippets/__init__.py b/backend/tests/integration/services/snippets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/services/snippets/test_favorites_service.py b/backend/tests/integration/services/snippets/test_favorites_service.py new file mode 100644 index 0000000..e6f8eb4 --- /dev/null +++ b/backend/tests/integration/services/snippets/test_favorites_service.py @@ -0,0 +1,197 @@ +from uuid import uuid4 + +import pytest + +from src.adapters.postgres.models import LanguageEnum +from src.api.v1.schemas.snippets import FavoritesSortingEnum +from src.core import exceptions as exc + + +async def test_add_to_favorites_success( + favorites_service, snippet_factory, active_user +): + snippet, _ = await snippet_factory.create(active_user) + + await favorites_service.add_to_favorites(active_user, snippet.uuid) + + ( + fav_snippets, + total, + ) = await favorites_service._repo.get_favorites_paginated( + 0, 10, active_user.id, sort_by=None + ) + assert any(f.id == snippet.id for f in fav_snippets) + + +async def test_add_to_favorites_already_exists( + favorites_service, snippet_factory, active_user +): + snippet, _ = await snippet_factory.create(active_user) + await favorites_service.add_to_favorites(active_user, snippet.uuid) + + with pytest.raises(exc.FavoritesAlreadyError): + await favorites_service.add_to_favorites(active_user, snippet.uuid) + + +async def test_add_to_favorites_snippet_not_found( + favorites_service, active_user +): + with pytest.raises(exc.SnippetNotFoundError): + await favorites_service.add_to_favorites(active_user, uuid4()) + + +async def test_remove_from_favorites_success( + favorites_service, snippet_factory, active_user +): + snippet, _ = await snippet_factory.create(active_user) + await favorites_service.add_to_favorites(active_user, snippet.uuid) + + await favorites_service.remove_from_favorites(active_user, snippet.uuid) + + ( + fav_snippets, + total, + ) = await favorites_service._repo.get_favorites_paginated( + 0, 10, active_user.id, sort_by=None + ) + assert all(f.id != snippet.id for f in fav_snippets) + + +async def test_remove_from_favorites_not_exists( + favorites_service, snippet_factory, active_user +): + snippet, _ = await snippet_factory.create(active_user) + + with pytest.raises(exc.FavoritesAlreadyError): + await favorites_service.remove_from_favorites( + active_user, snippet.uuid + ) + + +async def test_remove_from_favorites_snippet_not_found( + favorites_service, active_user +): + with pytest.raises(exc.SnippetNotFoundError): + await favorites_service.remove_from_favorites(active_user, uuid4()) + + +async def test_get_favorites_paginated_basic( + favorites_service, setup_favorites +): + user1, _, favorites = setup_favorites + + result, total = await favorites_service._repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + ) + + assert total == len(favorites) + assert all(s.id in [f.id for f in favorites] for s in result) + + +async def test_get_favorites_paginated_filter_by_language( + favorites_service, setup_favorites +): + user1, _, _ = setup_favorites + + result, total = await favorites_service._repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + language=LanguageEnum.PYTHON, + ) + + assert all(s.language == LanguageEnum.PYTHON for s in result) + + +async def test_get_favorites_paginated_filter_by_tag( + favorites_service, setup_favorites +): + user1, _, _ = setup_favorites + + result, total = await favorites_service._repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + tags=["test"], + ) + + assert all("test" in [t.name for t in s.tags] for s in result) + + +async def test_get_favorites_paginated_sorting_snippet_date( + favorites_service, setup_favorites +): + user1, _, _ = setup_favorites + + result, _ = await favorites_service._repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.SNIPPET_DATE, + ) + + assert all( + result[i].created_at >= result[i + 1].created_at + for i in range(len(result) - 1) + ) + + +async def test_get_favorites_paginated_sorting_title( + favorites_service, setup_favorites +): + user1, _, _ = setup_favorites + + result, _ = await favorites_service._repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.TITLE, + ) + + titles = [s.title for s in result] + assert titles == sorted(titles, key=str.lower) + + +async def test_get_favorites_paginated_pagination( + favorites_service, setup_favorites +): + user1, _, _ = setup_favorites + + page1, total1 = await favorites_service._repo.get_favorites_paginated( + offset=0, + limit=2, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + ) + page2, total2 = await favorites_service._repo.get_favorites_paginated( + offset=2, + limit=2, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + ) + + assert total1 == total2 == 3 + assert len(page1) == 2 + assert len(page2) == 1 + assert not {s.id for s in page1}.intersection({s.id for s in page2}) + + +async def test_get_favorites_paginated_filter_by_username( + favorites_service, setup_favorites +): + user1, user2, _ = setup_favorites + + result, total = await favorites_service._repo.get_favorites_paginated( + offset=0, + limit=10, + user_id=user1.id, + sort_by=FavoritesSortingEnum.DATE_ADDED, + username=user2.username, + ) + + assert all(s.user_id == user2.id for s in result) diff --git a/backend/tests/integration/services/snippets/test_search_service.py b/backend/tests/integration/services/snippets/test_search_service.py new file mode 100644 index 0000000..a7ab890 --- /dev/null +++ b/backend/tests/integration/services/snippets/test_search_service.py @@ -0,0 +1,75 @@ +import json + + +async def test_search_by_title_returns_correct_snippets( + search_service, setup_snippets, redis_client +): + user1 = setup_snippets["user1"] + u1_pub_py = setup_snippets["u1_public_py"] + + await redis_client.flushall() + + response = await search_service.search_by_title( + u1_pub_py.title, user1.id, limit=10 + ) + + assert isinstance(response.results, list) + assert any(s.uuid == u1_pub_py.uuid for s in response.results) + u1_priv_py = setup_snippets["u1_private_py"] + response_private = await search_service.search_by_title( + u1_priv_py.title, user1.id, limit=10 + ) + assert any(s.uuid == u1_priv_py.uuid for s in response_private.results) + + +async def test_search_by_title_respects_privacy( + search_service, setup_snippets +): + user1 = setup_snippets["user1"] + u2_priv_js = setup_snippets["u2_private_js"] + + response = await search_service.search_by_title( + u2_priv_js.title, user1.id, limit=10 + ) + assert all(s.uuid != u2_priv_js.uuid for s in response.results) + + +async def test_search_by_title_uses_cache( + search_service, setup_snippets, redis_client +): + user1 = setup_snippets["user1"] + u1_pub_py = setup_snippets["u1_public_py"] + + await redis_client.flushall() + + await search_service.search_by_title(u1_pub_py.title, user1.id, limit=10) + + cache_key = f"search:{user1.id}:{u1_pub_py.title.lower()}" + cached = await redis_client.get(cache_key) + assert cached is not None + + cached_data = json.loads(cached) + assert any( + item["uuid"] == str(u1_pub_py.uuid) for item in cached_data["results"] + ) + + +async def test_search_by_title_partial_match(search_service, setup_snippets): + user1 = setup_snippets["user1"] + u1_pub_py = setup_snippets["u1_public_py"] + + keyword = u1_pub_py.title.split()[0] + response = await search_service.search_by_title( + keyword, user1.id, limit=10 + ) + assert any(keyword in s.title for s in response.results) + + +async def test_search_by_title_limit(search_service, setup_snippets): + user1 = setup_snippets["user1"] + u1_pub_py = setup_snippets["u1_public_py"] + + response = await search_service.search_by_title( + u1_pub_py.title, user1.id, limit=1 + ) + assert len(response.results) == 1 diff --git a/backend/tests/integration/services/snippets/test_snippet_service.py b/backend/tests/integration/services/snippets/test_snippet_service.py new file mode 100644 index 0000000..f881351 --- /dev/null +++ b/backend/tests/integration/services/snippets/test_snippet_service.py @@ -0,0 +1,241 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +import src.core.exceptions as exc +from src.adapters.postgres.models import LanguageEnum, SnippetModel +from src.api.v1.schemas.snippets import ( + SnippetCreateSchema, + SnippetUpdateRequestSchema, +) + + +@pytest.fixture +def snippet_create_data(active_user, faker): + return SnippetCreateSchema( + title=faker.sentence(nb_words=3), + language=LanguageEnum.PYTHON, + is_private=False, + content=faker.text(), + description=faker.sentence(), + tags=[faker.word(), faker.word()], + user_id=active_user.id, + ) + + +@pytest.fixture +def snippet_update_data(faker): + return SnippetUpdateRequestSchema( + title="A Whole New Title", + language=LanguageEnum.JAVASCRIPT, + is_private=True, + content="new content for the snippet", + description="a new description", + tags=["new", "updated"], + ) + + +async def get_snippet_by_title(db, title: str): + stmt = select(SnippetModel).where(SnippetModel.title == title) + result = await db.execute(stmt) + return result.scalar_one() + + +async def test_create_snippet_success( + db, + snippet_service, + snippet_doc_repo, + snippet_model_repo, + active_user, + snippet_create_data, +): + response_schema = await snippet_service.create_snippet(snippet_create_data) + + assert response_schema.title == snippet_create_data.title + assert response_schema.content == snippet_create_data.content + assert response_schema.username == active_user.username + assert set(response_schema.tags) == set(snippet_create_data.tags) + + model = await snippet_model_repo.get_by_uuid(response_schema.uuid) + assert model is not None + assert model.title == snippet_create_data.title + assert model.user_id == active_user.id + + document = await snippet_doc_repo.get_by_id(model.mongodb_id) + assert document is not None + assert document.content == snippet_create_data.content + assert document.description == snippet_create_data.description + + +async def test_create_snippet_duplicate_title( + snippet_service, active_user, snippet_create_data +): + await snippet_service.create_snippet(snippet_create_data) + + with pytest.raises(exc.SnippetAlreadyExistsError): + await snippet_service.create_snippet(snippet_create_data) + + +async def test_create_snippet_rollback_on_db_error( + snippet_service, snippet_doc_repo, mocker, snippet_create_data +): + mocker.patch.object( + snippet_service._model_repo, + "create_with_tags", + side_effect=SQLAlchemyError("Simulated DB error"), + ) + delete_spy = mocker.spy(snippet_service._doc_repo, "delete_document") + + with pytest.raises(SQLAlchemyError): + await snippet_service.create_snippet(snippet_create_data) + + delete_spy.assert_called_once() + + +async def test_get_own_private_snippet(db, snippet_service, setup_snippets): + user1 = setup_snippets["user1"] + private_snippet = setup_snippets["u1_private_py"] + + response = await snippet_service.get_snippet_by_uuid( + private_snippet.uuid, user1 + ) + + assert response is not None + assert response.uuid == private_snippet.uuid + assert response.title == private_snippet.title + assert response.content is not None + + +async def test_get_other_user_public_snippet( + db, snippet_service, setup_snippets +): + user1 = setup_snippets["user1"] + other_public_snippet = setup_snippets["u2_public_py"] + + response = await snippet_service.get_snippet_by_uuid( + other_public_snippet.uuid, user1 + ) + + assert response is not None + assert response.uuid == other_public_snippet.uuid + assert response.title == other_public_snippet.title + + +async def test_get_other_user_private_snippet_no_permission( + db, snippet_service, setup_snippets +): + user1 = setup_snippets["user1"] + other_private_snippet = setup_snippets["u2_private_js"] + + with pytest.raises(exc.NoPermissionError): + await snippet_service.get_snippet_by_uuid( + other_private_snippet.uuid, user1 + ) + + +async def test_get_other_user_private_snippet_as_admin( + db, snippet_service, setup_snippets, user_factory +): + admin_user = await user_factory.create_active(db) + admin_user.is_admin = True + await db.flush() + + private_snippet = setup_snippets["u2_private_js"] + + response = await snippet_service.get_snippet_by_uuid( + private_snippet.uuid, admin_user + ) + + assert response is not None + assert response.uuid == private_snippet.uuid + assert response.title == private_snippet.title + + +async def test_get_snippet_not_found_in_db(snippet_service, setup_snippets): + user1 = setup_snippets["user1"] + non_existent_uuid = uuid4() + + with pytest.raises(exc.SnippetNotFoundError): + await snippet_service.get_snippet_by_uuid(non_existent_uuid, user1) + + +async def test_get_snippet_document_not_found( + db, snippet_service, snippet_doc_repo, setup_snippets +): + user1 = setup_snippets["user1"] + snippet = setup_snippets["u1_public_py"] + + await snippet_doc_repo.delete(snippet.mongodb_id) + + with pytest.raises(exc.SnippetNotFoundError): + await snippet_service.get_snippet_by_uuid(snippet.uuid, user1) + + +async def test_update_own_snippet_success( + db, snippet_service, setup_snippets, snippet_update_data, snippet_doc_repo +): + user1 = setup_snippets["user1"] + snippet_to_update = setup_snippets["u1_public_py"] + + response = await snippet_service.update_snippet( + snippet_to_update.uuid, snippet_update_data, user1 + ) + + assert response.title == snippet_update_data.title + assert response.language == snippet_update_data.language + assert response.is_private == snippet_update_data.is_private + assert response.content == snippet_update_data.content + assert response.description == snippet_update_data.description + assert set(response.tags) == set(snippet_update_data.tags) + + updated_doc = await snippet_doc_repo.get_by_id( + snippet_to_update.mongodb_id + ) + assert updated_doc.content == snippet_update_data.content + assert updated_doc.description == snippet_update_data.description + + await db.refresh(snippet_to_update) + assert snippet_to_update.title == snippet_update_data.title + assert {tag.name for tag in snippet_to_update.tags} == set( + snippet_update_data.tags + ) + + +async def test_update_other_user_snippet_no_permission( + db, snippet_service, setup_snippets, snippet_update_data +): + user1 = setup_snippets["user1"] + other_snippet = setup_snippets["u2_public_py"] + + with pytest.raises(exc.NoPermissionError): + await snippet_service.update_snippet( + other_snippet.uuid, snippet_update_data, user1 + ) + + +async def test_update_other_user_snippet_as_admin( + db, snippet_service, setup_snippets, user_factory, snippet_update_data +): + admin_user = await user_factory.create_active(db) + admin_user.is_admin = True + await db.flush() + + private_snippet = setup_snippets["u1_private_py"] + + response = await snippet_service.update_snippet( + private_snippet.uuid, snippet_update_data, admin_user + ) + + assert response is not None + assert response.title == snippet_update_data.title + + +async def test_update_snippet_not_found( + snippet_service, active_user, snippet_update_data +): + with pytest.raises(exc.SnippetNotFoundError): + await snippet_service.update_snippet( + uuid4(), snippet_update_data, active_user + ) diff --git a/backend/tests/invalid_avatar.txt b/backend/tests/invalid_avatar.txt new file mode 100644 index 0000000..ac263f7 --- /dev/null +++ b/backend/tests/invalid_avatar.txt @@ -0,0 +1 @@ +this is not an image \ No newline at end of file diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_email.py b/backend/tests/unit/test_email.py new file mode 100644 index 0000000..725cbba --- /dev/null +++ b/backend/tests/unit/test_email.py @@ -0,0 +1,61 @@ +import aiosmtplib +import pytest + + +async def test_send_activation_email_success(email_sender_stub, mocker): + mock = mocker.patch("aiosmtplib.send", return_value=None) + + await email_sender_stub.send_activation_email( + "user@example.com", "token123" + ) + + mock.assert_called_once() + message = mock.call_args[0][0] + assert "Activate your account" in message["Subject"] + assert "token123" in message.get_content() + + +async def test_send_email_auth_error(email_sender_stub, mocker): + mock = mocker.patch( + "aiosmtplib.send", + side_effect=aiosmtplib.SMTPAuthenticationError( + 535, "Authentication failed" + ), + ) + + with pytest.raises(aiosmtplib.SMTPAuthenticationError): + await email_sender_stub.send_activation_email( + "user@example.com", "token123" + ) + + mock.assert_called_once() + + +async def test_send_email_recipient_error(email_sender_stub, mocker): + mock = mocker.patch( + "aiosmtplib.send", + side_effect=aiosmtplib.SMTPRecipientsRefused( + {"user@example.com": (550, "Mailbox not found")} + ), + ) + + with pytest.raises(aiosmtplib.SMTPRecipientsRefused): + await email_sender_stub.send_activation_email( + "user@example.com", "token123" + ) + + mock.assert_called_once() + + +async def test_send_email_connect_error(email_sender_stub, mocker): + mock = mocker.patch( + "aiosmtplib.send", + side_effect=aiosmtplib.SMTPConnectError("Connection failed"), + ) + + with pytest.raises(aiosmtplib.SMTPConnectError): + await email_sender_stub.send_activation_email( + "user@example.com", "token123" + ) + + mock.assert_called_once() diff --git a/backend/tests/utils/__init__.py b/backend/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/utils/user.py b/backend/tests/utils/user.py new file mode 100644 index 0000000..9601501 --- /dev/null +++ b/backend/tests/utils/user.py @@ -0,0 +1,6 @@ +def create_user_data(faker): + return { + "email": faker.unique.email(), + "username": faker.unique.user_name(), + "password": "Test1234!", + } diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..0132dd4 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,1593 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosmtplib" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/e1/cc58e0be242f0b410707e001ed22c689435964fcaab42108887426e44fff/aiosmtplib-4.0.2.tar.gz", hash = "sha256:f0b4933e7270a8be2b588761e5b12b7334c11890ee91987c2fb057e72f566da6", size = 61052, upload-time = "2025-08-25T02:39:07.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/2f/db9414bbeacee48ab0c7421a0319b361b7c15b5c3feebcd38684f5d5f849/aiosmtplib-4.0.2-py3-none-any.whl", hash = "sha256:72491f96e6de035c28d29870186782eccb2f651db9c5f8a32c9db689327f5742", size = 27048, upload-time = "2025-08-25T02:39:06.089Z" }, +] + +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "azure-core" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/d4ff3bc3ddf155156460bff340bbe9533f99fac54ddea165f35a8619f162/azure_core-1.36.0.tar.gz", hash = "sha256:22e5605e6d0bf1d229726af56d9e92bc37b6e726b141a18be0b4d424131741b7", size = 351139, upload-time = "2025-10-15T00:33:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl", hash = "sha256:fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b", size = 213302, upload-time = "2025-10-15T00:33:51.058Z" }, +] + +[[package]] +name = "azure-storage-blob" +version = "12.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/7c/2fd872e11a88163f208b9c92de273bf64bb22d0eef9048cc6284d128a77a/azure_storage_blob-12.27.1.tar.gz", hash = "sha256:a1596cc4daf5dac9be115fcb5db67245eae894cf40e4248243754261f7b674a6", size = 597579, upload-time = "2025-10-29T12:27:16.185Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9e/1c90a122ea6180e8c72eb7294adc92531b0e08eb3d2324c2ba70d37f4802/azure_storage_blob-12.27.1-py3-none-any.whl", hash = "sha256:65d1e25a4628b7b6acd20ff7902d8da5b4fde8e46e19c8f6d213a3abc3ece272", size = 428954, upload-time = "2025-10-29T12:27:18.072Z" }, +] + +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" }, + { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" }, + { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" }, + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, +] + +[[package]] +name = "beanie" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "lazy-model" }, + { name = "pydantic" }, + { name = "pymongo" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/c3/21152df5974f6b690a74a990a1b706102ad694b56bd2a59f7903b6424696/beanie-2.0.0.tar.gz", hash = "sha256:07982e42618cea01722f62d2b4028514a508a2c2c2c71ff85f07f6009112ffb3", size = 169854, upload-time = "2025-07-20T06:55:27.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/36/c40577bc8e3564639b89db32aff1e9e8af14c990e3a7ed85a79b74ec4b78/beanie-2.0.0-py3-none-any.whl", hash = "sha256:0d5c0e0de09f2a316c74d17bbba1ceb68ebcbfd3046ae5be69038b2023682372", size = 87051, upload-time = "2025-07-20T06:55:25.944Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/6a/1405343016bce8354b29d90aad6b0bf6485b5e60404516e4b9a3a9646cf0/billiard-4.2.2.tar.gz", hash = "sha256:e815017a062b714958463e07ba15981d802dc53d41c5b69d28c5a7c238f8ecf3", size = 155592, upload-time = "2025-09-20T14:44:40.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/80/ef8dff49aae0e4430f81842f7403e14e0ca59db7bbaf7af41245b67c6b25/billiard-4.2.2-py3-none-any.whl", hash = "sha256:4bc05dcf0d1cc6addef470723aac2a6232f3c7ed7475b0b580473a9145829457", size = 86896, upload-time = "2025-09-20T14:44:39.157Z" }, +] + +[[package]] +name = "celery" +version = "5.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload-time = "2025-11-10T00:11:34.296Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload-time = "2025-11-10T00:11:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload-time = "2025-11-10T00:11:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload-time = "2025-11-10T00:11:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload-time = "2025-11-10T00:11:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload-time = "2025-11-10T00:11:43.27Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload-time = "2025-11-10T00:11:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload-time = "2025-11-10T00:11:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload-time = "2025-11-10T00:11:48.563Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload-time = "2025-11-10T00:11:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload-time = "2025-11-10T00:11:52.184Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload-time = "2025-11-10T00:11:53.871Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload-time = "2025-11-10T00:11:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload-time = "2025-11-10T00:11:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload-time = "2025-11-10T00:11:59.519Z" }, + { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload-time = "2025-11-10T00:12:01.592Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload-time = "2025-11-10T00:12:03.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload-time = "2025-11-10T00:12:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload-time = "2025-11-10T00:12:07.137Z" }, + { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload-time = "2025-11-10T00:12:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload-time = "2025-11-10T00:12:11.195Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload-time = "2025-11-10T00:12:12.843Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload-time = "2025-11-10T00:12:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload-time = "2025-11-10T00:12:17.274Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload-time = "2025-11-10T00:12:19.292Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload-time = "2025-11-10T00:12:21.251Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, + { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload-time = "2025-11-10T00:12:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload-time = "2025-11-10T00:12:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload-time = "2025-11-10T00:12:32.195Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload-time = "2025-11-10T00:12:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload-time = "2025-11-10T00:12:36.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload-time = "2025-11-10T00:12:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload-time = "2025-11-10T00:12:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload-time = "2025-11-10T00:12:42.231Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload-time = "2025-11-10T00:12:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload-time = "2025-11-10T00:12:46.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload-time = "2025-11-10T00:12:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload-time = "2025-11-10T00:12:50.182Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload-time = "2025-11-10T00:12:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload-time = "2025-11-10T00:12:54.667Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload-time = "2025-11-10T00:12:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload-time = "2025-11-10T00:12:58.687Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload-time = "2025-11-10T00:13:00.502Z" }, + { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload-time = "2025-11-10T00:13:02.747Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload-time = "2025-11-10T00:13:04.689Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload-time = "2025-11-10T00:13:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload-time = "2025-11-10T00:13:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload-time = "2025-11-10T00:13:10.734Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload-time = "2025-11-10T00:13:12.712Z" }, + { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/35/c495bffc2056f2dadb32434f1feedd79abde2a7f8363e1974afa9c33c7e2/cryptography-45.0.7.tar.gz", hash = "sha256:4b1654dfc64ea479c242508eb8c724044f1e964a47d1d1cacc5132292d851971", size = 744980, upload-time = "2025-09-01T11:15:03.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/91/925c0ac74362172ae4516000fe877912e33b5983df735ff290c653de4913/cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee", size = 7041105, upload-time = "2025-09-01T11:13:59.684Z" }, + { url = "https://files.pythonhosted.org/packages/fc/63/43641c5acce3a6105cf8bd5baeceeb1846bb63067d26dae3e5db59f1513a/cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6", size = 4205799, upload-time = "2025-09-01T11:14:02.517Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/c238dd9107f10bfde09a4d1c52fd38828b1aa353ced11f358b5dd2507d24/cryptography-45.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:577470e39e60a6cd7780793202e63536026d9b8641de011ed9d8174da9ca5339", size = 4430504, upload-time = "2025-09-01T11:14:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/62/62/24203e7cbcc9bd7c94739428cd30680b18ae6b18377ae66075c8e4771b1b/cryptography-45.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4bd3e5c4b9682bc112d634f2c6ccc6736ed3635fc3319ac2bb11d768cc5a00d8", size = 4209542, upload-time = "2025-09-01T11:14:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e3/e7de4771a08620eef2389b86cd87a2c50326827dea5528feb70595439ce4/cryptography-45.0.7-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:465ccac9d70115cd4de7186e60cfe989de73f7bb23e8a7aa45af18f7412e75bf", size = 3889244, upload-time = "2025-09-01T11:14:08.152Z" }, + { url = "https://files.pythonhosted.org/packages/96/b8/bca71059e79a0bb2f8e4ec61d9c205fbe97876318566cde3b5092529faa9/cryptography-45.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:16ede8a4f7929b4b7ff3642eba2bf79aa1d71f24ab6ee443935c0d269b6bc513", size = 4461975, upload-time = "2025-09-01T11:14:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/58/67/3f5b26937fe1218c40e95ef4ff8d23c8dc05aa950d54200cc7ea5fb58d28/cryptography-45.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8978132287a9d3ad6b54fcd1e08548033cc09dc6aacacb6c004c73c3eb5d3ac3", size = 4209082, upload-time = "2025-09-01T11:14:11.229Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e4/b3e68a4ac363406a56cf7b741eeb80d05284d8c60ee1a55cdc7587e2a553/cryptography-45.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b6a0e535baec27b528cb07a119f321ac024592388c5681a5ced167ae98e9fff3", size = 4460397, upload-time = "2025-09-01T11:14:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/22/49/2c93f3cd4e3efc8cb22b02678c1fad691cff9dd71bb889e030d100acbfe0/cryptography-45.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a24ee598d10befaec178efdff6054bc4d7e883f615bfbcd08126a0f4931c83a6", size = 4337244, upload-time = "2025-09-01T11:14:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/030f400de0bccccc09aa262706d90f2ec23d56bc4eb4f4e8268d0ddf3fb8/cryptography-45.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fa26fa54c0a9384c27fcdc905a2fb7d60ac6e47d14bc2692145f2b3b1e2cfdbd", size = 4568862, upload-time = "2025-09-01T11:14:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/3034a3a353efa65116fa20eb3c990a8c9f0d3db4085429040a7eef9ada5f/cryptography-45.0.7-cp311-abi3-win32.whl", hash = "sha256:bef32a5e327bd8e5af915d3416ffefdbe65ed975b646b3805be81b23580b57b8", size = 2936578, upload-time = "2025-09-01T11:14:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/b3/61/0ab90f421c6194705a99d0fa9f6ee2045d916e4455fdbb095a9c2c9a520f/cryptography-45.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:3808e6b2e5f0b46d981c24d79648e5c25c35e59902ea4391a0dcb3e667bf7443", size = 3405400, upload-time = "2025-09-01T11:14:18.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/e8/c436233ddf19c5f15b25ace33979a9dd2e7aa1a59209a0ee8554179f1cc0/cryptography-45.0.7-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bfb4c801f65dd61cedfc61a83732327fafbac55a47282e6f26f073ca7a41c3b2", size = 7021824, upload-time = "2025-09-01T11:14:20.954Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4c/8f57f2500d0ccd2675c5d0cc462095adf3faa8c52294ba085c036befb901/cryptography-45.0.7-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81823935e2f8d476707e85a78a405953a03ef7b7b4f55f93f7c2d9680e5e0691", size = 4202233, upload-time = "2025-09-01T11:14:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/59b7790b4ccaed739fc44775ce4645c9b8ce54cbec53edf16c74fd80cb2b/cryptography-45.0.7-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3994c809c17fc570c2af12c9b840d7cea85a9fd3e5c0e0491f4fa3c029216d59", size = 4423075, upload-time = "2025-09-01T11:14:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/d4f07ea21434bf891faa088a6ac15d6d98093a66e75e30ad08e88aa2b9ba/cryptography-45.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dad43797959a74103cb59c5dac71409f9c27d34c8a05921341fb64ea8ccb1dd4", size = 4204517, upload-time = "2025-09-01T11:14:25.679Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/924a723299848b4c741c1059752c7cfe09473b6fd77d2920398fc26bfb53/cryptography-45.0.7-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce7a453385e4c4693985b4a4a3533e041558851eae061a58a5405363b098fcd3", size = 3882893, upload-time = "2025-09-01T11:14:27.1Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/4dab2ff0a871cc2d81d3ae6d780991c0192b259c35e4d83fe1de18b20c70/cryptography-45.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b04f85ac3a90c227b6e5890acb0edbaf3140938dbecf07bff618bf3638578cf1", size = 4450132, upload-time = "2025-09-01T11:14:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/b2882b65db8fc944585d7fb00d67cf84a9cef4e77d9ba8f69082e911d0de/cryptography-45.0.7-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:48c41a44ef8b8c2e80ca4527ee81daa4c527df3ecbc9423c41a420a9559d0e27", size = 4204086, upload-time = "2025-09-01T11:14:30.572Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fa/1d5745d878048699b8eb87c984d4ccc5da4f5008dfd3ad7a94040caca23a/cryptography-45.0.7-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f3df7b3d0f91b88b2106031fd995802a2e9ae13e02c36c1fc075b43f420f3a17", size = 4449383, upload-time = "2025-09-01T11:14:32.046Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/fc61f87931bc030598e1876c45b936867bb72777eac693e905ab89832670/cryptography-45.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd342f085542f6eb894ca00ef70236ea46070c8a13824c6bde0dfdcd36065b9b", size = 4332186, upload-time = "2025-09-01T11:14:33.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/09700ddad7443ccb11d674efdbe9a832b4455dc1f16566d9bd3834922ce5/cryptography-45.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1993a1bb7e4eccfb922b6cd414f072e08ff5816702a0bdb8941c247a6b1b287c", size = 4561639, upload-time = "2025-09-01T11:14:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/8f4c1337e9d3b94d8e50ae0b08ad0304a5709d483bfcadfcc77a23dbcb52/cryptography-45.0.7-cp37-abi3-win32.whl", hash = "sha256:18fcf70f243fe07252dcb1b268a687f2358025ce32f9f88028ca5c364b123ef5", size = 2926552, upload-time = "2025-09-01T11:14:36.929Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ff/026513ecad58dacd45d1d24ebe52b852165a26e287177de1d545325c0c25/cryptography-45.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:7285a89df4900ed3bfaad5679b1e668cb4b38a8de1ccbfc84b05f34512da0a90", size = 3392742, upload-time = "2025-09-01T11:14:38.368Z" }, +] + +[[package]] +name = "deprecated" +version = "1.2.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/97/06afe62762c9a8a86af0cfb7bfdab22a43ad17138b07af5b1a58442690a2/deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d", size = 2928744, upload-time = "2025-01-27T10:46:25.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/c6/ac0b6c1e2d138f1002bcf799d330bd6d85084fece321e662a14223794041/Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec", size = 9998, upload-time = "2025-01-27T10:46:09.186Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "faker" +version = "37.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" }, +] + +[[package]] +name = "fastapi" +version = "0.117.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/7e/d9788300deaf416178f61fb3c2ceb16b7d0dc9f82a08fdb87a5e64ee3cc7/fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a", size = 307155, upload-time = "2025-09-20T20:16:56.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/d9d3e8eeefbe93be1c50060a9d9a9f366dba66f288bb518a9566a23a8631/fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552", size = 95959, upload-time = "2025-09-20T20:16:53.661Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kombu" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, +] + +[[package]] +name = "lazy-model" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/fa/158a07f8c25c76568534328bf3ab8d16dba92abcb27cc9cfd84bbc652815/lazy-model-0.3.0.tar.gz", hash = "sha256:e425a189897dc926cc79af196a7cb385d1fd3ac7a7bccb4436fc93661f63b811", size = 8172, upload-time = "2025-04-22T17:03:33.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/a4/55bb305df9fe0d343ff8f0dd4da25b2cc33ba65f8596238aa7a4ecbe9777/lazy_model-0.3.0-py3-none-any.whl", hash = "sha256:67c112cad3fbc1816d32c070bf3b3ac1f48aefeb4e46e9eb70e12acc92c6859d", size = 13719, upload-time = "2025-04-22T17:03:34.764Z" }, +] + +[[package]] +name = "limits" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "psycopg" +version = "3.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f1/0258a123c045afaf3c3b60c22ccff077bceeb24b8dc2c593270899353bd0/psycopg-3.2.10.tar.gz", hash = "sha256:0bce99269d16ed18401683a8569b2c5abd94f72f8364856d56c0389bcd50972a", size = 160380, upload-time = "2025-09-08T09:13:37.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/90/422ffbbeeb9418c795dae2a768db860401446af0c6768bc061ce22325f58/psycopg-3.2.10-py3-none-any.whl", hash = "sha256:ab5caf09a9ec42e314a21f5216dbcceac528e0e05142e42eea83a3b28b320ac3", size = 206586, upload-time = "2025-09-08T09:07:50.121Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pymongo" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c0c6732fbd358b75a07e17d7e588fd23d481b9812ca96ceeff90bbf879fc/pymongo-4.15.1.tar.gz", hash = "sha256:b9f379a4333dc3779a6bf7adfd077d4387404ed1561472743486a9c58286f705", size = 2470613, upload-time = "2025-09-16T16:39:47.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/22/02ac885d8accb4c86ae92e99681a09f3fd310c431843fc850e141b42ab17/pymongo-4.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:622957eed757e44d9605c43b576ef90affb61176d9e8be7356c1a2948812cb84", size = 974492, upload-time = "2025-09-16T16:38:50.437Z" }, + { url = "https://files.pythonhosted.org/packages/56/bf/71685b6b2d085dbaadf029b1ea4a1bc7a1bc483452513dea283b47a5f7c0/pymongo-4.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c5283dffcf601b793a57bb86819a467473bbb1bf21cd170c0b9648f933f22131", size = 974191, upload-time = "2025-09-16T16:38:52.725Z" }, + { url = "https://files.pythonhosted.org/packages/df/98/141edc92fa97af96b4c691e10a7225ac3e552914e88b7a8d439bd6bc9fcc/pymongo-4.15.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:def51dea1f8e336aed807eb5d2f2a416c5613e97ec64f07479681d05044c217c", size = 1962311, upload-time = "2025-09-16T16:38:54.319Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a9/601b91607af1dec8035b46ba67a5a023c819ccedd40d6f6232e15bf76030/pymongo-4.15.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24171b2015052b2f0a3f8cbfa38b973fa87f6474e88236a4dfeb735983f9f49e", size = 2039667, upload-time = "2025-09-16T16:38:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/4f/71/02e9a5248e0a9dfc371fd7350f8b11eac03d9eb3662328978f37613d319a/pymongo-4.15.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64b60ed7220c52f8c78c7af8d2c58f7e415732e21b3ff7e642169efa6e0b11e7", size = 2003579, upload-time = "2025-09-16T16:38:57.576Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d1/b1a9520b33e022ed1c0d2d43e8805ba18d3d686fc9c9d89a507593f6dd86/pymongo-4.15.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58236ce5ba3a79748c1813221b07b411847fd8849ff34c2891ba56f807cce3e5", size = 1964307, upload-time = "2025-09-16T16:38:59.219Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d1/1d205a762020f056c05899a912364c48bac0f3438502b36d057aa1da3ca5/pymongo-4.15.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7461e777b3da96568c1f077b1fbf9e0c15667ac4d8b9a1cf90d80a69fe3be609", size = 1913879, upload-time = "2025-09-16T16:39:01.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d1/0a3ab2440ea00b6423f33c84e6433022fd51f3561dede9346f54f39cf4dd/pymongo-4.15.1-cp313-cp313-win32.whl", hash = "sha256:45f0a2fb09704ca5e0df08a794076d21cbe5521d3a8ceb8ad6d51cef12f5f4e7", size = 938007, upload-time = "2025-09-16T16:39:03.427Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/e9ea839af2caadfde91774549a6f72450b72efdc92117995e7117d4b1270/pymongo-4.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:b70201a6dbe19d0d10a886989d3ba4b857ea6ef402a22a61c8ca387b937cc065", size = 962236, upload-time = "2025-09-16T16:39:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f8/0a92a72993b2e1c110ee532650624ca7ae15c5e45906dbae4f063a2fd32a/pymongo-4.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:6892ebf8b2bc345cacfe1301724195d87162f02d01c417175e9f27d276a2f198", size = 944138, upload-time = "2025-09-16T16:39:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/e5/eb/2ba257482844bb2e3c82c6b266d6e811bc610fa80408133e352cc1afb3c9/pymongo-4.15.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:db439288516514713c8ee09c9baaf66bc4b0188fbe4cd578ef3433ee27699aab", size = 1030987, upload-time = "2025-09-16T16:39:08.914Z" }, + { url = "https://files.pythonhosted.org/packages/0d/86/8c6eab3767251ba77a3604d3b6b0826d0af246bd04b2d16aced3a54f08b0/pymongo-4.15.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:234c80a5f21c8854cc5d6c2f5541ff17dd645b99643587c5e7ed1e21d42003b6", size = 1030996, upload-time = "2025-09-16T16:39:10.429Z" }, + { url = "https://files.pythonhosted.org/packages/5b/26/c1bc0bcb64f39b9891b8b537f21cc37d668edd8b93f47ed930af7f95649c/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b570dc8179dcab980259b885116b14462bcf39170e30d8cbcce6f17f28a2ac5b", size = 2290670, upload-time = "2025-09-16T16:39:12.348Z" }, + { url = "https://files.pythonhosted.org/packages/82/af/f5e8b6c404a3678a99bf0b704f7b19fa14a71edb42d724eb09147aa1d3be/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb6321bde02308d4d313b487d19bfae62ea4d37749fc2325b1c12388e05e4c31", size = 2377711, upload-time = "2025-09-16T16:39:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/af/f4/63bcc1760bf3e0925cb6cb91b2b3ba756c113b1674a14b41efe7e3738b8d/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc808588289f693aba80fae8272af4582a7d6edc4e95fb8fbf65fe6f634116ce", size = 2337097, upload-time = "2025-09-16T16:39:15.717Z" }, + { url = "https://files.pythonhosted.org/packages/d0/dc/0cfada0426556b4b04144fb00ce6a1e7535ab49623d4d9dd052d27ea46c0/pymongo-4.15.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99236fd0e0cf6b048a4370d0df6820963dc94f935ad55a2e29af752272abd6c9", size = 2288295, upload-time = "2025-09-16T16:39:17.385Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a8/081a80f60042d2b8cd6a1c091ecaa186f1ef216b587d06acd0743e1016c6/pymongo-4.15.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2277548bb093424742325b2a88861d913d8990f358fc71fd26004d1b87029bb8", size = 2227616, upload-time = "2025-09-16T16:39:19.025Z" }, + { url = "https://files.pythonhosted.org/packages/56/d0/a6007e0c3c5727391ac5ea40e93a1e7d14146c65ac4ca731c0680962eb48/pymongo-4.15.1-cp313-cp313t-win32.whl", hash = "sha256:754a5d75c33d49691e2b09a4e0dc75959e271a38cbfd92c6b36f7e4eafc4608e", size = 987225, upload-time = "2025-09-16T16:39:20.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/c9bf6dcd647a8cf7abbad5814dfb7d8a16e6ab92a3e56343b3bcb454a6d3/pymongo-4.15.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8d62e68ad21661e536555d0683087a14bf5c74b242a4446c602d16080eb9e293", size = 1017521, upload-time = "2025-09-16T16:39:22.319Z" }, + { url = "https://files.pythonhosted.org/packages/31/ea/102f7c9477302fa05e5303dd504781ac82400e01aab91bfba9c290253bd6/pymongo-4.15.1-cp313-cp313t-win_arm64.whl", hash = "sha256:56bbfb79b51e95f4b1324a5a7665f3629f4d27c18e2002cfaa60c907cc5369d9", size = 992963, upload-time = "2025-09-16T16:39:23.957Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.406" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987, upload-time = "2025-09-18T19:52:44.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308, upload-time = "2025-09-18T19:51:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258, upload-time = "2025-09-18T19:52:00.184Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554, upload-time = "2025-09-18T19:52:02.753Z" }, + { url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181, upload-time = "2025-09-18T19:52:05.279Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599, upload-time = "2025-09-18T19:52:07.497Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178, upload-time = "2025-09-18T19:52:10.189Z" }, + { url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474, upload-time = "2025-09-18T19:52:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531, upload-time = "2025-09-18T19:52:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267, upload-time = "2025-09-18T19:52:17.649Z" }, + { url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120, upload-time = "2025-09-18T19:52:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084, upload-time = "2025-09-18T19:52:23.032Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105, upload-time = "2025-09-18T19:52:25.263Z" }, + { url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284, upload-time = "2025-09-18T19:52:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314, upload-time = "2025-09-18T19:52:30.212Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360, upload-time = "2025-09-18T19:52:32.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448, upload-time = "2025-09-18T19:52:35.545Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458, upload-time = "2025-09-18T19:52:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snippetly-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "aiosmtplib" }, + { name = "alembic" }, + { name = "asyncpg" }, + { name = "azure-storage-blob" }, + { name = "bcrypt" }, + { name = "beanie" }, + { name = "celery" }, + { name = "fastapi" }, + { name = "itsdangerous" }, + { name = "pillow" }, + { name = "prometheus-client" }, + { name = "psycopg" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "slowapi" }, + { name = "sqlalchemy" }, + { name = "starlette-admin" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "ruff" }, +] +test = [ + { name = "faker" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.12.15" }, + { name = "aiosmtplib", specifier = ">=4.0.2" }, + { name = "alembic", specifier = ">=1.16.5" }, + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "azure-storage-blob", specifier = ">=12.24.0" }, + { name = "bcrypt", specifier = ">=4.3.0" }, + { name = "beanie", specifier = ">=2.0.0" }, + { name = "celery", specifier = ">=5.5.3" }, + { name = "fastapi", specifier = ">=0.117.1" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "pillow", specifier = ">=11.3.0" }, + { name = "prometheus-client", specifier = ">=0.21.1" }, + { name = "psycopg", specifier = ">=3.2.10" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.11.9" }, + { name = "pydantic-settings", specifier = ">=2.10.1" }, + { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "redis", specifier = ">=6.4.0" }, + { name = "slowapi", specifier = ">=0.1.9" }, + { name = "sqlalchemy", specifier = ">=2.0.43" }, + { name = "starlette-admin", specifier = ">=0.15.1" }, + { name = "uvicorn", specifier = ">=0.38.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.406" }, + { name = "ruff", specifier = ">=0.13.1" }, +] +test = [ + { name = "faker", specifier = ">=37.12.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, +] + +[[package]] +name = "starlette-admin" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/84/bb719df244cb8b720a81bd88aff18ababceb1f3e9afaf44563ba61b2d1b4/starlette_admin-0.15.1.tar.gz", hash = "sha256:beccdb8695ecadc9f314e0b5afd2b04e067538e8273d0c3199b671198b5ee43e", size = 2098915, upload-time = "2025-05-26T15:37:56.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/41/8f0e354441aae2fc843e7fafb7e64fbfef541f3ac2d238c7b05b1136ffd0/starlette_admin-0.15.1-py3-none-any.whl", hash = "sha256:a832e8a0e8a16c9c3f2012ddf352867ad8e730f21192caa85225798091cba130", size = 2169980, upload-time = "2025-05-26T15:37:53.251Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..82577e1 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,30 @@ +version: "3.9" + +# Development-only overrides: adds port mappings and PGAdmin UI +services: + db: + ports: + - "${POSTGRES_PORT:-5432}:5432" + + redis: + ports: + - "${REDIS_PORT:-6379}:6379" + + pgadmin: + image: dpage/pgadmin4:8 + container_name: snippetly-pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@local.test} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + depends_on: + db: + condition: service_healthy + ports: + # Bind to localhost only for security (access via SSH tunnel) + - "127.0.0.1:9090:80" + volumes: + - pgadmin:/var/lib/pgadmin + restart: unless-stopped + +volumes: + pgadmin: diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml new file mode 100644 index 0000000..23ac13a --- /dev/null +++ b/docker-compose.monitoring.yml @@ -0,0 +1,77 @@ +# Monitoring stack: Prometheus + Grafana +# Usage: +# Development: docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.monitoring.yml up -d +# Production: docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.monitoring.yml up -d + +services: + prometheus: + image: prom/prometheus:v2.48.0 + container_name: snippetly-prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + volumes: + - ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + ports: + # Bind to localhost only - access via SSH tunnel + - "127.0.0.1:9090:9090" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:9090/-/healthy || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + + grafana: + image: grafana/grafana:10.2.2 + container_name: snippetly-grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin123} + - GF_INSTALL_PLUGINS= + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=http://localhost:3000 + - GF_ANALYTICS_REPORTING_ENABLED=false + volumes: + - grafana-data:/var/lib/grafana + - ./infra/grafana/provisioning:/etc/grafana/provisioning:ro + - ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + # Bind to localhost only - access via SSH tunnel + - "127.0.0.1:3000:3000" + depends_on: + - prometheus + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + reservations: + memory: 128M + cpus: '0.25' + +volumes: + prometheus-data: + driver: local + grafana-data: + driver: local diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..b832c15 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,42 @@ +# Development-only overrides: adds port mappings and PGAdmin UI +services: + db: + ports: + - "${POSTGRES_PORT:-5432}:5432" + + redis: + ports: + - "${REDIS_PORT:-6379}:6379" + + mongodb: + ports: + - "${MONGO_PORT:-27017}:27017" + + backend: + ports: + - "${BACKEND_PORT:-8000}:8000" + + frontend: + ports: + - "80:80" + + pgadmin: + image: dpage/pgadmin4:8 + container_name: snippetly-pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@local.test} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + PGADMIN_CONFIG_SERVER_MODE: 'False' + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False' + depends_on: + db: + condition: service_healthy + ports: + # Bind to localhost only for security (access via SSH tunnel) + - "127.0.0.1:${PGADMIN_PORT:-9090}:80" + volumes: + - pgadmin:/var/lib/pgadmin + restart: unless-stopped + +volumes: + pgadmin: \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d4acf7b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,47 @@ +# Production-only overrides: HTTPS with Let's Encrypt via nginx reverse proxy +services: + # Nginx reverse proxy with HTTPS termination + nginx-proxy: + image: nginx:1.25-alpine + container_name: snippetly-nginx-proxy + ports: + - "80:80" + - "443:443" + volumes: + - ./infra/nginx/prod-nginx.conf:/etc/nginx/nginx.conf:ro + - ./infra/nginx/snippetly.codes.conf:/etc/nginx/conf.d/snippetly.codes.conf:ro + - /opt/app-data/certbot/conf:/etc/letsencrypt:ro + - /opt/app-data/certbot/www:/var/www/certbot:ro + depends_on: + frontend: + condition: service_started + networks: + - default + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Certbot for Let's Encrypt certificate management + certbot: + image: certbot/certbot:v2.11.0 + container_name: snippetly-certbot + volumes: + - /opt/app-data/certbot/conf:/etc/letsencrypt + - /opt/app-data/certbot/www:/var/www/certbot + networks: + - default + # Runs certificate renewal check twice daily + entrypoint: "/bin/sh" + command: > + -c "trap exit TERM; + while :; do + echo 'Running certbot renewal check...'; + certbot renew --webroot --webroot-path=/var/www/certbot --quiet --deploy-hook 'docker exec snippetly-nginx-proxy nginx -s reload' || echo 'Certbot renew failed or not needed'; + echo 'Next renewal check in 12 hours...'; + sleep 12h & wait $$!; + done" + restart: unless-stopped diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..b18347b --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,56 @@ +services: + test_db: + image: postgres:16-alpine + container_name: snippetly-test-db + env_file: + - ./backend/.env.test + ports: + - "55432:5432" + restart: unless-stopped + + test_redis: + image: redis:7-alpine + container_name: snippetly-test-redis + command: [ "redis-server", "--appendonly", "no" ] + ports: + - "6380:6379" + healthcheck: + test: [ "CMD-SHELL", "redis-cli ping || exit 1" ] + interval: 5s + retries: 5 + + test_mongodb: + image: mongo:7-jammy + container_name: snippetly-test-mongodb + env_file: + - ./backend/.env.test + ports: + - "27018:27017" + command: [ "--logpath", "/dev/null", "--quiet" ] + healthcheck: + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ] + interval: 30s + timeout: 5s + retries: 5 + + test_backend: + build: + context: ./backend + dockerfile: docker/test/Dockerfile + container_name: snippetly-test-backend + env_file: + - ./backend/.env.test + depends_on: + - test_db + - test_redis + - test_mongodb + command: bash -c "uv run alembic upgrade head && uv run pytest; sleep infinity" + volumes: + - ./backend/src:/app/src + - ./backend/tests:/app/tests + - ./backend/static/avatars:/app/static/avatars + - ./backend/pyproject.toml:/app/pyproject.toml + - ./backend/uv.lock:/app/uv.lock + +volumes: + pgdata_test: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..80dac52 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,179 @@ +services: + db: + image: postgres:16-alpine + container_name: snippetly-db + env_file: + - ./backend/.env + volumes: + - /opt/app-data/postgres:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-snippetly} -d ${POSTGRES_DB:-snippetly}" ] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 256M + cpus: '0.5' + + redis: + image: redis:7-alpine + container_name: snippetly-redis + command: [ "redis-server", "--appendonly", "yes" ] + volumes: + - /opt/app-data/redis:/data + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "redis-cli ping || exit 1" ] + interval: 5s + timeout: 3s + retries: 5 + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + reservations: + memory: 128M + cpus: '0.25' + + mongodb: + image: mongo:7-jammy + container_name: snippetly-mongodb + env_file: + - ./backend/.env + volumes: + - /opt/app-data/mongo:/data/db + restart: unless-stopped + command: [ + "--logpath", "/dev/null", + "--quiet" + ] + healthcheck: + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ] + interval: 30s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 512M + cpus: '1.0' + reservations: + memory: 256M + cpus: '0.5' + + migrate: + image: ${BACKEND_IMAGE} + container_name: snippetly-migrate + env_file: + - ./backend/.env + depends_on: + - db + command: uv run alembic upgrade head + + backend: + image: ${BACKEND_IMAGE} + container_name: snippetly-backend + env_file: + - ./backend/.env + depends_on: + - db + - redis + - mongodb + expose: + - "8000" + volumes: + - ./backend/static/avatars:/app/static/avatars + healthcheck: + test: [ "CMD-SHELL", "curl -fsS http://localhost:8000/api/health || exit 1" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + deploy: + resources: + limits: + memory: 1G + cpus: '1.0' + reservations: + memory: 512M + cpus: '0.5' + + celery-worker: + image: ${BACKEND_IMAGE} + container_name: snippetly-celery-worker + command: uv run celery -A src.worker.app.app worker --loglevel=info + env_file: + - ./backend/.env + depends_on: + - db + - redis + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "uv run celery -A src.worker.app.app inspect ping -d celery@$${HOSTNAME} -t 10 || exit 1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 30s + deploy: + resources: + limits: + memory: 768M + cpus: '1.0' + reservations: + memory: 384M + cpus: '0.5' + + celery-beat: + image: ${BACKEND_IMAGE} + container_name: snippetly-celery-beat + command: uv run celery -A src.worker.app.app beat --loglevel=info + env_file: + - ./backend/.env + depends_on: + - db + - redis + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "ps aux | grep '[c]elery.*beat' || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + reservations: + memory: 256M + cpus: '0.25' + + frontend: + image: ${FRONTEND_IMAGE} + container_name: snippetly-frontend + depends_on: + - backend + command: ["nginx", "-g", "daemon off;"] + expose: + - "80" + restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + cpus: '0.5' + reservations: + memory: 128M + cpus: '0.25' + +networks: + default: + name: snippetly-net \ No newline at end of file diff --git a/frontend/.env.sample b/frontend/.env.sample new file mode 100644 index 0000000..d486b2f --- /dev/null +++ b/frontend/.env.sample @@ -0,0 +1 @@ +VITE_SERVER_BASE_URL=http://localhost:8000 \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..265f50c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +package-lock.json \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ed1c535 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +## Multistage Dockerfile for both development (Vite dev server) and production (Nginx) + +# --- base deps stage --- +FROM node:20-alpine AS base +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . + +# --- development stage --- +FROM base AS dev +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] + +# --- build stage --- +FROM base AS build +ARG VITE_SERVER_BASE_URL="" +ENV VITE_SERVER_BASE_URL=${VITE_SERVER_BASE_URL} +RUN npm run build + +# --- production runtime (Nginx) --- +FROM nginx:1.27-alpine AS prod +WORKDIR /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist . +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d5e3c12 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..439d04b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Snippetly + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9618cc5 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + + # Static root generated by Vite build + root /usr/share/nginx/html; + index index.html; + + # Serve static files + location / { + try_files $uri $uri/ /index.html; + } + + # API reverse proxy to backend service in Docker network + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + gzip_min_length 256; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..659e0bb --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6320 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@hookform/resolvers": "^5.2.2", + "@tanstack/react-query": "^5.90.2", + "@uiw/react-codemirror": "^4.25.2", + "axios": "^1.12.2", + "lodash.debounce": "^4.0.8", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.63.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.9.1", + "sass": "^1.93.0", + "zod": "^4.1.11" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/lodash.debounce": "^4.0.9", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.1.5", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.4.0", + "jsdom": "^25.0.1", + "typescript": "~5.8.3", + "typescript-eslint": "^8.44.0", + "vite": "^7.1.8", + "vitest": "^3.1.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.0.tgz", + "integrity": "sha512-61Hfv3cF07XvUxNeC3E7jhG8XNi1Yom1G0lRC936oLnlF+jrbrv8rc/J98XlYzcsAoTVupfsf5fLej1aI8kyIg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.9.0.tgz", + "integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.11.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", + "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.0.tgz", + "integrity": "sha512-wZxW+9XDytH3SKvS8cQzMyQCaaazH8XL1EMHleHe00wVzsv7NBQKVW2yzEHrRhmM7ZOhVdItPbvlRBvMp9ej7A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.4", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.4.tgz", + "integrity": "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", + "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", + "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", + "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", + "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", + "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", + "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", + "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", + "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", + "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", + "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", + "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", + "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", + "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", + "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", + "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", + "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", + "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", + "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", + "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", + "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", + "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", + "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", + "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", + "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", + "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", + "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", + "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", + "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/type-utils": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.45.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", + "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", + "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.45.0", + "@typescript-eslint/types": "^8.45.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", + "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", + "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", + "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", + "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", + "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.45.0", + "@typescript-eslint/tsconfig-utils": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", + "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", + "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.45.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.2.tgz", + "integrity": "sha512-s2fbpdXrSMWEc86moll/d007ZFhu6jzwNu5cWv/2o7egymvLeZO52LWkewgbr+BUCGWGPsoJVWeaejbsb/hLcw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.2", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.2.tgz", + "integrity": "sha512-XP3R1xyE0CP6Q0iR0xf3ed+cJzJnfmbLelgJR6osVVtMStGGZP3pGQjjwDRYptmjGHfEELUyyBLdY25h0BQg7w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.2", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", + "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.38", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", + "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001746", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", + "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.228", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz", + "integrity": "sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.23.tgz", + "integrity": "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.63.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", + "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", + "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz", + "integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", + "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.3", + "@rollup/rollup-android-arm64": "4.52.3", + "@rollup/rollup-darwin-arm64": "4.52.3", + "@rollup/rollup-darwin-x64": "4.52.3", + "@rollup/rollup-freebsd-arm64": "4.52.3", + "@rollup/rollup-freebsd-x64": "4.52.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", + "@rollup/rollup-linux-arm-musleabihf": "4.52.3", + "@rollup/rollup-linux-arm64-gnu": "4.52.3", + "@rollup/rollup-linux-arm64-musl": "4.52.3", + "@rollup/rollup-linux-loong64-gnu": "4.52.3", + "@rollup/rollup-linux-ppc64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-gnu": "4.52.3", + "@rollup/rollup-linux-riscv64-musl": "4.52.3", + "@rollup/rollup-linux-s390x-gnu": "4.52.3", + "@rollup/rollup-linux-x64-gnu": "4.52.3", + "@rollup/rollup-linux-x64-musl": "4.52.3", + "@rollup/rollup-openharmony-arm64": "4.52.3", + "@rollup/rollup-win32-arm64-msvc": "4.52.3", + "@rollup/rollup-win32-ia32-msvc": "4.52.3", + "@rollup/rollup-win32-x64-gnu": "4.52.3", + "@rollup/rollup-win32-x64-msvc": "4.52.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.45.0.tgz", + "integrity": "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.45.0", + "@typescript-eslint/parser": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.8.tgz", + "integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..97bf23e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,55 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite build && vite preview", + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@hookform/resolvers": "^5.2.2", + "@tanstack/react-query": "^5.90.2", + "@uiw/react-codemirror": "^4.25.2", + "axios": "^1.12.2", + "lodash.debounce": "^4.0.8", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.63.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.9.1", + "sass": "^1.93.0", + "zod": "^4.1.11" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/lodash.debounce": "^4.0.9", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.1.5", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.4.0", + "jsdom": "^25.0.1", + "typescript": "~5.8.3", + "typescript-eslint": "^8.44.0", + "vite": "^7.1.8", + "vitest": "^3.1.5" + } +} diff --git a/frontend/public/icons/google.webp b/frontend/public/icons/google.webp new file mode 100644 index 0000000..a8021ee Binary files /dev/null and b/frontend/public/icons/google.webp differ diff --git a/frontend/src/App.module.scss b/frontend/src/App.module.scss new file mode 100644 index 0000000..11fb8bf --- /dev/null +++ b/frontend/src/App.module.scss @@ -0,0 +1,33 @@ +@use "./styles/mixins" as *; +.app { + display: flex; + flex-direction: column; + + align-items: center; + min-height: 100vh; +} + +.mainContent { + display: flex; + flex-grow: 1; + flex-direction: column; + + box-sizing: border-box; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding-top: 80px; + padding-bottom: 56px; + + color: var(--color-primary); + + @include on-desktop { + padding-top: 96px; + padding-bottom: 80px; + } +} + +header { + box-sizing: border-box; + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b18ba57 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,18 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from './components/Navbar/Navbar'; +import styles from './App.module.scss'; +import Footer from './components/Footer/Footer'; + +const App: React.FC = () => ( +
+
+ +
+
+ +
+
+
+); + +export default App diff --git a/frontend/src/Root.tsx b/frontend/src/Root.tsx new file mode 100644 index 0000000..fc45f23 --- /dev/null +++ b/frontend/src/Root.tsx @@ -0,0 +1,91 @@ +import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; +import App from "./App"; +import LandingPage from "./modules/LandingPage/LandingPage"; +import SignInPage from "./modules/AuthPage/SignInPage"; +import SignUpPage from "./modules/AuthPage/SignUpPage"; +import NotFoundPage from "./modules/NotFoundPage/NotFoundPage"; +import PasswordResetPage from "./modules/AuthPage/PasswordResetPage"; +import SetNewPasswordPage from "./modules/AuthPage/SetNewPasswordPage"; +import FinishRegistrationPage from "./modules/AuthPage/FinishRegistrationPage"; +import { AuthProvider } from "./contexts/AuthContext"; +import FinishRegistrationTokenPage from "./modules/AuthPage/FinishRegistrationTokenPage"; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AuthCallbackPage from "./modules/AuthPage/AuthCallbackPage"; +import SnippetsPage from "./modules/SnippetsPage/SnippetsPage"; +import SnippetDetailsPage from "./modules/SnippetDetailsPage/SnippetDetailsPage"; +import PublicOnlyRoute from "./components/PublicOnlyRoute/PublicOnlyRoute"; +import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; +import { Toaster } from "react-hot-toast"; +import SnippetFormPage from "./modules/SnippetFormPage/SnippetFormPage"; +import ProfilePage from "./modules/ProfilePage/ProfilePage"; +import Overview from "./modules/ProfilePage/Overview"; +import Snippets from "./modules/ProfilePage/Snippets"; +import Settings from "./modules/ProfilePage/Settings"; +import ProfileRedirector from "./modules/ProfilePage/ProfileRedirector"; +import FavoritesPage from "./modules/FavoritesPage/FavoritesPage"; +import { SnippetProvider } from "./contexts/SnippetContext"; +import Edit from "./modules/ProfilePage/Edit"; +import ChangePasswordPage from "./modules/ChangePasswordPage/ChangePasswordPage"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + }, + }, +}); + +export const Root = () => ( + + + + + + }> + } /> + } /> + + }> + } /> + } /> + + } /> + } /> + + + } /> + } /> + + + + }> + + } /> + } /> + } /> + } /> + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + + + } /> + } /> + + + } /> + + + + + + + +) \ No newline at end of file diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts new file mode 100644 index 0000000..0139bf6 --- /dev/null +++ b/frontend/src/api/authClient.ts @@ -0,0 +1,91 @@ +import axios from 'axios'; +import type { AxiosResponse } from 'axios'; +import type { AccessToken } from '../types/Tokens'; + +const SERVER_BASE_URL = import.meta.env.VITE_SERVER_BASE_URL || ''; + +export const authClient = axios.create({ + baseURL: `${SERVER_BASE_URL}/api/v1/auth`, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, +}) + +export const register = (username: string, email: string, password: string) => { + return authClient.post('/register', { + username, + email, + password, + }) +} + +export const activate = (activation_token: string | undefined) => { + return authClient.post('/activate', { activation_token }); +} + +type LoginType = { + access_token: string; +} + +export const login = ( + login: string, + password: string +): Promise> => { + return authClient.post('/login', { login, password }); +} + +type RefreshResponse = { + access_token: string; +}; + +export const refresh = (): Promise> => { + return authClient.post('/refresh'); +}; + +export const resetRequest = (email: string) => { + return authClient.post('/reset-password/request', { email }); +} + +export const resetComplete = ( + password: string, + email: string, + password_reset_token: string | undefined +) => { + return authClient.post('/reset-password/complete', { password, email, password_reset_token }); +} + +export const changePassword = ( + old_password: string, + new_password: string, + token: AccessToken +) => { + return authClient.post('/change-password', { + old_password, + new_password + }, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); +} + +export const logout = (accessToken: AccessToken) => { + return authClient.post('/logout', {}, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); +} + +export const logoutAll = (accessToken: AccessToken) => { + return authClient.post('/revoke-all-tokens', {}, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); +} + +export const loginGoogleCallback = (code: string) => { + return authClient.post('/google/callback', { code }, { + headers: { 'Content-Type': 'application/json' } + }); +}; \ No newline at end of file diff --git a/frontend/src/api/profileClient.ts b/frontend/src/api/profileClient.ts new file mode 100644 index 0000000..b26fa17 --- /dev/null +++ b/frontend/src/api/profileClient.ts @@ -0,0 +1,57 @@ +import axios from 'axios'; +import type { AxiosResponse } from 'axios'; +import type { ProfileType } from '../types/ProfileType'; +import type { AccessToken } from '../types/Tokens'; + +const SERVER_BASE_URL: string = import.meta.env.VITE_SERVER_BASE_URL || ''; + +export const profileClient = axios.create({ + baseURL: `${SERVER_BASE_URL}/api/v1/profile`, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, +}) + +export const getProfile = (token: AccessToken): Promise> => { + return profileClient.get('/', { + headers: { Authorization: `Bearer ${token}` }, + }); +}; + +export const getProfileByUsername = (username: string, token: AccessToken): Promise> => { + return profileClient.get(`/${username}`, { + headers: { Authorization: `Bearer ${token}` }, + }); +}; + +export const updateProfile = ( + token: AccessToken, + profile: Partial +): Promise> => { + return profileClient.patch( + '/', + profile, + { headers: { Authorization: `Bearer ${token}` } } + ); +}; + +export const setAvatar = ( + token: AccessToken, + avatarFile: File +): Promise> => { + const formData = new FormData(); + formData.append('avatar', avatarFile); + return profileClient.post( + '/avatar', + formData, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'multipart/form-data', + }, + } + ); +}; + +export const removeAvatar = () => { + return profileClient.delete(''); +} \ No newline at end of file diff --git a/frontend/src/api/snippetsClient.ts b/frontend/src/api/snippetsClient.ts new file mode 100644 index 0000000..cf4e565 --- /dev/null +++ b/frontend/src/api/snippetsClient.ts @@ -0,0 +1,106 @@ +import axios from 'axios'; +import type { AxiosInstance, AxiosResponse } from 'axios'; +import type { SnippetType } from '../types/SnippetType'; +import type { NewSnippetType } from '../types/NewSnippetType'; +import type { SnippetDetailsType } from '../types/SnippetDetailsType'; +import type { AccessToken } from '../types/Tokens'; +import type { FiltersType } from '../types/FiltersType'; +import type { SnippetListResponse } from '../types/SnippetListResponse'; + +const SERVER_BASE_URL: string = import.meta.env.VITE_SERVER_BASE_URL || ''; + +export const snippetsClient: AxiosInstance = axios.create({ + baseURL: `${SERVER_BASE_URL}/api/v1/snippets`, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, +}); + +export const getAll = ( + token: AccessToken, + params: FiltersType = {} +): Promise> => { + const { tags, ...otherParams } = params; + const searchParams = new URLSearchParams(); + Object.entries(otherParams).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, String(value)); + } + }); + if (tags && Array.isArray(tags)) { + tags.forEach(tag => { + searchParams.append('tags', tag); + }); + } + + return snippetsClient.get( + `/?${searchParams.toString()}`, + { + headers: { 'Authorization': `Bearer ${token}` }, + } + ); +}; + +export const getFavorites = (token: AccessToken) => { + return snippetsClient.get( + '/favorites/', + { headers: { 'Authorization': `Bearer ${token}` } }, + ); +} + +export const getById = (uuid: string, token: string | undefined): Promise> => { + return snippetsClient.get( + `/${uuid}`, + { headers: { 'Authorization': `Bearer ${token}` } }, + ); +}; + +export const create = (snippet: NewSnippetType, token: string | undefined): Promise> => { + return snippetsClient.post( + '/', + snippet, + { headers: { 'Authorization': `Bearer ${token}` } }, + ); +}; + +export const addFavorite = (token: AccessToken, uuid: string) => { + return snippetsClient.post( + '/favorites/', + { uuid }, + { headers: { 'Authorization': `Bearer ${token}` } }, + ); +}; + +export const update = ( + uuid: string, + snippet: Partial, + token: string | undefined, +): Promise> => { + return snippetsClient.patch( + `/${uuid}`, + snippet, + { headers: { 'Authorization': `Bearer ${token}` } }, + ); +}; + +export const remove = (uuid: string, token: AccessToken): Promise> => { + return snippetsClient.delete( + `/${uuid}`, + { headers: { 'Authorization': `Bearer ${token}` } }, + ); +}; + +export const removeFavorite = (token: AccessToken, uuid: string) => { + return snippetsClient.delete( + `/favorites/${uuid}`, + { headers: { 'Authorization': `Bearer ${token}` } }, + ) +}; + +export const search = (token: AccessToken, query: string) => { + return snippetsClient.get( + `/search/${query}`, + { headers: { 'Authorization': `Bearer ${token}` } }, + ) +} \ No newline at end of file diff --git a/frontend/src/components/CodeEditor/CodeEditor.module.scss b/frontend/src/components/CodeEditor/CodeEditor.module.scss new file mode 100644 index 0000000..e2f9413 --- /dev/null +++ b/frontend/src/components/CodeEditor/CodeEditor.module.scss @@ -0,0 +1,20 @@ +@use "../../styles/mixins" as *; +.editor { + display: flex; + flex-direction: column; + font-size: 14px; + + width: 100%; + max-width: 672px; + min-height: 150px; + + border-radius: 8px; + padding: 16px; + box-sizing: border-box; + + background-color: var(--secondary-bg-color); + + @include on-desktop { + min-height: 300px; + } +} \ No newline at end of file diff --git a/frontend/src/components/CodeEditor/CodeEditor.tsx b/frontend/src/components/CodeEditor/CodeEditor.tsx new file mode 100644 index 0000000..c4fea90 --- /dev/null +++ b/frontend/src/components/CodeEditor/CodeEditor.tsx @@ -0,0 +1,39 @@ +import styles from './CodeEditor.module.scss'; +import CodeMirror from '@uiw/react-codemirror'; +import { javascript } from '@codemirror/lang-javascript'; +import { python } from '@codemirror/lang-python'; +import { oneDark } from '@codemirror/theme-one-dark'; + +type Props = { + language: string + value?: string + onChange?: (value: string) => void + readonly?: boolean +} + +const CodeEditor: React.FC = ({ + language, + value = '', + onChange = () => { }, + readonly = false, +}) => ( +
+ +
+); + +export default CodeEditor; \ No newline at end of file diff --git a/frontend/src/components/CodeEditor/index.ts b/frontend/src/components/CodeEditor/index.ts new file mode 100644 index 0000000..8ba4ef2 --- /dev/null +++ b/frontend/src/components/CodeEditor/index.ts @@ -0,0 +1 @@ +export * from './CodeEditor'; \ No newline at end of file diff --git a/frontend/src/components/CustomToast/CustomToast.module.scss b/frontend/src/components/CustomToast/CustomToast.module.scss new file mode 100644 index 0000000..5162153 --- /dev/null +++ b/frontend/src/components/CustomToast/CustomToast.module.scss @@ -0,0 +1,75 @@ +@use "../../styles/mixins" as *; +.toast { + display: flex; + align-items: center; + gap: 8px; + position: absolute; + top: 48px; + right: 0; + margin: 0; + border-radius: 8px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + background-color: var(--notification-color); + color: var(--secondary-text-color); + min-width: 300px; + max-width: 500px; + padding: 12px; + + transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); + + @include on-desktop { + top: 54px; + } + + opacity: 0; + transform: translateX(100%); +} + +.button { + position: relative; + z-index: 10; + border: none; + background-color: transparent; + color: var(--color-white); + cursor: pointer; +} + +.container { + display: flex; + flex-direction: column; +} + +.title { + font-family: "Inter", sans-serif; + margin: 0; +} + +.message { + margin: 0; + padding: 0; + white-space: pre-wrap; + font-size: 14px; +} + +.info { + border-left: 4px solid var(--color-accent); +} + +.success { + border-left: 4px solid var(--color-success); +} + +.error { + border-left: 4px solid var(--color-red); +} + +.visible { + opacity: 1; + transform: translateX(0); +} + +.exiting { + opacity: 0; + transform: translateX(100%); +} diff --git a/frontend/src/components/CustomToast/CustomToast.tsx b/frontend/src/components/CustomToast/CustomToast.tsx new file mode 100644 index 0000000..033d3d0 --- /dev/null +++ b/frontend/src/components/CustomToast/CustomToast.tsx @@ -0,0 +1,38 @@ +import { toast, type Toast } from 'react-hot-toast'; +import styles from './CustomToast.module.scss'; + +type Props = { + t: Toast; + title: string; + message: string; + type: 'info' | 'success' | 'error' +} + +const CustomToast: React.FC = ({ t, title, message, type }) => ( +
+ +
+ {title} + {message} +
+
+); + +export default CustomToast; \ No newline at end of file diff --git a/frontend/src/components/CustomToast/index.ts b/frontend/src/components/CustomToast/index.ts new file mode 100644 index 0000000..0cbf550 --- /dev/null +++ b/frontend/src/components/CustomToast/index.ts @@ -0,0 +1 @@ +export * from './CustomToast'; \ No newline at end of file diff --git a/frontend/src/components/Footer/Footer.module.scss b/frontend/src/components/Footer/Footer.module.scss new file mode 100644 index 0000000..2f9ac06 --- /dev/null +++ b/frontend/src/components/Footer/Footer.module.scss @@ -0,0 +1,23 @@ +@use "../../styles/mixins" as *; +.footer { + display: flex; + justify-content: center; + + background-color: var(--secondary-bg-color); + min-width: 100%; + + padding-block: 16px; + + @include on-desktop { + padding-block: 32px; + } + + &Content { + @include page-padding-inline; + display: flex; + align-items: center; + gap: 32px; + max-width: 1200px; + width: 100%; + } +} diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx new file mode 100644 index 0000000..e6d7d1a --- /dev/null +++ b/frontend/src/components/Footer/Footer.tsx @@ -0,0 +1,17 @@ +import Logo from '../Logo/Logo'; +import styles from './Footer.module.scss'; + +const Footer: React.FC = () => ( +
+
+ + +

+ Your go-to platform for storing, sharing, and discovering code snippets.
+ Simplify your development workflow. +

+
+
+) + +export default Footer; \ No newline at end of file diff --git a/frontend/src/components/Footer/index.ts b/frontend/src/components/Footer/index.ts new file mode 100644 index 0000000..db4e675 --- /dev/null +++ b/frontend/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; \ No newline at end of file diff --git a/frontend/src/components/Heart/Heart.module.scss b/frontend/src/components/Heart/Heart.module.scss new file mode 100644 index 0000000..c9e5d01 --- /dev/null +++ b/frontend/src/components/Heart/Heart.module.scss @@ -0,0 +1,34 @@ +.heart { + color: #888888; + cursor: pointer; + transition: color 0.2s linear, transform 0.2s cubic-bezier(.4,0,.2,1), fill 0.2s linear; + + &:not(.heartActive):hover { + color: var(--color-white); + transform: scale(1.07); + } + + &Active { + color: var(--color-red); + fill: var(--color-red); + animation: heartPulse 0.25s cubic-bezier(.4,0,.2,1); + } +} + +@keyframes heartPulse { + 0% { + transform: scale(1); + } + 38% { + transform: scale(1.2); + } + 54% { + transform: scale(0.95); + } + 70% { + transform: scale(1.07); + } + 100% { + transform: scale(1); + } +} \ No newline at end of file diff --git a/frontend/src/components/Heart/Heart.tsx b/frontend/src/components/Heart/Heart.tsx new file mode 100644 index 0000000..3d3c314 --- /dev/null +++ b/frontend/src/components/Heart/Heart.tsx @@ -0,0 +1,27 @@ +import styles from './Heart.module.scss'; + +type Props = { + isFilled: boolean; +} + +const Heart: React.FC = ({ isFilled }) => ( + + + +); + +export default Heart; \ No newline at end of file diff --git a/frontend/src/components/Heart/index.ts b/frontend/src/components/Heart/index.ts new file mode 100644 index 0000000..bd37f87 --- /dev/null +++ b/frontend/src/components/Heart/index.ts @@ -0,0 +1 @@ +export * from './Heart'; \ No newline at end of file diff --git a/frontend/src/components/Loader/Loader.module.scss b/frontend/src/components/Loader/Loader.module.scss new file mode 100644 index 0000000..2b8a077 --- /dev/null +++ b/frontend/src/components/Loader/Loader.module.scss @@ -0,0 +1,34 @@ +.loader { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + + &Content { + border-radius: 50%; + width: 2em; + height: 2em; + margin: 1em auto; + border: 0.3em solid #ddd; + border-left-color: transparent; + animation: load8 1.2s infinite linear; + } +} + +.buttonContent { + height: 1em; + width: 1em; + border-width: 0.2em; + border-style: solid; + border-color: #ddd; + border-left-color: transparent; +} + +@keyframes load8 { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/frontend/src/components/Loader/Loader.tsx b/frontend/src/components/Loader/Loader.tsx new file mode 100644 index 0000000..c0e1004 --- /dev/null +++ b/frontend/src/components/Loader/Loader.tsx @@ -0,0 +1,16 @@ +import styles from './Loader.module.scss'; + +type Props = { + buttonContent?: boolean +} + +export const Loader: React.FC = ({ buttonContent }) => ( +
+
+
+); diff --git a/frontend/src/components/Loader/index.ts b/frontend/src/components/Loader/index.ts new file mode 100644 index 0000000..f51e655 --- /dev/null +++ b/frontend/src/components/Loader/index.ts @@ -0,0 +1 @@ +export * from './Loader'; \ No newline at end of file diff --git a/frontend/src/components/Logo/Logo.module.scss b/frontend/src/components/Logo/Logo.module.scss new file mode 100644 index 0000000..ff27da8 --- /dev/null +++ b/frontend/src/components/Logo/Logo.module.scss @@ -0,0 +1,26 @@ +@use "../../styles/mixins" as *; +.logo { + color: var(--color-accent); +} + +.large { + font-size: 24px; + font-weight: 700; + + @include on-tablet { + font-size: 28px; + } + + @include on-tablet { + font-size: 32px; + } +} + +.small { + font-weight: 700; + font-size: 16px; + + @include on-tablet { + font-size: 20px; + } +} diff --git a/frontend/src/components/Logo/Logo.tsx b/frontend/src/components/Logo/Logo.tsx new file mode 100644 index 0000000..f50db1d --- /dev/null +++ b/frontend/src/components/Logo/Logo.tsx @@ -0,0 +1,19 @@ +import { NavLink } from "react-router-dom"; +import styles from './Logo.module.scss'; + +type Props = { type: 'large' | 'small' } + + +const Logo: React.FC = ({ type }) => ( + + Snippetly + +); + +export default Logo; \ No newline at end of file diff --git a/frontend/src/components/Logo/index.ts b/frontend/src/components/Logo/index.ts new file mode 100644 index 0000000..4d06f64 --- /dev/null +++ b/frontend/src/components/Logo/index.ts @@ -0,0 +1 @@ +export * from './Logo'; \ No newline at end of file diff --git a/frontend/src/components/MainButton/MainButton.module.scss b/frontend/src/components/MainButton/MainButton.module.scss new file mode 100644 index 0000000..c716118 --- /dev/null +++ b/frontend/src/components/MainButton/MainButton.module.scss @@ -0,0 +1,40 @@ +@use "../../styles/mixins" as *; +.button { + display: flex; + align-items: center; + justify-content: center; + + cursor: pointer; + height: 40px; + width: 150px; + + border-radius: 999px; + border: none; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + + background-color: var(--color-accent); + color: var(--primary-bg-color); + + transition: background-color 0.2s ease-out; + + @include on-tablet { + height: 48px; + width: 190px; + } + + &:hover { + background-color: var(--color-accent-hover); + } +} + +.button:disabled, +.button[aria-disabled="true"] { + background-color: var(--color-disabled-bg, #353945); + color: var(--color-disabled-text, #6c6c6c); + cursor: not-allowed; + box-shadow: none; + opacity: 0.7; + pointer-events: none; + transition: background-color 0.2s ease-out, color 0.2s ease-out, opacity 0.2s ease-out; +} diff --git a/frontend/src/components/MainButton/MainButton.tsx b/frontend/src/components/MainButton/MainButton.tsx new file mode 100644 index 0000000..fd2c925 --- /dev/null +++ b/frontend/src/components/MainButton/MainButton.tsx @@ -0,0 +1,21 @@ +import styles from './MainButton.module.scss'; + +import React from 'react'; + +type Props = { + content: React.ReactNode; + disabled?: boolean; + [key: string]: any; +}; + +const MainButton: React.FC = ({ content, disabled = false, ...rest }) => ( + +); + +export default MainButton; \ No newline at end of file diff --git a/frontend/src/components/MainButton/index.ts b/frontend/src/components/MainButton/index.ts new file mode 100644 index 0000000..abad4a5 --- /dev/null +++ b/frontend/src/components/MainButton/index.ts @@ -0,0 +1 @@ +export * from './MainButton'; \ No newline at end of file diff --git a/frontend/src/components/Navbar/Navbar.module.scss b/frontend/src/components/Navbar/Navbar.module.scss new file mode 100644 index 0000000..4417326 --- /dev/null +++ b/frontend/src/components/Navbar/Navbar.module.scss @@ -0,0 +1,50 @@ +@use "../../styles/mixins" as *; + +.nav { + display: flex; + justify-content: space-between; + align-items: center; + + background-color: var(--secondary-bg-color); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + padding: 16px; + max-height: 48px; + box-sizing: border-box; + + @include on-tablet { + max-height: 64px; + } + + @include on-desktop { + max-height: 80px; + padding-inline: 32px; + padding-block: 24px; + } + + position: fixed; + left: 0; + right: 0; + top: 0; + z-index: 999; +} + +.list { + display: flex; + gap: 32px; + + align-items: center; +} + +.link { + font-family: "Space Mono", monospace; + color: var(--color-white); + @include set-transition(color); + + &:hover { + color: var(--color-purple); + } +} + +.activeLink { + color: var(--color-purple); +} diff --git a/frontend/src/components/Navbar/Navbar.tsx b/frontend/src/components/Navbar/Navbar.tsx new file mode 100644 index 0000000..a0234c2 --- /dev/null +++ b/frontend/src/components/Navbar/Navbar.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import styles from './Navbar.module.scss'; +import Logo from '../Logo/Logo'; +import { NavLink } from 'react-router-dom'; +import { useAuthContext } from '../../contexts/AuthContext'; + +const Navbar: React.FC = () => { + const { isAuthenticated } = useAuthContext(); + + return ( + + ); +}; + +export default Navbar; diff --git a/frontend/src/components/Navbar/index.ts b/frontend/src/components/Navbar/index.ts new file mode 100644 index 0000000..434e039 --- /dev/null +++ b/frontend/src/components/Navbar/index.ts @@ -0,0 +1 @@ +export * from './Navbar'; \ No newline at end of file diff --git a/frontend/src/components/Pagination/Pagination.module.scss b/frontend/src/components/Pagination/Pagination.module.scss new file mode 100644 index 0000000..70a2163 --- /dev/null +++ b/frontend/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,56 @@ +.pagination { + display: flex; + align-self: center; + gap: 16px; + + > * { + display: flex; + align-items: center; + justify-content: center; + } + + &Pages { + display: flex; + gap: 8px; + } + + &Switcher { + background-color: #252525; + color: #d4d4d4; + border: 1px solid #3a4047; + border-radius: 4px; + font-weight: 400; + width: 70px; + cursor: pointer; + opacity: 1; + transition: opacity 0.2s, background-color 0.2s; + + &:hover:not(:disabled) { + background-color: #178ca4; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &Item { + background-color: var(--snippet-hover-bg-color); + border: 1px solid #3a4047; + border-radius: 4px; + box-sizing: border-box; + color: var(--color-white); + cursor: pointer; + font-weight: 600; + padding: 12px 16px; + + &:not(&Active):hover { + background-color: #3a4047; + } + + &Active { + background-color: #178ca4; + } + } +} \ No newline at end of file diff --git a/frontend/src/components/Pagination/Pagination.tsx b/frontend/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..cc52230 --- /dev/null +++ b/frontend/src/components/Pagination/Pagination.tsx @@ -0,0 +1,128 @@ +import type { FiltersType } from '../../types/FiltersType'; +import styles from './Pagination.module.scss'; + +type Props = { + totalPages: number; + currentPage: number; + onPageChange: (key: keyof FiltersType, value: any) => void; +} + +const Pagination: React.FC = ({ + totalPages, + currentPage, + onPageChange, +}) => { + const SIBLING_COUNT = 2; + const EDGE_COUNT = 2; + + function getPaginationItems(): (number | string)[] { + const page = currentPage ?? 1; + const pages: (number | string)[] = []; + if (totalPages <= EDGE_COUNT * 2 + SIBLING_COUNT * 2 + 1) { + for (let i = 1; i <= totalPages; i++) pages.push(i); + return pages; + } + + const startPages = []; + for (let i = 1; i <= EDGE_COUNT; i++) { + startPages.push(i); + } + const endPages = []; + for (let i = totalPages - EDGE_COUNT + 1; i <= totalPages; i++) { + endPages.push(i); + } + + const safePage = typeof page === 'number' && page > 0 ? page : 1; + + const siblingsStart = Math.max( + Math.min( + safePage - SIBLING_COUNT, + totalPages - EDGE_COUNT - SIBLING_COUNT * 2 + ), + EDGE_COUNT + 1 + ); + const siblingsEnd = Math.min( + Math.max( + safePage + SIBLING_COUNT, + EDGE_COUNT + SIBLING_COUNT * 2 + 1 + ), + totalPages - EDGE_COUNT + ); + + pages.push(...startPages); + + if (siblingsStart > EDGE_COUNT + 1) { + pages.push('...'); + } + + for (let i = siblingsStart; i <= siblingsEnd; i++) { + pages.push(i); + } + + if (siblingsEnd < totalPages - EDGE_COUNT) { + pages.push('...'); + } + + pages.push(...endPages); + + return pages; + }; + + return ( + + ) +} +export default Pagination; \ No newline at end of file diff --git a/frontend/src/components/Pagination/index.ts b/frontend/src/components/Pagination/index.ts new file mode 100644 index 0000000..2b110ec --- /dev/null +++ b/frontend/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export * from './Pagination'; \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.module.scss b/frontend/src/components/ProtectedRoute/ProtectedRoute.module.scss new file mode 100644 index 0000000..f3e9b39 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.module.scss @@ -0,0 +1,4 @@ +.main { + display: flex; + min-height: 100vh; +} \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..b053696 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,32 @@ +import styles from './ProtectedRoute.module.scss'; +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { Loader } from '../Loader'; + +const ProtectedRoute = () => { + const { isAuthenticated, isTokenLoading } = useAuthContext(); + + if (isTokenLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute/index.ts b/frontend/src/components/ProtectedRoute/index.ts new file mode 100644 index 0000000..f481d32 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/index.ts @@ -0,0 +1 @@ +export * from './ProtectedRoute'; \ No newline at end of file diff --git a/frontend/src/components/PublicOnlyRoute/PublicOnlyRoute.module.scss b/frontend/src/components/PublicOnlyRoute/PublicOnlyRoute.module.scss new file mode 100644 index 0000000..f3e9b39 --- /dev/null +++ b/frontend/src/components/PublicOnlyRoute/PublicOnlyRoute.module.scss @@ -0,0 +1,4 @@ +.main { + display: flex; + min-height: 100vh; +} \ No newline at end of file diff --git a/frontend/src/components/PublicOnlyRoute/PublicOnlyRoute.tsx b/frontend/src/components/PublicOnlyRoute/PublicOnlyRoute.tsx new file mode 100644 index 0000000..b1f45f6 --- /dev/null +++ b/frontend/src/components/PublicOnlyRoute/PublicOnlyRoute.tsx @@ -0,0 +1,31 @@ +import styles from './PublicOnlyRoute.module.scss'; +import { Navigate, Outlet } from 'react-router-dom'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { Loader } from '../Loader'; + +const PublicOnlyRoute = () => { + const { isAuthenticated, isTokenLoading } = useAuthContext(); + + if (isTokenLoading) { + return ( +
+ +
+ ); + } + if (isAuthenticated) { + return ; + } + + return ; +}; + +export default PublicOnlyRoute; \ No newline at end of file diff --git a/frontend/src/components/PublicOnlyRoute/index.ts b/frontend/src/components/PublicOnlyRoute/index.ts new file mode 100644 index 0000000..b6f277d --- /dev/null +++ b/frontend/src/components/PublicOnlyRoute/index.ts @@ -0,0 +1 @@ +export * from './PublicOnlyRoute'; \ No newline at end of file diff --git a/frontend/src/components/Snippet/Snippet.module.scss b/frontend/src/components/Snippet/Snippet.module.scss new file mode 100644 index 0000000..78bf4b3 --- /dev/null +++ b/frontend/src/components/Snippet/Snippet.module.scss @@ -0,0 +1,94 @@ +@use "../../styles/mixins" as *; +.snippet { + @include set-transition(background-color); + + appearance: none; + border: none; + color: inherit; + font: inherit; + text-align: inherit; + padding: 0; + margin: 0; + + display: flex; + flex-direction: column; + gap: 16px; + min-height: 140px; + background-color: var(--secondary-bg-color); + width: 300px; + border-radius: 8px; + box-shadow: 0 4px 24px 0 rgba(0, 0, 0, 0.12); + padding: 24px; + cursor: pointer; + + &:hover { + background-color: var(--snippet-hover-bg-color); + } +} + +.head { + display: flex; + flex-direction: column; + gap: 6px; +} + +.title { + font-size: 20px; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.language { + font-family: "Inter", sans-serif; + color: #d4d4d4; + font-size: 14px; + margin: 0; + padding: 0; + text-transform: capitalize; +} + +.content { + position: relative; + background-color: #1e1e1e; + border-radius: 4px; + font-size: 12px; + height: 48px; + overflow: hidden; + white-space: pre-wrap; + + &::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 50%; + pointer-events: none; + background: linear-gradient(to top, #1e1e1e, transparent); + z-index: 1; + } + padding: 12px; +} + +.line { + border: 1px solid var(--notification-color); +} + +.tags { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.button { + background-color: transparent; + border: none; +} \ No newline at end of file diff --git a/frontend/src/components/Snippet/Snippet.tsx b/frontend/src/components/Snippet/Snippet.tsx new file mode 100644 index 0000000..2c0969b --- /dev/null +++ b/frontend/src/components/Snippet/Snippet.tsx @@ -0,0 +1,64 @@ +import { Link } from 'react-router-dom'; +import styles from './Snippet.module.scss'; +import type { SnippetType } from '../../types/SnippetType'; +import Tag from '../Tag/Tag'; +import { useSnippetContext } from '../../contexts/SnippetContext'; +import Heart from '../Heart/Heart'; +import { addFavorite, removeFavorite } from '../../api/snippetsClient'; +import { useAuthContext } from '../../contexts/AuthContext'; + +type Props = { + snippet: SnippetType, +} + +const Snippet: React.FC = ({ snippet }) => { + const { favoriteSnippetsIds, setFavoriteSnippetsIds } = useSnippetContext(); + const { accessToken } = useAuthContext(); + + function handleHeartClick(event: React.MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (favoriteSnippetsIds.includes(snippet.uuid)) { + removeFavorite(accessToken, snippet.uuid); + setFavoriteSnippetsIds(prev => prev.filter(id => id !== snippet.uuid)); + } else { + addFavorite(accessToken, snippet.uuid); + setFavoriteSnippetsIds(prev => [...prev, snippet.uuid]); + } + } + + return ( + +
+
+

{snippet.title}

+ +
+

Language: {snippet.language}

+
+ +
+ {snippet.content} +
+ +
+ +
+ {snippet.tags.map(tag => ( + + ))} +
+ + ); +} + +export default Snippet; \ No newline at end of file diff --git a/frontend/src/components/Snippet/index.ts b/frontend/src/components/Snippet/index.ts new file mode 100644 index 0000000..4623e42 --- /dev/null +++ b/frontend/src/components/Snippet/index.ts @@ -0,0 +1 @@ +export * from './Snippet'; \ No newline at end of file diff --git a/frontend/src/components/Tag/Tag.module.scss b/frontend/src/components/Tag/Tag.module.scss new file mode 100644 index 0000000..0298e67 --- /dev/null +++ b/frontend/src/components/Tag/Tag.module.scss @@ -0,0 +1,30 @@ +.tag { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + color: var(--secondary-bg-color); + font-family: "Inter", sans-serif; + font-size: 12px; + background-color: var(--color-accent); + width: fit-content; + border-radius: 20px; + padding: 4px 8px; + font-weight: 600; +} + +.removeButton { + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + border: none; + width: fit-content; + height: fit-content; + cursor: pointer; + + &:hover { + color: var(--color-white); + } +} \ No newline at end of file diff --git a/frontend/src/components/Tag/Tag.tsx b/frontend/src/components/Tag/Tag.tsx new file mode 100644 index 0000000..79f28be --- /dev/null +++ b/frontend/src/components/Tag/Tag.tsx @@ -0,0 +1,29 @@ +import styles from './Tag.module.scss'; + +type Props = { + content: string + onClose?: () => void +} + +const Tag: React.FC = ({ content, onClose }) => { + return ( +
+ {content} + + {onClose && ( + + )} +
+ ) +} + +export default Tag; \ No newline at end of file diff --git a/frontend/src/components/Tag/index.ts b/frontend/src/components/Tag/index.ts new file mode 100644 index 0000000..561faba --- /dev/null +++ b/frontend/src/components/Tag/index.ts @@ -0,0 +1 @@ +export * from './Tag'; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..4d1190d --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,97 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { Loader } from "../components/Loader"; +import type { AccessToken } from "../types/Tokens"; +import { refresh } from "../api/authClient"; + +type AuthContextType = { + accessToken: AccessToken; + isAuthenticated: boolean; + isTokenLoading: boolean; + setAccessToken: (token: AccessToken) => void; + refreshAuthToken: () => Promise; + email: string; + setEmail: (email: string) => void; +}; + +const AuthContext = createContext(null); + +type Props = { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [accessToken, setAccessTokenState] = useState(undefined); + const [isTokenLoading, setIsTokenLoading] = useState(true); + + const setAccessToken = (token: AccessToken) => setAccessTokenState(token); + + const [email, setEmailState] = useState(() => localStorage.getItem('reset_email') || ''); + + const setEmail = (newEmail: string) => { + setEmailState(newEmail); + if (newEmail) { + localStorage.setItem('reset_email', newEmail); + } else { + localStorage.removeItem('reset_email'); + } + }; + + const refreshAuthToken = async (): Promise => { + const response = await refresh(); + const newAccessToken: AccessToken = response.data.access_token; + setAccessToken(newAccessToken); + + return newAccessToken; + }; + + useEffect(() => { + const attemptRestoreSession = async () => { + try { + await refreshAuthToken(); + } catch (error) { + setAccessToken(undefined); + } finally { + setIsTokenLoading(false); + } + }; + + attemptRestoreSession(); + }, []); + + const value: AuthContextType = { + accessToken, + isAuthenticated: !!accessToken, + isTokenLoading, + email, + setEmail, + setAccessToken, + refreshAuthToken, + }; + + if (isTokenLoading) { + return ( +
+ +
+ ); + } + + return ( + + {children} + + ) +} + +export const useAuthContext = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuthContext must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/contexts/SnippetContext.tsx b/frontend/src/contexts/SnippetContext.tsx new file mode 100644 index 0000000..30712c7 --- /dev/null +++ b/frontend/src/contexts/SnippetContext.tsx @@ -0,0 +1,55 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { getFavorites } from "../api/snippetsClient"; +import { useAuthContext } from "./AuthContext"; + +type SnippetContextType = { + favoriteSnippetsIds: string[], + setFavoriteSnippetsIds: React.Dispatch>, +}; + +const SnippetContext = createContext(null); + +type Props = { + children: ReactNode; +} + +export const SnippetProvider: React.FC = ({ children }) => { + const [favoriteSnippetsIds, setFavoriteSnippetsIds] = useState([]); + const { accessToken } = useAuthContext(); + + const value: SnippetContextType = { + favoriteSnippetsIds, + setFavoriteSnippetsIds, + }; + + useEffect(() => { + if (accessToken) { + (async () => { + try { + const response = await getFavorites(accessToken); + if (response && response.data && Array.isArray(response.data.snippets)) { + setFavoriteSnippetsIds(response.data.snippets.map(snip => snip.uuid)); + } else { + setFavoriteSnippetsIds([]); + } + } catch (err) { + setFavoriteSnippetsIds([]); + } + })(); + } + }, [accessToken]); + + return ( + + {children} + + ) +} + +export const useSnippetContext = () => { + const context = useContext(SnippetContext); + if (!context) { + throw new Error('useSnippetContext must be used within an SnippetProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2c7963c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client'; +import './styles/base.scss'; +import { Root } from './Root.tsx'; + +createRoot(document.getElementById('root')!).render(); diff --git a/frontend/src/modules/AuthPage/AuthCallbackPage.tsx b/frontend/src/modules/AuthPage/AuthCallbackPage.tsx new file mode 100644 index 0000000..93fadd6 --- /dev/null +++ b/frontend/src/modules/AuthPage/AuthCallbackPage.tsx @@ -0,0 +1,73 @@ +import { useSearchParams, useNavigate } from 'react-router-dom'; +import styles from './AuthPage.module.scss'; +import { useEffect, useState } from 'react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { Loader } from '../../components/Loader'; +import MainButton from '../../components/MainButton/MainButton'; +import { flushSync } from 'react-dom'; +import { loginGoogleCallback } from '../../api/authClient'; + +const AuthCallbackPage: React.FC = () => { + const [searchParams] = useSearchParams(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { setAccessToken } = useAuthContext(); + const navigate = useNavigate(); + + useEffect(() => { + const code = searchParams.get('code'); + if (!code) { + setError('No code provided in callback.'); + return; + } + + const fetchCallback = async () => { + setIsLoading(true); + setError(null); + try { + const response = await loginGoogleCallback(code as string); + flushSync(() => setAccessToken(response.data.access_token)); + navigate('/snippets', { + replace: true, + state: { + title: 'Signed In Successfully', + message: 'You have signed in with Google.', + type: 'success', + } + }); + } catch (err: any) { + setError( + err.response?.data?.message || + err.message || + 'An error occurred during authentication.' + ); + } finally { + setIsLoading(false); + } + }; + + fetchCallback(); + }, []); + + return ( +
+ {isLoading ? ( + + ) : error ? ( +
+

{error}

+ + navigate('/sign-in')} + /> +
+ ) : ( +
Redirecting...
+ )} +
+ ); +} + +export default AuthCallbackPage; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/AuthPage.module.scss b/frontend/src/modules/AuthPage/AuthPage.module.scss new file mode 100644 index 0000000..7e26fce --- /dev/null +++ b/frontend/src/modules/AuthPage/AuthPage.module.scss @@ -0,0 +1,185 @@ +@use "../../styles/mixins" as *; +.main { + display: flex; + flex-direction: column; + flex: 1; + + justify-content: center; + align-items: center; +} + +.form { + display: flex; + flex-direction: column; + gap: 16px; + font-family: "Inter", sans-serif; + border-radius: 8px; + + max-width: 384px; + width: 100%; + padding: 32px; + background-color: var(--secondary-bg-color); + + > button { + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + min-width: 100%; + } + + a { + font-size: 12px; + color: var(--color-accent); + @include set-transition; + + &:hover { + color: var(--color-purple); + } + + &.back { + font-size: 13px; + align-self: center; + } + } +} + +.inputs { + display: flex; + flex-direction: column; + gap: 16px; + + &Item { + display: flex; + flex-direction: column; + gap: 8px; + } + + &Title { + font-weight: 600; + font-size: 14px; + } + + &Description { + font-weight: normal; + font-size: 14px; + margin: 0 0 8px 0; + } +} + +.container { + position: relative; +} + +.text { + font-size: 14px; + margin: 0; + + a { + font-size: 14px; + } +} + +.button { + cursor: pointer; + font-size: 14px; + font-weight: 600; + border-radius: 999px; + background-color: var(--color-accent); + border: none; + color: var(--primary-bg-color); + height: 40px; + + @include on-tablet { + font-size: 16px; + } + + &:hover { + background-color: var(--color-accent-hover); + } +} + +.eye { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: none; + right: 10px; + cursor: pointer; + border: none; +} + +.error { + margin: 0; + font-size: 14px; + color: var(--color-red); + + &Title { + font-size: 32px; + margin: 16px; + } +} + +.success { + color: var(--color-success); +} + +.strength { + display: flex; + flex-direction: column; + gap: 4px; + + &-Status { + @include set-transition(color, 0.4s); + margin: 0; + padding: 0; + font-size: 14px; + + &-Weak { + color: #ef4444; + } + &-Medium { + color: #fb923c; + } + &-Strong { + color: #10b881; + } + } + &-Line { + @include set-transition(background-color, 0.4s); + height: 8px; + border-radius: 8px; + + &-Weak { + background-color: #ef4444; + } + &-Medium { + background-color: #fb923c; + } + &-Strong { + background-color: #10b881; + } + } +} + +.google { + background-color: var(--primary-bg-color); + color: var(--color-white); + border-radius: 8px; + border: 1px solid var(--color-accent); + height: 36px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + display: flex; + gap: 16px; + + &:hover { + background-color: #2F363F; + } + + .googleIcon { + width: 20px; + height: 20px; + } +} \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/CrossedEye.tsx b/frontend/src/modules/AuthPage/CrossedEye.tsx new file mode 100644 index 0000000..904089f --- /dev/null +++ b/frontend/src/modules/AuthPage/CrossedEye.tsx @@ -0,0 +1,21 @@ +const CrossedEye: React.FC = () => ( + + + +); + +export default CrossedEye; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/FinishRegistrationPage.tsx b/frontend/src/modules/AuthPage/FinishRegistrationPage.tsx new file mode 100644 index 0000000..e4f7728 --- /dev/null +++ b/frontend/src/modules/AuthPage/FinishRegistrationPage.tsx @@ -0,0 +1,33 @@ +import styles from './AuthPage.module.scss'; + +const FinishRegistrationPage: React.FC = () => { + + return ( +
+

Finish Registration

+ +
event.preventDefault()} + > +
+
+
+ A confirmation link has been sent to your email address +
+
+
+ Please click the link in the email to activate your Snippetly account. + Check your spam folder if you don't see it within a few minutes. +
+
+
+
+
+
+ ); +} + +export default FinishRegistrationPage; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/FinishRegistrationTokenPage.tsx b/frontend/src/modules/AuthPage/FinishRegistrationTokenPage.tsx new file mode 100644 index 0000000..2343da8 --- /dev/null +++ b/frontend/src/modules/AuthPage/FinishRegistrationTokenPage.tsx @@ -0,0 +1,61 @@ +import { Navigate, useParams } from 'react-router-dom'; +import styles from './AuthPage.module.scss'; +import { useEffect, useState } from 'react'; +import { activate } from '../../api/authClient'; +import { Loader } from '../../components/Loader'; + +const FinishRegistrationTokenPage = () => { + const { token } = useParams(); + + const [isLoading, setIsLoading] = useState(true); + const [isValid, setIsValid] = useState(null); + + useEffect(() => { + if (!token) { + setIsValid(false); + setIsLoading(false); + return; + } + + activate(token) + .then(response => { + if (response && response.status === 200) { + setIsValid(true); + } else { + setIsValid(false); + } + }) + .catch(() => { + setIsValid(false); + }) + .finally(() => setIsLoading(false)); + }, [token]); + + return ( +
+ {isLoading ? ( + + ) : isValid === true ? ( + + ) : ( + + )} +
+ ); +} + +export default FinishRegistrationTokenPage; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/GoogleSignIn.tsx b/frontend/src/modules/AuthPage/GoogleSignIn.tsx new file mode 100644 index 0000000..fc3e582 --- /dev/null +++ b/frontend/src/modules/AuthPage/GoogleSignIn.tsx @@ -0,0 +1,23 @@ +import styles from './AuthPage.module.scss'; + +type Props = { + type: 'signup' | 'signin'; + onClick: (event: React.MouseEvent) => void; +} + +const GoogleSignIn: React.FC = ({ type, onClick }) => ( + +); + +export default GoogleSignIn; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/PasswordResetPage.tsx b/frontend/src/modules/AuthPage/PasswordResetPage.tsx new file mode 100644 index 0000000..607814a --- /dev/null +++ b/frontend/src/modules/AuthPage/PasswordResetPage.tsx @@ -0,0 +1,144 @@ +import { Link, useNavigate } from 'react-router-dom'; +import styles from './AuthPage.module.scss'; + +import { useState } from 'react'; +import MainButton from '../../components/MainButton/MainButton'; +import { resetRequest } from '../../api/authClient'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useMutation } from '@tanstack/react-query'; + +const PasswordResetPage: React.FC = () => { + const [emailInputValue, setEmailInputValue] = useState(''); + + const navigate = useNavigate(); + + const { setEmail } = useAuthContext(); + + const [errorContent, setErrorContent] = useState(''); + + const errors = { + empty: 'Email is required', + invalid: 'Please enter a valid email address', + server: 'Something went wrong. Please try again later.' + } + + const emailRegex = /^[a-zA-Z0-9+._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + + function handleEmailInputChange(event: React.ChangeEvent) { + setEmailInputValue(event.target.value); + + if (errorContent === errors.empty) { + setErrorContent(''); + } + + if (errorContent === errors.invalid && emailRegex.test(emailInputValue)) { + setErrorContent(''); + } + } + + function handleEmailInputBlur() { + if (!emailRegex.test(emailInputValue)) { + setErrorContent(errors.invalid); + } + } + + const { mutate, isSuccess, isError } = useMutation({ + mutationFn: (email: string) => resetRequest(email), + }) + + function handleFormSubmit(event: React.FormEvent) { + event.preventDefault(); + let hasError = false; + + if (emailInputValue.length === 0) { + setErrorContent(errors.empty); + hasError = true; + } + + if (!emailRegex.test(emailInputValue) && emailInputValue.length !== 0) { + setErrorContent(errors.invalid); + hasError = true; + } + + if (hasError) return; + + setEmail(emailInputValue); + mutate(emailInputValue); + } + + return ( +
+

Reset Your Password

+ + {isError ? ( +
+ {errors.server} + + { navigate('/') }} + /> +
+ ) : isSuccess ? ( +
+ If an account with that email exists, + we've sent password reset instructions. + Please check your inbox. +
+ ) : ( +
+
+
+

+ Enter your email address below, and we'll send you a link to reset your password. +

+ + + {errorContent && ( +

+ {errorContent} +

+ )} +
+
+ + + + Back to Sign In +
+ )} +
+ ) +} + +export default PasswordResetPage; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/SetNewPasswordPage.tsx b/frontend/src/modules/AuthPage/SetNewPasswordPage.tsx new file mode 100644 index 0000000..cbb0fab --- /dev/null +++ b/frontend/src/modules/AuthPage/SetNewPasswordPage.tsx @@ -0,0 +1,286 @@ +import styles from './AuthPage.module.scss'; +import { useState } from 'react'; +import UncrossedEye from './UncrossedEye'; +import CrossedEye from './CrossedEye'; +import { useNavigate, useParams } from 'react-router-dom'; +import { resetComplete } from '../../api/authClient'; +import { useAuthContext } from '../../contexts/AuthContext'; + +type Status = 'Weak' | 'Medium' | 'Strong'; + +const SetNewPasswordPage: React.FC = () => { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [isPasswordConfirmVisible, setIsPasswordConfirmVisible] = useState(false); + + const [passwordStatus, setPasswordStatus] = useState('Weak'); + + const [passwordErrorContent, setPasswordErrorContent] = useState(''); + const [passwordConfirmErrorContent, setPasswordConfirmErrorContent] = useState(''); + + const [passwordInputValue, setPasswordInputValue] = useState(''); + const [passwordConfirmInputValue, setPasswordConfirmInputValue] = useState(''); + + const navigate = useNavigate(); + + const { token } = useParams(); + const { email, setEmail } = useAuthContext(); + + const errorMessages = { + passwordEmpty: 'New password is required', + passwordConfirmEmpty: 'Please confirm your new password', + passwordMismatch: 'Passwords do not match', + passwordLength: 'Password must be at least 8 characters long', + passwordIncludeLetters: 'Include both uppercase and lowercase letters', + passwordIncludeNumber: 'Include at least one number', + passwordNonPrinting: 'Non-printing symbols are not allowed', + passwordSpecial: 'Include at least one special character' + }; + + function checkErrors(password: string): string { + if (!password) return errorMessages.passwordEmpty; + if (password.length < 8) return errorMessages.passwordLength; + if (/\s/.test(password)) return errorMessages.passwordNonPrinting; + if (!/^[\x20-\x7E]+$/.test(password)) return errorMessages.passwordNonPrinting; + if (!/[^A-Za-z0-9]/.test(password)) return errorMessages.passwordSpecial; + if (!/[0-9]/.test(password)) return errorMessages.passwordIncludeNumber; + if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) return errorMessages.passwordIncludeLetters; + return ''; + } + + function changePasswordStatus(password: string) { + let score = 0; + + const regex = { + has_uppercase: /[A-Z]/, + has_lowercase: /[a-z]/, + has_number: /[0-9]/, + has_special: /[^A-Za-z0-9]/, + }; + + if (password.length >= 8) { + score += 1; + } + + if (regex.has_uppercase.test(password)) { + score += 1; + } + if (regex.has_lowercase.test(password)) { + score += 1; + } + if (regex.has_number.test(password)) { + score += 1; + } + + if (regex.has_special.test(password)) { + score += 1; + } + + if (score <= 2) { + setPasswordStatus('Weak'); + } else if (score <= 4) { + setPasswordStatus('Medium'); + } else { + setPasswordStatus('Strong'); + } + } + + function handlePasswordInputChange(event: React.ChangeEvent) { + const newValue = event.target.value; + setPasswordInputValue(newValue); + changePasswordStatus(newValue); + setPasswordErrorContent(''); + + if (passwordConfirmInputValue) { + if (newValue !== passwordConfirmInputValue) { + setPasswordConfirmErrorContent(errorMessages.passwordMismatch); + } else { + setPasswordConfirmErrorContent(''); + } + } + } + + function handlePasswordConfirmInputChange(event: React.ChangeEvent) { + const newValue = event.target.value; + setPasswordConfirmInputValue(newValue); + if (!newValue) { + setPasswordConfirmErrorContent(errorMessages.passwordConfirmEmpty); + } else if (passwordInputValue !== newValue) { + setPasswordConfirmErrorContent(errorMessages.passwordMismatch); + } else { + setPasswordConfirmErrorContent(''); + } + } + + function handleFormSubmit(event: React.FormEvent) { + event.preventDefault(); + + const passwordError = checkErrors(passwordInputValue); + setPasswordErrorContent(passwordError); + + if (!passwordConfirmInputValue) { + setPasswordConfirmErrorContent(errorMessages.passwordConfirmEmpty); + return; + } + + if (!passwordError && passwordInputValue !== passwordConfirmInputValue) { + setPasswordConfirmErrorContent(errorMessages.passwordMismatch); + return; + } + + resetComplete(passwordInputValue, email, token) + .then(response => { + if (response.status === 200) { + setEmail(''); + navigate('/sign-in', { + state: { + title: "Password Changed", + message: "Your password has been successfully updated. You can now sign in.", + type: "success" + } + }); + } else { + navigate('/sign-in', { + state: { + title: "Password Change Failed", + message: "There was a problem updating your password. Please try the reset link again or request a new one.", + type: "error" + } + }); + } + }) + } + + return ( +
+

Set New Password

+ +
+
+
+ +
+ + +
+ + {passwordErrorContent && ( + + )} + + {passwordInputValue && ( +
+
+

{passwordStatus}

+
+ )} +
+
+ +
+ + +
+ + {passwordConfirmErrorContent && ( + + )} +
+
+ + +
+
+ ); +}; + +export default SetNewPasswordPage; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/SignInPage.tsx b/frontend/src/modules/AuthPage/SignInPage.tsx new file mode 100644 index 0000000..8709cc2 --- /dev/null +++ b/frontend/src/modules/AuthPage/SignInPage.tsx @@ -0,0 +1,273 @@ +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import styles from './AuthPage.module.scss'; +import { useEffect, useState } from 'react'; +import UncrossedEye from './UncrossedEye'; +import CrossedEye from './CrossedEye'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { login } from '../../api/authClient'; +import CustomToast from '../../components/CustomToast/CustomToast'; +import toast, { type Toast } from 'react-hot-toast'; +import { useMutation } from '@tanstack/react-query'; +import { Loader } from '../../components/Loader'; +import GoogleSignIn from './GoogleSignIn'; +import { flushSync } from 'react-dom'; + +const SignInPage: React.FC = () => { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + + const [emailInputValue, setEmailInputValue] = useState(''); + const [passwordInputValue, setPasswordInputValue] = useState(''); + + const [emailErrorContent, setEmailErrorContent] = useState(''); + const [passwordErrorContent, setPasswordErrorContent] = useState(''); + + const navigate = useNavigate(); + + const { setAccessToken } = useAuthContext(); + + const errors = { + emailEmpty: 'Email or Username: can’t be blank', + passwordEmpty: 'Password: can’t be blank', + emailNotFound: 'User with such email or username not registered.', + passwordWrong: 'Entered Invalid password! Check your keyboard layout or Caps Lock. Forgot your password?', + } + + const { mutate: signIn, isPending } = useMutation({ + mutationFn: async ({ emailOrUsername, password }: { emailOrUsername: string, password: string }) => { + return login(emailOrUsername, password); + }, + onSuccess: (response) => { + // Use flushSync to ensure the access token is set *before* navigating. + // This avoids a wrong behaviour where navigation happens before the token is actually set, + // which could cause protected routes/components to not recognize the authenticated state. + flushSync(() => { + setAccessToken(response.data.access_token); + }); + navigate('/snippets', { + replace: true, + state: { + title: 'Signed In Successfully', + message: 'You have signed in.', + type: 'success', + } + }); + }, + onError: (error: any) => { + if (error?.response?.data?.detail && typeof error.response.data.detail === 'string') { + const detail = error.response.data.detail; + if (detail.includes('not registered')) { + setEmailErrorContent(errors.emailNotFound); + } else if (detail.includes('Invalid password')) { + setPasswordErrorContent(errors.passwordWrong); + } else { + setEmailErrorContent('Sign in failed. Please try again.'); + } + } else { + setEmailErrorContent('Sign in failed. Please try again.'); + } + } + }); + + function handleEmailInputChange(event: React.ChangeEvent) { + setEmailInputValue(event.target.value); + + if (emailErrorContent) { + setEmailErrorContent(''); + } + } + + function handlePasswordInputChange(event: React.ChangeEvent) { + setPasswordInputValue(event.target.value); + + if (passwordErrorContent) { + setPasswordErrorContent(''); + } + } + + function validatePassword() { + if (!passwordInputValue.trim()) { + setPasswordErrorContent(errors.passwordEmpty); + return false; + } + + const hasCapital = /[A-Z]/.test(passwordInputValue); + const hasNumber = /[0-9]/.test(passwordInputValue); + const hasSpecial = /[^A-Za-z0-9]/.test(passwordInputValue); + + if (!hasCapital || !hasNumber || !hasSpecial) { + setPasswordErrorContent(errors.passwordWrong); + return false; + } + + setPasswordErrorContent(''); + return true; + } + + function handleFormSubmit(event: React.FormEvent) { + event.preventDefault(); + + let hasError = false; + + if (!emailInputValue.trim()) { + setEmailErrorContent(errors.emailEmpty); + hasError = true; + } + + if (!validatePassword()) { + hasError = true; + } + + if (hasError) return; + + signIn({ + emailOrUsername: emailInputValue, + password: passwordInputValue, + }); + } + + function handleSignInWithGoogle(event: React.MouseEvent) { + event.preventDefault(); + const SERVER_BASE_URL = import.meta.env.VITE_SERVER_BASE_URL; + window.location.href = `${SERVER_BASE_URL}/api/v1/auth/google/url`; + } + + const location = useLocation(); + + useEffect(() => { + if (location.state && ( + location.state.title || + location.state.message || + location.state.type + )) { + const { title = '', message = '', type = 'info' } = location.state || {}; + + toast.custom((t: Toast) => ( + + ), { + duration: 2500, + }); + + navigate(location.pathname, { replace: true, state: null }); + } + }, [location, navigate]); + + return ( +
+

Sign In

+ +
+
+
+ + + + {emailErrorContent && ( + + )} +
+
+ +
+ + +
+ + {passwordErrorContent && ( + + )} + + + Forgot the password? + +
+
+ + + + + +

+ Need an account? Sign Up +

+ +
+ ) +} + +export default SignInPage; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/SignUpPage.tsx b/frontend/src/modules/AuthPage/SignUpPage.tsx new file mode 100644 index 0000000..9ba647f --- /dev/null +++ b/frontend/src/modules/AuthPage/SignUpPage.tsx @@ -0,0 +1,331 @@ +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import styles from './AuthPage.module.scss'; +import { useEffect, useState } from 'react'; +import UncrossedEye from './UncrossedEye'; +import CrossedEye from './CrossedEye'; +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { register as registerRequest } from '../../api/authClient'; +import { useMutation } from '@tanstack/react-query'; +import { Loader } from '../../components/Loader'; +import toast, { type Toast } from 'react-hot-toast'; +import CustomToast from '../../components/CustomToast/CustomToast'; +import GoogleSignIn from './GoogleSignIn'; + +type SignUpForm = { + username: string; + email: string; + password: string; + confirmPassword: string; +}; + +const SignUpPage: React.FC = () => { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [serverEmailError, setServerEmailError] = useState(''); + const [serverUsernameError, setServerUsernameError] = useState(''); + const [serverPasswordError, setServerPasswordError] = useState(''); + + const serverErrors = { + emailTaken: 'This email is taken. Want to log in?', + usernameTaken: 'This username is taken.', + }; + + const location = useLocation(); + const navigate = useNavigate(); + + const { mutate: signUp, isPending } = useMutation({ + mutationFn: ({ username, email, password }: + { username: string; email: string; password: string }) => + registerRequest(username, email, password), + onSuccess: () => { + navigate('/activate-account'); + }, + onError: (error: any) => { + if (error?.response?.data?.detail && typeof error.response.data.detail === 'string') { + const detail = error.response.data.detail; + if (detail.includes('username')) { + setServerUsernameError(serverErrors.usernameTaken); + } + if (detail.includes('email')) { + setServerEmailError(serverErrors.emailTaken); + } + if (detail.includes('Password') || detail.includes('password')) { + setServerPasswordError(detail); + } + } + }, + }); + + const signupSchema = z.object({ + username: z.string() + .min(3) + .max(40) + .regex(/^[A-Za-z][A-Za-z0-9_]*$/), + email: z.email(), + password: z.string() + .min(8) + .max(30) + .regex(/^(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*()_+\-=?&])\S+$/), + confirmPassword: z.string(), + }).refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], + }); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm({ + resolver: zodResolver(signupSchema), + }); + + const watchedFields = watch(); + + useEffect(() => { + if (serverEmailError) { + setServerEmailError(''); + } + }, [watchedFields.email]); + + useEffect(() => { + if (serverUsernameError) { + setServerUsernameError(''); + } + }, [watchedFields.username]); + + useEffect(() => { + if (serverPasswordError) { + setServerPasswordError(''); + } + }, [watchedFields.password]); + + useEffect(() => { + if (location.state && ( + location.state.title || + location.state.message || + location.state.type + )) { + const { title = '', message = '', type = 'success' } = location.state || {}; + + toast.custom((t: Toast) => ( + + ), { + duration: 2500, + }); + + navigate(location.pathname, { replace: true, state: null }); + } + }, [location, navigate]); + + async function handleFormSubmit(form: SignUpForm) { + signUp({ + username: form.username, + email: form.email, + password: form.password, + }); + } + + function handleSignUpWithGoogle(event: React.MouseEvent) { + event.preventDefault(); + + const SERVER_BASE_URL = import.meta.env.VITE_SERVER_BASE_URL; + window.location.href = `${SERVER_BASE_URL}/api/v1/auth/google/url`; + } + + return ( +
+

Sign Up

+
+
+
+ + + {serverUsernameError && ( +

{serverUsernameError}

+ )} + {errors.username && ( + + )} +
+
+ + + {serverEmailError && ( +

{serverEmailError}

+ )} + {errors.email && ( + + )} +
+
+ +
+ + {serverPasswordError && ( +

{serverPasswordError}

+ )} + +
+ {errors.password && ( + + )} +
+
+ +
+ + +
+ {errors.confirmPassword && ( + + )} +
+
+ + + +

+ Have an account? Sign In +

+ +
+ ); +}; + +export default SignUpPage; \ No newline at end of file diff --git a/frontend/src/modules/AuthPage/UncrossedEye.tsx b/frontend/src/modules/AuthPage/UncrossedEye.tsx new file mode 100644 index 0000000..e12de74 --- /dev/null +++ b/frontend/src/modules/AuthPage/UncrossedEye.tsx @@ -0,0 +1,19 @@ +const UncrossedEye = () => ( + + + +); + +export default UncrossedEye; \ No newline at end of file diff --git a/frontend/src/modules/ChangePasswordPage/ChangePasswordPage.module.scss b/frontend/src/modules/ChangePasswordPage/ChangePasswordPage.module.scss new file mode 100644 index 0000000..d272c9f --- /dev/null +++ b/frontend/src/modules/ChangePasswordPage/ChangePasswordPage.module.scss @@ -0,0 +1,129 @@ +@use "../../styles/mixins" as *; +.main { + display: flex; + flex-direction: column; + flex: 1; + + justify-content: center; + align-items: center; +} + +.form { + display: flex; + flex-direction: column; + gap: 16px; + font-family: "Inter", sans-serif; + border-radius: 8px; + + max-width: 384px; + width: 100%; + padding: 32px; + background-color: var(--secondary-bg-color); + + &Item { + display: flex; + flex-direction: column; + gap: 6px; + } + + > button { + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + min-width: 100%; + } + + a { + font-size: 12px; + color: var(--color-accent); + @include set-transition; + + &:hover { + color: var(--color-purple); + } + + &.back { + font-size: 13px; + align-self: center; + } + } +} + +.button { + cursor: pointer; + font-size: 14px; + font-weight: 600; + border-radius: 999px; + background-color: var(--color-accent); + border: none; + color: var(--primary-bg-color); + height: 40px; + + @include on-tablet { + font-size: 16px; + } + + &:hover { + background-color: var(--color-accent-hover); + } +} + +.container { + position: relative; +} + +.eye { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: none; + right: 10px; + cursor: pointer; + border: none; +} + +.error { + margin: 0; + font-size: 14px; + color: var(--color-red); +} + +.strength { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; + + &-Status { + @include set-transition(color, 0.4s); + margin: 0; + padding: 0; + font-size: 14px; + + &-Weak { + color: #ef4444; + } + &-Medium { + color: #fb923c; + } + &-Strong { + color: #10b881; + } + } + &-Line { + @include set-transition(background-color, 0.4s); + height: 8px; + border-radius: 8px; + + &-Weak { + background-color: #ef4444; + } + &-Medium { + background-color: #fb923c; + } + &-Strong { + background-color: #10b881; + } + } +} diff --git a/frontend/src/modules/ChangePasswordPage/ChangePasswordPage.tsx b/frontend/src/modules/ChangePasswordPage/ChangePasswordPage.tsx new file mode 100644 index 0000000..784d9b5 --- /dev/null +++ b/frontend/src/modules/ChangePasswordPage/ChangePasswordPage.tsx @@ -0,0 +1,404 @@ +import { useState } from 'react'; +import styles from './ChangePasswordPage.module.scss'; +import UncrossedEye from '../AuthPage/UncrossedEye'; +import CrossedEye from '../AuthPage/CrossedEye'; +import { changePassword } from '../../api/authClient'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useMutation } from '@tanstack/react-query'; +import { Loader } from '../../components/Loader'; +import toast, { type Toast } from 'react-hot-toast'; +import CustomToast from '../../components/CustomToast/CustomToast'; +import { AxiosError } from 'axios'; +import { useNavigate } from 'react-router-dom'; + +type Status = 'Weak' | 'Medium' | 'Strong'; + +const ChangePasswordPage = () => { + const [isPasswordOldVisible, setIsPasswordOldVisible] = useState(false); + const [isPasswordNewVisible, setIsPasswordNewVisible] = useState(false); + const [isPasswordConfirmVisible, setIsPasswordConfirmVisible] = useState(false); + + const [passwordStatus, setPasswordStatus] = useState('Weak'); + + const [oldPasswordErrorContent, setOldPasswordErrorContent] = useState(''); + const [newPasswordErrorContent, setNewPasswordErrorContent] = useState(''); + const [confirmPasswordErrorContent, setConfirmPasswordErrorContent] = useState(''); + + const [oldPasswordInputValue, setOldPasswordInputValue] = useState(''); + const [newPasswordInputValue, setNewPasswordInputValue] = useState(''); + const [confirmPasswordInputValue, setConfirmPasswordInputValue] = useState(''); + + const { accessToken } = useAuthContext(); + const navigate = useNavigate(); + + const errorMessages = { + oldPasswordEmpty: 'Old password is required', + passwordEmpty: 'New password is required', + passwordConfirmEmpty: 'Please confirm your new password', + passwordMismatch: 'Passwords do not match', + passwordSameAsOld: 'New password must be different from old password', + passwordLength: 'Password must be at least 8 characters long', + passwordIncludeLetters: 'Include both uppercase and lowercase letters', + passwordIncludeNumber: 'Include at least one number', + passwordNonPrinting: 'Non-printing symbols are not allowed', + passwordSpecial: 'Include at least one special character' + }; + + function changePasswordStatus(password: string) { + let score = 0; + + const regex = { + has_uppercase: /[A-Z]/, + has_lowercase: /[a-z]/, + has_number: /[0-9]/, + has_special: /[^A-Za-z0-9]/, + }; + + if (password.length >= 8) { + score += 1; + } + + if (regex.has_uppercase.test(password)) { + score += 1; + } + if (regex.has_lowercase.test(password)) { + score += 1; + } + if (regex.has_number.test(password)) { + score += 1; + } + + if (regex.has_special.test(password)) { + score += 1; + } + + if (score <= 2) { + setPasswordStatus('Weak'); + } else if (score <= 4) { + setPasswordStatus('Medium'); + } else { + setPasswordStatus('Strong'); + } + } + + function handleOldPasswordInputChange(event: React.ChangeEvent) { + const value = event.target.value; + setOldPasswordInputValue(value); + + setOldPasswordErrorContent(!value ? errorMessages.oldPasswordEmpty : ''); + + if (newPasswordInputValue && value === newPasswordInputValue) { + setNewPasswordErrorContent(errorMessages.passwordSameAsOld); + } else if (newPasswordErrorContent === errorMessages.passwordSameAsOld) { + if (!(newPasswordErrorContent === errorMessages.passwordLength && newPasswordInputValue.length < 8)) { + setNewPasswordErrorContent(''); + } + } + } + + function checkNewPasswordErrors(password: string): string { + if (!password) return errorMessages.passwordEmpty; + if (password.length < 8) return errorMessages.passwordLength; + if (/\s/.test(password)) return errorMessages.passwordNonPrinting; + if (!/^[\x20-\x7E]+$/.test(password)) return errorMessages.passwordNonPrinting; + if (!/[^A-Za-z0-9]/.test(password)) return errorMessages.passwordSpecial; + if (!/[0-9]/.test(password)) return errorMessages.passwordIncludeNumber; + if (!/[A-Z]/.test(password) || !/[a-z]/.test(password)) return errorMessages.passwordIncludeLetters; + return ''; + } + + function handleNewPasswordInputChange(event: React.ChangeEvent) { + const newValue = event.target.value; + setNewPasswordInputValue(newValue); + changePasswordStatus(newValue); + + setNewPasswordErrorContent(''); + + if (oldPasswordInputValue && newValue === oldPasswordInputValue) { + setNewPasswordErrorContent(errorMessages.passwordSameAsOld); + } + + if (confirmPasswordInputValue) { + if (newValue !== confirmPasswordInputValue) { + setConfirmPasswordErrorContent(errorMessages.passwordMismatch); + } else { + setConfirmPasswordErrorContent(''); + } + } + } + + function handleConfirmPasswordInputChange(event: React.ChangeEvent) { + const newValue = event.target.value; + setConfirmPasswordInputValue(newValue); + if (!newValue) { + setConfirmPasswordErrorContent(errorMessages.passwordConfirmEmpty); + } else if (newPasswordInputValue !== newValue) { + setConfirmPasswordErrorContent(errorMessages.passwordMismatch); + } else { + setConfirmPasswordErrorContent(''); + } + } + + const { mutate, isPending } = useMutation({ + mutationFn: () => changePassword(oldPasswordInputValue, newPasswordInputValue, accessToken), + onSuccess: () => navigate('/profile', { + state: { + title: 'Password Changed', + message: 'Your password has been successfully updated', + type: 'success', + } + }), + onError: (error: AxiosError<{ detail: string | { msg?: string; message?: string }[] | { msg?: string; message?: string } }>) => { + console.log(error) + + let errorMessage = "An unexpected problem occurred while changing your password."; + + if (error.response?.data?.detail) { + const detail = error.response.data.detail; + if (typeof detail === 'string') { + errorMessage = detail; + } else if (Array.isArray(detail)) { + const first = detail[0]; + if (typeof first === 'string') { + errorMessage = first; + } else if (typeof first === 'object' && (first?.msg || first?.message)) { + errorMessage = first.msg || first.message || errorMessage; + } + } else if (typeof detail === 'object' && (detail?.msg || detail?.message)) { + errorMessage = detail.msg || detail.message || errorMessage; + } + } + + toast.custom((t: Toast) => ( + + ), { + duration: 2500, + }); + }, + }) + + function handleFormSubmit(event: React.FormEvent) { + event.preventDefault(); + + if (oldPasswordInputValue && oldPasswordInputValue.length < 8) { + setOldPasswordErrorContent(errorMessages.passwordLength); + return; + } + + const newPasswordError = checkNewPasswordErrors(newPasswordInputValue); + if (newPasswordError) { + setNewPasswordErrorContent(newPasswordError); + return; + } + + if (oldPasswordInputValue === newPasswordInputValue) { + setNewPasswordErrorContent(errorMessages.passwordSameAsOld); + return; + } + + if (!confirmPasswordInputValue) { + setConfirmPasswordErrorContent(errorMessages.passwordConfirmEmpty); + return; + } + if (newPasswordInputValue !== confirmPasswordInputValue) { + setConfirmPasswordErrorContent(errorMessages.passwordMismatch); + return; + } + + if (oldPasswordErrorContent || newPasswordErrorContent || confirmPasswordErrorContent) { + return; + } + + if (passwordStatus !== 'Strong') { + toast.custom((t: Toast) => ( + + ), { + duration: 2500, + }); + return; + } + + mutate(); + } + + return ( +
+

Change password

+
+
+ +
+ + +
+ + {oldPasswordErrorContent && ( + + )} +
+
+ +
+ + +
+ + {newPasswordErrorContent && ( + + )} + + {newPasswordInputValue && ( +
+
+

{passwordStatus}

+
+ )} +
+
+ +
+ + +
+ + {confirmPasswordErrorContent && ( + + )} +
+ +
+
+ ); +} + +export default ChangePasswordPage; \ No newline at end of file diff --git a/frontend/src/modules/FavoritesPage/FavoritesPage.module.scss b/frontend/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 0000000..a416a46 --- /dev/null +++ b/frontend/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,9 @@ +.main { + @include page-padding-inline; +} + +.snippets { + display: flex; + flex-wrap: wrap; + gap: 16px; +} \ No newline at end of file diff --git a/frontend/src/modules/FavoritesPage/FavoritesPage.tsx b/frontend/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 0000000..bc5de60 --- /dev/null +++ b/frontend/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,59 @@ +import styles from './FavoritesPage.module.scss'; +import { useSnippetContext } from "../../contexts/SnippetContext"; +import { getById } from "../../api/snippetsClient"; +import { useAuthContext } from '../../contexts/AuthContext'; +import type { SnippetDetailsType } from '../../types/SnippetDetailsType'; +import Snippet from '../../components/Snippet/Snippet'; +import { useQuery } from '@tanstack/react-query'; +import { Loader } from '../../components/Loader'; + +const FavoritesPage = () => { + const { favoriteSnippetsIds } = useSnippetContext(); + const { accessToken } = useAuthContext(); + + const { + data: snippets = [], + isLoading, + isError, + } = useQuery({ + queryKey: ['favoriteSnippets', favoriteSnippetsIds, accessToken], + queryFn: async () => { + if (favoriteSnippetsIds.length === 0) { + return []; + } + const responses = await Promise.all( + favoriteSnippetsIds.map(id => getById(id, accessToken)) + ); + return responses + .map(response => response?.data) + .filter(Boolean) as SnippetDetailsType[]; + }, + enabled: !!accessToken, + }); + + return ( +
+

Favorite Snippets

+ + {isLoading ? ( + + ) : isError ? ( +
Failed to load your favorite snippets. Please try again later.
+ ) : favoriteSnippetsIds.length === 0 ? ( +
You haven't added any snippets to your favorites yet.
+ ) : snippets.length === 0 ? ( +
+ Could not load your favorite snippets or they may have been removed. +
+ ) : ( +
+ {snippets.map(snippet => ( + + ))} +
+ )} +
+ ); +} + +export default FavoritesPage; \ No newline at end of file diff --git a/frontend/src/modules/LandingPage/LandingPage.module.scss b/frontend/src/modules/LandingPage/LandingPage.module.scss new file mode 100644 index 0000000..4a89810 --- /dev/null +++ b/frontend/src/modules/LandingPage/LandingPage.module.scss @@ -0,0 +1,66 @@ +@use "../../styles/mixins" as *; +.main { + @include page-padding-inline; + + display: flex; + flex-direction: column; + + justify-content: center; + align-items: center; + + gap: 36px; + + @include on-tablet { + gap: 42px; + } + + @include on-desktop { + gap: 46px; + } +} + +.title, +.text { + margin: 0; + text-align: center; +} + +.description { + display: flex; + flex-direction: column; + + gap: 24px; + + @include on-tablet { + gap: 32px; + } + + @include on-desktop { + gap: 40px; + } +} + +.button { + cursor: pointer; + height: 40px; + width: 150px; + + border-radius: 999px; + border: none; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + + background-color: var(--color-accent); + color: var(--primary-bg-color); + + @include set-transition(background-color); + + @include on-tablet { + height: 48px; + width: 190px; + } + + &:hover { + background-color: var(--color-accent-hover); + } +} diff --git a/frontend/src/modules/LandingPage/LandingPage.tsx b/frontend/src/modules/LandingPage/LandingPage.tsx new file mode 100644 index 0000000..83f4e6c --- /dev/null +++ b/frontend/src/modules/LandingPage/LandingPage.tsx @@ -0,0 +1,38 @@ +import { useNavigate } from 'react-router-dom'; +import CodeEditor from '../../components/CodeEditor/CodeEditor'; +import MainButton from '../../components/MainButton/MainButton'; +import styles from './LandingPage.module.scss'; + +const LandingPage: React.FC = () => { + const navigate = useNavigate(); + + return ( +
+
+

Store and Share Your Code Snippets.

+

+ Save your code snippets, share them with other + developers, and discover new solutions for your + projects. +

+
+ + + navigate('/snippets')} /> +
+ ) +} + +export default LandingPage; \ No newline at end of file diff --git a/frontend/src/modules/NotFoundPage/NotFoundPage.module.scss b/frontend/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 0000000..a0bca26 --- /dev/null +++ b/frontend/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,12 @@ +.main { + display: flex; + flex-direction: column; + flex: 1; + + justify-content: center; + align-items: center; +} + +.text { + font-size: 32px; +} \ No newline at end of file diff --git a/frontend/src/modules/NotFoundPage/NotFoundPage.tsx b/frontend/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000..594a9b5 --- /dev/null +++ b/frontend/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,19 @@ +import { useNavigate } from 'react-router-dom'; +import MainButton from '../../components/MainButton/MainButton'; +import styles from './NotFoundPage.module.scss'; + +const NotFoundPage: React.FC = () => { + const navigate = useNavigate(); + return ( +
+

404

+

Page Not Found

+ navigate('/', { replace: true })} + /> +
+ ) +} + +export default NotFoundPage; \ No newline at end of file diff --git a/frontend/src/modules/ProfilePage/Edit.module.scss b/frontend/src/modules/ProfilePage/Edit.module.scss new file mode 100644 index 0000000..68ab835 --- /dev/null +++ b/frontend/src/modules/ProfilePage/Edit.module.scss @@ -0,0 +1,124 @@ +.edit { + display: flex; + flex-direction: column; + width: 100%; + + background-color: var(--secondary-bg-color); + border-radius: 6px; + padding: 16px; + gap: 16px; + box-shadow: 0 2px 16px 0 rgba(20, 20, 20, 0.24), + 0 1.5px 4px 0 rgba(0, 0, 0, 0.2); + + &Title { + color: var(--color-accent); + border-bottom: 1px solid var(--notification-color); + padding-bottom: 6px; + } +} + +.form { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 40px; + row-gap: 16px; + + &Item { + display: flex; + flex-direction: column; + gap: 6px; + } +} + +.bio { + grid-column: 1 / -1; +} + +.textarea { + border: 1px solid var(--color-accent); + outline: none; + resize: none; + font-family: "Inter", "Segoe UI", Arial, sans-serif; + font-size: 1rem; + background: var(--primary-bg-color); + color: var(--color-white); + padding: 8px 40px 8px 12px; + box-shadow: 0 3px 14px 0 rgba(80, 70, 180, 0.06); + box-sizing: border-box; + border-radius: 6px; + transition: border 0.22s cubic-bezier(0.24, 0.8, 0.35, 1), + box-shadow 0.22s cubic-bezier(0.24, 0.8, 0.35, 1), background-color 0.16s, + color 0.16s; + + &:hover { + border: 1px solid var(--color-accent-hover); + background-color: #22232b; + } + + &:focus { + border: 1px solid var(--color-accent-hover); + box-shadow: 0 0 0 1px var(--color-accent-hover); + background-color: #22232b; + color: var(--color-white); + } +} + +.buttons { + display: flex; + justify-content: flex-end; + gap: 16px; + padding-inline: 16px; + border-top: 1px solid var(--notification-color); + padding-top: 16px; + grid-column: 1 / -1; + + button { + display: flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + width: 100px; + height: 45px; + border-radius: 99px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s, + color 0.2s; + } + + &Save { + background-color: var(--color-accent); + color: var(--primary-bg-color, #fff); + box-shadow: 0 2px 12px 0 rgba(160, 132, 250, 0.25), + 0 1.5px 4px 0 rgba(0, 0, 0, 0.07); + border: 1px solid var(--color-accent, #a084fa); + box-sizing: border-box; + + &:hover:not(:disabled) { + background-color: var(--color-accent-hover, #c3b3fa); + color: #fff; + box-shadow: 0 4px 18px 0 rgba(160, 132, 250, 0.36), + 0 2px 8px 0 rgba(0, 0, 0, 0.14); + border-color: var(--color-accent-hover, #c3b3fa); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + } + + &Cancel { + border: 1px solid var(--notification-color, #5a6270); + background-color: var(--color-bg-grey, #22232b); + color: var(--color-accent, #a084fa); + + &:hover { + background-color: var(--color-bg-grey-light, #282a36); + color: var(--color-accent-hover, #c3b3fa); + border-color: var(--color-accent, #a084fa); + } + } +} diff --git a/frontend/src/modules/ProfilePage/Edit.tsx b/frontend/src/modules/ProfilePage/Edit.tsx new file mode 100644 index 0000000..2b6ed35 --- /dev/null +++ b/frontend/src/modules/ProfilePage/Edit.tsx @@ -0,0 +1,249 @@ +import { useNavigate, useOutletContext } from 'react-router-dom'; +import styles from './Edit.module.scss'; +import type { ProfileType } from '../../types/ProfileType'; +import { useRef, useState } from 'react'; +import { useOnClickOutside } from '../shared/hooks/useOnClickOutside'; +import { updateProfile } from '../../api/profileClient'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader } from '../../components/Loader'; +import toast from 'react-hot-toast'; +import CustomToast from '../../components/CustomToast/CustomToast'; + +const GENDER_OPTIONS = [ + { value: 'male', label: 'Male' }, + { value: 'female', label: 'Female' }, + { value: 'other', label: 'Other' }, + { value: 'prefer_not_to_say', label: 'Prefer not to say' }, +]; + +const GENDER_NOT_SPECIFIED_LABEL = 'Not specified'; +function getGenderLabel(val: string | null | undefined) { + if (!val) return GENDER_NOT_SPECIFIED_LABEL; + const found = GENDER_OPTIONS.find(o => o.value === val); + return found ? found.label : GENDER_NOT_SPECIFIED_LABEL; +} + +const Edit = () => { + const { profile } = useOutletContext<{ profile: ProfileType }>(); + const { accessToken } = useAuthContext(); + const [isGenderDropdownOpen, setIsGenderDropdownOpen] = useState(false); + const genderDropdownRef = useRef(null); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const formatDateForInput = (dateString?: string): string => { + if (!dateString) return ''; + try { + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + const date = new Date(dateString); + if (isNaN(date.getTime())) return ''; + return date.toISOString().split('T')[0]; + } catch { + return ''; + } + }; + + type EditableProfileFields = { + first_name: string; + last_name: string; + date_of_birth: string; + gender: string | null; + info: string; + }; + + const initialData = useRef({ + first_name: profile.first_name ?? '', + last_name: profile.last_name ?? '', + date_of_birth: formatDateForInput(profile.date_of_birth), + gender: typeof profile.gender === 'string' && + (profile.gender === 'male' || profile.gender === 'female' || profile.gender === 'other' || profile.gender === 'prefer_not_to_say') + ? profile.gender + : null, + info: profile.info ?? '', + }); + + const [formData, setFormData] = useState(initialData.current); + const [isChanged, setIsChanged] = useState(false); + + function handleFormDataChange(key: keyof typeof formData, value: string) { + const updatedFormData = { ...formData, [key]: value }; + + const isAllEqual = Object.entries(initialData.current).every( + ([k, v]) => { + const currentValue = updatedFormData[k as keyof typeof updatedFormData]; + + if (k === 'gender') { + const isCurrentNotSpecified = !currentValue || currentValue === 'other' || currentValue === 'prefer_not_to_say'; + const isInitialNotSpecified = !v || v === 'other' || v === 'prefer_not_to_say'; + return isCurrentNotSpecified === isInitialNotSpecified; + } + + if (k === 'first_name' || k === 'last_name' || k === 'info') { + return (currentValue?.toString().trim() || '') === (v?.toString().trim() || ''); + } + return currentValue === v; + } + ); + + setIsChanged(!isAllEqual); + + setFormData(prev => ({ + ...prev, + [key]: value, + })); + } + + function handleGenderChange(value: string) { + handleFormDataChange('gender', value); + setIsGenderDropdownOpen(false); + } + + const { mutate, isPending } = useMutation({ + mutationFn: () => { + const changedFields = Object.fromEntries( + Object.entries(formData).filter( + ([key, value]) => initialData.current[key as keyof EditableProfileFields] !== value + ).map(([key, value]) => { + if (key === 'gender') { + if (value === 'male' || value === 'female') { + return [key, value]; + } else { + return [key, null]; + } + } + const trimmedValue = + key === 'first_name' || key === 'last_name' || key === 'info' + ? value?.toString().trim() + : value; + return [key, trimmedValue]; + }) + ); + return updateProfile(accessToken, changedFields); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profile', profile.username, accessToken] }); + queryClient.invalidateQueries({ queryKey: ['myProfile', accessToken] }); + + navigate(`/profile/${profile.username}`, { + state: { + title: 'Profile Updated', + message: 'Your profile has been updated successfully.', + type: 'success' + } + }); + }, + onError: (error) => { + console.error('Error updating profile:', error); + toast.custom(t => ( + + ), { + duration: 2500, + }); + } + }); + + function handleFormSubmit(event: React.FormEvent) { + event.preventDefault(); + mutate(); + } + + useOnClickOutside(genderDropdownRef as React.RefObject, () => setIsGenderDropdownOpen(false)); + + return ( +
+

Edit Personal Information

+ +
+
+ + handleFormDataChange('first_name', event.target.value)} + /> +
+ +
+ + handleFormDataChange('last_name', event.target.value)} + /> +
+ +
+ + handleFormDataChange('date_of_birth', event.target.value)} + /> +
+ +
+ +
+ + {isGenderDropdownOpen && ( +
+ {GENDER_OPTIONS.map(option => ( + + ))} +
+ )} +
+
+ +
+ +