From 93ba8e9ac26e43cc2ef34114a7d051e5f003ae74 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Mon, 4 May 2026 23:59:48 +0200 Subject: [PATCH] BIG BIG BIG BIG BIG UPDATES Co-authored-by: Copilot --- .gitignore | 11 +- README.md | 103 +- TODO | 35 + app/api/proxy/router.py | 89 - app/models/proxy.py | 47 - app/models/service.py | 7 - app/services/mitmproxy/manager.py | 42 - .env.example => backend/.env.example | 3 +- Dockerfile => backend/Dockerfile | 11 +- {app => backend/app}/__init__.py | 0 {app => backend/app}/api/__init__.py | 0 {app => backend/app}/api/auth/__init__.py | 0 {app => backend/app}/api/auth/router.py | 3 +- backend/app/api/editor/router.py | 62 + {app => backend/app}/api/proxy/__init__.py | 0 backend/app/api/proxy/router.py | 300 +++ {app => backend/app}/core/__init__.py | 0 {app => backend/app}/core/config.py | 4 +- {app => backend/app}/core/mongodb.py | 3 +- {app => backend/app}/core/redis.py | 0 {app => backend/app}/core/security.py | 37 +- {app => backend/app}/integrations/__init__.py | 0 .../app}/integrations/docker/__init__.py | 8 +- .../app}/integrations/docker/compose.py | 12 +- .../app}/integrations/docker/editor.py | 13 +- .../app}/integrations/docker/models.py | 0 .../app}/integrations/docker/ports.py | 22 +- {app => backend/app}/main.py | 14 +- {app => backend/app}/models/__init__.py | 0 {app => backend/app}/models/auth.py | 0 backend/app/models/example.py | 28 + backend/app/models/proxy.py | 115 ++ backend/app/models/service.py | 17 + {app => backend/app}/repositories/__init__.py | 0 {app => backend/app}/repositories/auth.py | 3 +- backend/app/repositories/examples.py | 70 + {app => backend/app}/repositories/proxy.py | 54 +- {app => backend/app}/services/__init__.py | 0 backend/app/services/editor/python_lsp.py | 323 ++++ .../app}/services/mitmproxy/__init__.py | 0 backend/app/services/mitmproxy/examples.py | 134 ++ backend/app/services/mitmproxy/health.py | 185 ++ backend/app/services/mitmproxy/manager.py | 283 +++ backend/app/services/mitmproxy/options.py | 156 ++ .../app}/services/mitmproxy/validator.py | 2 +- pyproject.toml => backend/pyproject.toml | 4 +- {tests => backend/tests}/api/test_auth.py | 0 backend/tests/api/test_editor_router.py | 52 + {tests => backend/tests}/api/test_proxy.py | 231 ++- {tests => backend/tests}/conftest.py | 8 +- backend/tests/services/test_health.py | 43 + .../tests/services/test_mitmproxy_manager.py | 75 + .../tests/services/test_mitmproxy_options.py | 51 + backend/tests/services/test_python_lsp.py | 30 + uv.lock => backend/uv.lock | 494 ++++- docker-compose.yml | 42 +- frontend/.dockerignore | 4 + frontend/Dockerfile | 16 + frontend/index.html | 12 + frontend/nginx.conf | 22 + frontend/package-lock.json | 1670 +++++++++++++++++ frontend/package.json | 30 + frontend/src/App.vue | 49 + frontend/src/api.ts | 109 ++ frontend/src/components/AppHeader.vue | 34 + frontend/src/components/AppLogo.vue | 7 + frontend/src/components/CodeEditor.vue | 157 ++ frontend/src/components/EditorPane.vue | 27 + frontend/src/components/ExamplesPanel.vue | 79 + frontend/src/components/HealthPanel.vue | 111 ++ frontend/src/components/LoginView.vue | 35 + frontend/src/components/LogsPanel.vue | 19 + frontend/src/components/OptionsPanel.vue | 142 ++ frontend/src/components/ProxyList.vue | 52 + frontend/src/components/ProxyWorkbench.vue | 156 ++ frontend/src/components/RuntimePanel.vue | 54 + frontend/src/components/ServicePanel.vue | 68 + frontend/src/components/StatusPill.vue | 9 + frontend/src/composables/useControlPlane.ts | 571 ++++++ frontend/src/editor/pythonLsp.ts | 65 + frontend/src/env.d.ts | 8 + frontend/src/main.ts | 6 + frontend/src/styles.css | 1003 ++++++++++ frontend/src/types.ts | 90 + frontend/tsconfig.json | 19 + frontend/vite.config.ts | 20 + package.json | 16 + setup.sh | 152 +- 88 files changed, 7591 insertions(+), 447 deletions(-) create mode 100644 TODO delete mode 100644 app/api/proxy/router.py delete mode 100644 app/models/proxy.py delete mode 100644 app/models/service.py delete mode 100644 app/services/mitmproxy/manager.py rename .env.example => backend/.env.example (93%) rename Dockerfile => backend/Dockerfile (66%) rename {app => backend/app}/__init__.py (100%) rename {app => backend/app}/api/__init__.py (100%) rename {app => backend/app}/api/auth/__init__.py (100%) rename {app => backend/app}/api/auth/router.py (99%) create mode 100644 backend/app/api/editor/router.py rename {app => backend/app}/api/proxy/__init__.py (100%) create mode 100644 backend/app/api/proxy/router.py rename {app => backend/app}/core/__init__.py (100%) rename {app => backend/app}/core/config.py (88%) rename {app => backend/app}/core/mongodb.py (95%) rename {app => backend/app}/core/redis.py (100%) rename {app => backend/app}/core/security.py (87%) rename {app => backend/app}/integrations/__init__.py (100%) rename {app => backend/app}/integrations/docker/__init__.py (75%) rename {app => backend/app}/integrations/docker/compose.py (91%) rename {app => backend/app}/integrations/docker/editor.py (88%) rename {app => backend/app}/integrations/docker/models.py (100%) rename {app => backend/app}/integrations/docker/ports.py (82%) rename {app => backend/app}/main.py (82%) rename {app => backend/app}/models/__init__.py (100%) rename {app => backend/app}/models/auth.py (100%) create mode 100644 backend/app/models/example.py create mode 100644 backend/app/models/proxy.py create mode 100644 backend/app/models/service.py rename {app => backend/app}/repositories/__init__.py (100%) rename {app => backend/app}/repositories/auth.py (99%) create mode 100644 backend/app/repositories/examples.py rename {app => backend/app}/repositories/proxy.py (72%) rename {app => backend/app}/services/__init__.py (100%) create mode 100644 backend/app/services/editor/python_lsp.py rename {app => backend/app}/services/mitmproxy/__init__.py (100%) create mode 100644 backend/app/services/mitmproxy/examples.py create mode 100644 backend/app/services/mitmproxy/health.py create mode 100644 backend/app/services/mitmproxy/manager.py create mode 100644 backend/app/services/mitmproxy/options.py rename {app => backend/app}/services/mitmproxy/validator.py (98%) rename pyproject.toml => backend/pyproject.toml (92%) rename {tests => backend/tests}/api/test_auth.py (100%) create mode 100644 backend/tests/api/test_editor_router.py rename {tests => backend/tests}/api/test_proxy.py (50%) rename {tests => backend/tests}/conftest.py (90%) create mode 100644 backend/tests/services/test_health.py create mode 100644 backend/tests/services/test_mitmproxy_manager.py create mode 100644 backend/tests/services/test_mitmproxy_options.py create mode 100644 backend/tests/services/test_python_lsp.py rename uv.lock => backend/uv.lock (75%) create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AppHeader.vue create mode 100644 frontend/src/components/AppLogo.vue create mode 100644 frontend/src/components/CodeEditor.vue create mode 100644 frontend/src/components/EditorPane.vue create mode 100644 frontend/src/components/ExamplesPanel.vue create mode 100644 frontend/src/components/HealthPanel.vue create mode 100644 frontend/src/components/LoginView.vue create mode 100644 frontend/src/components/LogsPanel.vue create mode 100644 frontend/src/components/OptionsPanel.vue create mode 100644 frontend/src/components/ProxyList.vue create mode 100644 frontend/src/components/ProxyWorkbench.vue create mode 100644 frontend/src/components/RuntimePanel.vue create mode 100644 frontend/src/components/ServicePanel.vue create mode 100644 frontend/src/components/StatusPill.vue create mode 100644 frontend/src/composables/useControlPlane.ts create mode 100644 frontend/src/editor/pythonLsp.ts create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 package.json mode change 100644 => 100755 setup.sh diff --git a/.gitignore b/.gitignore index 372ff9b..32890d9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,13 @@ .vscode/ .env __pycache__/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ +**/__pycache__/ +**/.pytest_cache/ +**/.ruff_cache/ +node_modules/ +dist/ +*.tsbuildinfo +backend/.venv/ +frontend/node_modules/ +frontend/dist/ diff --git a/README.md b/README.md index dc60476..452dfa4 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,84 @@ -# thePlug - plugger/proxy Barese +# thePlug -icona di una presa della spina idk +Wrapper web per gestire proxy `mitmdump` davanti ai servizi di una competizione +CTF attack/defense. -## Architettura +## Struttura -``` -FastAPI + MongoDB + Redis +```text +backend/ FastAPI, MongoDB, Redis, mitmdump manager +frontend/ Vue 3, Vite, Monaco editor ``` -## Quick Setup (prod) +## Setup rapido ```bash -bash <(curl -s https://git.reloia.uk/reloia/thePlug/raw/branch/master/setup.sh) +./setup.sh ``` -## Setup Dev +Lo script crea `backend/.env`, genera le credenziali mancanti e avvia lo stack +Docker completo: -imposta le variabili d'ambiente +```text +Frontend http://localhost:5173 +Backend http://localhost:3671 +``` + +Per installare anche il servizio systemd user: ```bash -cp .env.example .env -# modifica le variabili -nano .env +./setup.sh service ``` -variabili auth richieste in `.env`: +## Dev backend ```bash -JWT_SECRET_KEY=change-this-secret -JWT_ALGORITHM=HS256 -JWT_EXPIRE_MINUTES=30 -AUTH_USERNAME=admin -AUTH_PASSWORD=change-this-password +cp backend/.env.example backend/.env +docker compose --env-file backend/.env up -d mongodb redis +npm run dev:backend ``` -avvia i servizi e il server locale +Docs FastAPI in dev: `http://localhost:3671/docs`. + +## Dev frontend + ```bash -docker compose up -d mongodb redis +cd frontend +npm install +npm run dev ``` + +Il dev server usa `/api` come proxy verso `http://127.0.0.1:3671`. + +## Test + ```bash -fastapi dev app/main.py +npm run test ``` -accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs) +Equivalente a: -## Auth API +```bash +cd backend && uv run ruff check . && uv run pytest +cd frontend && npm run build +``` -Gli endpoint auth disponibili sono solo: +## API principali - `POST /auth/login` - `POST /auth/logout` +- `GET /proxy` +- `POST /proxy` +- `POST /proxy/{port}/start` +- `POST /proxy/{port}/stop` +- `POST /proxy/{port}/restart` +- `GET /proxy/{port}/health` +- `GET /proxy/{port}/logs` +- `GET /proxy/mitmproxy/options` +- `GET /proxy/mitmproxy/examples` -Esempio login: - -```bash -curl -X POST http://localhost:3671/auth/login \ - -H "Content-Type: application/json" \ - -d '{"username":"admin","password":"change-this-password"}' -``` - -L'access token ottenuto va inviato come bearer token: - -```bash -curl http://localhost:3671/proxy \ - -H "Authorization: Bearer " -``` - -Esempio logout (revoca server-side del token): - -```bash -curl -X POST http://localhost:3671/auth/logout \ - -H "Authorization: Bearer " -``` - -## Documentazione per Sviluppo - -[MitMProxy examples](https://docs.mitmproxy.org/stable/addons/examples/) +## Riferimenti +- Mitmproxy addons: https://docs.mitmproxy.org/stable/addons/examples/ +- Mitmproxy modes: https://docs.mitmproxy.org/stable/concepts/modes/ +- Mitmproxy options: https://docs.mitmproxy.org/stable/concepts/options/ diff --git a/TODO b/TODO new file mode 100644 index 0000000..8d69dd5 --- /dev/null +++ b/TODO @@ -0,0 +1,35 @@ +UI SCHIFOSA +andrebbe rifatto proprio il layout. + +dovrebbe essere in ordine di priorità, l'editor non può stare così tanto sotto. +card come: +- Runtime mapping +- What start does +vanno eliminati. + +di default quando si crea un reverse proxy, l'intenzione è quella di spostare la porta del servizio ad un'altra e mettere il proxy al posto della porta del servizio iniziale. + +anche ```Python tools ready``` è inutile + +Addon editor come titolo non mi piace + +``` +No proxies configured +Create a draft, save it, then start it when you want mitmdump to own the route. +``` +troppa roba, riassumere. + +Options card buggattissima lato UI, inoltre a questo punto va fatta una scrematura sulle opzioni sulle cose che possono servire veramente, inutile mettere troppe cose. + +gli esempi vanno aggiornati con roba reale utile. + + +capire come testare davvero sulla mia macchina + +cambiare text sulla pagina di login + +cambiare dove e come viene mostrato alert quando c'è errore di autenticazione (loggati in precedenza con token scaduto.) + +rendere lo script davvero funzionante + +solo il backend non deve runnare dentro docker ma sulla macchina reale. \ No newline at end of file diff --git a/app/api/proxy/router.py b/app/api/proxy/router.py deleted file mode 100644 index 78b1965..0000000 --- a/app/api/proxy/router.py +++ /dev/null @@ -1,89 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status - -from core.security import require_authenticated_user -from models.proxy import ProxyCreateInput, ProxyUpdateInput -from repositories.proxy import ( - create_proxy, - delete_proxy, - get_proxy_by_port, - update_proxy, -) -from repositories.proxy import ( - list_proxies as list_proxy_documents, -) -from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code - -router = APIRouter( - prefix="/proxy", - tags=["Proxy"], - dependencies=[Depends(require_authenticated_user)], -) - - -async def _validate_code_or_raise(code: str) -> None: - try: - await validate_proxy_code(code) - except ProxyCodeValidationError as error: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=error.to_http_detail(), - ) from error - - -@router.get("") -async def list_proxies_handler(): - return await list_proxy_documents() - - -@router.get("/{proxy_port}") -async def get_proxy(proxy_port: int): - proxy = await get_proxy_by_port(proxy_port) - if proxy is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Proxy not found", - ) - return proxy - - -@router.post("", status_code=status.HTTP_201_CREATED) -async def create_proxy_handler(payload: ProxyCreateInput): - existing_proxy = await get_proxy_by_port(payload.proxy_port) - if existing_proxy is not None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Proxy already exists on port {payload.proxy_port}", - ) - - await _validate_code_or_raise(payload.code) - - return await create_proxy( - proxy_port=payload.proxy_port, - name=payload.name, - service={"port": payload.service.port}, - code=payload.code, - ) - - -@router.put("/{proxy_port}") -async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput): - if payload.code is not None: - await _validate_code_or_raise(payload.code) - - updated_proxy = await update_proxy(proxy_port, payload) - if updated_proxy is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Proxy not found", - ) - return updated_proxy - - -@router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_proxy(proxy_port: int): - deleted = await delete_proxy(proxy_port) - if not deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Proxy not found", - ) diff --git a/app/models/proxy.py b/app/models/proxy.py deleted file mode 100644 index c0bae42..0000000 --- a/app/models/proxy.py +++ /dev/null @@ -1,47 +0,0 @@ -from datetime import datetime - -from models.service import ServiceEndpointDocument -from pydantic import BaseModel, ConfigDict, Field - - -class ProxyDocument(BaseModel): - model_config = ConfigDict(extra="forbid") - - proxy_port: int = Field(..., description="Port exposed by the proxy") - name: str = Field(..., description="Human readable proxy name") - code: str = Field(..., description="Proxy source or identifier") - service: ServiceEndpointDocument = Field( - ..., - description="Associated upstream service", - ) - created_at: datetime | None = Field( - default=None, - description="Creation timestamp in UTC", - ) - updated_at: datetime | None = Field( - default=None, - description="Last update timestamp in UTC", - ) - - -class ProxyCreateInput(BaseModel): - model_config = ConfigDict(extra="forbid") - - proxy_port: int = Field(..., description="Port exposed by the proxy") - name: str = Field(..., description="Human readable proxy name") - code: str = Field(..., description="Proxy source or identifier") - service: ServiceEndpointDocument = Field( - ..., - description="Associated upstream service", - ) - - -class ProxyUpdateInput(BaseModel): - model_config = ConfigDict(extra="forbid") - - name: str | None = Field(default=None, description="Human readable proxy name") - code: str | None = Field(default=None, description="Proxy source or identifier") - service: ServiceEndpointDocument | None = Field( - default=None, - description="Associated upstream service", - ) \ No newline at end of file diff --git a/app/models/service.py b/app/models/service.py deleted file mode 100644 index 019bf37..0000000 --- a/app/models/service.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel, ConfigDict, Field - - -class ServiceEndpointDocument(BaseModel): - model_config = ConfigDict(extra="forbid") - - port: int = Field(..., description="Upstream service port") diff --git a/app/services/mitmproxy/manager.py b/app/services/mitmproxy/manager.py deleted file mode 100644 index 209a1c9..0000000 --- a/app/services/mitmproxy/manager.py +++ /dev/null @@ -1,42 +0,0 @@ -from pathlib import Path - -from integrations import docker -from integrations.docker import DockerContainer -from integrations.docker.ports import _get_random_port - - -async def create_proxy( - port: int, - service: DockerContainer, - code: str, -) -> dict[str, object]: - """ - Utility function that will: - - run ``integrations/docker`` utilities to change the old port of original service to a random one - /- write ``code`` into ``/tmp/theplug/{random}.py`` - - start a subprocess via asyncio - - return the proxy infos (pid, ) - """ - path = Path(f"/tmp/theplug/{service['name']}.py") - path.parent.mkdir(parents=True, exist_ok=True) - - path.write_text(code, encoding="utf-8") - - # da qui bisogna: - # scegliere una porta random alla quale spostare il docker - # modificare il docker via integrations.docker.editor.change_port ( service.folder, service.name, port, random_nuova_port ) - random_port = _get_random_port() - changed = await docker.change_port( - service["name"], - service["folder"], - port, - random_port, - ) - - return { - "service": service["name"], - "previous_port": port, - "upstream_port": random_port, - "script_path": str(path), - "port_changed": changed, - } diff --git a/.env.example b/backend/.env.example similarity index 93% rename from .env.example rename to backend/.env.example index 5520098..295b7db 100644 --- a/.env.example +++ b/backend/.env.example @@ -1,4 +1,5 @@ API_PORT=3671 +FRONTEND_PORT=5173 MONGO_INITDB_ROOT_PASSWORD=pass# esegui 'openssl rand -hex 5' in un terminale e copia qui MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin MONGODB_DB_NAME=theplug @@ -7,4 +8,4 @@ JWT_SECRET_KEY= # esegui 'openssl rand -hex 20' in un terminale e copia qui JWT_ALGORITHM=HS256 JWT_EXPIRE_MINUTES=30 AUTH_USERNAME=admin -AUTH_PASSWORD= # esegui 'openssl rand -hex 20' in un terminale e copia qui \ No newline at end of file +AUTH_PASSWORD= # esegui 'openssl rand -hex 20' in un terminale e copia qui diff --git a/Dockerfile b/backend/Dockerfile similarity index 66% rename from Dockerfile rename to backend/Dockerfile index cd708c9..31a22b5 100644 --- a/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,8 @@ FROM python:3.14-slim ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src WORKDIR /app @@ -9,11 +10,15 @@ COPY pyproject.toml ./ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir \ "authlib>=1.6.0" \ + "basedpyright>=1.39.0" \ "fastapi[standard]>=0.135.3" \ + "mitmproxy>=12.2.2" \ "motor>=3.7.1" \ - "passlib[bcrypt]>=1.7.4" \ + "pwdlib[argon2,bcrypt]>=0.3.0" \ + "pyyaml>=6.0.3" \ "pydantic-settings>=2.13.1" \ - "redis>=7.4.0" + "redis>=7.4.0" \ + "ruff>=0.15.10" COPY app ./src diff --git a/app/__init__.py b/backend/app/__init__.py similarity index 100% rename from app/__init__.py rename to backend/app/__init__.py diff --git a/app/api/__init__.py b/backend/app/api/__init__.py similarity index 100% rename from app/api/__init__.py rename to backend/app/api/__init__.py diff --git a/app/api/auth/__init__.py b/backend/app/api/auth/__init__.py similarity index 100% rename from app/api/auth/__init__.py rename to backend/app/api/auth/__init__.py diff --git a/app/api/auth/router.py b/backend/app/api/auth/router.py similarity index 99% rename from app/api/auth/router.py rename to backend/app/api/auth/router.py index 1f6dacd..a8501d1 100644 --- a/app/api/auth/router.py +++ b/backend/app/api/auth/router.py @@ -1,14 +1,13 @@ from datetime import UTC, datetime from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Response, status - from core.security import ( AuthContext, create_access_token, get_auth_context, verify_password, ) +from fastapi import APIRouter, Depends, HTTPException, Response, status from models.auth import LoginInput, TokenResponse from repositories.auth import get_user_by_username, revoke_token diff --git a/backend/app/api/editor/router.py b/backend/app/api/editor/router.py new file mode 100644 index 0000000..fc93247 --- /dev/null +++ b/backend/app/api/editor/router.py @@ -0,0 +1,62 @@ +from core.security import ( + InvalidTokenError, + authenticate_token, + require_authenticated_user, +) +from fastapi import APIRouter, Depends, HTTPException, WebSocket, status +from services.editor import python_lsp +from starlette.websockets import WebSocketDisconnect + +router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"]) + + +@router.get("/session", dependencies=[Depends(require_authenticated_user)]) +async def python_editor_session(): + if python_lsp.lsp_binary() is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="basedpyright-langserver is not installed", + ) + + session = python_lsp.get_session() + return { + "root_uri": session.root_uri, + "file_uri": session.file_uri, + } + + +async def authenticate_editor_socket(websocket: WebSocket) -> None: + token = websocket.query_params.get("token") + if token is None: + authorization = websocket.headers.get("authorization", "") + scheme, _, value = authorization.partition(" ") + if scheme.lower() == "bearer" and value: + token = value + + if not token: + raise InvalidTokenError + + await authenticate_token(token) + + +@router.websocket("/ws") +async def python_editor_socket(websocket: WebSocket): + try: + await authenticate_editor_socket(websocket) + except InvalidTokenError: + await websocket.close(code=4401) + return + + if python_lsp.lsp_binary() is None: + await websocket.close(code=1011, reason="basedpyright unavailable") + return + + session = python_lsp.get_session() + await websocket.accept() + + try: + await python_lsp.bridge(websocket, session) + except WebSocketDisconnect: + return + except RuntimeError: + await websocket.close(code=1011, reason="language server failed") diff --git a/app/api/proxy/__init__.py b/backend/app/api/proxy/__init__.py similarity index 100% rename from app/api/proxy/__init__.py rename to backend/app/api/proxy/__init__.py diff --git a/backend/app/api/proxy/router.py b/backend/app/api/proxy/router.py new file mode 100644 index 0000000..bf3276a --- /dev/null +++ b/backend/app/api/proxy/router.py @@ -0,0 +1,300 @@ +from pathlib import Path + +from core.security import require_authenticated_user +from fastapi import APIRouter, Depends, HTTPException, Request, status +from models.example import AddonExampleCreateInput +from models.proxy import ProxyCreateInput, ProxyUpdateInput +from pymongo.errors import DuplicateKeyError +from repositories.examples import ( + create_custom_example, + delete_custom_example, + list_custom_examples, +) +from repositories.proxy import ( + create_proxy, + delete_proxy, + get_proxy_by_port, + update_proxy, + update_proxy_runtime, +) +from repositories.proxy import ( + list_proxies as list_proxy_documents, +) +from services.mitmproxy.examples import BUILTIN_EXAMPLES +from services.mitmproxy.health import check_proxy +from services.mitmproxy.manager import MitmdumpError, manager +from services.mitmproxy.options import ( + MitmOptionError, + load_options, + public_options, + validate_options, +) +from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code + +SUPPORTED_MODES = { + "regular", + "local", + "wireguard", + "reverse", + "transparent", + "tun", + "upstream", + "socks5", + "dns", +} +SUPPORTED_PROTOCOLS = { + "http", + "https", + "tcp", + "tls", + "udp", + "dtls", + "dns", + "http3", + "quic", +} + +router = APIRouter( + prefix="/proxy", + tags=["Proxy"], + dependencies=[Depends(require_authenticated_user)], +) + + +async def _validate_code_or_raise(code: str) -> None: + try: + await validate_proxy_code(code) + except ProxyCodeValidationError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=error.to_http_detail(), + ) from error + + +def validate_mode(mode: str) -> None: + if mode not in SUPPORTED_MODES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Unsupported proxy mode: {mode}", + ) + + +def validate_mode_spec(mode_spec: str | None) -> None: + if mode_spec is None: + return + mode = mode_spec.split(":", 1)[0].split("@", 1)[0] + validate_mode(mode) + + +def validate_protocol(protocol: str) -> None: + if protocol not in SUPPORTED_PROTOCOLS: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Unsupported reverse proxy protocol: {protocol}", + ) + + +async def validate_mitm_options(options: dict[str, object]) -> None: + try: + await validate_options(options) + except MitmOptionError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(error), + ) from error + + +async def get_proxy_or_404(proxy_port: int): + proxy = await get_proxy_by_port(proxy_port) + if proxy is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Proxy not found", + ) + return proxy + + +@router.get("") +async def list_proxies_handler(): + return await list_proxy_documents() + + +@router.get("/services") +async def list_services(request: Request): + return getattr(request.app.state, "services", []) + + +@router.get("/mitmproxy/options") +async def list_mitmproxy_options(): + return public_options(await load_options()) + + +@router.get("/mitmproxy/examples") +async def list_mitmproxy_examples(): + return [*BUILTIN_EXAMPLES, *await list_custom_examples()] + + +@router.post("/mitmproxy/examples", status_code=status.HTTP_201_CREATED) +async def create_mitmproxy_example(payload: AddonExampleCreateInput): + await _validate_code_or_raise(payload.code) + try: + return await create_custom_example(payload) + except DuplicateKeyError as error: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Example already exists: {payload.id}", + ) from error + + +@router.delete( + "/mitmproxy/examples/{example_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_mitmproxy_example(example_id: str): + if any(example.id == example_id for example in BUILTIN_EXAMPLES): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Built-in examples cannot be deleted", + ) + + deleted = await delete_custom_example(example_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Example not found", + ) + + +@router.get("/{proxy_port}") +async def get_proxy(proxy_port: int): + return await get_proxy_or_404(proxy_port) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_proxy_handler(payload: ProxyCreateInput): + existing_proxy = await get_proxy_by_port(payload.proxy_port) + if existing_proxy is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Proxy already exists on port {payload.proxy_port}", + ) + + await _validate_code_or_raise(payload.code) + validate_mode(payload.mode) + validate_mode_spec(payload.mode_spec) + validate_protocol(payload.protocol) + await validate_mitm_options(payload.mitm_options) + + return await create_proxy(payload) + + +@router.put("/{proxy_port}") +async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput): + if payload.code is not None: + await _validate_code_or_raise(payload.code) + if payload.mode is not None: + validate_mode(payload.mode) + if "mode_spec" in payload.model_fields_set: + validate_mode_spec(payload.mode_spec) + if payload.protocol is not None: + validate_protocol(payload.protocol) + if payload.mitm_options is not None: + await validate_mitm_options(payload.mitm_options) + + updated_proxy = await update_proxy(proxy_port, payload) + if updated_proxy is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Proxy not found", + ) + return updated_proxy + + +@router.post("/{proxy_port}/start") +async def start_proxy(proxy_port: int): + proxy = await get_proxy_or_404(proxy_port) + + try: + runtime = await manager.start(proxy) + except MitmdumpError as error: + updated = await update_proxy_runtime( + proxy_port, + {"status": "error", "last_error": str(error)}, + ) + return updated or proxy + + return await update_proxy_runtime(proxy_port, runtime) + + +@router.post("/{proxy_port}/stop") +async def stop_proxy(proxy_port: int): + proxy = await get_proxy_or_404(proxy_port) + runtime = await manager.stop(proxy) + return await update_proxy_runtime(proxy_port, runtime) + + +@router.post("/{proxy_port}/restart") +async def restart_proxy(proxy_port: int): + proxy = await get_proxy_or_404(proxy_port) + stopped = await manager.stop(proxy) + proxy = await update_proxy_runtime(proxy_port, stopped) or proxy + try: + runtime = await manager.start(proxy) + except MitmdumpError as error: + updated = await update_proxy_runtime( + proxy_port, + {"status": "error", "last_error": str(error)}, + ) + return updated or proxy + return await update_proxy_runtime(proxy_port, runtime) + + +@router.get("/{proxy_port}/status") +async def proxy_status(proxy_port: int): + proxy = await get_proxy_or_404(proxy_port) + runtime_status = await manager.status(proxy) + if runtime_status != proxy.status: + values: dict[str, object] = {"status": runtime_status} + if runtime_status != "running": + values["pid"] = None + proxy = await update_proxy_runtime(proxy_port, values) or proxy + return {"proxy_port": proxy.proxy_port, "status": runtime_status, "pid": proxy.pid} + + +@router.get("/{proxy_port}/health") +async def proxy_health(proxy_port: int): + proxy = await get_proxy_or_404(proxy_port) + return await check_proxy(proxy) + + +@router.get("/{proxy_port}/logs") +async def proxy_logs(proxy_port: int): + proxy = await get_proxy_or_404(proxy_port) + if proxy.log_path is None: + return {"proxy_port": proxy.proxy_port, "log": ""} + + path = Path(proxy.log_path) + if not path.is_file(): + return {"proxy_port": proxy.proxy_port, "log": ""} + + return { + "proxy_port": proxy.proxy_port, + "log": path.read_text(encoding="utf-8", errors="replace")[-8000:], + } + + +@router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_proxy(proxy_port: int): + proxy = await get_proxy_by_port(proxy_port) + if proxy is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Proxy not found", + ) + await manager.stop(proxy) + deleted = await delete_proxy(proxy_port) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Proxy not found", + ) diff --git a/app/core/__init__.py b/backend/app/core/__init__.py similarity index 100% rename from app/core/__init__.py rename to backend/app/core/__init__.py diff --git a/app/core/config.py b/backend/app/core/config.py similarity index 88% rename from app/core/config.py rename to backend/app/core/config.py index 67be4eb..ad2ee29 100644 --- a/app/core/config.py +++ b/backend/app/core/config.py @@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", + env_file=(".env", "backend/.env"), env_file_encoding="utf-8", case_sensitive=False, extra="ignore", @@ -20,4 +20,4 @@ class Settings(BaseSettings): AUTH_PASSWORD: str -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/app/core/mongodb.py b/backend/app/core/mongodb.py similarity index 95% rename from app/core/mongodb.py rename to backend/app/core/mongodb.py index e384c62..b968802 100644 --- a/app/core/mongodb.py +++ b/backend/app/core/mongodb.py @@ -10,6 +10,7 @@ class _MongoDB: raise RuntimeError("MongoDB client has not been initialized") return self.client[settings.MONGODB_DB_NAME] + mongodb = _MongoDB() @@ -33,4 +34,4 @@ async def ensure_mongodb_available() -> None: client = mongodb.client if client is None: raise RuntimeError("MongoDB client has not been initialized") - await client.admin.command("ping") \ No newline at end of file + await client.admin.command("ping") diff --git a/app/core/redis.py b/backend/app/core/redis.py similarity index 100% rename from app/core/redis.py rename to backend/app/core/redis.py diff --git a/app/core/security.py b/backend/app/core/security.py similarity index 87% rename from app/core/security.py rename to backend/app/core/security.py index db1b3c1..d5d4163 100644 --- a/app/core/security.py +++ b/backend/app/core/security.py @@ -5,13 +5,12 @@ from uuid import uuid4 from authlib.jose import jwt # type: ignore from authlib.jose.errors import JoseError # type: ignore +from core.config import settings from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from models.auth import AccessTokenPayload, AuthUserDocument from pwdlib import PasswordHash from pwdlib.hashers.argon2 import Argon2Hasher - -from core.config import settings -from models.auth import AccessTokenPayload, AuthUserDocument from repositories.auth import get_user_by_username, is_token_revoked pwd_context = PasswordHash((Argon2Hasher(),)) @@ -42,6 +41,8 @@ def hash_password(password: str) -> str: def verify_password(plain_password: str, password_hash: str) -> bool: + if hasattr(pwd_context, "verify"): + return pwd_context.verify(plain_password, password_hash) return pwd_context.check(plain_password, password_hash) @@ -74,10 +75,26 @@ def decode_access_token(token: str) -> AccessTokenPayload: claims = jwt.decode(token, settings.JWT_SECRET_KEY) claims.validate() return AccessTokenPayload.model_validate(dict(claims)) - except (JoseError, ValueError, TypeError): + except JoseError, ValueError, TypeError: raise InvalidTokenError from None +async def authenticate_token(token: str) -> AuthContext: + try: + payload = decode_access_token(token) + except InvalidTokenError: + raise InvalidTokenError from None + + if await is_token_revoked(payload.jti): + raise InvalidTokenError + + user = await get_user_by_username(payload.sub) + if user is None: + raise InvalidTokenError + + return AuthContext(token=token, user=user, payload=payload) + + async def get_auth_context( credentials: Annotated[ HTTPAuthorizationCredentials | None, @@ -87,21 +104,11 @@ async def get_auth_context( if credentials is None or credentials.scheme.lower() != "bearer": raise token_auth_error() - token = credentials.credentials try: - payload = decode_access_token(token) + return await authenticate_token(credentials.credentials) except InvalidTokenError: raise token_auth_error() from None - if await is_token_revoked(payload.jti): - raise token_auth_error() - - user = await get_user_by_username(payload.sub) - if user is None: - raise token_auth_error() - - return AuthContext(token=token, user=user, payload=payload) - async def require_authenticated_user( auth_context: Annotated[AuthContext, Depends(get_auth_context)], diff --git a/app/integrations/__init__.py b/backend/app/integrations/__init__.py similarity index 100% rename from app/integrations/__init__.py rename to backend/app/integrations/__init__.py diff --git a/app/integrations/docker/__init__.py b/backend/app/integrations/docker/__init__.py similarity index 75% rename from app/integrations/docker/__init__.py rename to backend/app/integrations/docker/__init__.py index e12fd8e..a076b5d 100644 --- a/app/integrations/docker/__init__.py +++ b/backend/app/integrations/docker/__init__.py @@ -7,9 +7,9 @@ from integrations.docker.editor import change_port from integrations.docker.models import DockerContainer __all__ = [ - "DockerContainer", - "find_docker_compose", - "get_services", + "DockerContainer", + "find_docker_compose", + "get_services", "get_all_services_in_dir", - "change_port", + "change_port", ] diff --git a/app/integrations/docker/compose.py b/backend/app/integrations/docker/compose.py similarity index 91% rename from app/integrations/docker/compose.py rename to backend/app/integrations/docker/compose.py index 4de0fe0..69ca659 100644 --- a/app/integrations/docker/compose.py +++ b/backend/app/integrations/docker/compose.py @@ -2,7 +2,6 @@ import asyncio from pathlib import Path import yaml - from integrations.docker.models import DockerContainer from integrations.docker.ports import _get_service_port @@ -16,7 +15,9 @@ COMPOSE_NAMES = ( def _get_visible_folders(directory: Path) -> list[Path]: return [ - p for p in Path(directory).iterdir() if p.is_dir() and not p.name.startswith(".") + p + for p in Path(directory).iterdir() + if p.is_dir() and not p.name.startswith(".") ] @@ -76,10 +77,7 @@ def get_all_services_in_dir(directory: str | Path) -> list[DockerContainer]: return services -async def run_docker_command( - file: Path, - command: list[str] -) -> dict | None: +async def run_docker_command(file: Path, command: list[str]) -> dict | None: if not command: return None @@ -89,7 +87,7 @@ async def run_docker_command( *full_command, cwd=str(file.parent), stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() diff --git a/app/integrations/docker/editor.py b/backend/app/integrations/docker/editor.py similarity index 88% rename from app/integrations/docker/editor.py rename to backend/app/integrations/docker/editor.py index fd0dd3d..5c8b85e 100644 --- a/app/integrations/docker/editor.py +++ b/backend/app/integrations/docker/editor.py @@ -1,7 +1,6 @@ from pathlib import Path import yaml - from integrations.docker.compose import find_docker_compose, run_docker_command from integrations.docker.ports import _get_port_index, _replace_port_in_mapping @@ -13,10 +12,10 @@ def _backup_path_for(compose_path: Path) -> Path: async def change_port( - service_name: str, - folder: Path, - port_from: int, - port_to: int, + service_name: str, + folder: Path, + port_from: int, + port_to: int, ) -> bool: compose_data = find_docker_compose(folder) if compose_data is None: @@ -45,7 +44,9 @@ async def change_port( original_text = compose_path.read_text() backup_path.write_text(compose_path.read_text()) - compose_path.write_text(yaml.dump(docker_compose, default_flow_style=False, sort_keys=False)) + compose_path.write_text( + yaml.dump(docker_compose, default_flow_style=False, sort_keys=False) + ) result = await run_docker_command( compose_path, diff --git a/app/integrations/docker/models.py b/backend/app/integrations/docker/models.py similarity index 100% rename from app/integrations/docker/models.py rename to backend/app/integrations/docker/models.py diff --git a/app/integrations/docker/ports.py b/backend/app/integrations/docker/ports.py similarity index 82% rename from app/integrations/docker/ports.py rename to backend/app/integrations/docker/ports.py index ad437b3..0f4ef2b 100644 --- a/app/integrations/docker/ports.py +++ b/backend/app/integrations/docker/ports.py @@ -2,16 +2,16 @@ import socket from collections.abc import Mapping -def _get_random_port() -> int: - # os automatically assigns an available port when binding to port 0, so we can use that to find a free port +def get_random_port() -> int: + # Binding to port 0 asks the OS for an available ephemeral port. try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(('', 0)) + s.bind(("", 0)) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) port = s.getsockname()[1] return port except Exception as e: - raise RuntimeError(f"Could not find an available port: {e}") + raise RuntimeError(f"Could not find an available port: {e}") from e def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None: @@ -24,7 +24,7 @@ def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None: return None try: return int(published) - except (TypeError, ValueError): + except TypeError, ValueError: return None parts = str(mapping).split(":") @@ -48,9 +48,9 @@ def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None: def _replace_port_in_mapping( - mapping: str | int | Mapping[str, object], - port_from: int, - port_to: int, + mapping: str | int | Mapping[str, object], + port_from: int, + port_to: int, ) -> str | dict[str, object]: if isinstance(mapping, Mapping): updated = dict(mapping) @@ -70,9 +70,11 @@ def _replace_port_in_mapping( return str(port_to) -def _get_port_index(mappings: list[str | int | Mapping[str, object]], port: int) -> int | None: +def _get_port_index( + mappings: list[str | int | Mapping[str, object]], + port: int, +) -> int | None: for i, mapping in enumerate(mappings): if _get_service_port(mapping) == port: return i return None - diff --git a/app/main.py b/backend/app/main.py similarity index 82% rename from app/main.py rename to backend/app/main.py index 9f258bc..632b5a1 100644 --- a/app/main.py +++ b/backend/app/main.py @@ -3,16 +3,17 @@ import sys from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI - from api.auth.router import router as auth_router +from api.editor.router import router as editor_router from api.proxy.router import router as proxy_router from core.config import settings from core.mongodb import close_mongodb, init_mongodb from core.redis import close_redis, init_redis from core.security import hash_password +from fastapi import FastAPI from integrations.docker import get_all_services_in_dir from repositories.auth import ensure_auth_indexes, ensure_single_account +from repositories.examples import ensure_example_indexes from repositories.proxy import ensure_proxy_indexes @@ -26,15 +27,15 @@ async def lifespan(_: FastAPI): password_hash=hash_password(settings.AUTH_PASSWORD), ) await ensure_proxy_indexes() + await ensure_example_indexes() home_dir = str(Path.home()) app.state.services = get_all_services_in_dir(home_dir) or [] if not app.state.services: warning = ( - "\n" + "!" * 60 + "\n" - " WARNING: No services found in home directory!\n" - f" Scanned: {home_dir}\n" - + "!" * 60 + "\n" + "\n" + "!" * 60 + "\n" + " WARNING: No services found in home directory!\n" + f" Scanned: {home_dir}\n" + "!" * 60 + "\n" ) print(warning, file=sys.stderr) @@ -56,6 +57,7 @@ app = FastAPI( ) app.include_router(auth_router) +app.include_router(editor_router) app.include_router(proxy_router) diff --git a/app/models/__init__.py b/backend/app/models/__init__.py similarity index 100% rename from app/models/__init__.py rename to backend/app/models/__init__.py diff --git a/app/models/auth.py b/backend/app/models/auth.py similarity index 100% rename from app/models/auth.py rename to backend/app/models/auth.py diff --git a/backend/app/models/example.py b/backend/app/models/example.py new file mode 100644 index 0000000..0c9e630 --- /dev/null +++ b/backend/app/models/example.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class AddonExampleDocument(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str = Field(..., description="Stable example identifier") + name: str = Field(..., description="Display name") + description: str = Field(default="", description="Short explanation") + category: str = Field(default="custom", description="Example category") + code: str = Field(..., description="Addon source code") + source_url: str | None = Field(default=None, description="Reference URL") + built_in: bool = Field(default=False, description="Provided by thePlug") + created_at: datetime | None = None + updated_at: datetime | None = None + + +class AddonExampleCreateInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str = Field(..., description="Stable example identifier") + name: str = Field(..., description="Display name") + description: str = Field(default="", description="Short explanation") + category: str = Field(default="custom", description="Example category") + code: str = Field(..., description="Addon source code") + source_url: str | None = Field(default=None, description="Reference URL") diff --git a/backend/app/models/proxy.py b/backend/app/models/proxy.py new file mode 100644 index 0000000..323c58e --- /dev/null +++ b/backend/app/models/proxy.py @@ -0,0 +1,115 @@ +from datetime import datetime +from typing import Any + +from models.service import ServiceEndpointDocument +from pydantic import BaseModel, ConfigDict, Field + + +class HealthCheckDocument(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool = Field(default=True, description="Enable active health checks") + protocol: str | None = Field( + default=None, + description="Override protocol used by the health check", + ) + path: str = Field(default="/", description="HTTP path to check") + method: str = Field(default="GET", description="HTTP method to use") + expected_status: int | None = Field( + default=None, + description="Expected HTTP status code", + ) + udp_payload: str = Field(default="", description="UDP payload to send") + expect_response: bool = Field( + default=True, + description="Wait for a response when the protocol supports it", + ) + verify_tls: bool = Field( + default=False, + description="Verify TLS certificates during health checks", + ) + timeout_seconds: float = Field( + default=2.0, + gt=0, + le=30, + description="Per-target health check timeout", + ) + + +class ProxyDocument(BaseModel): + model_config = ConfigDict(extra="forbid") + + proxy_port: int = Field(..., description="Port exposed by the proxy") + name: str = Field(..., description="Human readable proxy name") + code: str = Field(..., description="Proxy source or identifier") + mode: str = Field(default="reverse", description="mitmproxy proxy mode") + mode_spec: str | None = Field( + default=None, + description="Raw mitmproxy mode specification. Overrides mode/protocol target.", + ) + protocol: str = Field(default="http", description="mitmproxy reverse protocol") + listen_host: str = Field(default="0.0.0.0", description="Proxy listen host") + mitm_options: dict[str, Any] = Field( + default_factory=dict, + description="mitmdump options passed through --set", + ) + healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument) + status: str = Field(default="stopped", description="Runtime proxy status") + pid: int | None = Field(default=None, description="mitmdump process id") + script_path: str | None = Field(default=None, description="Generated addon path") + config_path: str | None = Field(default=None, description="Generated config path") + log_path: str | None = Field(default=None, description="mitmdump log path") + last_error: str | None = Field(default=None, description="Last runtime error") + service: ServiceEndpointDocument = Field( + ..., + description="Associated upstream service", + ) + created_at: datetime | None = Field( + default=None, + description="Creation timestamp in UTC", + ) + updated_at: datetime | None = Field( + default=None, + description="Last update timestamp in UTC", + ) + + +class ProxyCreateInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + proxy_port: int = Field(..., description="Port exposed by the proxy") + name: str = Field(..., description="Human readable proxy name") + code: str = Field(..., description="Proxy source or identifier") + mode: str = Field(default="reverse", description="mitmproxy proxy mode") + mode_spec: str | None = Field( + default=None, + description="Raw mitmproxy mode specification. Overrides mode/protocol target.", + ) + protocol: str = Field(default="http", description="mitmproxy reverse protocol") + listen_host: str = Field(default="0.0.0.0", description="Proxy listen host") + mitm_options: dict[str, Any] = Field(default_factory=dict) + healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument) + service: ServiceEndpointDocument = Field( + ..., + description="Associated upstream service", + ) + + +class ProxyUpdateInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, description="Human readable proxy name") + code: str | None = Field(default=None, description="Proxy source or identifier") + mode: str | None = Field(default=None, description="mitmproxy proxy mode") + mode_spec: str | None = Field( + default=None, + description="Raw mitmproxy mode specification. Overrides mode/protocol target.", + ) + protocol: str | None = Field(default=None, description="mitmproxy reverse protocol") + listen_host: str | None = Field(default=None, description="Proxy listen host") + mitm_options: dict[str, Any] | None = Field(default=None) + healthcheck: HealthCheckDocument | None = Field(default=None) + service: ServiceEndpointDocument | None = Field( + default=None, + description="Associated upstream service", + ) diff --git a/backend/app/models/service.py b/backend/app/models/service.py new file mode 100644 index 0000000..2b3c504 --- /dev/null +++ b/backend/app/models/service.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class ServiceEndpointDocument(BaseModel): + model_config = ConfigDict(extra="forbid") + + host: str = Field(default="127.0.0.1", description="Upstream service host") + port: int = Field(..., description="Original upstream service port") + name: str | None = Field(default=None, description="Docker Compose service name") + folder: str | None = Field( + default=None, + description="Docker Compose project folder", + ) + upstream_port: int | None = Field( + default=None, + description="Current port used by mitmdump as upstream", + ) diff --git a/app/repositories/__init__.py b/backend/app/repositories/__init__.py similarity index 100% rename from app/repositories/__init__.py rename to backend/app/repositories/__init__.py diff --git a/app/repositories/auth.py b/backend/app/repositories/auth.py similarity index 99% rename from app/repositories/auth.py rename to backend/app/repositories/auth.py index c6dc35f..1269e42 100644 --- a/app/repositories/auth.py +++ b/backend/app/repositories/auth.py @@ -2,10 +2,9 @@ from collections.abc import Mapping from datetime import UTC, datetime from typing import Any -from motor.motor_asyncio import AsyncIOMotorCollection - from core.mongodb import get_database from models.auth import AuthUserDocument +from motor.motor_asyncio import AsyncIOMotorCollection USERS_COLLECTION_NAME = "auth_users" REVOKED_COLLECTION_NAME = "revoked_tokens" diff --git a/backend/app/repositories/examples.py b/backend/app/repositories/examples.py new file mode 100644 index 0000000..1c87aaa --- /dev/null +++ b/backend/app/repositories/examples.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping +from datetime import UTC, datetime +from typing import Any + +from core.mongodb import get_database +from models.example import AddonExampleCreateInput, AddonExampleDocument +from motor.motor_asyncio import AsyncIOMotorCollection + +COLLECTION_NAME = "addon_examples" + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +async def collection() -> AsyncIOMotorCollection[dict[str, Any]]: + database = await get_database() + return database[COLLECTION_NAME] + + +def deserialize(raw: Mapping[str, Any] | None) -> AddonExampleDocument | None: + if raw is None: + return None + document = dict(raw) + document.pop("_id", None) + return AddonExampleDocument.model_validate(document) + + +async def ensure_example_indexes() -> None: + examples = await collection() + await examples.create_index("id", unique=True, name="uq_addon_example_id") + + +async def list_custom_examples() -> list[AddonExampleDocument]: + examples = await collection() + cursor = examples.find({}).sort("name", 1) + + result: list[AddonExampleDocument] = [] + async for raw in cursor: + example = deserialize(raw) + if example is not None: + result.append(example) + return result + + +async def create_custom_example( + payload: AddonExampleCreateInput, +) -> AddonExampleDocument: + now = utcnow() + example = AddonExampleDocument( + id=payload.id, + name=payload.name, + description=payload.description, + category=payload.category, + code=payload.code, + source_url=payload.source_url, + built_in=False, + created_at=now, + updated_at=now, + ) + + examples = await collection() + await examples.insert_one(example.model_dump(exclude_none=True)) + return example + + +async def delete_custom_example(example_id: str) -> bool: + examples = await collection() + result = await examples.delete_one({"id": example_id}) + return result.deleted_count > 0 diff --git a/app/repositories/proxy.py b/backend/app/repositories/proxy.py similarity index 72% rename from app/repositories/proxy.py rename to backend/app/repositories/proxy.py index 5d00a49..1081504 100644 --- a/app/repositories/proxy.py +++ b/backend/app/repositories/proxy.py @@ -2,13 +2,11 @@ from collections.abc import Mapping from datetime import UTC, datetime from typing import Any +from core.mongodb import get_database +from models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput from motor.motor_asyncio import AsyncIOMotorCollection from pymongo import ReturnDocument -from core.mongodb import get_database -from models.proxy import ProxyDocument, ProxyUpdateInput -from models.service import ServiceEndpointDocument - COLLECTION_NAME = "proxies" @@ -34,29 +32,19 @@ async def ensure_proxy_indexes() -> None: await collection.create_index("proxy_port", unique=True, name="uq_proxy_port") -async def create_proxy( - proxy_port: int, - name: str, - upstream_port: int, - code: str, -) -> ProxyDocument: - """ - Saves a Proxy to the database. - - :param proxy_port: the port the proxy is listening on - :param name: the name of the service - :param upstream_port: the upstream port the proxy is forwarding to - :param code: the code of the service - :return: - """ +async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument: now = _utcnow() proxy = ProxyDocument( - proxy_port=proxy_port, - name=name, - code=code, - service=ServiceEndpointDocument( - port=upstream_port, - ), + proxy_port=payload.proxy_port, + name=payload.name, + code=payload.code, + mode=payload.mode, + mode_spec=payload.mode_spec, + protocol=payload.protocol, + listen_host=payload.listen_host, + mitm_options=payload.mitm_options, + healthcheck=payload.healthcheck, + service=payload.service, created_at=now, updated_at=now, ) @@ -103,6 +91,22 @@ async def update_proxy( return _deserialize_proxy(raw) +async def update_proxy_runtime( + proxy_port: int, + values: dict[str, object], +) -> ProxyDocument | None: + payload = dict(values) + payload["updated_at"] = _utcnow() + + collection = await _collection() + raw = await collection.find_one_and_update( + {"proxy_port": proxy_port}, + {"$set": payload}, + return_document=ReturnDocument.AFTER, + ) + return _deserialize_proxy(raw) + + async def delete_proxy(proxy_port: int) -> bool: collection = await _collection() result = await collection.delete_one({"proxy_port": proxy_port}) diff --git a/app/services/__init__.py b/backend/app/services/__init__.py similarity index 100% rename from app/services/__init__.py rename to backend/app/services/__init__.py diff --git a/backend/app/services/editor/python_lsp.py b/backend/app/services/editor/python_lsp.py new file mode 100644 index 0000000..be246ca --- /dev/null +++ b/backend/app/services/editor/python_lsp.py @@ -0,0 +1,323 @@ +import asyncio +import json +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path + +WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "theplug-lsp" +CONFIG_FILE = "pyrightconfig.json" +ENTRY_FILE = "proxy_addon.py" + +STUB_FILES = { + "typings/mitmproxy/__init__.pyi": """from . import ( + command, + ctx, + dns, + http, + tcp, + tls, + udp, + websocket, +) + +__all__ = [ + "command", + "ctx", + "dns", + "http", + "tcp", + "tls", + "udp", + "websocket", +] +""", + "typings/mitmproxy/ctx.pyi": """class _Logger: + def debug(self, message: object) -> None: ... + def info(self, message: object) -> None: ... + def warn(self, message: object) -> None: ... + def error(self, message: object) -> None: ... + def alert(self, message: object) -> None: ... + + +class _Options: + def __getattr__(self, name: str) -> object: ... + + +log: _Logger +options: _Options +""", + "typings/mitmproxy/http.pyi": """from typing import Any + + +class Headers(dict[str, str]): + ... + + +class Request: + host: str + path: str + headers: Headers + query: dict[str, str] + content: bytes + + +class Response: + headers: Headers + status_code: int + content: bytes + + @classmethod + def make( + cls, + status_code: int, + content: bytes | str = b"", + headers: dict[str, str] | None = None, + ) -> "Response": ... + + +class WebSocketMessage: + content: bytes + from_client: bool + + +class WebSocketData: + messages: list[WebSocketMessage] + + +class HTTPFlow: + request: Request + response: Response | None + websocket: WebSocketData | None + metadata: dict[str, Any] +""", + "typings/mitmproxy/tcp.pyi": """class TCPMessage: + content: bytes + from_client: bool + + +class TCPFlow: + messages: list[TCPMessage] +""", + "typings/mitmproxy/dns.pyi": """class Question: + name: str + type: int + + +class ResourceRecord: + def __init__( + self, + *, + name: str, + type: int, + class_: int, + ttl: int, + data: bytes, + ) -> None: ... + + +class Message: + question: Question | None + + def succeed(self, records: list[ResourceRecord]) -> "Message": ... + + +class _Types: + A: int + + +class _Classes: + IN: int + + +types: _Types +classes: _Classes + + +class DNSFlow: + request: Message + response: Message | None +""", + "typings/mitmproxy/command.pyi": """from collections.abc import Callable +from typing import TypeVar + +F = TypeVar("F", bound=Callable[..., object]) + + +def command(name: str) -> Callable[[F], F]: ... +""", + "typings/mitmproxy/websocket.pyi": """class WebSocketMessage: + content: bytes + from_client: bool +""", + "typings/mitmproxy/tls.pyi": """class ClientHelloData: ... +""", + "typings/mitmproxy/udp.pyi": """class UDPMessage: + content: bytes + from_client: bool +""", +} + + +class LspFrameBuffer: + def __init__(self) -> None: + self.buffer = bytearray() + + def feed(self, chunk: bytes) -> list[str]: + self.buffer.extend(chunk) + messages: list[str] = [] + while True: + header_end = self.buffer.find(b"\r\n\r\n") + if header_end < 0: + return messages + + header = self.buffer[:header_end].decode("ascii", errors="ignore") + size = content_length(header) + if size is None: + del self.buffer[: header_end + 4] + continue + + frame_end = header_end + 4 + size + if len(self.buffer) < frame_end: + return messages + + body = bytes(self.buffer[header_end + 4 : frame_end]) + del self.buffer[:frame_end] + messages.append(body.decode("utf-8")) + + +@dataclass(slots=True) +class PythonEditorSession: + root_path: Path + file_path: Path + + @property + def root_uri(self) -> str: + return self.root_path.as_uri() + + @property + def file_uri(self) -> str: + return self.file_path.as_uri() + + +def content_length(header: str) -> int | None: + for line in header.split("\r\n"): + if ":" not in line: + continue + name, value = line.split(":", 1) + if name.lower() == "content-length": + return int(value.strip()) + return None + + +def encode_message(message: str) -> bytes: + payload = message.encode("utf-8") + header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii") + return header + payload + + +def lsp_binary() -> str | None: + return shutil.which("basedpyright-langserver") + + +def get_session() -> PythonEditorSession: + root_path = WORKSPACE_ROOT + file_path = root_path / ENTRY_FILE + + root_path.mkdir(parents=True, exist_ok=True) + file_path.touch(exist_ok=True) + + config = { + "include": [ENTRY_FILE], + "stubPath": "typings", + "typeCheckingMode": "basic", + "pythonVersion": "3.14", + "reportMissingTypeStubs": False, + "reportMissingImports": "warning", + "useLibraryCodeForTypes": True, + } + (root_path / CONFIG_FILE).write_text( + json.dumps(config, indent=2), + encoding="utf-8", + ) + + for relative_path, content in STUB_FILES.items(): + path = root_path / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + return PythonEditorSession(root_path=root_path, file_path=file_path) + + +async def open_process(session: PythonEditorSession) -> asyncio.subprocess.Process: + binary = lsp_binary() + if binary is None: + raise RuntimeError("basedpyright-langserver is not installed") + + return await asyncio.create_subprocess_exec( + binary, + "--stdio", + cwd=session.root_path, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + +async def forward_client(websocket, writer: asyncio.StreamWriter) -> None: + while True: + message = await websocket.receive_text() + writer.write(encode_message(message)) + await writer.drain() + + +async def forward_server(reader: asyncio.StreamReader, websocket) -> None: + frames = LspFrameBuffer() + while True: + chunk = await reader.read(65536) + if not chunk: + return + for message in frames.feed(chunk): + await websocket.send_text(message) + + +async def drain_errors(reader: asyncio.StreamReader) -> None: + while True: + chunk = await reader.read(4096) + if not chunk: + return + + +async def bridge(websocket, session: PythonEditorSession) -> None: + process = await open_process(session) + stdin = process.stdin + stdout = process.stdout + stderr = process.stderr + + if stdin is None or stdout is None or stderr is None: + process.kill() + await process.wait() + raise RuntimeError("basedpyright-langserver did not expose stdio pipes") + + send_task = asyncio.create_task(forward_client(websocket, stdin)) + receive_task = asyncio.create_task(forward_server(stdout, websocket)) + error_task = asyncio.create_task(drain_errors(stderr)) + + try: + done, pending = await asyncio.wait( + {send_task, receive_task, error_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + for task in done: + task.result() + finally: + if process.returncode is None: + stdin.close() + await stdin.wait_closed() + try: + await asyncio.wait_for(process.wait(), timeout=1) + except TimeoutError: + process.kill() + await process.wait() diff --git a/app/services/mitmproxy/__init__.py b/backend/app/services/mitmproxy/__init__.py similarity index 100% rename from app/services/mitmproxy/__init__.py rename to backend/app/services/mitmproxy/__init__.py diff --git a/backend/app/services/mitmproxy/examples.py b/backend/app/services/mitmproxy/examples.py new file mode 100644 index 0000000..457086e --- /dev/null +++ b/backend/app/services/mitmproxy/examples.py @@ -0,0 +1,134 @@ +from models.example import AddonExampleDocument + +DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/" + +BUILTIN_EXAMPLES = [ + AddonExampleDocument( + id="http-add-response-header", + name="HTTP add response header", + description=( + "Injects a fixed response header on every HTTP response so you can " + "verify that traffic is passing through the proxy response path." + ), + category="http", + source_url=DOCS_EXAMPLES_URL, + built_in=True, + code="""from mitmproxy import http + + +def response(flow: http.HTTPFlow) -> None: + flow.response.headers["x-theplug"] = "enabled" +""", + ), + AddonExampleDocument( + id="http-modify-query", + name="HTTP modify query", + description=( + "Adds or overwrites a query parameter before each request is " + "forwarded upstream. Useful for forced debug or feature flags." + ), + category="http", + source_url=DOCS_EXAMPLES_URL, + built_in=True, + code="""from mitmproxy import http + + +def request(flow: http.HTTPFlow) -> None: + flow.request.query["debug"] = "1" +""", + ), + AddonExampleDocument( + id="http-reply-from-proxy", + name="HTTP reply from proxy", + description=( + "Serves a local HTTP response for a selected path without touching " + "the upstream service. Useful for probes, maintenance, or stubs." + ), + category="http", + source_url=DOCS_EXAMPLES_URL, + built_in=True, + code="""from mitmproxy import http + + +def request(flow: http.HTTPFlow) -> None: + if flow.request.path == "/healthz": + flow.response = http.Response.make( + 200, + b"ok", + {"content-type": "text/plain"}, + ) +""", + ), + AddonExampleDocument( + id="websocket-rewrite", + name="WebSocket rewrite", + description=( + "Rewrites matching bytes inside live WebSocket frames to alter " + "runtime flags or payload markers in transit." + ), + category="websocket", + source_url=DOCS_EXAMPLES_URL, + built_in=True, + code="""from mitmproxy import http + + +def websocket_message(flow: http.HTTPFlow) -> None: + if flow.websocket is None: + return + message = flow.websocket.messages[-1] + message.content = message.content.replace(b"debug=false", b"debug=true") +""", + ), + AddonExampleDocument( + id="tcp-log-messages", + name="TCP log messages", + description=( + "Logs raw TCP message direction and size for each frame so you can " + "observe binary traffic without altering it." + ), + category="tcp", + source_url=DOCS_EXAMPLES_URL, + built_in=True, + code="""import logging + +from mitmproxy import tcp + + +def tcp_message(flow: tcp.TCPFlow) -> None: + message = flow.messages[-1] + direction = "client" if message.from_client else "server" + logging.info("%s sent %d bytes", direction, len(message.content)) +""", + ), + AddonExampleDocument( + id="dns-spoof-a-record", + name="DNS spoof A record", + description=( + "Overrides one DNS A record while forwarding every other query " + "unchanged. Useful for selective service redirection." + ), + category="dns", + source_url=DOCS_EXAMPLES_URL, + built_in=True, + code="""import ipaddress + +from mitmproxy import dns + + +def dns_request(flow: dns.DNSFlow) -> None: + question = flow.request.question + if question and question.name == "target.local" and question.type == dns.types.A: + flow.response = flow.request.succeed( + [ + dns.ResourceRecord( + name="target.local", + type=dns.types.A, + class_=dns.classes.IN, + ttl=60, + data=ipaddress.ip_address("127.0.0.1").packed, + ) + ] + ) +""", + ), +] diff --git a/backend/app/services/mitmproxy/health.py b/backend/app/services/mitmproxy/health.py new file mode 100644 index 0000000..31bb6a6 --- /dev/null +++ b/backend/app/services/mitmproxy/health.py @@ -0,0 +1,185 @@ +import asyncio +import ssl +from dataclasses import dataclass + +from models.proxy import HealthCheckDocument, ProxyDocument + + +@dataclass(slots=True) +class Target: + name: str + host: str + port: int + protocol: str + + +async def check_proxy(proxy: ProxyDocument) -> dict[str, object]: + if not proxy.healthcheck.enabled: + return {"enabled": False, "overall": "disabled", "checks": []} + + upstream_port = proxy.service.upstream_port or proxy.service.port + checks = await asyncio.gather( + check_target( + Target( + name="upstream", + host=proxy.service.host, + port=upstream_port, + protocol=health_protocol(proxy), + ), + proxy.healthcheck, + ), + check_target( + Target( + name="proxy", + host=listen_host_for_check(proxy.listen_host), + port=proxy.proxy_port, + protocol=health_protocol(proxy), + ), + proxy.healthcheck, + ), + ) + overall = "healthy" if all(check["healthy"] for check in checks) else "unhealthy" + return {"enabled": True, "overall": overall, "checks": checks} + + +def health_protocol(proxy: ProxyDocument) -> str: + return proxy.healthcheck.protocol or proxy.protocol + + +def listen_host_for_check(host: str) -> str: + if host in {"", "0.0.0.0", "::"}: + return "127.0.0.1" + return host + + +async def check_target( + target: Target, + config: HealthCheckDocument, +) -> dict[str, object]: + try: + match target.protocol: + case "http": + return await check_http(target, config, use_tls=False) + case "https": + return await check_http(target, config, use_tls=True) + case "tcp": + return await check_tcp(target, config, use_tls=False) + case "tls": + return await check_tcp(target, config, use_tls=True) + case "udp" | "dtls" | "quic" | "dns" | "http3": + return await check_udp(target, config) + case _: + return result(target, False, f"Unsupported protocol: {target.protocol}") + except TimeoutError: + return result(target, False, "Timed out") + except OSError as error: + return result(target, False, str(error)) + + +async def check_http( + target: Target, + config: HealthCheckDocument, + *, + use_tls: bool, +) -> dict[str, object]: + reader, writer = await open_connection(target, config, use_tls=use_tls) + try: + path = config.path if config.path.startswith("/") else f"/{config.path}" + request = ( + f"{config.method} {path} HTTP/1.1\r\n" + f"Host: {target.host}\r\n" + "Connection: close\r\n\r\n" + ) + writer.write(request.encode("ascii")) + await writer.drain() + line = await asyncio.wait_for(reader.readline(), config.timeout_seconds) + finally: + writer.close() + await writer.wait_closed() + + parts = line.decode("iso-8859-1", errors="replace").split() + status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None + expected = config.expected_status + healthy = status == expected if expected is not None else status is not None + return result(target, healthy, None, {"status": status}) + + +async def check_tcp( + target: Target, + config: HealthCheckDocument, + *, + use_tls: bool, +) -> dict[str, object]: + _reader, writer = await open_connection(target, config, use_tls=use_tls) + writer.close() + await writer.wait_closed() + return result(target, True) + + +async def check_udp(target: Target, config: HealthCheckDocument) -> dict[str, object]: + loop = asyncio.get_running_loop() + transport, protocol = await loop.create_datagram_endpoint( + lambda: DatagramProbe(config.expect_response), + remote_addr=(target.host, target.port), + ) + try: + payload = config.udp_payload.encode() + transport.sendto(payload) + if not config.expect_response: + return result(target, True, None, {"mode": "send-only"}) + data = await asyncio.wait_for(protocol.response, config.timeout_seconds) + return result(target, True, None, {"bytes": len(data)}) + finally: + transport.close() + + +async def open_connection( + target: Target, + config: HealthCheckDocument, + *, + use_tls: bool, +): + if use_tls and config.verify_tls: + context = ssl.create_default_context() + elif use_tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + else: + context = None + return await asyncio.wait_for( + asyncio.open_connection(target.host, target.port, ssl=context), + config.timeout_seconds, + ) + + +class DatagramProbe(asyncio.DatagramProtocol): + def __init__(self, expect_response: bool): + self.response = asyncio.get_running_loop().create_future() + if not expect_response: + self.response.set_result(b"") + + def datagram_received(self, data, _addr): + if not self.response.done(): + self.response.set_result(data) + + def error_received(self, exc): + if not self.response.done(): + self.response.set_exception(exc) + + +def result( + target: Target, + healthy: bool, + error: str | None = None, + details: dict[str, object] | None = None, +) -> dict[str, object]: + return { + "target": target.name, + "host": target.host, + "port": target.port, + "protocol": target.protocol, + "healthy": healthy, + "error": error, + "details": details or {}, + } diff --git a/backend/app/services/mitmproxy/manager.py b/backend/app/services/mitmproxy/manager.py new file mode 100644 index 0000000..ad60ade --- /dev/null +++ b/backend/app/services/mitmproxy/manager.py @@ -0,0 +1,283 @@ +import asyncio +import os +import shutil +import signal +from pathlib import Path + +import yaml +from integrations import docker +from integrations.docker.ports import get_random_port +from models.proxy import ProxyDocument +from services.mitmproxy.options import MitmOptionError, validate_options + + +class MitmdumpError(Exception): + pass + + +class MitmdumpManager: + def __init__(self, work_dir: str = "/tmp/theplug", command: str = "mitmdump"): + self.work_dir = Path(work_dir) + self.command = command + self.processes: dict[int, asyncio.subprocess.Process] = {} + self.locks: dict[int, asyncio.Lock] = {} + + async def start(self, proxy: ProxyDocument) -> dict[str, object]: + async with self.lock_for(proxy.proxy_port): + return await self.start_unlocked(proxy) + + async def stop(self, proxy: ProxyDocument) -> dict[str, object]: + async with self.lock_for(proxy.proxy_port): + return await self.stop_unlocked(proxy) + + async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]: + if await self.status(proxy) == "running": + raise MitmdumpError("Proxy is already running") + + command_path = shutil.which(self.command) + if command_path is None: + raise MitmdumpError("mitmdump executable not found in PATH") + + try: + await validate_options(proxy.mitm_options) + except MitmOptionError as error: + raise MitmdumpError(str(error)) from error + upstream_port = await self.prepare_upstream(proxy) + script_path = self.write_script(proxy) + config_path = self.write_config(proxy) + log_path = self.log_path(proxy) + command = self.build_command( + command_path, + proxy, + upstream_port, + script_path, + config_path, + ) + + try: + process = await self.spawn(command, log_path) + except Exception as error: + await self.restore_upstream(proxy, upstream_port) + raise MitmdumpError(str(error)) from error + + failed = await self.exited_early(process) + if failed: + await self.restore_upstream(proxy, upstream_port) + reason = self.read_log_tail(log_path) or "mitmdump exited during startup" + raise MitmdumpError(reason) + + self.processes[proxy.proxy_port] = process + return { + "status": "running", + "pid": process.pid, + "script_path": str(script_path), + "config_path": str(config_path), + "log_path": str(log_path), + "service.upstream_port": upstream_port, + "last_error": None, + } + + async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]: + process = self.processes.pop(proxy.proxy_port, None) + if process is not None and process.returncode is None: + await self.stop_process(process) + elif proxy.pid is not None: + await self.stop_pid(proxy.pid) + + await self.restore_upstream(proxy, proxy.service.upstream_port) + return { + "status": "stopped", + "pid": None, + "service.upstream_port": None, + "last_error": None, + } + + async def status(self, proxy: ProxyDocument) -> str: + process = self.processes.get(proxy.proxy_port) + if process is not None: + if process.returncode is None: + return "running" + self.processes.pop(proxy.proxy_port, None) + return "stopped" + + if proxy.pid is None: + return "stopped" if proxy.status == "running" else proxy.status + + try: + os.kill(proxy.pid, 0) + except ProcessLookupError: + return "stopped" + except PermissionError: + return "running" + return "running" + + async def prepare_upstream(self, proxy: ProxyDocument) -> int | None: + if proxy.mode_spec is not None or proxy.mode != "reverse": + return None + if proxy.service.upstream_port is not None: + return proxy.service.upstream_port + + if proxy.service.name is None or proxy.service.folder is None: + return proxy.service.port + + upstream_port = get_random_port() + changed = await docker.change_port( + proxy.service.name, + Path(proxy.service.folder), + proxy.service.port, + upstream_port, + ) + if not changed: + raise MitmdumpError("Unable to move Docker service port") + return upstream_port + + async def restore_upstream( + self, + proxy: ProxyDocument, + upstream_port: int | None, + ) -> None: + if upstream_port is None: + return + if proxy.service.name is None or proxy.service.folder is None: + return + if upstream_port == proxy.service.port: + return + + await docker.change_port( + proxy.service.name, + Path(proxy.service.folder), + upstream_port, + proxy.service.port, + ) + + def build_command( + self, + command_path: str, + proxy: ProxyDocument, + upstream_port: int | None, + script_path: Path, + config_path: Path, + ) -> list[str]: + command = [ + command_path, + "--mode", + self.mode_for(proxy, upstream_port), + "--listen-host", + proxy.listen_host, + "--set", + f"confdir={config_path.parent}", + "-s", + str(script_path), + ] + if self.needs_listen_port(proxy): + command.extend(["--listen-port", str(proxy.proxy_port)]) + return command + + def mode_for(self, proxy: ProxyDocument, upstream_port: int | None) -> str: + if proxy.mode_spec: + return proxy.mode_spec + + match proxy.mode: + case "reverse": + port = upstream_port or proxy.service.port + return ( + f"reverse:{proxy.protocol}://{proxy.service.host}:{port}" + f"@{proxy.proxy_port}" + ) + case "upstream": + return ( + f"upstream:{proxy.protocol}://{proxy.service.host}:" + f"{proxy.service.port}@{proxy.proxy_port}" + ) + case "wireguard": + return f"wireguard@{proxy.proxy_port}" + case _: + return proxy.mode + + def needs_listen_port(self, proxy: ProxyDocument) -> bool: + if proxy.mode_spec is not None: + return False + return proxy.mode in {"regular", "socks5", "dns", "transparent"} + + def write_script(self, proxy: ProxyDocument) -> Path: + path = self.work_dir / "addons" / f"{proxy.proxy_port}.py" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(proxy.code, encoding="utf-8") + return path + + def write_config(self, proxy: ProxyDocument) -> Path: + path = self.work_dir / "conf" / str(proxy.proxy_port) / "config.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + options = {"block_global": False, **proxy.mitm_options} + path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8") + return path + + def log_path(self, proxy: ProxyDocument) -> Path: + path = self.work_dir / "logs" / f"{proxy.proxy_port}.log" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + async def spawn(self, command: list[str], log_path: Path): + with log_path.open("ab") as log_file: + return await asyncio.create_subprocess_exec( + *command, + stdout=log_file, + stderr=log_file, + start_new_session=True, + ) + + async def stop_process(self, process) -> None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + + try: + await asyncio.wait_for(process.wait(), timeout=5) + except TimeoutError: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return + await process.wait() + + async def exited_early(self, process) -> bool: + try: + await asyncio.wait_for(process.wait(), timeout=0.5) + except TimeoutError: + return False + return True + + async def stop_pid(self, pid: int) -> None: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + return + + for _ in range(10): + await asyncio.sleep(0.2) + try: + os.kill(pid, 0) + except ProcessLookupError: + return + + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + return + + def read_log_tail(self, path: Path, limit: int = 4000) -> str: + if not path.exists(): + return "" + content = path.read_text(encoding="utf-8", errors="replace") + return content[-limit:].strip() + + def lock_for(self, proxy_port: int) -> asyncio.Lock: + lock = self.locks.get(proxy_port) + if lock is None: + lock = asyncio.Lock() + self.locks[proxy_port] = lock + return lock + + +manager = MitmdumpManager() diff --git a/backend/app/services/mitmproxy/options.py b/backend/app/services/mitmproxy/options.py new file mode 100644 index 0000000..b1f7e11 --- /dev/null +++ b/backend/app/services/mitmproxy/options.py @@ -0,0 +1,156 @@ +import asyncio +import re +import shutil +from dataclasses import dataclass +from typing import Any + +import yaml + +RESERVED_OPTIONS = {"mode", "listen_host", "listen_port", "scripts"} +OPTIONS_DOCS_URL = "https://docs.mitmproxy.org/stable/concepts/options/" + + +@dataclass(slots=True) +class MitmOption: + name: str + value_type: str + default: Any + description: str + choices: list[str] + + +class MitmOptionError(Exception): + pass + + +catalog_cache: dict[str, MitmOption] | None = None + + +async def load_options() -> dict[str, MitmOption]: + global catalog_cache + if catalog_cache is None: + catalog_cache = await read_options() + return catalog_cache + + +async def read_options(command: str = "mitmdump") -> dict[str, MitmOption]: + command_path = shutil.which(command) + if command_path is None: + raise MitmOptionError("mitmdump executable not found in PATH") + + process = await asyncio.create_subprocess_exec( + command_path, + "--options", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + message = stderr.decode("utf-8", errors="replace").strip() + raise MitmOptionError(message or "Unable to load mitmdump options") + + text = stdout.decode("utf-8", errors="replace") + defaults = yaml.safe_load(text) or {} + return parse_options(text, defaults) + + +def parse_options(text: str, defaults: dict[str, Any]) -> dict[str, MitmOption]: + comments: list[str] = [] + options: dict[str, MitmOption] = {} + + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + comments = [] + continue + if stripped.startswith("#"): + comments.append(stripped[1:].strip()) + continue + if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*:", stripped): + continue + + name = stripped.split(":", 1)[0] + description = " ".join(comments) + options[name] = MitmOption( + name=name, + value_type=parse_type(description), + default=defaults.get(name), + description=description, + choices=parse_choices(description), + ) + comments = [] + + return options + + +def parse_type(description: str) -> str: + match = re.search(r"Type ([^.]+)\.", description) + if match is None: + return "unknown" + return match.group(1).strip() + + +def parse_choices(description: str) -> list[str]: + match = re.search(r"Valid values are ([^.]+)\.", description) + if match is None: + return [] + return re.findall(r"'([^']+)'", match.group(1)) + + +async def validate_options(options: dict[str, Any]) -> None: + catalog = await load_options() + errors: list[str] = [] + + for name, value in options.items(): + option = catalog.get(name) + if option is None: + errors.append(f"Unknown mitmdump option: {name}") + continue + if name in RESERVED_OPTIONS: + errors.append(f"Option is managed by thePlug and cannot be set: {name}") + continue + message = validate_value(option, value) + if message: + errors.append(f"{name}: {message}") + + if errors: + raise MitmOptionError("; ".join(errors)) + + +def validate_value(option: MitmOption, value: Any) -> str | None: + value_type = option.value_type + if option.choices and str(value) not in option.choices: + return f"expected one of {', '.join(option.choices)}" + if value is None: + if value_type.startswith("optional "): + return None + return "cannot be null" + if value_type == "bool" and not isinstance(value, bool): + return "expected bool" + if value_type == "int" and not isinstance(value, int): + return "expected int" + if value_type == "str" and not isinstance(value, str): + return "expected str" + if value_type == "sequence of str": + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + return "expected list[str]" + if value_type == "optional str" and not isinstance(value, str): + return "expected str or null" + if value_type == "optional int" and not isinstance(value, int): + return "expected int or null" + return None + + +def public_options(options: dict[str, MitmOption]) -> list[dict[str, Any]]: + return [ + { + "name": option.name, + "type": option.value_type, + "default": option.default, + "description": option.description, + "choices": option.choices, + "reserved": option.name in RESERVED_OPTIONS, + "docs_url": f"{OPTIONS_DOCS_URL}#{option.name}", + } + for option in options.values() + ] diff --git a/app/services/mitmproxy/validator.py b/backend/app/services/mitmproxy/validator.py similarity index 98% rename from app/services/mitmproxy/validator.py rename to backend/app/services/mitmproxy/validator.py index 69ae67a..fbd298e 100644 --- a/app/services/mitmproxy/validator.py +++ b/backend/app/services/mitmproxy/validator.py @@ -5,7 +5,7 @@ import shutil import tempfile from typing import Any -TARGET_RUFF_CODES = ("F821", "F822", "F823", "E999") +TARGET_RUFF_CODES = ("F821", "F822", "F823") class ProxyCodeValidationError(Exception): diff --git a/pyproject.toml b/backend/pyproject.toml similarity index 92% rename from pyproject.toml rename to backend/pyproject.toml index 20ad945..53f6b34 100644 --- a/pyproject.toml +++ b/backend/pyproject.toml @@ -5,13 +5,16 @@ description = "Add your description here" requires-python = ">=3.14" dependencies = [ "authlib>=1.6.0", + "basedpyright>=1.39.0", "fastapi[standard]>=0.135.3", + "mitmproxy>=12.2.2", "motor>=3.7.1", "pwdlib[argon2,bcrypt]>=0.3.0", "pydantic>=2.12.5", "pydantic-settings>=2.13.1", "pyyaml>=6.0.3", "redis>=7.4.0", + "ruff>=0.15.10", ] [tool.ruff] @@ -33,5 +36,4 @@ addopts = "-q" [dependency-groups] dev = [ "pytest>=9.0.3", - "ruff>=0.15.10", ] diff --git a/tests/api/test_auth.py b/backend/tests/api/test_auth.py similarity index 100% rename from tests/api/test_auth.py rename to backend/tests/api/test_auth.py diff --git a/backend/tests/api/test_editor_router.py b/backend/tests/api/test_editor_router.py new file mode 100644 index 0000000..f9e314e --- /dev/null +++ b/backend/tests/api/test_editor_router.py @@ -0,0 +1,52 @@ +import pytest +from services.editor import python_lsp +from starlette.websockets import WebSocketDisconnect + + +def test_python_editor_session(client, monkeypatch): + session = python_lsp.PythonEditorSession( + root_path=python_lsp.WORKSPACE_ROOT, + file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE, + ) + monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp") + monkeypatch.setattr("api.editor.router.python_lsp.get_session", lambda: session) + + response = client.get("/proxy/editor/python/session") + + assert response.status_code == 200 + assert response.json() == { + "root_uri": session.root_uri, + "file_uri": session.file_uri, + } + + +def test_python_editor_socket_rejects_missing_token(client): + with pytest.raises(WebSocketDisconnect) as error: + with client.websocket_connect("/proxy/editor/python/ws"): + pass + + assert error.value.code == 4401 + + +def test_python_editor_socket_accepts_token(client, monkeypatch): + accepted = {"value": False} + + async def fake_authenticate(_: object) -> None: + return None + + async def fake_bridge(websocket, session) -> None: + accepted["value"] = True + await websocket.send_text(session.file_uri) + await websocket.close() + + monkeypatch.setattr( + "api.editor.router.authenticate_editor_socket", + fake_authenticate, + ) + monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp") + monkeypatch.setattr("api.editor.router.python_lsp.bridge", fake_bridge) + + with client.websocket_connect("/proxy/editor/python/ws?token=test-token") as socket: + assert socket.receive_text().endswith("/proxy_addon.py") + + assert accepted["value"] is True diff --git a/tests/api/test_proxy.py b/backend/tests/api/test_proxy.py similarity index 50% rename from tests/api/test_proxy.py rename to backend/tests/api/test_proxy.py index 523862e..0abe4b8 100644 --- a/tests/api/test_proxy.py +++ b/backend/tests/api/test_proxy.py @@ -1,8 +1,9 @@ import importlib from core.security import get_auth_context, require_authenticated_user -from models.proxy import ProxyDocument +from models.proxy import ProxyCreateInput, ProxyDocument from models.service import ServiceEndpointDocument +from services.mitmproxy.examples import BUILTIN_EXAMPLES from services.mitmproxy.validator import ProxyCodeValidationError proxy_router_module = importlib.import_module("api.proxy.router") @@ -101,22 +102,17 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object): assert proxy_port == 4040 return None - async def fake_create_proxy( - *, - proxy_port: int, - name: str, - service: dict[str, object], - code: str, - ) -> ProxyDocument: - captured["proxy_port"] = proxy_port - captured["name"] = name - captured["service"] = service - captured["code"] = code + async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument: + captured["proxy_port"] = payload.proxy_port + captured["name"] = payload.name + captured["service"] = payload.service.model_dump(exclude_none=True) + captured["code"] = payload.code + captured["protocol"] = payload.protocol return make_proxy_document( - proxy_port=proxy_port, - name=name, - code=code, - port=int(service["port"]), + proxy_port=payload.proxy_port, + name=payload.name, + code=payload.code, + port=payload.service.port, ) async def fake_validate_proxy_code(code: str) -> None: @@ -149,8 +145,9 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object): assert captured == { "proxy_port": 4040, "name": "edge", - "service": {"port": 8181}, + "service": {"host": "127.0.0.1", "port": 8181}, "code": "def proxy_handler():\n return 'ok'\n", + "protocol": "http", } @@ -261,3 +258,203 @@ def test_update_proxy_returns_422_when_code_validation_fails( assert body["message"] == "Proxy code has invalid Python syntax" assert body["errors"][0]["code"] == "COMPILE" assert update_called is False + + +def test_start_proxy_updates_runtime_state(client, monkeypatch: object): + proxy = make_proxy_document(proxy_port=4040) + captured: dict[str, object] = {} + + async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None: + assert proxy_port == 4040 + return proxy + + class FakeManager: + async def start(self, document: ProxyDocument) -> dict[str, object]: + assert document.proxy_port == 4040 + return { + "status": "running", + "pid": 1234, + "script_path": "/tmp/theplug/addons/4040.py", + "log_path": "/tmp/theplug/logs/4040.log", + "service.upstream_port": 9000, + "last_error": None, + } + + async def fake_update_proxy_runtime( + proxy_port: int, + values: dict[str, object], + ) -> ProxyDocument: + captured["proxy_port"] = proxy_port + captured["values"] = values + return proxy.model_copy(update={"status": "running", "pid": 1234}) + + monkeypatch.setattr( + proxy_router_module, + "get_proxy_by_port", + fake_get_proxy_by_port, + ) + monkeypatch.setattr(proxy_router_module, "manager", FakeManager()) + monkeypatch.setattr( + proxy_router_module, + "update_proxy_runtime", + fake_update_proxy_runtime, + ) + + response = client.post("/proxy/4040/start") + + assert response.status_code == 200 + assert response.json()["status"] == "running" + assert captured["proxy_port"] == 4040 + assert captured["values"]["pid"] == 1234 + + +def test_start_proxy_records_runtime_error(client, monkeypatch: object): + proxy = make_proxy_document(proxy_port=4040) + + async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None: + assert proxy_port == 4040 + return proxy + + class FakeManager: + async def start(self, document: ProxyDocument) -> dict[str, object]: + assert document.proxy_port == 4040 + raise proxy_router_module.MitmdumpError("mitmdump executable not found") + + async def fake_update_proxy_runtime( + proxy_port: int, + values: dict[str, object], + ) -> ProxyDocument: + assert proxy_port == 4040 + assert values == { + "status": "error", + "last_error": "mitmdump executable not found", + } + return proxy.model_copy( + update={"status": "error", "last_error": values["last_error"]} + ) + + monkeypatch.setattr( + proxy_router_module, + "get_proxy_by_port", + fake_get_proxy_by_port, + ) + monkeypatch.setattr(proxy_router_module, "manager", FakeManager()) + monkeypatch.setattr( + proxy_router_module, + "update_proxy_runtime", + fake_update_proxy_runtime, + ) + + response = client.post("/proxy/4040/start") + + assert response.status_code == 200 + assert response.json()["status"] == "error" + assert response.json()["last_error"] == "mitmdump executable not found" + + +def test_stop_proxy_updates_runtime_state(client, monkeypatch: object): + proxy = make_proxy_document(proxy_port=4040).model_copy( + update={"status": "running", "pid": 1234} + ) + + async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None: + assert proxy_port == 4040 + return proxy + + class FakeManager: + async def stop(self, document: ProxyDocument) -> dict[str, object]: + assert document.pid == 1234 + return { + "status": "stopped", + "pid": None, + "service.upstream_port": None, + "last_error": None, + } + + async def fake_update_proxy_runtime( + proxy_port: int, + values: dict[str, object], + ) -> ProxyDocument: + assert proxy_port == 4040 + assert values["status"] == "stopped" + return proxy.model_copy(update={"status": "stopped", "pid": None}) + + monkeypatch.setattr( + proxy_router_module, + "get_proxy_by_port", + fake_get_proxy_by_port, + ) + monkeypatch.setattr(proxy_router_module, "manager", FakeManager()) + monkeypatch.setattr( + proxy_router_module, + "update_proxy_runtime", + fake_update_proxy_runtime, + ) + + response = client.post("/proxy/4040/stop") + + assert response.status_code == 200 + assert response.json()["status"] == "stopped" + + +def test_list_mitmproxy_examples_returns_builtins_and_custom(client, monkeypatch): + async def fake_list_custom_examples(): + return [] + + monkeypatch.setattr( + proxy_router_module, + "list_custom_examples", + fake_list_custom_examples, + ) + + response = client.get("/proxy/mitmproxy/examples") + + assert response.status_code == 200 + body = response.json() + assert len(body) == len(BUILTIN_EXAMPLES) + assert body[0]["built_in"] is True + + +def test_create_mitmproxy_example_validates_and_persists(client, monkeypatch): + captured = {} + + async def fake_validate_proxy_code(code: str) -> None: + captured["validated"] = code + + async def fake_create_custom_example(payload): + captured["payload"] = payload + return { + "id": payload.id, + "name": payload.name, + "description": payload.description, + "category": payload.category, + "code": payload.code, + "source_url": payload.source_url, + "built_in": False, + } + + monkeypatch.setattr( + proxy_router_module, + "validate_proxy_code", + fake_validate_proxy_code, + ) + monkeypatch.setattr( + proxy_router_module, + "create_custom_example", + fake_create_custom_example, + ) + + response = client.post( + "/proxy/mitmproxy/examples", + json={ + "id": "custom-header", + "name": "Custom header", + "description": "Adds a custom response header", + "category": "http", + "code": "def response(flow):\n pass\n", + }, + ) + + assert response.status_code == 201 + assert response.json()["id"] == "custom-header" + assert captured["validated"] == "def response(flow):\n pass\n" diff --git a/tests/conftest.py b/backend/tests/conftest.py similarity index 90% rename from tests/conftest.py rename to backend/tests/conftest.py index 5a84984..cbf2f42 100644 --- a/tests/conftest.py +++ b/backend/tests/conftest.py @@ -41,12 +41,14 @@ def auth_context(fake_user): @pytest.fixture def app(fake_user, auth_context) -> FastAPI: from api.auth.router import router as auth_router + from api.editor.router import router as editor_router from api.proxy.router import router as proxy_router from core.security import AuthContext, get_auth_context, require_authenticated_user from models.auth import AuthUserDocument app_instance = FastAPI() app_instance.include_router(auth_router) + app_instance.include_router(editor_router) app_instance.include_router(proxy_router) async def _override_current_user() -> AuthUserDocument: @@ -55,9 +57,9 @@ def app(fake_user, auth_context) -> FastAPI: async def _override_auth_context() -> AuthContext: return auth_context - app_instance.dependency_overrides[ - require_authenticated_user - ] = _override_current_user + app_instance.dependency_overrides[require_authenticated_user] = ( + _override_current_user + ) app_instance.dependency_overrides[get_auth_context] = _override_auth_context return app_instance diff --git a/backend/tests/services/test_health.py b/backend/tests/services/test_health.py new file mode 100644 index 0000000..3ff9b42 --- /dev/null +++ b/backend/tests/services/test_health.py @@ -0,0 +1,43 @@ +import asyncio + +from models.proxy import ProxyDocument +from models.service import ServiceEndpointDocument +from services.mitmproxy.health import check_proxy + + +def test_check_proxy_reports_upstream_and_proxy_healthy(): + async def run(): + upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0) + proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0) + try: + upstream_port = upstream.sockets[0].getsockname()[1] + proxy_port = proxy.sockets[0].getsockname()[1] + document = ProxyDocument( + proxy_port=proxy_port, + name="health", + code="", + service=ServiceEndpointDocument(port=upstream_port), + ) + + health = await check_proxy(document) + finally: + upstream.close() + proxy.close() + await upstream.wait_closed() + await proxy.wait_closed() + + assert health["overall"] == "healthy" + assert [check["target"] for check in health["checks"]] == [ + "upstream", + "proxy", + ] + + asyncio.run(run()) + + +async def http_handler(reader, writer): + await reader.readline() + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + await writer.drain() + writer.close() + await writer.wait_closed() diff --git a/backend/tests/services/test_mitmproxy_manager.py b/backend/tests/services/test_mitmproxy_manager.py new file mode 100644 index 0000000..296665f --- /dev/null +++ b/backend/tests/services/test_mitmproxy_manager.py @@ -0,0 +1,75 @@ +import asyncio +from pathlib import Path + +from models.proxy import ProxyDocument +from models.service import ServiceEndpointDocument +from services.mitmproxy.manager import MitmdumpManager + + +def make_proxy(**updates): + data = { + "proxy_port": 8080, + "name": "svc", + "code": "", + "service": ServiceEndpointDocument(port=9000), + } + data.update(updates) + return ProxyDocument(**data) + + +def test_reverse_mode_is_built_from_service(): + manager = MitmdumpManager() + proxy = make_proxy(protocol="tcp") + + command = manager.build_command( + "mitmdump", + proxy, + 9100, + Path("/tmp/addon.py"), + Path("/tmp/conf/config.yaml"), + ) + + assert "reverse:tcp://127.0.0.1:9100@8080" in command + assert "--listen-port" not in command + + +def test_raw_mode_spec_overrides_generated_mode(): + manager = MitmdumpManager() + proxy = make_proxy(mode="regular", mode_spec="wireguard:/tmp/wg.conf@51820") + + command = manager.build_command( + "mitmdump", + proxy, + None, + Path("/tmp/addon.py"), + Path("/tmp/conf/config.yaml"), + ) + + assert "wireguard:/tmp/wg.conf@51820" in command + assert "--listen-port" not in command + + +def test_regular_mode_uses_proxy_port_as_listen_port(): + manager = MitmdumpManager() + proxy = make_proxy(mode="regular") + + command = manager.build_command( + "mitmdump", + proxy, + None, + Path("/tmp/addon.py"), + Path("/tmp/conf/config.yaml"), + ) + + assert "regular" in command + assert command[-2:] == ["--listen-port", "8080"] + + +def test_running_document_without_pid_is_treated_as_stopped(): + manager = MitmdumpManager() + proxy = make_proxy(status="running", pid=None) + + async def run(): + return await manager.status(proxy) + + assert asyncio.run(run()) == "stopped" diff --git a/backend/tests/services/test_mitmproxy_options.py b/backend/tests/services/test_mitmproxy_options.py new file mode 100644 index 0000000..33f1d85 --- /dev/null +++ b/backend/tests/services/test_mitmproxy_options.py @@ -0,0 +1,51 @@ +import pytest +from services.mitmproxy.options import ( + MitmOption, + parse_options, + public_options, + validate_value, +) + + +def test_parse_options_extracts_types_and_choices(): + text = """ +# Enable feature. Type bool. +enabled: false + +# Valid values are 'eager', 'lazy'. +strategy: eager + +# Hosts. Type sequence of str. +hosts: [] +""" + options = parse_options( + text, + {"enabled": False, "strategy": "eager", "hosts": []}, + ) + + assert options["enabled"].value_type == "bool" + assert options["strategy"].choices == ["eager", "lazy"] + assert options["hosts"].value_type == "sequence of str" + + +@pytest.mark.parametrize( + ("option", "value", "message"), + [ + (MitmOption("anticache", "bool", False, "", []), "true", "expected bool"), + (MitmOption("flow_detail", "int", 1, "", []), "1", "expected int"), + (MitmOption("allow_hosts", "sequence of str", [], "", []), "x", "list[str]"), + (MitmOption("strategy", "unknown", "eager", "", ["eager"]), "lazy", "one of"), + ], +) +def test_validate_value_rejects_wrong_types(option, value, message): + assert message in validate_value(option, value) + + +def test_public_options_include_docs_links(): + options = { + "flow_detail": MitmOption("flow_detail", "int", 1, "Flow detail.", []), + } + + public = public_options(options) + + assert public[0]["docs_url"].endswith("#flow_detail") diff --git a/backend/tests/services/test_python_lsp.py b/backend/tests/services/test_python_lsp.py new file mode 100644 index 0000000..0ad6171 --- /dev/null +++ b/backend/tests/services/test_python_lsp.py @@ -0,0 +1,30 @@ +from services.editor import python_lsp + + +def test_encode_message(): + message = '{"jsonrpc":"2.0"}' + + payload = python_lsp.encode_message(message) + + assert payload.endswith(message.encode("utf-8")) + assert payload.startswith( + f"Content-Length: {len(message.encode('utf-8'))}\r\n\r\n".encode("ascii") + ) + + +def test_frame_buffer_reads_split_messages(): + buffer = python_lsp.LspFrameBuffer() + first = python_lsp.encode_message('{"id":1}') + second = python_lsp.encode_message('{"id":2}') + + parts = [ + first[:12], + first[12:] + second[:10], + second[10:], + ] + + messages: list[str] = [] + for part in parts: + messages.extend(buffer.feed(part)) + + assert messages == ['{"id":1}', '{"id":2}'] diff --git a/uv.lock b/backend/uv.lock similarity index 75% rename from uv.lock rename to backend/uv.lock index bb8b07c..e301c14 100644 --- a/uv.lock +++ b/backend/uv.lock @@ -2,6 +2,28 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "aioquic" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cryptography" }, + { name = "pylsqpack" }, + { name = "pyopenssl" }, + { name = "service-identity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/1a/bf10b2c57c06c7452b685368cb1ac90565a6e686e84ec6f84465fb8f78f4/aioquic-1.2.0.tar.gz", hash = "sha256:f91263bb3f71948c5c8915b4d50ee370004f20a416f67fab3dcc90556c7e7199", size = 179891, upload-time = "2024-07-06T23:27:09.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/03/1c385739e504c70ab2a66a4bc0e7cd95cee084b374dcd4dc97896378400b/aioquic-1.2.0-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3e23964dfb04526ade6e66f5b7cd0c830421b8138303ab60ba6e204015e7cb0b", size = 1753473, upload-time = "2024-07-06T23:26:20.809Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1f/4d1c40714db65be828e1a1e2cce7f8f4b252be67d89f2942f86a1951826c/aioquic-1.2.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:84d733332927b76218a3b246216104116f766f5a9b2308ec306cd017b3049660", size = 2083563, upload-time = "2024-07-06T23:26:24.254Z" }, + { url = "https://files.pythonhosted.org/packages/15/48/56a8c9083d1deea4ccaf1cbf5a91a396b838b4a0f8650f4e9f45c7879a38/aioquic-1.2.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2466499759b31ea4f1d17f4aeb1f8d4297169e05e3c1216d618c9757f4dd740d", size = 2555697, upload-time = "2024-07-06T23:26:26.16Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/fa4c981a8a8a903648d4cd6e12c0fca7f44e3ef4ba15a8b99a26af05b868/aioquic-1.2.0-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd75015462ca5070a888110dc201f35a9f4c7459f9201b77adc3c06013611bb8", size = 2149089, upload-time = "2024-07-06T23:26:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0f/4a280923313b831892caaa45348abea89e7dd2e4422a86699bb0e506b1dd/aioquic-1.2.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43ae3b11d43400a620ca0b4b4885d12b76a599c2cbddba755f74bebfa65fe587", size = 2205221, upload-time = "2024-07-06T23:26:30.682Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/a6a1d1762ce06f13b68f524bb9c5f4d6ca7cda9b072d7e744626b89b77be/aioquic-1.2.0-cp38-abi3-win32.whl", hash = "sha256:910d8c91da86bba003d491d15deaeac3087d1b9d690b9edc1375905d8867b742", size = 1214037, upload-time = "2024-07-06T23:26:32.651Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/e8a8a75c93dee0ab229df3c2d17f63cd44d0ad5ee8540e2ec42779ce3a39/aioquic-1.2.0-cp38-abi3-win_amd64.whl", hash = "sha256:e3dcfb941004333d477225a6689b55fc7f905af5ee6a556eb5083be0354e653a", size = 1530339, upload-time = "2024-07-06T23:26:34.753Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -75,6 +97,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "authlib" version = "1.6.9" @@ -87,6 +127,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] +[[package]] +name = "basedpyright" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" }, +] + [[package]] name = "bcrypt" version = "5.0.0" @@ -138,6 +190,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -383,6 +462,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/4f/a5b3cfc3e1779c756c4fef790facef12d63088402a718b167b780d4f2ccc/fastar-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3152a80835ef11cbfaf153037dd694e0014f25d074792b9785421679acdbe179", size = 461150, upload-time = "2026-04-08T01:02:08.47Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -392,6 +488,28 @@ 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 = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -435,6 +553,15 @@ 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 = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -453,6 +580,15 @@ 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 = "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" @@ -465,6 +601,27 @@ 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 = "kaitaistruct" +version = "0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/b8/ca7319556912f68832daa4b81425314857ec08dfccd8dbc8c0f65c992108/kaitaistruct-0.11.tar.gz", hash = "sha256:053ee764288e78b8e53acf748e9733268acbd579b8d82a427b1805453625d74b", size = 11519, upload-time = "2025-09-08T15:46:25.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/4a/cf14bf3b1f5ffb13c69cf5f0ea78031247790558ee88984a8bdd22fae60d/kaitaistruct-0.11-py2.py3-none-any.whl", hash = "sha256:5c6ce79177b4e193a577ecd359e26516d1d6d000a0bffd6e1010f2a46a62a561", size = 11372, upload-time = "2025-09-08T15:46:23.635Z" }, +] + +[[package]] +name = "ldap3" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -516,6 +673,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mitmproxy" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aioquic" }, + { name = "argon2-cffi" }, + { name = "asgiref" }, + { name = "bcrypt" }, + { name = "brotli" }, + { name = "certifi" }, + { name = "cryptography" }, + { name = "flask" }, + { name = "h11" }, + { name = "h2" }, + { name = "hyperframe" }, + { name = "kaitaistruct" }, + { name = "ldap3" }, + { name = "mitmproxy-rs" }, + { name = "msgpack" }, + { name = "publicsuffix2" }, + { name = "pydivert", marker = "sys_platform == 'win32'" }, + { name = "pyopenssl" }, + { name = "pyparsing" }, + { name = "pyperclip" }, + { name = "ruamel-yaml" }, + { name = "sortedcontainers" }, + { name = "tornado" }, + { name = "urwid" }, + { name = "wsproto" }, + { name = "zstandard" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/14/32b4c3a9c3380e0cf1bed1976951e70510c06f1ba7c0dc46f9e69e3965ae/mitmproxy-12.2.2-py3-none-any.whl", hash = "sha256:7b111ba3b83b34b0d9b653044685db7c3f5fbc63b39b8f06439642da83910713", size = 1651385, upload-time = "2026-04-12T21:49:54.312Z" }, +] + +[[package]] +name = "mitmproxy-linux" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/f2/8c776f9bf013752c4521fc8382efc7b55cb238cea69b7963200b4f8da293/mitmproxy_linux-0.12.9.tar.gz", hash = "sha256:94b10fee02aa42287739623cef921e1a53955005d45c9e2fa309ae9f0bf8d37d", size = 1299779, upload-time = "2026-01-30T14:54:13.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/6e/10a2fbcf564e18254293dc7118dc4ec72f3e5897509d7b4f804ab23df5cd/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4413e27c692f30036ad6d73432826e728ede026fac8e51651d0c545dd0177f2", size = 987838, upload-time = "2026-01-30T14:53:59.602Z" }, + { url = "https://files.pythonhosted.org/packages/20/c5/2eeb523019b1ad84ec659fc41b007cbc90ac99e2451c4e7ba7a28d910b04/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee842865a05f69196004ddcb29d50af0602361d9d6acee04f370f7e01c3674e8", size = 1067258, upload-time = "2026-01-30T14:54:01.872Z" }, +] + +[[package]] +name = "mitmproxy-macos" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/71/d5899c5d1593403bccdd4b56306d03a200e14483318f86b882a144f79a32/mitmproxy_macos-0.12.9-py3-none-any.whl", hash = "sha256:20e024fbfeeecbdb4ee2a1e8361d18782146777fdc1e00dcfecd52c22a3219bf", size = 2569740, upload-time = "2026-01-30T14:54:03.379Z" }, +] + +[[package]] +name = "mitmproxy-rs" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mitmproxy-linux", marker = "sys_platform == 'linux'" }, + { name = "mitmproxy-macos", marker = "sys_platform == 'darwin'" }, + { name = "mitmproxy-windows", marker = "os_name == 'nt'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/5c/16a61303da76cd34aa6ddbd7ef6ac66d9ef8514c4d3a5b71831169d63236/mitmproxy_rs-0.12.9.tar.gz", hash = "sha256:c6ffc35c002c675cac534442d92d1cdebd66fafd63754ad33b92ae968ea6e449", size = 1334424, upload-time = "2026-01-30T14:54:15.043Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/78/dc9f4b4ef894709853407291ab281e478cb122b993633125b858eea523ba/mitmproxy_rs-0.12.9-cp312-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:afeb3a2da2bc26474e1a2febaea4432430c5fde890dfce33bc4c1e65e6baef1b", size = 7145620, upload-time = "2026-01-30T14:54:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6f/1ebd9ca748bf62eb90657b41692c46716cff03aaf134260a249a2ae2d251/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245922663440330c4b5a36d0194ed559b1dbd5e38545db2eb947180ed12a5e92", size = 3084785, upload-time = "2026-01-30T14:54:06.797Z" }, + { url = "https://files.pythonhosted.org/packages/10/af/fc2f2b30a6ade8646d276c4813f68b86d775696d467f12df32613d22c638/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fb9fb4aac9ecb82e2c3c5c439ef5e4961be7934d80ade5e9a99c0a944b8ea2f", size = 3252443, upload-time = "2026-01-30T14:54:08.908Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/b065c6a1eb27effec3368b03bdc842f6f611800ee5f990d994884286f160/mitmproxy_rs-0.12.9-cp312-abi3-win_amd64.whl", hash = "sha256:1fd716e87da8be3c62daa4325a5ff42bedd951fb8614c5f66caa94b7c21e2593", size = 3321769, upload-time = "2026-01-30T14:54:10.735Z" }, +] + +[[package]] +name = "mitmproxy-windows" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/83/2712af146c5f6a59a7f4658c02356b241c40ba19cb2b16db94235e95b699/mitmproxy_windows-0.12.9-py3-none-any.whl", hash = "sha256:fdec21fb66a5ba237d9106bfdc09d9428f315551bf4b41ba06b261e7beb56417", size = 464363, upload-time = "2026-01-30T14:54:12.531Z" }, +] + [[package]] name = "motor" version = "3.7.1" @@ -528,6 +764,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -546,6 +824,15 @@ 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 = "publicsuffix2" +version = "2.20191221" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/04/1759906c4c5b67b2903f546de234a824d4028ef24eb0b1122daa43376c20/publicsuffix2-2.20191221.tar.gz", hash = "sha256:00f8cc31aa8d0d5592a5ced19cccba7de428ebca985db26ac852d920ddd6fe7b", size = 99592, upload-time = "2019-12-21T11:30:44.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/16/053c2945c5e3aebeefb4ccd5c5e7639e38bc30ad1bdc7ce86c6d01707726/publicsuffix2-2.20191221-py2.py3-none-any.whl", hash = "sha256:786b5e36205b88758bd3518725ec8cfe7a8173f5269354641f581c6b80a99893", size = 89033, upload-time = "2019-12-21T11:30:41.744Z" }, +] + [[package]] name = "pwdlib" version = "0.3.0" @@ -563,6 +850,27 @@ bcrypt = [ { name = "bcrypt" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -658,6 +966,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pydivert" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/71/2da9bcf742df3ab23f75f10fedca074951dd13a84bda8dea3077f68ae9a6/pydivert-2.1.0.tar.gz", hash = "sha256:f0e150f4ff591b78e35f514e319561dadff7f24a82186a171dd4d465483de5b4", size = 91057, upload-time = "2017-10-20T21:36:58.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/8f/86d7931c62013a5a7ebf4e1642a87d4a6050c0f570e714f61b0df1984c62/pydivert-2.1.0-py2.py3-none-any.whl", hash = "sha256:382db488e3c37c03ec9ec94e061a0b24334d78dbaeebb7d4e4d32ce4355d9da1", size = 104718, upload-time = "2017-10-20T21:36:56.726Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -667,6 +984,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pylsqpack" +version = "0.3.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/a0/20b34e654b911a9abb736b242cc0a11912bc79ea3e911f139ea756e39ea2/pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890", size = 677187, upload-time = "2026-03-29T15:42:40.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/88/71b79d334f67dd595fbed5f3a337e2aa997a96e452bb1b64120bccf5679d/pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d", size = 162525, upload-time = "2026-03-29T15:42:25.436Z" }, + { url = "https://files.pythonhosted.org/packages/4e/96/f0a7625075394e93db42bd476abb7240ff1a474acd1ad404158baf68dc6a/pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc", size = 168643, upload-time = "2026-03-29T15:42:26.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/de/49ec59856ea41468ed879ec143fc429729e37e4860b2119959a2a66fb652/pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a", size = 246930, upload-time = "2026-03-29T15:42:28.136Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d3/3e748fa5317782bfe68a7eaf890524aee48281c59f07e9bdfd7774f158db/pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c", size = 249234, upload-time = "2026-03-29T15:42:29.583Z" }, + { url = "https://files.pythonhosted.org/packages/22/5b/06f5e354ed882ce036ed65f2a393c98d0f6c71a23fa64b53251ddeb40a7b/pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c", size = 250274, upload-time = "2026-03-29T15:42:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/61/0e/c95cae2817a5c272b7a3132376165aa16875efcccbbd3e6608f5082770cc/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9", size = 246966, upload-time = "2026-03-29T15:42:32.035Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/d5e84c3b4b2fa716df9e95aeb40d3bfb4de50c21cccccd66e194cfc084ac/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b", size = 246546, upload-time = "2026-03-29T15:42:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/88e442ced83c0305f50f45bf521bbce3344ef0c29c3442f010086ff0c124/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0", size = 248517, upload-time = "2026-03-29T15:42:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c2/886348974bba20db2a80cf37e97203d7334223b3c1c1babe4159dd12626d/pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767", size = 153483, upload-time = "2026-03-29T15:42:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/0d/22/adbce7adfb41b8f5f222195f7f4f5e58655aa3e83f525bc5f3882b07d6e8/pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff", size = 156145, upload-time = "2026-03-29T15:42:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2e/6fb6d797ce88741a0e18984bbab69160abc0971a41f4478cab6c8255a8dc/pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9", size = 153424, upload-time = "2026-03-29T15:42:38.73Z" }, +] + [[package]] name = "pymongo" version = "4.16.0" @@ -698,6 +1034,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" }, ] +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -832,6 +1198,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "ruff" version = "0.15.10" @@ -870,6 +1245,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/64/982e07b93219cb52e1cca5d272cb579e2f3eb001956c9e7a9a6d106c9473/sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585", size = 456489, upload-time = "2026-03-31T09:39:27.524Z" }, ] +[[package]] +name = "service-identity" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -879,6 +1269,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -897,37 +1296,56 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "authlib" }, + { name = "basedpyright" }, { name = "fastapi", extra = ["standard"] }, + { name = "mitmproxy" }, { name = "motor" }, { name = "pwdlib", extra = ["argon2", "bcrypt"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, { name = "redis" }, + { name = "ruff" }, ] [package.dev-dependencies] dev = [ { name = "pytest" }, - { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.6.0" }, + { name = "basedpyright", specifier = ">=1.39.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, + { name = "mitmproxy", specifier = ">=12.2.2" }, { name = "motor", specifier = ">=3.7.1" }, { name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "redis", specifier = ">=7.4.0" }, + { name = "ruff", specifier = ">=0.15.10" }, ] [package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=9.0.3" }, - { name = "ruff", specifier = ">=0.15.10" }, +dev = [{ name = "pytest", specifier = ">=9.0.3" }] + +[[package]] +name = "tornado" +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, ] [[package]] @@ -975,6 +1393,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "urwid" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/59/67cd42db7c549c0c106d2b56d2d2ec1915c459e0a92722029efc5359e871/urwid-3.0.5.tar.gz", hash = "sha256:24be27ffafdb68c09cd95dc21b60ccfd02843320b25ce5feee1708b34fad5a23", size = 860746, upload-time = "2026-02-02T14:38:43.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/6a/efd8ad30ab250a4ad23dc7c07437b374921042cb35215e468d49977822f7/urwid-3.0.5-py3-none-any.whl", hash = "sha256:f188144261224fdfc9b56b4222869bd0eac90fd7895cf1e376129cdc7e13bc84", size = 296957, upload-time = "2026-02-02T14:38:42.044Z" }, +] + [[package]] name = "uvicorn" version = "0.44.0" @@ -1053,6 +1483,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, ] +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + [[package]] name = "websockets" version = "16.0" @@ -1079,3 +1518,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/docker-compose.yml b/docker-compose.yml index 9ba7067..b41bbcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,48 @@ services: + backend: + build: + context: ./backend + container_name: theplug-backend + restart: unless-stopped + env_file: + - path: ./backend/.env + required: false + environment: + MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin + MONGODB_DB_NAME: ${MONGO_DB:-theplug} + REDIS_URL: redis://redis:6379 + ports: + - "${API_PORT:-3671}:3671" + depends_on: + mongodb: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + build: + context: ./frontend + container_name: theplug-frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-5173}:80" + depends_on: + - backend + mongodb: image: mongo:8.2 container_name: theplug-mongodb restart: unless-stopped environment: MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug} - MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password} MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug} ports: - "27017:27017" volumes: - - mongodb_data:/data/db + - theplug_mongodb:/data/db healthcheck: - test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"] + test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"] interval: 10s timeout: 5s retries: 5 @@ -26,7 +56,7 @@ services: ports: - "6379:6379" volumes: - - redis_data:/data + - theplug_redis:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -35,5 +65,5 @@ services: start_period: 5s volumes: - mongodb_data: - redis_data: + theplug_mongodb: + theplug_redis: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..2ffe469 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +.vite +npm-debug.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a80161b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.29-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..aa9b5e2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + thePlug + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..6f312b2 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:3671/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + 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; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b26f438 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1670 @@ +{ + "name": "theplug-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "theplug-frontend", + "version": "0.1.0", + "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lint": "^6.9.5", + "@codemirror/lsp-client": "^6.2.3", + "@codemirror/search": "^6.7.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.1", + "codemirror": "^6.0.2", + "vue": "^3.5.26" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "typescript": "^5.9.3", + "vite": "^7.2.7", + "vue-tsc": "^3.1.8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "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.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.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.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/lsp-client": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/@codemirror/lsp-client/-/lsp-client-6.2.3.tgz", + "integrity": "sha512-tDGoLzOU8npz5TadVFtZEU0oRbJXEEM+HpRBfW4w/lWvIjQDkDAmqUtAlP04io1HMmWcGnTBoSwXVOTH+80otw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/language": "^6.11.0", + "@codemirror/lint": "^6.8.5", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.37.0", + "@lezer/highlight": "^1.2.1", + "marked": "^15.0.12", + "vscode-languageserver-protocol": "^3.17.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.1.tgz", + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "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/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.8.tgz", + "integrity": "sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.1.2", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "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/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "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/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/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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "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.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "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/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.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/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.8.tgz", + "integrity": "sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.8" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "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" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..328dbf3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "theplug-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vue-tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0", + "typecheck": "vue-tsc -b" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lint": "^6.9.5", + "@codemirror/lsp-client": "^6.2.3", + "@codemirror/search": "^6.7.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.1", + "codemirror": "^6.0.2", + "vue": "^3.5.26" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "typescript": "^5.9.3", + "vite": "^7.2.7", + "vue-tsc": "^3.1.8" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..a8a395b --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..99513b9 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,109 @@ +import type { + AddonExample, + DockerService, + HealthResult, + MitmOption, + PythonEditorSession, + ProxyDocument, + ProxyDraft, +} from "./types"; + +const TOKEN_KEY = "theplug.token"; +export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string | null) { + if (token) { + localStorage.setItem(TOKEN_KEY, token); + return; + } + localStorage.removeItem(TOKEN_KEY); +} + +export function buildSocketUrl(path: string) { + const token = getToken(); + const base = new URL(API_BASE, window.location.origin); + base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; + base.pathname = `${base.pathname.replace(/\/$/, "")}${path}`; + if (token) { + base.searchParams.set("token", token); + } + return base.toString(); +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = getToken(); + + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${API_BASE}${path}`, { ...init, headers }); + if (response.status === 204) { + return undefined as T; + } + const body = await response.json().catch(() => null); + if (!response.ok) { + const detail = body?.detail ?? response.statusText; + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + return body as T; +} + +export async function login(username: string, password: string) { + const response = await request<{ access_token: string }>("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + setToken(response.access_token); +} + +export async function logout() { + await request("/auth/logout", { method: "POST" }).catch(() => undefined); + setToken(null); +} + +export const api = { + proxies: () => request("/proxy"), + services: () => request("/proxy/services"), + options: () => request("/proxy/mitmproxy/options"), + examples: () => request("/proxy/mitmproxy/examples"), + editorSession: () => + request("/proxy/editor/python/session"), + createExample: (payload: AddonExample) => + request("/proxy/mitmproxy/examples", { + method: "POST", + body: JSON.stringify(payload), + }), + deleteExample: (id: string) => + request(`/proxy/mitmproxy/examples/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + createProxy: (payload: ProxyDraft) => + request("/proxy", { + method: "POST", + body: JSON.stringify(payload), + }), + updateProxy: (port: number, payload: Partial) => + request(`/proxy/${port}`, { + method: "PUT", + body: JSON.stringify(payload), + }), + deleteProxy: (port: number) => + request(`/proxy/${port}`, { method: "DELETE" }), + start: (port: number) => + request(`/proxy/${port}/start`, { method: "POST" }), + stop: (port: number) => + request(`/proxy/${port}/stop`, { method: "POST" }), + restart: (port: number) => + request(`/proxy/${port}/restart`, { method: "POST" }), + health: (port: number) => request(`/proxy/${port}/health`), + logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`), +}; diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..06fb21b --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/src/components/AppLogo.vue b/frontend/src/components/AppLogo.vue new file mode 100644 index 0000000..151fcc3 --- /dev/null +++ b/frontend/src/components/AppLogo.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue new file mode 100644 index 0000000..859c16f --- /dev/null +++ b/frontend/src/components/CodeEditor.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/components/EditorPane.vue b/frontend/src/components/EditorPane.vue new file mode 100644 index 0000000..dded7be --- /dev/null +++ b/frontend/src/components/EditorPane.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/ExamplesPanel.vue b/frontend/src/components/ExamplesPanel.vue new file mode 100644 index 0000000..c580ff5 --- /dev/null +++ b/frontend/src/components/ExamplesPanel.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/components/HealthPanel.vue b/frontend/src/components/HealthPanel.vue new file mode 100644 index 0000000..7feb1d6 --- /dev/null +++ b/frontend/src/components/HealthPanel.vue @@ -0,0 +1,111 @@ + + + diff --git a/frontend/src/components/LoginView.vue b/frontend/src/components/LoginView.vue new file mode 100644 index 0000000..02f487f --- /dev/null +++ b/frontend/src/components/LoginView.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/components/LogsPanel.vue b/frontend/src/components/LogsPanel.vue new file mode 100644 index 0000000..bdba82b --- /dev/null +++ b/frontend/src/components/LogsPanel.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/OptionsPanel.vue b/frontend/src/components/OptionsPanel.vue new file mode 100644 index 0000000..5e2e57f --- /dev/null +++ b/frontend/src/components/OptionsPanel.vue @@ -0,0 +1,142 @@ + + + diff --git a/frontend/src/components/ProxyList.vue b/frontend/src/components/ProxyList.vue new file mode 100644 index 0000000..117b4eb --- /dev/null +++ b/frontend/src/components/ProxyList.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/ProxyWorkbench.vue b/frontend/src/components/ProxyWorkbench.vue new file mode 100644 index 0000000..08688a9 --- /dev/null +++ b/frontend/src/components/ProxyWorkbench.vue @@ -0,0 +1,156 @@ + + + diff --git a/frontend/src/components/RuntimePanel.vue b/frontend/src/components/RuntimePanel.vue new file mode 100644 index 0000000..092ad31 --- /dev/null +++ b/frontend/src/components/RuntimePanel.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/components/ServicePanel.vue b/frontend/src/components/ServicePanel.vue new file mode 100644 index 0000000..0833a46 --- /dev/null +++ b/frontend/src/components/ServicePanel.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/components/StatusPill.vue b/frontend/src/components/StatusPill.vue new file mode 100644 index 0000000..8f45bfd --- /dev/null +++ b/frontend/src/components/StatusPill.vue @@ -0,0 +1,9 @@ + + + diff --git a/frontend/src/composables/useControlPlane.ts b/frontend/src/composables/useControlPlane.ts new file mode 100644 index 0000000..ef4e417 --- /dev/null +++ b/frontend/src/composables/useControlPlane.ts @@ -0,0 +1,571 @@ +import { + computed, + inject, + proxyRefs, + reactive, + ref, + watch, + type InjectionKey, +} from "vue"; + +import { api, getToken, login, logout } from "../api"; +import type { + AddonExample, + DockerService, + HealthResult, + MitmOption, + ProxyDocument, + ProxyDraft, +} from "../types"; + +const editorStorageKey = "theplug.editor.open"; +const toolStorageKey = "theplug.tool.view"; + +export const modes = [ + "reverse", + "regular", + "socks5", + "upstream", + "wireguard", + "transparent", + "local", + "tun", + "dns", +]; + +export const protocols = [ + "http", + "https", + "tcp", + "tls", + "udp", + "dtls", + "dns", + "http3", + "quic", +]; + +export type RuntimeAction = "start" | "stop" | "restart"; +export type ToolView = "options" | "examples" | "logs"; + +export function createDefaultDraft(port = 8080, code?: string): ProxyDraft { + return { + proxy_port: port, + name: "new-proxy", + code: + code ?? + `from mitmproxy import http + + +def request(flow: http.HTTPFlow) -> None: + pass +`, + mode: "reverse", + mode_spec: null, + protocol: "http", + listen_host: "0.0.0.0", + mitm_options: { + flow_detail: 1, + termlog_verbosity: "info", + }, + healthcheck: { + enabled: true, + protocol: null, + path: "/", + method: "GET", + expected_status: null, + udp_payload: "", + expect_response: true, + verify_tls: false, + timeout_seconds: 2, + }, + service: { + host: "127.0.0.1", + port: 9000, + name: null, + folder: null, + upstream_port: null, + }, + }; +} + +function readSession(key: string, fallback: string) { + if (typeof window === "undefined") return fallback; + return window.sessionStorage.getItem(key) ?? fallback; +} + +function writeSession(key: string, value: string) { + if (typeof window === "undefined") return; + window.sessionStorage.setItem(key, value); +} + +export function useControlPlane() { + const token = ref(getToken()); + const username = ref("admin"); + const password = ref(""); + const error = ref(""); + const notice = ref(""); + const loading = ref(false); + const proxies = ref([]); + const services = ref([]); + const options = ref([]); + const examples = ref([]); + const selectedPort = ref(null); + const health = ref(null); + const logs = ref(""); + const optionSearch = ref(""); + const exampleSearch = ref(""); + const customExampleName = ref(""); + const editorOpen = ref(readSession(editorStorageKey, "0") === "1"); + const toolView = ref( + readSession(toolStorageKey, "options") as ToolView, + ); + const selectedOptionName = ref(""); + const selectedExampleId = ref(""); + const draft = reactive(createDefaultDraft()); + + const selectedProxy = computed(() => + proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value), + ); + + const routeState = computed(() => { + const livePort = + selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null; + const dockerManaged = Boolean(draft.service.name && draft.service.folder); + const reverseManaged = draft.mode === "reverse" && !draft.mode_spec; + const remapped = livePort !== null && livePort !== draft.service.port; + const configuredTarget = `${draft.service.host}:${draft.service.port}`; + const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`; + + let startNotice = "Save stores configuration only. Start applies runtime routing."; + if (reverseManaged && dockerManaged) { + startNotice = + `Starting this reverse proxy will move the service from :${draft.service.port} ` + + `to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`; + } else if (draft.mode_spec) { + startNotice = + "This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured."; + } else if (draft.mode !== "reverse") { + startNotice = + "This mode does not use Docker port remapping. Start only launches the configured mitmdump listener."; + } + + let liveNotice = "No runtime remap is active."; + if (remapped) { + liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`; + } else if (reverseManaged && dockerManaged) { + liveNotice = "The original service port stays unchanged until Start is executed."; + } + + return { + configuredListen: `${draft.listen_host}:${draft.proxy_port}`, + configuredTarget, + liveTarget, + modeLabel: draft.mode_spec ?? `${draft.mode} / ${draft.protocol}`, + serviceLabel: draft.service.name ?? "manual upstream", + dockerManaged, + reverseManaged, + remapped, + livePort, + startNotice, + liveNotice, + }; + }); + + const filteredOptions = computed(() => { + const query = optionSearch.value.toLowerCase().trim(); + const available = options.value.filter((option) => !option.reserved); + const filtered = available + .filter((option) => { + if (!query) return true; + return `${option.name} ${option.description}`.toLowerCase().includes(query); + }) + .sort((a, b) => a.name.localeCompare(b.name)); + return filtered.slice(0, query ? 80 : 36); + }); + + const optionCount = computed( + () => options.value.filter((option) => !option.reserved).length, + ); + + const selectedOption = computed( + () => + filteredOptions.value.find((option) => option.name === selectedOptionName.value) ?? + filteredOptions.value[0] ?? + null, + ); + + const filteredExamples = computed(() => { + const query = exampleSearch.value.toLowerCase().trim(); + const filtered = examples.value.filter((example) => { + if (!query) return true; + return `${example.name} ${example.category} ${example.description}` + .toLowerCase() + .includes(query); + }); + const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name)); + return query ? sorted : sorted.slice(0, 24); + }); + + const selectedExample = computed( + () => + filteredExamples.value.find((example) => example.id === selectedExampleId.value) ?? + filteredExamples.value[0] ?? + null, + ); + + const activeOptions = computed(() => + Object.entries(draft.mitm_options) + .map(([name, value]) => ({ + name, + value: Array.isArray(value) ? value.join(", ") : String(value ?? ""), + option: options.value.find((item) => item.name === name), + })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + + const codeLineCount = computed(() => draft.code.split("\n").length); + + watch(selectedProxy, (proxy) => { + if (!proxy) return; + Object.assign(draft, structuredClone(proxy)); + health.value = null; + logs.value = ""; + }); + + watch(editorOpen, (value) => { + writeSession(editorStorageKey, value ? "1" : "0"); + }); + + watch(toolView, (value) => { + writeSession(toolStorageKey, value); + }); + + watch(filteredOptions, (items) => { + if (!items.some((item) => item.name === selectedOptionName.value)) { + selectedOptionName.value = items[0]?.name ?? ""; + } + }, { immediate: true }); + + watch(filteredExamples, (items) => { + if (!items.some((item) => item.id === selectedExampleId.value)) { + selectedExampleId.value = items[0]?.id ?? ""; + } + }, { immediate: true }); + + async function signIn() { + await run(async () => { + await login(username.value, password.value); + token.value = getToken(); + password.value = ""; + await refreshAll(); + }, "Logged in"); + } + + async function signOut() { + await logout(); + token.value = null; + proxies.value = []; + } + + async function refreshAll() { + await run(async () => { + const [proxyList, serviceList, optionList, exampleList] = await Promise.all([ + api.proxies(), + api.services(), + api.options(), + api.examples(), + ]); + proxies.value = proxyList; + services.value = serviceList; + options.value = optionList; + examples.value = exampleList; + if (!selectedPort.value && proxyList[0]) { + selectedPort.value = proxyList[0].proxy_port; + } + }); + } + + function newProxy() { + selectedPort.value = null; + health.value = null; + logs.value = ""; + Object.assign( + draft, + createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code), + ); + notice.value = "New proxy draft created. Save stores configuration only."; + toolView.value = "options"; + } + + async function saveProxy() { + await run(async () => { + const payload = cleanDraft(); + const saved = selectedProxy.value + ? await api.updateProxy(selectedProxy.value.proxy_port, payload) + : await api.createProxy(payload); + await refreshAll(); + selectedPort.value = saved.proxy_port; + }, "Proxy saved"); + } + + async function removeProxy() { + const proxy = selectedProxy.value; + if (!proxy) return; + await run(async () => { + await api.deleteProxy(proxy.proxy_port); + selectedPort.value = null; + await refreshAll(); + }, "Proxy deleted"); + } + + async function runtimeAction(kind: RuntimeAction) { + const proxy = selectedProxy.value; + if (!proxy) return; + await run(async () => { + const port = proxy.proxy_port; + const updated = + kind === "start" + ? await api.start(port) + : kind === "stop" + ? await api.stop(port) + : await api.restart(port); + replaceProxy(updated); + selectedPort.value = updated.proxy_port; + }, `Proxy ${kind} requested`); + } + + async function checkHealth() { + const proxy = selectedProxy.value; + if (!proxy) return; + await run(async () => { + health.value = await api.health(proxy.proxy_port); + }); + } + + async function loadLogs() { + const proxy = selectedProxy.value; + if (!proxy) return; + await run(async () => { + logs.value = (await api.logs(proxy.proxy_port)).log; + }); + } + + async function saveCurrentAsExample() { + const name = customExampleName.value.trim(); + if (!name) { + error.value = "Custom example name is required"; + return; + } + const id = + name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || + `example-${Date.now()}`; + await run(async () => { + await api.createExample({ + id, + name, + description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`, + category: "custom", + code: draft.code, + source_url: null, + built_in: false, + }); + examples.value = await api.examples(); + customExampleName.value = ""; + selectedExampleId.value = id; + }, "Custom example saved"); + } + + async function removeExample(id: string) { + await run(async () => { + await api.deleteExample(id); + examples.value = await api.examples(); + }, "Custom example deleted"); + } + + function applyService(service: DockerService, port: number) { + draft.service.name = service.name; + draft.service.folder = service.folder; + draft.service.port = port; + draft.service.host = "127.0.0.1"; + } + + function applyExample(example: AddonExample) { + selectedExampleId.value = example.id; + draft.code = example.code; + notice.value = editorOpen.value + ? `Loaded example: ${example.name}` + : `Loaded example: ${example.name}. Open the editor to review or modify the code.`; + } + + function selectExample(exampleId: string) { + selectedExampleId.value = exampleId; + } + + function setOption(option: MitmOption) { + selectedOptionName.value = option.name; + if (!(option.name in draft.mitm_options)) { + draft.mitm_options[option.name] = defaultFor(option); + } + } + + function selectOption(optionName: string) { + selectedOptionName.value = optionName; + } + + function removeOption(name: string) { + delete draft.mitm_options[name]; + } + + function updateOption(name: string, value: string, option?: MitmOption) { + if (option?.type === "bool") { + draft.mitm_options[name] = value === "true"; + return; + } + if (option?.type === "int" || option?.type === "optional int") { + draft.mitm_options[name] = value === "" ? null : Number(value); + return; + } + if (option?.type === "sequence of str") { + draft.mitm_options[name] = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + return; + } + draft.mitm_options[name] = value; + } + + function toggleEditor(force?: boolean) { + editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value; + } + + function setToolView(view: ToolView) { + toolView.value = view; + } + + function cleanDraft(): ProxyDraft { + return JSON.parse( + JSON.stringify({ + proxy_port: draft.proxy_port, + name: draft.name, + code: draft.code, + mode: draft.mode, + mode_spec: draft.mode_spec || null, + protocol: draft.protocol, + listen_host: draft.listen_host, + mitm_options: draft.mitm_options, + healthcheck: { + ...draft.healthcheck, + expected_status: draft.healthcheck.expected_status || null, + protocol: draft.healthcheck.protocol || null, + }, + service: { + host: draft.service.host, + port: draft.service.port, + name: draft.service.name || null, + folder: draft.service.folder || null, + upstream_port: draft.service.upstream_port || null, + }, + }), + ) as ProxyDraft; + } + + function replaceProxy(proxy: ProxyDocument) { + const index = proxies.value.findIndex( + (item) => item.proxy_port === proxy.proxy_port, + ); + if (index === -1) proxies.value.push(proxy); + else proxies.value[index] = proxy; + } + + function nextPort() { + const used = new Set(proxies.value.map((proxy) => proxy.proxy_port)); + let port = 8080; + while (used.has(port)) port += 1; + return port; + } + + function defaultFor(option: MitmOption) { + if (option.default !== null && option.default !== undefined) return option.default; + if (option.type === "bool") return false; + if (option.type === "int" || option.type === "optional int") return 0; + if (option.type === "sequence of str") return []; + return ""; + } + + async function run(task: () => Promise, success?: string) { + loading.value = true; + error.value = ""; + notice.value = ""; + try { + await task(); + if (success) notice.value = success; + } catch (err) { + error.value = err instanceof Error ? err.message : String(err); + } finally { + loading.value = false; + } + } + + return proxyRefs({ + activeOptions, + applyExample, + applyService, + checkHealth, + codeLineCount, + customExampleName, + draft, + editorOpen, + error, + exampleSearch, + filteredExamples, + filteredOptions, + health, + loadLogs, + loading, + logs, + newProxy, + notice, + optionCount, + optionSearch, + password, + proxies, + refreshAll, + removeExample, + removeOption, + removeProxy, + routeState, + runtimeAction, + saveCurrentAsExample, + saveProxy, + selectExample, + selectedExample, + selectedOption, + selectedPort, + selectedProxy, + selectOption, + services, + setOption, + setToolView, + signIn, + signOut, + toggleEditor, + token, + toolView, + updateOption, + username, + }); +} + +export type ControlPlane = ReturnType; + +export const controlPlaneKey: InjectionKey = Symbol("control-plane"); + +export function useControlPlaneContext() { + const context = inject(controlPlaneKey); + if (!context) { + throw new Error("Control plane context is not available"); + } + return context; +} diff --git a/frontend/src/editor/pythonLsp.ts b/frontend/src/editor/pythonLsp.ts new file mode 100644 index 0000000..43220b4 --- /dev/null +++ b/frontend/src/editor/pythonLsp.ts @@ -0,0 +1,65 @@ +import { + LSPClient, + languageServerExtensions, + type Transport, +} from "@codemirror/lsp-client"; + +import { buildSocketUrl } from "../api"; +import type { PythonEditorSession } from "../types"; + +export type PythonLspConnection = { + client: LSPClient; + socket: WebSocket; +}; + +export function connectPythonLsp( + session: PythonEditorSession, +): Promise { + const url = buildSocketUrl("/proxy/editor/python/ws"); + const socket = new WebSocket(url); + + return new Promise((resolve, reject) => { + let handlers: Array<(value: string) => void> = []; + let settled = false; + + socket.addEventListener("message", (event) => { + const value = + typeof event.data === "string" ? event.data : String(event.data ?? ""); + for (const handler of handlers) handler(value); + }); + + socket.addEventListener("open", () => { + const transport: Transport = { + send(message) { + socket.send(message); + }, + subscribe(handler) { + handlers.push(handler); + }, + unsubscribe(handler) { + handlers = handlers.filter((value) => value !== handler); + }, + }; + + const client = new LSPClient({ + rootUri: session.root_uri, + extensions: languageServerExtensions(), + }).connect(transport); + + settled = true; + resolve({ client, socket }); + }); + + socket.addEventListener("error", () => { + if (settled) return; + settled = true; + reject(new Error("Unable to connect to the Python language server")); + }); + + socket.addEventListener("close", (event) => { + if (settled || socket.readyState !== WebSocket.CLOSED) return; + settled = true; + reject(new Error(event.reason || "Python language server connection closed")); + }); + }); +} diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..c6271f0 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,8 @@ +/// + +declare module "*?worker" { + const workerConstructor: { + new (): Worker; + }; + export default workerConstructor; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..f2c36ba --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from "vue"; + +import App from "./App.vue"; +import "./styles.css"; + +createApp(App).mount("#app"); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..88cf49b --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,1003 @@ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +:root { + color-scheme: dark; + --bg: #08090b; + --bg-elevated: #0d1014; + --panel: #11151a; + --panel-2: #151b21; + --line: #252d35; + --line-strong: #33404d; + --text: #eef3f7; + --muted: #9da8b2; + --accent: #9be7ff; + --accent-2: #c6f2ff; + --ok: #8ef0b0; + --warn: #ffd88c; + --danger: #ff9f9f; + --radius-lg: 14px; + --radius-md: 10px; + --radius-sm: 8px; + --shadow: 0 20px 40px rgba(0, 0, 0, 0.22); +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; +} + +html { + background: var(--bg); +} + +body { + margin: 0; + color: var(--text); + font-family: "IBM Plex Sans", sans-serif; + background: + linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px), + var(--bg); + background-size: 28px 28px, 28px 28px, auto; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +input, +select, +textarea { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + transition: + border-color 140ms ease, + background 140ms ease, + color 140ms ease; +} + +button { + cursor: pointer; + padding: 0.7rem 0.9rem; + color: var(--text); + background: #171d24; +} + +button:hover:not(:disabled) { + border-color: var(--line-strong); + background: #1c232b; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button.primary { + color: #061016; + background: var(--accent); + border-color: #7dcfe7; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-2); +} + +button.ghost { + background: #12171d; +} + +button.danger { + color: #ffe1e1; + background: #2b1717; + border-color: #5f2d2d; +} + +button.danger:hover:not(:disabled) { + background: #361d1d; +} + +input, +select, +textarea { + width: 100%; + padding: 0.7rem 0.85rem; + color: var(--text); + background: #0d1116; +} + +input::placeholder, +textarea::placeholder { + color: #6f7a84; +} + +input:focus, +select:focus, +textarea:focus, +button:focus-visible, +summary:focus-visible, +a:focus-visible { + outline: none; + border-color: #66cbe6; + box-shadow: 0 0 0 2px rgba(102, 203, 230, 0.16); +} + +label { + display: grid; + gap: 0.45rem; + color: var(--muted); + font-size: 0.86rem; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +small, +.small, +.muted { + color: var(--muted); +} + +pre { + margin: 0; +} + +#app { + position: relative; +} + +.login-screen { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; +} + +.login-card { + width: min(460px, 100%); + display: grid; + gap: 1.2rem; + padding: 1.6rem; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--panel); + box-shadow: var(--shadow); +} + +.login-brand { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.9rem; + align-items: center; +} + +.login-card form { + display: grid; + gap: 0.9rem; +} + +.app-shell { + min-height: 100vh; + padding: 1rem; +} + +.app-header, +.panel { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--panel); + box-shadow: var(--shadow); +} + +.app-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 1rem; + align-items: center; + padding: 0.9rem 1rem; +} + +.brand { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.85rem; + align-items: center; +} + +.brand strong, +.brand small { + display: block; +} + +.brand strong { + font-size: 1rem; +} + +.brand small, +.team, +.eyebrow, +.status-pill { + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.brand small, +.eyebrow { + font-size: 0.72rem; +} + +.header-meta, +.header-actions { + display: flex; + align-items: center; + gap: 0.65rem; + justify-content: flex-end; +} + +.team { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.55rem; + border: 1px solid var(--line); + border-radius: 999px; + background: #0d1217; + color: var(--accent); + font-size: 0.68rem; +} + +.app-body { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 1rem; + align-items: start; + margin-top: 1rem; +} + +.panel-title { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.panel-title h2, +.workbench-copy h1, +.login-card h1 { + margin: 0.2rem 0 0; + font-size: clamp(1.18rem, 1.7vw, 1.5rem); + line-height: 1.1; +} + +.eyebrow { + margin: 0; + color: var(--accent); +} + +.counter, +.type-chip, +.route-token, +.route-target, +.status-pill { + font-family: "IBM Plex Mono", monospace; +} + +.counter { + display: inline-grid; + place-items: center; + min-width: 2rem; + min-height: 2rem; + padding: 0 0.55rem; + border: 1px solid var(--line); + border-radius: 999px; + background: #0d1216; + color: var(--accent); +} + +.rail { + position: sticky; + top: 1rem; + display: grid; + gap: 0.9rem; + padding: 0.95rem; + max-height: calc(100vh - 2rem); + overflow: hidden; +} + +.empty-state { + display: grid; + gap: 0.3rem; + padding: 0.9rem; + border: 1px dashed var(--line); + border-radius: var(--radius-md); + color: var(--muted); +} + +.proxy-list { + display: grid; + gap: 0.6rem; + overflow: auto; +} + +.proxy-card { + width: 100%; + display: grid; + gap: 0.45rem; + text-align: left; + padding: 0.8rem 0.85rem; + background: #10151b; +} + +.proxy-card.selected { + border-color: #4f6b7c; + background: #151c23; +} + +.proxy-card-head, +.proxy-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} + +.proxy-meta { + color: var(--muted); + font-size: 0.8rem; + font-family: "IBM Plex Mono", monospace; +} + +.proxy-route { + color: var(--text); + font-family: "IBM Plex Mono", monospace; + font-size: 0.83rem; +} + +.status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.7rem; + padding: 0.18rem 0.55rem; + border: 1px solid var(--line); + border-radius: 999px; + background: #0e1318; + font-size: 0.68rem; +} + +.status-pill.running { + color: var(--ok); + border-color: #25563a; +} + +.status-pill.error, +.status-pill.failed { + color: var(--danger); + border-color: #613434; +} + +.status-pill.starting, +.status-pill.restarting { + color: var(--warn); + border-color: #675029; +} + +.workbench { + display: grid; + gap: 1rem; +} + +.workbench-head, +.summary-panel, +.editor-pane, +.stack-panel { + padding: 1rem; +} + +.workbench-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; +} + +.workbench-copy { + display: grid; + gap: 0.7rem; +} + +.route-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; +} + +.route-token, +.route-target { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--line); + border-radius: 999px; + background: #0d1116; + font-size: 0.82rem; +} + +.route-arrow { + color: var(--muted); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.summary-copy, +.inline-note { + margin: 0; + color: var(--muted); + line-height: 1.45; +} + +.action-row, +.doc-actions, +.template-actions, +.tool-switcher { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.action-row { + justify-content: flex-end; + align-content: flex-start; +} + +.error, +.notice { + margin: 0; + padding: 0.8rem 0.9rem; + border-radius: var(--radius-md); + border: 1px solid; +} + +.error { + color: #ffd7d7; + background: #2a1717; + border-color: #5f2d2d; +} + +.notice { + color: #ddf7ff; + background: #152028; + border-color: #2f4d60; +} + +.compact { + display: inline-block; + width: fit-content; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.summary-panel { + display: grid; + gap: 0.85rem; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.metric-card { + display: grid; + gap: 0.2rem; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.metric-card strong { + font-size: 0.9rem; + font-family: "IBM Plex Mono", monospace; + line-height: 1.35; +} + +.metric-label { + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.summary-lines { + display: grid; + gap: 0.55rem; + color: var(--muted); +} + +.workbench-grid { + display: grid; + grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr); + gap: 1rem; + align-items: start; +} + +.overview-stack, +.utility-panel { + display: grid; + gap: 1rem; +} + +.compact-panel { + display: grid; + gap: 0.9rem; +} + +.stack-panel { + display: grid; + gap: 0.9rem; +} + +.tight-grid { + gap: 0.75rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.inline-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; +} + +.checkbox-row, +.inline-toggle { + display: flex; + align-items: center; + gap: 0.55rem; + min-height: 42px; +} + +.checkbox-row { + padding: 0.7rem 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: #0d1116; +} + +.checkbox-row input, +.inline-toggle input { + width: auto; + margin: 0; + accent-color: var(--accent); +} + +.service-grid { + display: grid; + gap: 0.7rem; +} + +.service-chip { + display: grid; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.port-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.port-chip { + padding: 0.38rem 0.6rem; + background: #0d1116; + font-family: "IBM Plex Mono", monospace; + font-size: 0.8rem; +} + +.port-chip.active { + color: #071117; + background: var(--accent); + border-color: #7dcfe7; +} + +.compact-details { + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.compact-details summary { + cursor: pointer; + padding: 0.8rem 0.9rem; + color: var(--text); + list-style: none; +} + +.compact-details summary::-webkit-details-marker { + display: none; +} + +.compact-details[open] summary { + border-bottom: 1px solid var(--line); +} + +.compact-details .form-grid { + padding: 0.9rem; +} + +.health-box { + display: grid; + gap: 0.7rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.health-box.ok { + border-color: #2a5437; +} + +.health-box.error { + border-color: #5f2d2d; +} + +.health-list { + display: grid; + gap: 0.45rem; +} + +.health-row { + display: grid; + grid-template-columns: minmax(0, 1.2fr) 90px minmax(0, 1fr); + gap: 0.6rem; + padding-top: 0.45rem; + border-top: 1px solid var(--line); + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.8rem; +} + +.tool-switcher button { + background: #0f1318; +} + +.tool-switcher button.active { + color: #081016; + background: var(--accent); + border-color: #7dcfe7; +} + +.tool-panel { + min-height: 0; +} + +.compact-table { + display: grid; + gap: 0.6rem; +} + +.table-head, +.table-row { + display: grid; + grid-template-columns: 180px minmax(0, 1fr) auto; + gap: 0.7rem; + align-items: center; +} + +.table-head { + color: var(--muted); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.table-body { + display: grid; + gap: 0.55rem; + max-height: 220px; + overflow: auto; +} + +.table-row { + padding: 0.6rem 0; + border-top: 1px solid var(--line); +} + +.table-link { + justify-self: start; + padding: 0; + border: 0; + background: none; + color: var(--accent); + font-family: "IBM Plex Mono", monospace; +} + +.table-link:hover { + background: none; + text-decoration: underline; +} + +.option-shell, +.example-shell { + display: grid; + grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1fr); + gap: 0.9rem; + min-height: 0; +} + +.option-browser, +.example-browser, +.option-doc, +.example-preview { + display: grid; + gap: 0.75rem; + padding: 0.85rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.option-table, +.example-index { + display: grid; + gap: 0.45rem; + max-height: 380px; + overflow: auto; +} + +.option-entry, +.example-entry { + width: 100%; + display: grid; + gap: 0.18rem; + text-align: left; + padding: 0.65rem 0.75rem; + background: #0d1116; +} + +.option-entry.selected, +.example-entry.selected { + border-color: #496171; + background: #131920; +} + +.option-doc-head { + display: flex; + gap: 0.8rem; + justify-content: space-between; + align-items: flex-start; +} + +.option-doc-head h3 { + margin: 0.2rem 0 0; + font-size: 1rem; +} + +.type-chip { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.5rem; + border: 1px solid var(--line); + border-radius: 999px; + background: #0d1116; + font-size: 0.72rem; +} + +.doc-copy { + margin: 0; + color: var(--text); + line-height: 1.55; +} + +.doc-facts, +.template-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.doc-facts span { + padding: 0.22rem 0.45rem; + border-radius: 999px; + background: #0d1116; + border: 1px solid var(--line); + color: var(--muted); + font-size: 0.76rem; +} + +.logs-panel .terminal { + min-height: 300px; + max-height: 420px; +} + +.terminal { + overflow: auto; + padding: 0.95rem; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: #090c0f; + color: #dce8ee; + font-family: "IBM Plex Mono", monospace; + font-size: 0.8rem; + line-height: 1.6; +} + +.editor-pane { + display: grid; + gap: 0.85rem; +} + +.editor-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.8rem; +} + +.editor-shell { + min-height: 620px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + overflow: hidden; + background: #0a0d0f; +} + +.code-editor-wrap { + display: grid; + grid-template-rows: auto 1fr; + min-height: 620px; +} + +.code-editor-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem 0.9rem; + border-bottom: 1px solid var(--line); + background: #0f1418; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.78rem; +} + +.code-editor { + width: 100%; + min-height: 0; +} + +.logo { + width: 44px; + height: 44px; + color: var(--accent); +} + +.logo rect, +.logo path { + fill: none; + stroke: currentColor; + stroke-width: 4; + stroke-linecap: round; + stroke-linejoin: round; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-thumb { + background: #29333c; + border-radius: 999px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +@media (max-width: 1320px) { + .workbench-grid { + grid-template-columns: 1fr; + } + + .option-shell, + .example-shell { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1120px) { + .app-body, + .summary-grid { + grid-template-columns: 1fr; + } + + .rail { + position: static; + max-height: none; + } +} + +@media (max-width: 860px) { + .app-header, + .workbench-head { + grid-template-columns: 1fr; + } + + .header-meta, + .header-actions, + .action-row { + justify-content: flex-start; + } + + .form-grid, + .metric-grid, + .table-head, + .table-row, + .health-row { + grid-template-columns: 1fr; + } + + .inline-grid { + grid-template-columns: 1fr; + } + + .editor-shell, + .code-editor-wrap, + .code-editor { + min-height: 500px; + } +} + +@media (max-width: 640px) { + .app-shell, + .login-screen { + padding: 0.75rem; + } + + .brand, + .login-brand { + grid-template-columns: 1fr; + } + + .proxy-card-head, + .proxy-meta { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..d0e4cb2 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,90 @@ +export type ServiceEndpoint = { + host: string; + port: number; + name?: string | null; + folder?: string | null; + upstream_port?: number | null; +}; + +export type HealthCheck = { + enabled: boolean; + protocol?: string | null; + path: string; + method: string; + expected_status?: number | null; + udp_payload: string; + expect_response: boolean; + verify_tls: boolean; + timeout_seconds: number; +}; + +export type ProxyDocument = { + proxy_port: number; + name: string; + code: string; + mode: string; + mode_spec?: string | null; + protocol: string; + listen_host: string; + mitm_options: Record; + healthcheck: HealthCheck; + status: string; + pid?: number | null; + script_path?: string | null; + config_path?: string | null; + log_path?: string | null; + last_error?: string | null; + service: ServiceEndpoint; + created_at?: string | null; + updated_at?: string | null; +}; + +export type DockerService = { + name: string; + ports: number[]; + folder: string; +}; + +export type MitmOption = { + name: string; + type: string; + default: unknown; + description: string; + choices: string[]; + reserved: boolean; + docs_url?: string | null; +}; + +export type AddonExample = { + id: string; + name: string; + description: string; + category: string; + code: string; + source_url?: string | null; + built_in: boolean; +}; + +export type HealthResult = { + enabled: boolean; + overall: string; + checks: Array<{ + target: string; + host: string; + port: number; + protocol: string; + healthy: boolean; + error?: string | null; + details: Record; + }>; +}; + +export type PythonEditorSession = { + root_uri: string; + file_uri: string; +}; + +export type ProxyDraft = Omit< + ProxyDocument, + "status" | "pid" | "script_path" | "config_path" | "log_path" | "last_error" +>; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..83b6d14 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b7435f0 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,20 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [vue()], + build: { + chunkSizeWarningLimit: 3000, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://127.0.0.1:3671", + changeOrigin: true, + ws: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b986ac3 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "theplug", + "private": true, + "version": "0.1.0", + "scripts": { + "dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py", + "dev:frontend": "cd frontend && npm run dev", + "test:backend": "cd backend && uv run pytest", + "lint:backend": "cd backend && uv run ruff check .", + "format:backend": "cd backend && uv run ruff format .", + "build:frontend": "cd frontend && npm run build", + "test": "npm run lint:backend && npm run test:backend && npm run build:frontend", + "compose:up": "./setup.sh", + "compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down" + } +} diff --git a/setup.sh b/setup.sh old mode 100644 new mode 100755 index 6e76622..6aa569c --- a/setup.sh +++ b/setup.sh @@ -6,121 +6,81 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -info() { echo -e "${GREEN}[+]${NC} $*"; } -warn() { echo -e "${YELLOW}[!]${NC} $*"; } -die() { echo -e "${RED}[✗] FATAL:${NC} $*" >&2; exit 1; } +info() { echo -e "${GREEN}[+]${NC} $*"; } +warn() { echo -e "${YELLOW}[!]${NC} $*"; } +die() { echo -e "${RED}[x] FATAL:${NC} $*" >&2; exit 1; } + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$ROOT_DIR/backend/.env" + +compose() { + docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@" +} + +ensure_env() { + if [[ -f "$ENV_FILE" ]]; then + return + fi + + warn "backend/.env not found, creating it from backend/.env.example" + cp "$ROOT_DIR/backend/.env.example" "$ENV_FILE" + + local mongo_pass jwt_secret auth_pass + mongo_pass=$(openssl rand -hex 8) + jwt_secret=$(openssl rand -hex 20) + auth_pass=$(openssl rand -hex 10) + + sed -i "s|^MONGO_INITDB_ROOT_PASSWORD=.*|MONGO_INITDB_ROOT_PASSWORD=$mongo_pass|" "$ENV_FILE" + sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$mongo_pass@localhost:27017/theplug?authSource=admin|" "$ENV_FILE" + sed -i "s|^JWT_SECRET_KEY=.*|JWT_SECRET_KEY=$jwt_secret|" "$ENV_FILE" + sed -i "s|^AUTH_PASSWORD=.*|AUTH_PASSWORD=$auth_pass|" "$ENV_FILE" + + info "Generated credentials in $ENV_FILE" + info "AUTH_USERNAME: admin" + info "AUTH_PASSWORD: $auth_pass" +} install_service() { - local uv_bin - uv_bin=$(command -v uv) - local api_port - api_port=$(grep -E '^API_PORT=' .env 2>/dev/null | cut -d '=' -f2) - api_port=${api_port:-3671} - mkdir -p ~/.config/systemd/user cat > ~/.config/systemd/user/theplug.service </dev/null || true -fi - -# ── 3. spin up dependencies ─────────────────────────────────────────────────── -info "Starting Redis and MongoDB..." -docker compose up -d redis mongodb || die "docker compose failed — are Redis/MongoDB defined in compose.yml?" - -# ── 4. ensure uv is available ───────────────────────────────────────────────── -if ! command -v uv &>/dev/null; then - warn "uv not found — installing..." - curl -LsSf https://astral.sh/uv/install.sh | sh || die "uv installation failed" - export PATH="$HOME/.local/bin:$PATH" - command -v uv &>/dev/null || die "uv installed but still not in PATH — open a new shell and retry" - info "uv installed: $(uv --version)" -fi - -# ── 5. venv + deps ──────────────────────────────────────────────────────────── -info "Pinning Python 3.14..." -uv python pin 3.14 - -info "Creating venv..." -uv venv - -info "Syncing dependencies..." -uv sync - -# ── 6. install service + start ──────────────────────────────────────────────── -install_service -systemctl --user start theplug - -echo -e "" -info "Automatically generated Secrets:" -info " MONGO_PASS: $MONGO_PASS" -info " JWT_SECRET: $JWT_SECRET" -info " AUTH_PASS: $AUTH_PASS - questa è importante" -warn "Save them — these won't be showed again (non è vero basta che leggi $PLUG_DIR/.env)" -echo -e "" - -info "Installed thePlug in $PLUG_DIR" -warn "Use 'systemctl --user status theplug' to check / 'journalctl --user -u theplug -f' to follow logs" -echo -e "" \ No newline at end of file +start_stack