diff --git a/.gitignore b/.gitignore index 0a89ef1a..44807820 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ portr !**/portr/ postgres-data node_modules +.mypy_cache +data/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c7aa361b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: "v0.2.2" + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + exclude: "(^.*/migrations/|^client/)" + - id: ruff-format + exclude: "(^.*/migrations/|^client/)" + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + exclude: "(^.*/migrations/|^client/)" + - id: check-merge-conflict + - id: debug-statements + - id: check-added-large-files + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + args: + - --follow-imports=skip + - --ignore-missing-imports + - --show-column-numbers + - --no-pretty + - --check-untyped-defs + exclude: '(^.*/migrations/|^client/|_tests\.py$)' diff --git a/admin/.dockerignore b/admin/.dockerignore new file mode 100644 index 00000000..647922c6 --- /dev/null +++ b/admin/.dockerignore @@ -0,0 +1,7 @@ +.mypy_cache +.pytest_cache +.venv +tmp +.env +**/__pycache__ +**/node_modules diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 00000000..a13357e3 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,4 @@ +.venv +.mypy_cache +.pytest_cache +__pycache__ diff --git a/admin/.python-version b/admin/.python-version new file mode 100644 index 00000000..171a6a93 --- /dev/null +++ b/admin/.python-version @@ -0,0 +1 @@ +3.12.1 diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 00000000..211503f5 --- /dev/null +++ b/admin/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20-slim as frontend-builder + +WORKDIR /app + +COPY src/web/package.json src/web/pnpm-lock.yaml ./ + +RUN npm i -g pnpm && pnpm install --frozen-lockfile + +COPY src/web . + +RUN pnpm build + +FROM python:3.12 as builder + +ENV PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +COPY requirements.lock . + +RUN python3 -m venv .venv + +RUN sed '/-e/d' requirements.lock > requirements.txt && pip install --no-cache-dir -r requirements.txt + +FROM python:3.12-slim as final + +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONPATH="/app/src:$PYTHONPATH" + +WORKDIR /app + +COPY --from=builder /app/.venv/ /app/.venv/ +COPY --from=frontend-builder /app/dist /app/src/web/dist +COPY . . + +ENTRYPOINT ["sh", "scripts/start.sh"] diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 00000000..461cdbf9 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,3 @@ +# portr-admin + +Describe your project here. diff --git a/admin/pyproject.toml b/admin/pyproject.toml new file mode 100644 index 00000000..d35b7dae --- /dev/null +++ b/admin/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "portr-admin" +version = "0.1.0" +description = "Add your description here" +authors = [{ name = "amalshaji", email = "amalshajid@gmail.com" }] +dependencies = [ + "nanoid>=2.0.0", + "fastapi>=0.109.2", + "uvicorn>=0.27.1", + "tortoise-orm[asyncpg]>=0.20.0", + "pydantic-settings>=2.2.0", + "httpx>=0.26.0", + "jinja2>=3.1.3", + "python-slugify[unidecode]>=8.0.4", + "python-ulid>=2.2.0", + "apscheduler>=3.10.4", + "pydantic>=2.6.1", + "email-validator>=2.1.0.post1", +] +readme = "README.md" +requires-python = ">= 3.8" + +[project.scripts] +hello = "portr_admin:hello" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.rye] +managed = true +dev-dependencies = [ + "pre-commit>=3.5.0", + "pytest>=8.0.1", + "factory-boy>=3.3.0", + "pytest-asyncio>=0.23.5", + "asgi-lifespan>=2.1.0", + "async-factory-boy>=1.0.1", + "mimesis>=14.0.0", +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/portr_admin"] diff --git a/admin/requirements-dev.lock b/admin/requirements-dev.lock new file mode 100644 index 00000000..7d228d61 --- /dev/null +++ b/admin/requirements-dev.lock @@ -0,0 +1,131 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +aiosqlite==0.17.0 + # via tortoise-orm +annotated-types==0.6.0 + # via pydantic +anyio==4.3.0 + # via httpx + # via starlette +apscheduler==3.10.4 + # via portr-admin +asgi-lifespan==2.1.0 +async-factory-boy==1.0.1 +asyncpg==0.29.0 + # via tortoise-orm +certifi==2024.2.2 + # via httpcore + # via httpx +cfgv==3.4.0 + # via pre-commit +click==8.1.7 + # via uvicorn +distlib==0.3.8 + # via virtualenv +dnspython==2.6.1 + # via email-validator +email-validator==2.1.0.post1 + # via portr-admin +factory-boy==3.3.0 + # via async-factory-boy +faker==23.2.1 + # via factory-boy +fastapi==0.109.2 + # via portr-admin +filelock==3.13.1 + # via virtualenv +h11==0.14.0 + # via httpcore + # via uvicorn +httpcore==1.0.3 + # via httpx +httpx==0.26.0 + # via portr-admin +identify==2.5.35 + # via pre-commit +idna==3.6 + # via anyio + # via email-validator + # via httpx +iniconfig==2.0.0 + # via pytest +iso8601==1.1.0 + # via tortoise-orm +jinja2==3.1.3 + # via portr-admin +markupsafe==2.1.5 + # via jinja2 +mimesis==14.0.0 +nanoid==2.0.0 + # via portr-admin +nodeenv==1.8.0 + # via pre-commit +packaging==23.2 + # via pytest +platformdirs==4.2.0 + # via virtualenv +pluggy==1.4.0 + # via pytest +pre-commit==3.6.2 +pydantic==2.6.1 + # via fastapi + # via portr-admin + # via pydantic-settings +pydantic-core==2.16.2 + # via pydantic +pydantic-settings==2.2.1 + # via portr-admin +pypika-tortoise==0.1.6 + # via tortoise-orm +pytest==8.0.1 + # via pytest-asyncio +pytest-asyncio==0.23.5 +python-dateutil==2.8.2 + # via faker +python-dotenv==1.0.1 + # via pydantic-settings +python-slugify==8.0.4 + # via portr-admin +python-ulid==2.2.0 + # via portr-admin +pytz==2024.1 + # via apscheduler + # via tortoise-orm +pyyaml==6.0.1 + # via pre-commit +setuptools==69.1.0 + # via nodeenv +six==1.16.0 + # via apscheduler + # via python-dateutil +sniffio==1.3.0 + # via anyio + # via asgi-lifespan + # via httpx +starlette==0.36.3 + # via fastapi +text-unidecode==1.3 + # via python-slugify +tortoise-orm==0.20.0 + # via portr-admin +typing-extensions==4.9.0 + # via aiosqlite + # via fastapi + # via pydantic + # via pydantic-core +tzlocal==5.2 + # via apscheduler +unidecode==1.3.8 + # via python-slugify +uvicorn==0.27.1 + # via portr-admin +virtualenv==20.25.0 + # via pre-commit diff --git a/admin/requirements.lock b/admin/requirements.lock new file mode 100644 index 00000000..ee12d7fe --- /dev/null +++ b/admin/requirements.lock @@ -0,0 +1,92 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +-e file:. +aiosqlite==0.17.0 + # via tortoise-orm +annotated-types==0.6.0 + # via pydantic +anyio==4.3.0 + # via httpx + # via starlette +apscheduler==3.10.4 + # via portr-admin +asyncpg==0.29.0 + # via tortoise-orm +certifi==2024.2.2 + # via httpcore + # via httpx +click==8.1.7 + # via uvicorn +dnspython==2.6.1 + # via email-validator +email-validator==2.1.0.post1 + # via portr-admin +fastapi==0.109.2 + # via portr-admin +h11==0.14.0 + # via httpcore + # via uvicorn +httpcore==1.0.3 + # via httpx +httpx==0.26.0 + # via portr-admin +idna==3.6 + # via anyio + # via email-validator + # via httpx +iso8601==1.1.0 + # via tortoise-orm +jinja2==3.1.3 + # via portr-admin +markupsafe==2.1.5 + # via jinja2 +nanoid==2.0.0 + # via portr-admin +pydantic==2.6.1 + # via fastapi + # via portr-admin + # via pydantic-settings +pydantic-core==2.16.2 + # via pydantic +pydantic-settings==2.2.1 + # via portr-admin +pypika-tortoise==0.1.6 + # via tortoise-orm +python-dotenv==1.0.1 + # via pydantic-settings +python-slugify==8.0.4 + # via portr-admin +python-ulid==2.2.0 + # via portr-admin +pytz==2024.1 + # via apscheduler + # via tortoise-orm +six==1.16.0 + # via apscheduler +sniffio==1.3.0 + # via anyio + # via httpx +starlette==0.36.3 + # via fastapi +text-unidecode==1.3 + # via python-slugify +tortoise-orm==0.20.0 + # via portr-admin +typing-extensions==4.9.0 + # via aiosqlite + # via fastapi + # via pydantic + # via pydantic-core +tzlocal==5.2 + # via apscheduler +unidecode==1.3.8 + # via python-slugify +uvicorn==0.27.1 + # via portr-admin diff --git a/admin/requirements.txt b/admin/requirements.txt new file mode 100644 index 00000000..14f6e68b --- /dev/null +++ b/admin/requirements.txt @@ -0,0 +1,90 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false + +aiosqlite==0.17.0 + # via tortoise-orm +annotated-types==0.6.0 + # via pydantic +anyio==4.3.0 + # via httpx + # via starlette +apscheduler==3.10.4 + # via portr-admin +asyncpg==0.29.0 + # via tortoise-orm +certifi==2024.2.2 + # via httpcore + # via httpx +click==8.1.7 + # via uvicorn +dnspython==2.6.1 + # via email-validator +email-validator==2.1.0.post1 + # via portr-admin +fastapi==0.109.2 + # via portr-admin +h11==0.14.0 + # via httpcore + # via uvicorn +httpcore==1.0.3 + # via httpx +httpx==0.26.0 + # via portr-admin +idna==3.6 + # via anyio + # via email-validator + # via httpx +iso8601==1.1.0 + # via tortoise-orm +jinja2==3.1.3 + # via portr-admin +markupsafe==2.1.5 + # via jinja2 +nanoid==2.0.0 + # via portr-admin +pydantic==2.6.1 + # via fastapi + # via portr-admin + # via pydantic-settings +pydantic-core==2.16.2 + # via pydantic +pydantic-settings==2.2.1 + # via portr-admin +pypika-tortoise==0.1.6 + # via tortoise-orm +python-dotenv==1.0.1 + # via pydantic-settings +python-slugify==8.0.4 + # via portr-admin +python-ulid==2.2.0 + # via portr-admin +pytz==2024.1 + # via apscheduler + # via tortoise-orm +six==1.16.0 + # via apscheduler +sniffio==1.3.0 + # via anyio + # via httpx +starlette==0.36.3 + # via fastapi +text-unidecode==1.3 + # via python-slugify +tortoise-orm==0.20.0 + # via portr-admin + # via aiosqlite + # via fastapi + # via pydantic + # via pydantic-core +tzlocal==5.2 + # via apscheduler +unidecode==1.3.8 + # via python-slugify +uvicorn==0.27.1 + # via portr-admin diff --git a/admin/scripts/pre-deploy.py b/admin/scripts/pre-deploy.py new file mode 100644 index 00000000..880ca407 --- /dev/null +++ b/admin/scripts/pre-deploy.py @@ -0,0 +1,14 @@ +import asyncio + + +from portr_admin.db import connect_db, disconnect_db +from portr_admin.services.settings import populate_global_settings + + +async def main(): + await connect_db(generate_schemas=True) + await populate_global_settings() + await disconnect_db() + + +asyncio.run(main()) diff --git a/admin/scripts/start.sh b/admin/scripts/start.sh new file mode 100755 index 00000000..6f3fb353 --- /dev/null +++ b/admin/scripts/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +python scripts/pre-deploy.py +uvicorn src.portr_admin.main:app --host 0.0.0.0 \ No newline at end of file diff --git a/admin/src/portr_admin/__init__.py b/admin/src/portr_admin/__init__.py new file mode 100644 index 00000000..923f7cf0 --- /dev/null +++ b/admin/src/portr_admin/__init__.py @@ -0,0 +1,2 @@ +def hello(): + return "Hello from portr-admin!" diff --git a/admin/src/portr_admin/apis/__init__.py b/admin/src/portr_admin/apis/__init__.py new file mode 100644 index 00000000..5557315b --- /dev/null +++ b/admin/src/portr_admin/apis/__init__.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter +from portr_admin.apis.v1 import api as api_v1 + +api = APIRouter(prefix="/api") +api.include_router(api_v1) diff --git a/admin/src/portr_admin/apis/pagination.py b/admin/src/portr_admin/apis/pagination.py new file mode 100644 index 00000000..2d66c114 --- /dev/null +++ b/admin/src/portr_admin/apis/pagination.py @@ -0,0 +1,28 @@ +from typing import Generic, TypeVar +from pydantic import BaseModel, ConfigDict +from tortoise.queryset import QuerySet +from tortoise.models import Model + +T = TypeVar("T") +Qs_T = TypeVar("Qs_T", bound=Model) + + +class PaginatedResponse(BaseModel, Generic[T]): + count: int + data: list[T] + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @classmethod + async def generate_response_for_page( + self, + qs: QuerySet[Qs_T], + page: int, + page_size: int = 10, + ): + if page < 1: + page = 1 + + self.count = await qs.count() + self.data = await qs.limit(page_size).offset((page - 1) * page_size) + return PaginatedResponse[T](count=self.count, data=self.data) # type: ignore diff --git a/admin/src/portr_admin/apis/security.py b/admin/src/portr_admin/apis/security.py new file mode 100644 index 00000000..c6facf86 --- /dev/null +++ b/admin/src/portr_admin/apis/security.py @@ -0,0 +1,57 @@ +from typing import Annotated +from fastapi import Cookie, Depends, Header + +from portr_admin.models.auth import Session +from portr_admin.models.user import Role, TeamUser, User +from portr_admin.utils.exception import PermissionDenied + + +class NotAuthenticated(Exception): + pass + + +async def get_current_user( + portr_session: Annotated[str | None, Cookie()] = None, +) -> User: + if portr_session is None: + raise NotAuthenticated + + session = await Session.filter(token=portr_session).select_related("user").first() + if session is None: + raise NotAuthenticated + + return session.user + + +async def get_current_team_user( + user: User = Depends(get_current_user), + x_team_slug: str | None = Header(), +) -> TeamUser: + if x_team_slug is None: + raise NotAuthenticated + + team_user = ( + await TeamUser.filter(user=user, team__slug=x_team_slug) + .select_related("team", "user", "user__github_user") + .first() + ) + if team_user is None: + raise NotAuthenticated + + return team_user + + +async def requires_superuser(user: User = Depends(get_current_user)) -> User: + if not user.is_superuser: + raise PermissionDenied("Only superuser can perform this action") + + return user + + +async def requires_admin( + team_user: TeamUser = Depends(get_current_team_user), +) -> TeamUser: + if team_user.role != Role.admin: + raise PermissionDenied("Only admin can perform this action") + + return team_user diff --git a/admin/src/portr_admin/apis/v1/__init__.py b/admin/src/portr_admin/apis/v1/__init__.py new file mode 100644 index 00000000..68d837b3 --- /dev/null +++ b/admin/src/portr_admin/apis/v1/__init__.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter +from portr_admin.apis.v1.auth import api as api_v1_auth +from portr_admin.apis.v1.team import api as api_v1_team +from portr_admin.apis.v1.user import api as api_v1_user +from portr_admin.apis.v1.connection import api as api_v1_connection +from portr_admin.apis.v1.settings import api as api_v1_settings + +api = APIRouter(prefix="/v1") +api.include_router(api_v1_auth) +api.include_router(api_v1_team) +api.include_router(api_v1_user) +api.include_router(api_v1_connection) +api.include_router(api_v1_settings) diff --git a/admin/src/portr_admin/apis/v1/auth.py b/admin/src/portr_admin/apis/v1/auth.py new file mode 100644 index 00000000..63a6898b --- /dev/null +++ b/admin/src/portr_admin/apis/v1/auth.py @@ -0,0 +1,111 @@ +import hashlib +import hmac +from fastapi import APIRouter, Depends, Header, Request, Response +from fastapi.responses import RedirectResponse +from portr_admin.config import settings +from portr_admin.models.user import User +from portr_admin.utils.github_auth import GithubOauth +from portr_admin.utils.token import generate_oauth_state +from portr_admin.services import user as user_service +from portr_admin.services import auth as auth_service +from portr_admin.apis import security +import logging +from fastapi import BackgroundTasks +import urllib.parse + +api = APIRouter(prefix="/auth", tags=["auth"]) + +GITHUB_CALLBACK_URL = "/api/v1/auth/github/callback" + + +@api.get("/is-first-signup") +async def is_first_signup(): + return {"is_first_signup": await User.filter().count() == 0} + + +@api.get("/github") +async def github_login(request: Request): + state = generate_oauth_state() + redirect_uri = f"{settings.domain_address()}{GITHUB_CALLBACK_URL}?state={state}" + + client = GithubOauth( + client_id=settings.github_app_client_id, + client_secret=settings.github_app_client_secret, + ) + + response = RedirectResponse(url=client.auth_url(state, redirect_uri)) + response.set_cookie( + key="oauth_state", + value=state, + httponly=True, + max_age=600, + secure=not settings.debug, + ) + + next_url = request.query_params.get("next") + if next_url: + response.set_cookie( + key="portr_next_url", + value=next_url, + httponly=True, + max_age=600, + secure=not settings.debug, + ) + + return response + + +@api.get("/github/callback") +async def github_callback(request: Request, code: str, state: str): + existing_state = request.cookies.get("oauth_state") + if state != existing_state: + return Response(status_code=400, content="Invalid state") + + user = await user_service.get_or_create_user_from_github(code) + token = await auth_service.login_user(user) + + next_url_encoded = request.cookies.get("portr_next_url") + next_url = urllib.parse.unquote(next_url_encoded) if next_url_encoded else None + + response = RedirectResponse(url=next_url or "/") + response.set_cookie( + key="portr_session", + value=token, + httponly=True, + max_age=60 * 60 * 24 * 7, + secure=not settings.debug, + ) + response.delete_cookie(key="portr_next_url") + + return response + + +@api.get("/github/events") +async def github_webhook_events( + request: Request, + background_tasks: BackgroundTasks, + x_hub_signature_256: str = Header(alias="X-Hub-Signature-256"), +): + body = await request.body() + hash_object = hmac.new( + settings.github_webhook_secret.encode("utf-8"), + msg=body, + digestmod=hashlib.sha256, + ) + expected_signature = "sha256=" + hash_object.hexdigest() + if hmac.compare_digest(expected_signature, x_hub_signature_256): + background_tasks.add_task( + auth_service.process_github_webhook, body.decode("utf-8") + ) + return Response(status_code=200) + + logger = logging.getLogger() + logger.error("Failed to validate webhook origin, invalid signature") + return Response(status_code=400) + + +@api.post("/logout") +async def logout(_=Depends(security.get_current_user)): + response = Response() + response.delete_cookie(key="portr_session") + return response diff --git a/admin/src/portr_admin/apis/v1/connection.py b/admin/src/portr_admin/apis/v1/connection.py new file mode 100644 index 00000000..60f7e392 --- /dev/null +++ b/admin/src/portr_admin/apis/v1/connection.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends + +from portr_admin.apis import security +from portr_admin.apis.pagination import PaginatedResponse +from portr_admin.enums import Enum +from portr_admin.models.connection import Connection, ConnectionStatus +from portr_admin.services import user as user_service +from portr_admin.models.user import TeamUser +from portr_admin.schemas.connection import ConnectionCreateSchema, ConnectionSchema +from portr_admin.utils.exception import ServiceError +from portr_admin.services import connection as connection_service + +api = APIRouter(prefix="/connections", tags=["connections"]) + + +class ConnectionQueryType(Enum): + active = "active" + recent = "recent" + + +@api.get("/", response_model=PaginatedResponse[ConnectionSchema]) +async def get_connections( + team_user: TeamUser = Depends(security.get_current_team_user), + type: ConnectionQueryType = ConnectionQueryType.recent, + page: int = 1, + page_size: int = 10, +): + qs = ( + Connection.filter(team=team_user.team) + .select_related("created_by", "team") + .prefetch_related("created_by__user") + .order_by("-created_at") + ) + if type == ConnectionQueryType.active: + qs = qs.filter(status=ConnectionStatus.active.value) + + return await PaginatedResponse.generate_response_for_page( + qs=qs.all(), page=page, page_size=page_size + ) + + +@api.post("/") +async def create_connection(data: ConnectionCreateSchema): + team_user = await user_service.get_team_user_by_secret_key(data.secret_key) + if not team_user: + raise ServiceError("Invalid secret key") + + connection = await connection_service.create_new_connection( + type=data.connection_type, # type: ignore + subdomain=data.subdomain, + created_by=team_user, + ) + return {"connection_id": connection.id} diff --git a/admin/src/portr_admin/apis/v1/settings.py b/admin/src/portr_admin/apis/v1/settings.py new file mode 100644 index 00000000..3dd64355 --- /dev/null +++ b/admin/src/portr_admin/apis/v1/settings.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends +from portr_admin.apis import security +from portr_admin.models.settings import GlobalSettings + +from portr_admin.schemas.settings import SettingsSchema + +api = APIRouter(prefix="/settings", tags=["settings"]) + + +@api.get("/", response_model=SettingsSchema) +async def get_settings(_=Depends(security.requires_superuser)): + settings = await GlobalSettings.first() + if not settings: + raise Exception("Global settings not found") + + return settings + + +@api.patch("/", response_model=SettingsSchema) +async def update_settings(data: SettingsSchema, _=Depends(security.requires_superuser)): + settings = await GlobalSettings.first() + if not settings: + raise Exception("Global settings not found") + + if data.smtp_enabled is False: + settings.smtp_enabled = False + await settings.save() + return settings + + for k, v in data.model_dump().items(): + setattr(settings, k, v) + await settings.save() + + return settings diff --git a/admin/src/portr_admin/apis/v1/team.py b/admin/src/portr_admin/apis/v1/team.py new file mode 100644 index 00000000..20cb2d60 --- /dev/null +++ b/admin/src/portr_admin/apis/v1/team.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, Depends +from portr_admin.apis.pagination import PaginatedResponse +from portr_admin.models.user import TeamUser, User +from portr_admin.apis import security +from portr_admin.schemas.team import AddUserToTeamSchema, NewTeamSchema, TeamSchema +from portr_admin.schemas.user import TeamUserSchemaForTeam +from portr_admin.services import team as team_service +from portr_admin.utils.exception import PermissionDenied + +api = APIRouter(prefix="/team", tags=["team"]) + + +@api.post("/", response_model=TeamSchema) +async def create_team( + data: NewTeamSchema, user: User = Depends(security.requires_superuser) +): + return await team_service.create_team(data.name, user) + + +@api.get("/users", response_model=PaginatedResponse[TeamUserSchemaForTeam]) +async def get_users( + team_user: TeamUser = Depends(security.get_current_team_user), + page: int = 1, + page_size: int = 10, +): + qs = ( + TeamUser.filter(team=team_user.team) + .select_related("user", "user__github_user") + .all() + ) + return await PaginatedResponse.generate_response_for_page( + qs=qs, page=page, page_size=page_size + ) + + +@api.post("/add", response_model=TeamUserSchemaForTeam) +async def add_user( + data: AddUserToTeamSchema, + team_user: TeamUser = Depends(security.requires_admin), +): + if data.set_superuser and not team_user.user.is_superuser: + raise PermissionDenied("Only superuser can set superuser") + + resp = await team_service.add_user_to_team( + team=team_user.team, + email=data.email, + role=data.role, + set_superuser=data.set_superuser, + ) + return resp diff --git a/admin/src/portr_admin/apis/v1/user.py b/admin/src/portr_admin/apis/v1/user.py new file mode 100644 index 00000000..957bd4c3 --- /dev/null +++ b/admin/src/portr_admin/apis/v1/user.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends +from portr_admin.apis import security + +from portr_admin.models.user import Team, TeamUser, User +from portr_admin.schemas.team import TeamSchema +from portr_admin.schemas.user import ( + TeamUserSchemaForCurrentUser, + UserSchema, + UserUpdateSchema, +) +from portr_admin.utils.token import generate_secret_key + +api = APIRouter(prefix="/user", tags=["user"]) + + +@api.get("/me", response_model=TeamUserSchemaForCurrentUser) +async def current_team_user( + team_user: TeamUser = Depends(security.get_current_team_user), +): + return team_user + + +@api.get("/me/teams", response_model=list[TeamSchema]) +async def current_user_teams( + user: TeamUser = Depends(security.get_current_user), +): + return await Team.filter(team_users__user=user).all() + + +@api.patch("/me/update", response_model=UserSchema) +async def update_user( + data: UserUpdateSchema, user: User = Depends(security.get_current_user) +): + for k, v in data.model_dump().items(): + if v is not None: + setattr(user, k, v) + await user.save() + return user + + +@api.patch("/me/rotate-secret-key") +async def rotate_secret_key(user: TeamUser = Depends(security.get_current_team_user)): + user.secret_key = generate_secret_key() + await user.save() + return {"secret_key": user.secret_key} diff --git a/admin/src/portr_admin/beats.py b/admin/src/portr_admin/beats.py new file mode 100644 index 00000000..af61816f --- /dev/null +++ b/admin/src/portr_admin/beats.py @@ -0,0 +1,19 @@ +from datetime import datetime, timedelta +from portr_admin.models.auth import Session +from portr_admin.models.connection import Connection, ConnectionStatus +import logging + +logger = logging.getLogger("fastapi") + + +async def clear_expired_sessions(): + logger.info("Clearing expired sessions") + await Session.filter(expires_at__lte=datetime.utcnow()).delete() + + +async def clear_unclaimed_connections(): + logger.info(f"{datetime.utcnow()} Clearing unclaimed connections") + await Connection.filter( + status=ConnectionStatus.reserved.value, + created_at__lte=datetime.utcnow() - timedelta(seconds=10), + ).delete() diff --git a/admin/src/portr_admin/config.py b/admin/src/portr_admin/config.py new file mode 100644 index 00000000..d28b2193 --- /dev/null +++ b/admin/src/portr_admin/config.py @@ -0,0 +1,25 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + debug: bool = False + db_url: str = "sqlite://db.sqlite" + domain: str + use_vite: bool = False + + github_app_client_id: str + github_app_client_secret: str + github_webhook_secret: str + + server_url: str + ssh_url: str + + model_config = SettingsConfigDict(env_file=".env") + + def domain_address(self): + if "localhost:" in self.domain: + return f"http://{self.domain}" + return f"https://{self.domain}" + + +settings = Settings() # type: ignore diff --git a/admin/src/portr_admin/conftest.py b/admin/src/portr_admin/conftest.py new file mode 100644 index 00000000..00659190 --- /dev/null +++ b/admin/src/portr_admin/conftest.py @@ -0,0 +1,12 @@ +import os +import pytest +from tortoise.contrib.test import finalizer, initializer + +from portr_admin.db import TORTOISE_MODELS + + +@pytest.fixture(scope="session", autouse=True) +def initialize_tests(request): + db_url = os.environ.get("TORTOISE_TEST_DB", "sqlite://:memory:") + initializer(TORTOISE_MODELS, db_url=db_url, app_label="models") + request.addfinalizer(finalizer) diff --git a/admin/src/portr_admin/db.py b/admin/src/portr_admin/db.py new file mode 100644 index 00000000..923cecc7 --- /dev/null +++ b/admin/src/portr_admin/db.py @@ -0,0 +1,22 @@ +from tortoise import Tortoise, connections +from portr_admin.config import settings + +TORTOISE_MODELS = [ + "portr_admin.models.auth", + "portr_admin.models.user", + "portr_admin.models.settings", + "portr_admin.models.connection", +] + + +async def connect_db(generate_schemas: bool = False): + await Tortoise.init( + db_url=settings.db_url, + modules={"models": TORTOISE_MODELS}, + ) + if generate_schemas: + await Tortoise.generate_schemas() + + +async def disconnect_db(): + await connections.close_all() diff --git a/admin/src/portr_admin/enums.py b/admin/src/portr_admin/enums.py new file mode 100644 index 00000000..8c7606ca --- /dev/null +++ b/admin/src/portr_admin/enums.py @@ -0,0 +1,8 @@ +from enum import Enum as BaseEnum +from typing import Any + + +class Enum(BaseEnum): + @classmethod + def choices(self) -> list[tuple[str, Any]]: + return [(e.name, e.value) for e in self] diff --git a/admin/src/portr_admin/main.py b/admin/src/portr_admin/main.py new file mode 100644 index 00000000..fd715598 --- /dev/null +++ b/admin/src/portr_admin/main.py @@ -0,0 +1,150 @@ +from typing import Annotated +from fastapi import Cookie, FastAPI, Request +from fastapi.responses import JSONResponse, RedirectResponse +from portr_admin.apis import api as api_v1 +from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore +from portr_admin.apis.security import NotAuthenticated, get_current_user +from portr_admin.beats import clear_expired_sessions, clear_unclaimed_connections +from portr_admin.config import settings +from portr_admin.db import connect_db, disconnect_db +from portr_admin.models.user import User +from portr_admin.utils.exception import PermissionDenied, ServiceError +from fastapi.templating import Jinja2Templates + +from portr_admin.utils.vite import generate_vite_tags +import urllib.parse +from fastapi import status +from contextlib import asynccontextmanager +from fastapi.staticfiles import StaticFiles + +templates = Jinja2Templates(directory="src/portr_admin/templates") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # connect to database + await connect_db(generate_schemas=True) + yield + # disconnect all db connections + await disconnect_db() + + +app = FastAPI(lifespan=lifespan) +app.include_router(api_v1) + + +scheduler = AsyncIOScheduler() +scheduler.add_job(clear_expired_sessions, "interval", hours=1) +scheduler.add_job(clear_unclaimed_connections, "interval", seconds=10) +scheduler.start() + + +@app.get("/") +async def render_index_template( + request: Request, + portr_session: Annotated[str | None, Cookie()] = None, +): + try: + user: User = await get_current_user(portr_session) + except NotAuthenticated: + return templates.TemplateResponse( + request=request, + name="index.html", + context={ + "request": request, + "use_vite": settings.use_vite, + "vite_tags": "" if settings.use_vite else generate_vite_tags(), + }, + ) + + first_team = await user.teams.filter().first() + if first_team is None: + return RedirectResponse(url="/new-team") + + return RedirectResponse(url=f"/{first_team.slug}/overview") + + +@app.get("/new-team") +async def render_index_template_for_setup_route( + request: Request, + portr_session: Annotated[str | None, Cookie()] = None, +): + try: + _ = await get_current_user(portr_session) + except NotAuthenticated: + next_url = request.url.path + "?" + request.url.query + next_url_encoded = urllib.parse.urlencode({"next": next_url}) + return RedirectResponse(url=f"/?{next_url_encoded}") + return templates.TemplateResponse( + request=request, + name="index.html", + context={ + "request": request, + "use_vite": settings.use_vite, + "vite_tags": "" if settings.use_vite else generate_vite_tags(), + }, + ) + + +@app.get("/{team}/overview") +@app.get("/{team}/connections") +@app.get("/{team}/users") +@app.get("/{team}/my-account") +@app.get("/{team}/settings") +@app.get("/{team}/new-team") +async def render_index_template_for_team_routes( + request: Request, + team: str, + portr_session: Annotated[str | None, Cookie()] = None, +): + try: + user: User = await get_current_user(portr_session) + except NotAuthenticated: + next_url = request.url.path + "?" + request.url.query + next_url_encoded = urllib.parse.urlencode({"next": next_url}) + return RedirectResponse(url=f"/?{next_url_encoded}") + + team = await user.teams.filter(slug=team).first() # type: ignore + if team is None: + return RedirectResponse(url="/") + + return templates.TemplateResponse( + request=request, + name="index.html", + context={ + "request": request, + "use_vite": settings.use_vite, + "vite_tags": "" if settings.use_vite else generate_vite_tags(), + }, + ) + + +@app.exception_handler(NotAuthenticated) +async def not_authenticated_exception_handler( + request: Request, exception: NotAuthenticated +): + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"message": "Not authenticated"}, + ) + + +@app.exception_handler(ServiceError) +async def service_error_exception_handler(request: Request, exception: ServiceError): + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, content={"message": exception.message} + ) + + +@app.exception_handler(PermissionDenied) +async def permission_denied_exception_handler( + request: Request, exception: PermissionDenied +): + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, content={"message": exception.message} + ) + + +app.mount("/static", StaticFiles(directory="src/portr_admin/static"), name="static") +if not settings.use_vite: + app.mount("/", StaticFiles(directory="src/web/dist/static"), name="web-static") diff --git a/admin/src/portr_admin/model_mixins.py b/admin/src/portr_admin/model_mixins.py new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/portr_admin/models/__init__.py b/admin/src/portr_admin/models/__init__.py new file mode 100644 index 00000000..6a2596d7 --- /dev/null +++ b/admin/src/portr_admin/models/__init__.py @@ -0,0 +1,16 @@ +from tortoise import Model, fields + + +class PkModelMixin(Model): + id = fields.IntField(pk=True) + + class Meta: + abstract = True + + +class TimestampModelMixin(Model): + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + abstract = True diff --git a/admin/src/portr_admin/models/auth.py b/admin/src/portr_admin/models/auth.py new file mode 100644 index 00000000..791adae9 --- /dev/null +++ b/admin/src/portr_admin/models/auth.py @@ -0,0 +1,20 @@ +import datetime +from tortoise import Model, fields + +from portr_admin.models import PkModelMixin, TimestampModelMixin +from portr_admin.models.user import User +from portr_admin.utils.token import generate_session_token + + +class Session(PkModelMixin, TimestampModelMixin, Model): # type: ignore + user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + "models.User", related_name="sessions" + ) + token = fields.CharField( + max_length=255, unique=True, default=generate_session_token + ) + expires_at = fields.DatetimeField( + index=True, + default=lambda: datetime.datetime.now(datetime.UTC) + + datetime.timedelta(days=7), + ) diff --git a/admin/src/portr_admin/models/connection.py b/admin/src/portr_admin/models/connection.py new file mode 100644 index 00000000..da33d43b --- /dev/null +++ b/admin/src/portr_admin/models/connection.py @@ -0,0 +1,39 @@ +from tortoise import Model, fields +from portr_admin.enums import Enum + +from portr_admin.models import TimestampModelMixin + +from portr_admin.models.user import Team, TeamUser +from portr_admin.utils.token import generate_connection_id + + +class ConnectionType(str, Enum): + http = "http" + tcp = "tcp" + + +class ConnectionStatus(str, Enum): + reserved = "reserved" + active = "active" + closed = "closed" + + +class Connection(TimestampModelMixin, Model): + id = fields.CharField(max_length=26, pk=True, default=generate_connection_id) + type = fields.CharField(max_length=255, choices=ConnectionType.choices()) + subdomain = fields.CharField(max_length=255, null=True) + port = fields.IntField(null=True) + status = fields.CharField( + max_length=255, + choices=ConnectionStatus.choices(), + default=ConnectionStatus.reserved.value, + index=True, + ) + created_by: fields.ForeignKeyRelation[TeamUser] = fields.ForeignKeyField( + "models.TeamUser", related_name="connections" + ) + started_at = fields.DatetimeField(null=True) + closed_at = fields.DatetimeField(null=True) + team: fields.ForeignKeyRelation[Team] = fields.ForeignKeyField( + "models.Team", related_name="connections" + ) diff --git a/admin/src/portr_admin/models/settings.py b/admin/src/portr_admin/models/settings.py new file mode 100644 index 00000000..75717512 --- /dev/null +++ b/admin/src/portr_admin/models/settings.py @@ -0,0 +1,14 @@ +from tortoise import Model, fields + +from portr_admin.models import PkModelMixin, TimestampModelMixin + + +class GlobalSettings(PkModelMixin, TimestampModelMixin, Model): # type: ignore + smtp_enabled = fields.BooleanField(default=False) + smtp_host = fields.CharField(max_length=255, null=True) + smtp_port = fields.IntField(null=True) + smtp_username = fields.CharField(max_length=255, null=True) + smtp_password = fields.CharField(max_length=255, null=True) + from_address = fields.CharField(max_length=255, null=True) + add_user_email_subject = fields.CharField(max_length=255, null=True) + add_user_email_body = fields.TextField(null=True) diff --git a/admin/src/portr_admin/models/user.py b/admin/src/portr_admin/models/user.py new file mode 100644 index 00000000..2ae4b71d --- /dev/null +++ b/admin/src/portr_admin/models/user.py @@ -0,0 +1,63 @@ +from typing import Any, Coroutine, Iterable +from tortoise import Model, fields +from tortoise.backends.base.client import BaseDBAsyncClient +from portr_admin.enums import Enum +import slugify # type: ignore +from portr_admin.models import PkModelMixin, TimestampModelMixin +from portr_admin.utils.token import generate_secret_key + + +class User(PkModelMixin, TimestampModelMixin, Model): # type: ignore + email = fields.CharField(max_length=255, unique=True) + first_name = fields.CharField(max_length=255, null=True) + last_name = fields.CharField(max_length=255, null=True) + is_superuser = fields.BooleanField(default=False) + + teams: fields.ManyToManyRelation["Team"] + + +class GithubUser(PkModelMixin, Model): # type: ignore + github_access_token = fields.CharField(max_length=255) + github_avatar_url = fields.CharField(max_length=255) + user: fields.OneToOneRelation[User] = fields.OneToOneField( + "models.User", related_name="github_user", on_delete=fields.CASCADE + ) + + +class Team(PkModelMixin, TimestampModelMixin, Model): # type: ignore + name = fields.CharField(max_length=255, unique=True) + slug = fields.CharField(max_length=255, unique=True, index=True) + users = fields.ManyToManyField( + "models.User", related_name="teams", through="team_users" + ) + + async def _pre_save( # type: ignore + self, + using_db: BaseDBAsyncClient | None = None, + update_fields: Iterable[str] | None = None, + ) -> Coroutine[Any, Any, None]: + self.slug = slugify.slugify(self.name) + return await super()._pre_save(using_db, update_fields) # type: ignore + + +class Role(str, Enum): + admin = "admin" + member = "member" + + +class TeamUser(TimestampModelMixin, Model): + user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + "models.User", related_name="team_users" + ) + team: fields.ForeignKeyRelation[Team] = fields.ForeignKeyField( + "models.Team", related_name="team_users" + ) + secret_key = fields.CharField( + max_length=42, unique=True, index=True, default=generate_secret_key + ) + role = fields.CharField( + max_length=255, choices=Role.choices(), default=Role.member.value + ) + + class Meta: + table = "team_users" diff --git a/admin/src/portr_admin/py.typed b/admin/src/portr_admin/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/portr_admin/schemas/__init__.py b/admin/src/portr_admin/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/portr_admin/schemas/connection.py b/admin/src/portr_admin/schemas/connection.py new file mode 100644 index 00000000..9cb7f787 --- /dev/null +++ b/admin/src/portr_admin/schemas/connection.py @@ -0,0 +1,23 @@ +import datetime +from pydantic import BaseModel + +from portr_admin.schemas.user import TeamUserSchemaForConnection + + +class ConnectionSchema(BaseModel): + id: str + type: str + subdomain: str | None + port: int | None + status: str + created_at: datetime.datetime + started_at: datetime.datetime | None + closed_at: datetime.datetime | None + + created_by: TeamUserSchemaForConnection + + +class ConnectionCreateSchema(BaseModel): + connection_type: str + secret_key: str + subdomain: str | None diff --git a/admin/src/portr_admin/schemas/settings.py b/admin/src/portr_admin/schemas/settings.py new file mode 100644 index 00000000..c77f8057 --- /dev/null +++ b/admin/src/portr_admin/schemas/settings.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class SettingsSchema(BaseModel): + smtp_enabled: bool + smtp_host: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password: str | None = None + from_address: str | None = None + add_user_email_subject: str | None = None + add_user_email_body: str | None = None diff --git a/admin/src/portr_admin/schemas/team.py b/admin/src/portr_admin/schemas/team.py new file mode 100644 index 00000000..b682a4bc --- /dev/null +++ b/admin/src/portr_admin/schemas/team.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, EmailStr + +from portr_admin.models.user import Role + + +class NewTeamSchema(BaseModel): + name: str + + +class TeamSchema(BaseModel): + id: int + name: str + slug: str + + +class AddUserToTeamSchema(BaseModel): + email: EmailStr + role: Role + set_superuser: bool diff --git a/admin/src/portr_admin/schemas/user.py b/admin/src/portr_admin/schemas/user.py new file mode 100644 index 00000000..9e35fc0e --- /dev/null +++ b/admin/src/portr_admin/schemas/user.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel + +from portr_admin.models.user import Role + + +class GithubUserSchema(BaseModel): + github_avatar_url: str + + +class UserSchema(BaseModel): + email: str + first_name: str | None + last_name: str | None + is_superuser: bool + + +class UserSchemaForCurrentUser(UserSchema): + github_user: GithubUserSchema | None + + +class TeamUserSchemaForCurrentUser(BaseModel): + id: int + secret_key: str + role: Role + + user: UserSchemaForCurrentUser + + +class TeamUserSchemaForTeam(BaseModel): + id: int + role: Role + + user: UserSchemaForCurrentUser + + +class TeamUserSchemaForConnection(BaseModel): + id: int + + user: UserSchema + + +class UserUpdateSchema(BaseModel): + first_name: str | None = None + last_name: str | None = None diff --git a/admin/src/portr_admin/services/__init__.py b/admin/src/portr_admin/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/portr_admin/services/auth.py b/admin/src/portr_admin/services/auth.py new file mode 100644 index 00000000..ecd86ddf --- /dev/null +++ b/admin/src/portr_admin/services/auth.py @@ -0,0 +1,12 @@ +from typing import Any +from portr_admin.models.auth import Session +from portr_admin.models.user import User + + +async def login_user(user: User) -> str: + session = await Session.create(user=user) + return session.token + + +async def process_github_webhook(data: Any): + pass diff --git a/admin/src/portr_admin/services/connection.py b/admin/src/portr_admin/services/connection.py new file mode 100644 index 00000000..9813b551 --- /dev/null +++ b/admin/src/portr_admin/services/connection.py @@ -0,0 +1,21 @@ +from portr_admin.models.connection import Connection, ConnectionType +from portr_admin.models.user import TeamUser +from portr_admin.utils.exception import ServiceError + + +async def create_new_connection( + type: ConnectionType, + created_by: TeamUser, + subdomain: str | None = None, + port: int | None = None, +) -> Connection: + if type == ConnectionType.http and not subdomain: + raise ServiceError("subdomain is required for http connections") + + return await Connection.create( + type=type, + subdomain=subdomain if type == ConnectionType.http else None, + port=port if type == ConnectionType.tcp else None, + created_by=created_by, + team=created_by.team, + ) diff --git a/admin/src/portr_admin/services/settings.py b/admin/src/portr_admin/services/settings.py new file mode 100644 index 00000000..193e855c --- /dev/null +++ b/admin/src/portr_admin/services/settings.py @@ -0,0 +1,28 @@ +from portr_admin.models.settings import GlobalSettings +import logging + + +DEFAULT_SMTP_ENABLED = False +DEFAULT_ADD_USER_EMAIL_SUBJECT = """ +You've been added to team {{teamName}} on Portr! +""".strip() +DEFAULT_ADD_USER_EMAIL_BODY = """ +Hello {{email}} + +You've been added to team "{{teamName}}" on Portr. + +Get started by signing in with your github account at {{appUrl}} +""".strip() + + +async def populate_global_settings(): + logger = logging.getLogger() + settings = await GlobalSettings.first() + if not settings: + logger.info("Creating default global settings") + settings = await GlobalSettings.create( + smtp_enabled=DEFAULT_SMTP_ENABLED, + add_user_email_subject=DEFAULT_ADD_USER_EMAIL_SUBJECT, + add_user_email_body=DEFAULT_ADD_USER_EMAIL_BODY, + ) + return settings diff --git a/admin/src/portr_admin/services/team.py b/admin/src/portr_admin/services/team.py new file mode 100644 index 00000000..ead5a2b2 --- /dev/null +++ b/admin/src/portr_admin/services/team.py @@ -0,0 +1,38 @@ +from portr_admin.services import user as user_service +from portr_admin.models.user import Role, Team, TeamUser, User +from tortoise import transactions + +from portr_admin.utils.exception import ServiceError +from tortoise.exceptions import IntegrityError + + +@transactions.atomic() +async def create_team(name: str, user: User) -> Team: + try: + team = await Team.create(name=name, owner=user) + except IntegrityError: + raise ServiceError("Team with this name already exists") + + _ = await user_service.create_team_user(team, user, Role.admin) + return team + + +@transactions.atomic() +async def add_user_to_team( + team: Team, email: str, role: Role, set_superuser: bool = False +) -> TeamUser: + user_part_of_team = await TeamUser.filter(team=team, user__email=email).exists() + if user_part_of_team: + raise ServiceError("User is already part of the team") + + user, _ = await User.get_or_create( + email=email, defaults={"is_superuser": set_superuser} + ) + created_team_user = await user_service.create_team_user( + team=team, user=user, role=role + ) + return ( + await TeamUser.filter(id=created_team_user.pk) + .select_related("user", "user__github_user") + .first() # type: ignore + ) diff --git a/admin/src/portr_admin/services/user.py b/admin/src/portr_admin/services/user.py new file mode 100644 index 00000000..0e4320d1 --- /dev/null +++ b/admin/src/portr_admin/services/user.py @@ -0,0 +1,53 @@ +from portr_admin.models.user import GithubUser, Role, Team, TeamUser, User +from portr_admin.utils.github_auth import GithubOauth +from portr_admin.config import settings +from tortoise import transactions + + +@transactions.atomic() +async def get_or_create_user_from_github(code: str): + client = GithubOauth( + client_id=settings.github_app_client_id, + client_secret=settings.github_app_client_secret, + ) + token = await client.get_access_token(code) + github_user = await client.get_user(token) + + # if the user emails are private, we need to get the emails + # pick the first verified and primary email + if not github_user["email"]: + emails = await client.get_emails(token) + for email in emails: + if email["verified"] and email["primary"]: + github_user["email"] = email["email"] + break + + is_superuser = await User.filter().count() == 0 + + user, _ = await User.get_or_create( + email=github_user["email"], + defaults={"is_superuser": is_superuser}, + ) + + github_user_obj, created = await GithubUser.get_or_create( + user=user, + defaults={ + "github_access_token": token, + "github_avatar_url": github_user["avatar_url"], + }, + ) + + if not created: + github_user_obj.github_access_token = token + github_user_obj.github_avatar_url = github_user["avatar_url"] + await github_user_obj.save() + + return user + + +async def create_team_user(team: Team, user: User, role: Role) -> TeamUser: + return await TeamUser.create(team=team, user=user, role=role.value) + + +async def get_team_user_by_secret_key(secret_key: str) -> TeamUser | None: + return await TeamUser.filter(secret_key=secret_key).select_related("team").first() diff --git a/internal/server/admin/static/favicon.svg b/admin/src/portr_admin/static/favicon.svg similarity index 100% rename from internal/server/admin/static/favicon.svg rename to admin/src/portr_admin/static/favicon.svg diff --git a/internal/server/admin/static/logo.svg b/admin/src/portr_admin/static/logo.svg similarity index 100% rename from internal/server/admin/static/logo.svg rename to admin/src/portr_admin/static/logo.svg diff --git a/internal/server/admin/templates/index.html b/admin/src/portr_admin/templates/index.html similarity index 91% rename from internal/server/admin/templates/index.html rename to admin/src/portr_admin/templates/index.html index d3aedff2..b0e09b0d 100644 --- a/internal/server/admin/templates/index.html +++ b/admin/src/portr_admin/templates/index.html @@ -14,7 +14,7 @@
- {% if UseVite %} + {% if use_vite %} - {% else %} {{ ViteTags }} {% endif %} + {% else %}{{ vite_tags | safe }}{% endif %} diff --git a/admin/src/portr_admin/tests/__init__.py b/admin/src/portr_admin/tests/__init__.py new file mode 100644 index 00000000..02721ed8 --- /dev/null +++ b/admin/src/portr_admin/tests/__init__.py @@ -0,0 +1,30 @@ +from fastapi.testclient import TestClient as BaseTestClient +from portr_admin.main import app +from portr_admin.models.user import TeamUser, User +from portr_admin.tests.factories import SessionFactory + + +class TestClient: + @classmethod + async def get_client(cls): + # async so that the signature matches the other method + return BaseTestClient(app) + + @classmethod + async def get_logged_in_client(cls, auth_user: User | TeamUser): + # Separate into two methods? + if isinstance(auth_user, User): + user = auth_user + team_user = None + else: + user = auth_user.user + team_user = auth_user + + client = await cls.get_client() + + session = await SessionFactory.create(user=user) + client.cookies["portr_session"] = session.token + if team_user: + client.headers["x-team-slug"] = team_user.team.slug + + return client diff --git a/admin/src/portr_admin/tests/api_tests/__init__.py b/admin/src/portr_admin/tests/api_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/portr_admin/tests/api_tests/test_auth.py b/admin/src/portr_admin/tests/api_tests/test_auth.py new file mode 100644 index 00000000..d5f543f2 --- /dev/null +++ b/admin/src/portr_admin/tests/api_tests/test_auth.py @@ -0,0 +1,50 @@ +from portr_admin.tests import TestClient +from tortoise.contrib import test + +from portr_admin.tests.factories import TeamUserFactory, UserFactory + + +class PageTests(test.TestCase): + async def asyncSetUp(self) -> None: + await super().asyncSetUp() + self.client = await TestClient.get_client() + self.user = await UserFactory.create() + self.team_user = await TeamUserFactory.create() + self.user_auth_client = await TestClient.get_logged_in_client( + auth_user=self.user + ) + self.team_user_auth_client = await TestClient.get_logged_in_client( + auth_user=self.team_user + ) + + def test_root_page_should_pass(self): + resp = self.client.get("/") + assert resp.status_code == 200 + + def test_new_team_page_not_logged_in_should_redirect_to_root(self): + resp = self.client.get("/new-team", follow_redirects=False) + assert resp.status_code == 307 + assert resp.headers["location"] == "/?next=%2Fnew-team%3F" + + def test_team_page_not_logged_in_should_redirect_to_root(self): + resp = self.client.get("/test-team/overview", follow_redirects=False) + assert resp.status_code == 307 + assert resp.headers["location"] == "/?next=%2Ftest-team%2Foverview%3F" + + async def test_team_page_logged_in_should_pass(self): + resp = self.user_auth_client.get("/new-team", follow_redirects=False) + assert resp.status_code == 200 + + async def test_root_page_logged_in_without_teams_should_redirect_to_new_team_page( + self, + ): + resp = self.user_auth_client.get("/", follow_redirects=False) + assert resp.status_code == 307 + assert resp.headers["location"] == "/new-team" + + async def test_root_page_logged_in_with_teams_should_redirect_to_first_team_overview_page( + self, + ): + resp = self.team_user_auth_client.get("/", follow_redirects=False) + assert resp.status_code == 307 + assert resp.headers["location"] == f"/{self.team_user.team.slug}/overview" diff --git a/admin/src/portr_admin/tests/api_tests/test_connection.py b/admin/src/portr_admin/tests/api_tests/test_connection.py new file mode 100644 index 00000000..5477a7c7 --- /dev/null +++ b/admin/src/portr_admin/tests/api_tests/test_connection.py @@ -0,0 +1,83 @@ +from portr_admin.models.connection import Connection, ConnectionStatus +from portr_admin.tests import TestClient +from tortoise.contrib import test + +from portr_admin.tests.factories import ConnectionFactory, TeamUserFactory + + +class ConnectionTests(test.TestCase): + async def asyncSetUp(self) -> None: + await super().asyncSetUp() + self.team_user = await TeamUserFactory.create() + self.client = await TestClient.get_client() + self.team_user_client = await TestClient.get_logged_in_client(self.team_user) + + self.active_connection_1 = await ConnectionFactory.create( + status=ConnectionStatus.active, team=self.team_user.team + ) + self.closed_connection_2 = await ConnectionFactory.create( + status=ConnectionStatus.closed, team=self.team_user.team + ) + + async def test_create_new_connection(self): + resp = self.client.post( + "/api/v1/connections/", + json={ + "connection_type": "http", + "secret_key": self.team_user.secret_key, + "subdomain": "test-subdomain", + }, + ) + assert resp.status_code == 200 + created_connection_id = resp.json()["connection_id"] + + created_connection = ( + await Connection.filter(id=created_connection_id) + .select_related("created_by") + .first() + ) + + assert created_connection is not None + assert created_connection.created_by == self.team_user + assert created_connection.subdomain == "test-subdomain" + assert created_connection.port is None + assert created_connection.type == "http" + assert created_connection.status == "reserved" + + async def test_create_new_connection_with_wrong_secret_key_should_fail(self): + resp = self.client.post( + "/api/v1/connections/", + json={ + "connection_type": "http", + "secret_key": "random-secret-key", + "subdomain": "test-subdomain", + }, + ) + assert resp.status_code == 400 + assert resp.json() == {"message": "Invalid secret key"} + + async def test_list_active_connections(self): + resp = self.team_user_client.get( + "/api/v1/connections/", + params={"type": "active"}, + ) + assert resp.status_code == 200 + assert resp.json()["count"] == 1 + assert resp.json()["data"][0]["id"] == self.active_connection_1.id + + async def test_list_recent_connections(self): + resp = self.team_user_client.get( + "/api/v1/connections/", + params={"type": "recent"}, + ) + assert resp.status_code == 200 + assert resp.json()["count"] == 2 + + async def test_list_recent_connections_pagination(self): + resp = self.team_user_client.get( + "/api/v1/connections/?page_size=1", + params={"type": "recent"}, + ) + assert resp.status_code == 200 + assert resp.json()["count"] == 2 + assert len(resp.json()["data"]) == 1 diff --git a/admin/src/portr_admin/tests/factories.py b/admin/src/portr_admin/tests/factories.py new file mode 100644 index 00000000..5d986c64 --- /dev/null +++ b/admin/src/portr_admin/tests/factories.py @@ -0,0 +1,47 @@ +from portr_admin.models.auth import Session +from portr_admin.models.connection import Connection, ConnectionStatus, ConnectionType +from portr_admin.models.user import Team, TeamUser, User +from factory import SubFactory, Sequence, LazyAttribute # type: ignore +from async_factory_boy.factory.tortoise import AsyncTortoiseFactory # type: ignore +import mimesis + + +class UserFactory(AsyncTortoiseFactory): + class Meta: + model = User + + email = LazyAttribute(lambda _: mimesis.Person().email()) + + +class SessionFactory(AsyncTortoiseFactory): + class Meta: + model = Session + + user = SubFactory(UserFactory) + + +class TeamFactory(AsyncTortoiseFactory): + class Meta: + model = Team + + name = Sequence(lambda n: f"test team-{n}") + slug = Sequence(lambda n: f"test-team-{n}") + + +class TeamUserFactory(AsyncTortoiseFactory): + class Meta: + model = TeamUser + + user = SubFactory(UserFactory) + team = SubFactory(TeamFactory) + role = "admin" + + +class ConnectionFactory(AsyncTortoiseFactory): + class Meta: + model = Connection + + type = ConnectionType.http + subdomain = LazyAttribute(lambda _: mimesis.Person().username()) + status = ConnectionStatus.reserved + created_by = SubFactory(TeamUserFactory) diff --git a/admin/src/portr_admin/tests/service_tests/__init__.py b/admin/src/portr_admin/tests/service_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/portr_admin/tests/service_tests/test_connection_service.py b/admin/src/portr_admin/tests/service_tests/test_connection_service.py new file mode 100644 index 00000000..20149b8c --- /dev/null +++ b/admin/src/portr_admin/tests/service_tests/test_connection_service.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock +from portr_admin.models.connection import ConnectionType +import pytest +from tortoise.contrib.test import SimpleTestCase +from portr_admin.services import connection as connection_service +from portr_admin.utils.exception import ServiceError +from unittest.mock import patch + + +class ConnectionServiceTests(SimpleTestCase): + def setUp(self) -> None: + super().setUp() + self.team_user = MagicMock(team=MagicMock()) + + async def test_create_http_connection_without_subdomain_should_fail(self): + with pytest.raises(ServiceError) as e: + await connection_service.create_new_connection( + type=ConnectionType.http, created_by=MagicMock(), subdomain=None + ) + assert str(e.value) == "subdomain is required for http connections" + + @patch("portr_admin.models.connection.Connection.create") + async def test_create_http_connection_with_subdomain_should_succeed( + self, create_fn + ): + await connection_service.create_new_connection( + type=ConnectionType.http, + created_by=self.team_user, + subdomain="test-subdomain", + ) + + create_fn.assert_called_once_with( + type=ConnectionType.http, + subdomain="test-subdomain", + port=None, + created_by=self.team_user, + team=self.team_user.team, + ) + + @patch("portr_admin.models.connection.Connection.create") + async def test_create_tcp_connection_should_succeed(self, create_fn): + await connection_service.create_new_connection( + type=ConnectionType.tcp, + created_by=self.team_user, + subdomain="test-subdomain", + ) + + create_fn.assert_called_once_with( + type=ConnectionType.tcp, + subdomain=None, + port=None, + created_by=self.team_user, + team=self.team_user.team, + ) diff --git a/admin/src/portr_admin/utils/__init__.py b/admin/src/portr_admin/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/admin/src/portr_admin/utils/exception.py b/admin/src/portr_admin/utils/exception.py new file mode 100644 index 00000000..7a7f282b --- /dev/null +++ b/admin/src/portr_admin/utils/exception.py @@ -0,0 +1,11 @@ +class PortrError(Exception): + def __init__(self, message: str | None = None) -> None: + self.message = message + + +class ServiceError(PortrError): + pass + + +class PermissionDenied(PortrError): + pass diff --git a/admin/src/portr_admin/utils/github_auth.py b/admin/src/portr_admin/utils/github_auth.py new file mode 100644 index 00000000..8ae63904 --- /dev/null +++ b/admin/src/portr_admin/utils/github_auth.py @@ -0,0 +1,66 @@ +from typing import TypedDict +import httpx + + +class GithubUser(TypedDict): + email: str + avatar_url: str + + +class GithubUserEmail(TypedDict): + email: str + verified: bool + primary: bool + visibility: str + + +class GithubOauth: + AUTH_ENDPOINT = "https://github.com/login/oauth/authorize" + TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token" + USER_ENDPOINT = "https://api.github.com/user" + EMAILS_ENDPOINT = "https://api.github.com/user/emails" + + def __init__(self, client_id, client_secret): + self.client_id = client_id + self.client_secret = client_secret + + def auth_url(self, state: str, redirect_uri: str): + return f"https://github.com/login/oauth/authorize?client_id={self.client_id}&redirect_uri={redirect_uri}&state={state}&scope=user:email" + + async def get_access_token(self, code: str) -> str: + async with httpx.AsyncClient() as client: + response = await client.post( + self.TOKEN_ENDPOINT, + data={ + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + }, + headers={"Accept": "application/json"}, + ) + response.raise_for_status() + return response.json()["access_token"] + + async def get_user(self, access_token: str) -> GithubUser: + async with httpx.AsyncClient() as client: + response = await client.get( + self.USER_ENDPOINT, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + ) + response.raise_for_status() + return response.json() + + async def get_emails(self, access_token: str) -> list[GithubUserEmail]: + async with httpx.AsyncClient() as client: + response = await client.get( + self.EMAILS_ENDPOINT, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, + ) + response.raise_for_status() + return response.json() diff --git a/admin/src/portr_admin/utils/token.py b/admin/src/portr_admin/utils/token.py new file mode 100644 index 00000000..1d879b9f --- /dev/null +++ b/admin/src/portr_admin/utils/token.py @@ -0,0 +1,18 @@ +import nanoid +from ulid import ULID + + +def generate_secret_key() -> str: + return nanoid.generate(size=42) + + +def generate_oauth_state() -> str: + return nanoid.generate(size=26) + + +def generate_session_token() -> str: + return nanoid.generate(size=32) + + +def generate_connection_id() -> str: + return str(ULID()) diff --git a/admin/src/portr_admin/utils/vite.py b/admin/src/portr_admin/utils/vite.py new file mode 100644 index 00000000..48e87421 --- /dev/null +++ b/admin/src/portr_admin/utils/vite.py @@ -0,0 +1,26 @@ +from functools import cache +import json + +from pathlib import Path + +MANIFEST_PATH = ( + Path(__file__).parent.parent.parent / "web/dist/static/.vite/manifest.json" +) + + +@cache +def generate_vite_tags() -> str: + if not MANIFEST_PATH.exists(): + raise FileNotFoundError("manifest.json not found") + + manifest_json = json.loads(MANIFEST_PATH.read_text()) + + tag = "" + + for style in manifest_json["index.html"]["css"]: + tag += f'' + + if manifest_json["index.html"]["file"]: + tag += f'' + + return tag.strip() diff --git a/internal/server/admin/web/.gitignore b/admin/src/web/.gitignore similarity index 100% rename from internal/server/admin/web/.gitignore rename to admin/src/web/.gitignore diff --git a/internal/server/admin/web/components.json b/admin/src/web/components.json similarity index 100% rename from internal/server/admin/web/components.json rename to admin/src/web/components.json diff --git a/internal/server/admin/web/index.html b/admin/src/web/index.html similarity index 100% rename from internal/server/admin/web/index.html rename to admin/src/web/index.html diff --git a/internal/server/admin/web/package.json b/admin/src/web/package.json similarity index 97% rename from internal/server/admin/web/package.json rename to admin/src/web/package.json index 6b2ef0be..5f9cf277 100644 --- a/internal/server/admin/web/package.json +++ b/admin/src/web/package.json @@ -1,5 +1,5 @@ { - "name": "web", + "name": "portr-web", "private": true, "version": "0.0.0", "type": "module", diff --git a/admin/src/web/pnpm-lock.yaml b/admin/src/web/pnpm-lock.yaml new file mode 100644 index 00000000..cb3be7e4 --- /dev/null +++ b/admin/src/web/pnpm-lock.yaml @@ -0,0 +1,2020 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + bits-ui: + specifier: ^0.15.1 + version: 0.15.1(svelte@4.2.9) + lucide-svelte: + specifier: ^0.292.0 + version: 0.292.0(svelte@4.2.9) + moment: + specifier: ^2.30.1 + version: 2.30.1 + svelte-legos: + specifier: ^0.2.2 + version: 0.2.2(svelte@4.2.9) + svelte-sonner: + specifier: ^0.3.9 + version: 0.3.11(svelte@4.2.9) + zod: + specifier: ^3.22.4 + version: 3.22.4 + +devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^2.5.3 + version: 2.5.3(svelte@4.2.9)(vite@5.0.12) + '@tsconfig/svelte': + specifier: ^5.0.2 + version: 5.0.2 + autoprefixer: + specifier: ^10.4.16 + version: 10.4.17(postcss@8.4.33) + clsx: + specifier: ^2.1.0 + version: 2.1.0 + formsnap: + specifier: ^0.4.2 + version: 0.4.3(svelte@4.2.9)(sveltekit-superforms@1.13.4)(zod@3.22.4) + highlight.js: + specifier: ^11.9.0 + version: 11.9.0 + postcss: + specifier: ^8.4.32 + version: 8.4.33 + postcss-load-config: + specifier: ^4.0.2 + version: 4.0.2(postcss@8.4.33) + radix-icons-svelte: + specifier: ^1.2.1 + version: 1.2.1 + svelte: + specifier: ^4.2.8 + version: 4.2.9 + svelte-check: + specifier: ^3.6.2 + version: 3.6.3(postcss-load-config@4.0.2)(postcss@8.4.33)(svelte@4.2.9) + svelte-headless-table: + specifier: ^0.17.7 + version: 0.17.7(svelte@4.2.9) + svelte-highlight: + specifier: ^7.4.7 + version: 7.4.8 + svelte-routing: + specifier: ^2.11.0 + version: 2.11.0 + tailwind-merge: + specifier: ^2.2.0 + version: 2.2.1 + tailwind-variants: + specifier: ^0.1.19 + version: 0.1.20(tailwindcss@3.4.1) + tailwindcss: + specifier: ^3.4.0 + version: 3.4.1 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + vite: + specifier: ^5.0.12 + version: 5.0.12 + +packages: + + /@alloc/quick-lru@5.2.0: + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + dev: true + + /@ampproject/remapping@2.2.1: + resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.22 + + /@babel/runtime@7.23.9: + resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: true + + /@esbuild/aix-ppc64@0.19.12: + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.19.12: + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.19.12: + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.19.12: + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.19.12: + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.19.12: + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.19.12: + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.19.12: + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.19.12: + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.19.12: + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.19.12: + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.19.12: + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.19.12: + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.19.12: + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.19.12: + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.19.12: + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.19.12: + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.19.12: + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.19.12: + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.19.12: + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.19.12: + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.19.12: + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.19.12: + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@floating-ui/core@1.6.0: + resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} + dependencies: + '@floating-ui/utils': 0.2.1 + dev: false + + /@floating-ui/dom@1.6.1: + resolution: {integrity: sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==} + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/utils': 0.2.1 + dev: false + + /@floating-ui/utils@0.2.1: + resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} + dev: false + + /@internationalized/date@3.5.1: + resolution: {integrity: sha512-LUQIfwU9e+Fmutc/DpRTGXSdgYZLBegi4wygCWDSVmUdLTaMHsQyASDiJtREwanwKuQLq0hY76fCJ9J/9I2xOQ==} + dependencies: + '@swc/helpers': 0.5.3 + dev: false + + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: true + + /@jridgewell/gen-mapping@0.3.3: + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.22 + + /@jridgewell/resolve-uri@3.1.1: + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + + /@jridgewell/set-array@1.1.2: + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + /@jridgewell/trace-mapping@0.3.22: + resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + + /@melt-ui/svelte@0.68.0(svelte@4.2.9): + resolution: {integrity: sha512-/QvA98hnYEodZtHJ71+ocum/WWp30hVNt3F8uiZKnNYwZDaiQYjlyR9AaGKYcZLCe6R68op1mfCzc0kTzJilyA==} + peerDependencies: + svelte: '>=3 <5' + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/dom': 1.6.1 + '@internationalized/date': 3.5.1 + dequal: 2.0.3 + focus-trap: 7.5.4 + nanoid: 5.0.4 + svelte: 4.2.9 + dev: false + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.0 + dev: true + + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: true + optional: true + + /@polka/url@1.0.0-next.24: + resolution: {integrity: sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==} + dev: true + + /@rollup/rollup-android-arm-eabi@4.9.6: + resolution: {integrity: sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-android-arm64@4.9.6: + resolution: {integrity: sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-arm64@4.9.6: + resolution: {integrity: sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-x64@4.9.6: + resolution: {integrity: sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-gnueabihf@4.9.6: + resolution: {integrity: sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-gnu@4.9.6: + resolution: {integrity: sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-musl@4.9.6: + resolution: {integrity: sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-riscv64-gnu@4.9.6: + resolution: {integrity: sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-gnu@4.9.6: + resolution: {integrity: sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-musl@4.9.6: + resolution: {integrity: sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-arm64-msvc@4.9.6: + resolution: {integrity: sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-ia32-msvc@4.9.6: + resolution: {integrity: sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-x64-msvc@4.9.6: + resolution: {integrity: sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@2.5.3)(svelte@4.2.9)(vite@5.0.12): + resolution: {integrity: sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==} + engines: {node: '>=18.13'} + hasBin: true + requiresBuild: true + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.3 + dependencies: + '@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.2.9)(vite@5.0.12) + '@types/cookie': 0.6.0 + cookie: 0.6.0 + devalue: 4.3.2 + esm-env: 1.0.0 + import-meta-resolve: 4.0.0 + kleur: 4.1.5 + magic-string: 0.30.5 + mrmime: 2.0.0 + sade: 1.8.1 + set-cookie-parser: 2.6.0 + sirv: 2.0.4 + svelte: 4.2.9 + tiny-glob: 0.2.9 + vite: 5.0.12 + dev: true + + /@sveltejs/vite-plugin-svelte-inspector@1.0.4(@sveltejs/vite-plugin-svelte@2.5.3)(svelte@4.2.9)(vite@5.0.12): + resolution: {integrity: sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ==} + engines: {node: ^14.18.0 || >= 16} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^2.2.0 + svelte: ^3.54.0 || ^4.0.0 + vite: ^4.0.0 + dependencies: + '@sveltejs/vite-plugin-svelte': 2.5.3(svelte@4.2.9)(vite@5.0.12) + debug: 4.3.4 + svelte: 4.2.9 + vite: 5.0.12 + transitivePeerDependencies: + - supports-color + dev: true + + /@sveltejs/vite-plugin-svelte@2.5.3(svelte@4.2.9)(vite@5.0.12): + resolution: {integrity: sha512-erhNtXxE5/6xGZz/M9eXsmI7Pxa6MS7jyTy06zN3Ck++ldrppOnOlJwHHTsMC7DHDQdgUp4NAc4cDNQ9eGdB/w==} + engines: {node: ^14.18.0 || >= 16} + peerDependencies: + svelte: ^3.54.0 || ^4.0.0 || ^5.0.0-next.0 + vite: ^4.0.0 + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 1.0.4(@sveltejs/vite-plugin-svelte@2.5.3)(svelte@4.2.9)(vite@5.0.12) + debug: 4.3.4 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.5 + svelte: 4.2.9 + svelte-hmr: 0.15.3(svelte@4.2.9) + vite: 5.0.12 + vitefu: 0.2.5(vite@5.0.12) + transitivePeerDependencies: + - supports-color + dev: true + + /@swc/helpers@0.5.3: + resolution: {integrity: sha512-FaruWX6KdudYloq1AHD/4nU+UsMTdNE8CKyrseXWEcgjDAbvkwJg2QGPAnfIJLIWsjZOSPLOAykK6fuYp4vp4A==} + dependencies: + tslib: 2.6.2 + dev: false + + /@tsconfig/svelte@5.0.2: + resolution: {integrity: sha512-BRbo1fOtyVbhfLyuCWw6wAWp+U8UQle+ZXu84MYYWzYSEB28dyfnRBIE99eoG+qdAC0po6L2ScIEivcT07UaMA==} + dev: true + + /@types/cookie@0.6.0: + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + dev: true + + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + + /@types/pug@2.0.10: + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + dev: true + + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + dev: true + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + dev: true + + /any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + dev: true + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: true + + /aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + + /autoprefixer@10.4.17(postcss@8.4.33): + resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.22.3 + caniuse-lite: 1.0.30001581 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.33 + postcss-value-parser: 4.2.0 + dev: true + + /axobject-query@4.0.0: + resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==} + dependencies: + dequal: 2.0.3 + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /bits-ui@0.15.1(svelte@4.2.9): + resolution: {integrity: sha512-1Np8bT6W6SC2tKESfm0CySW+7+xU5S0GuUZqIxC41atZE3WIRiRlzXEYHxW88w6UaLFzZ51ns4E7pchkdV5XCQ==} + peerDependencies: + svelte: ^4.0.0 + dependencies: + '@internationalized/date': 3.5.1 + '@melt-ui/svelte': 0.68.0(svelte@4.2.9) + nanoid: 5.0.4 + svelte: 4.2.9 + dev: false + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /browserslist@4.22.3: + resolution: {integrity: sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001581 + electron-to-chromium: 1.4.648 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.22.3) + dev: true + + /buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + dev: true + + /camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + dev: true + + /caniuse-lite@1.0.30001581: + resolution: {integrity: sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ==} + dev: true + + /canvas-confetti@1.9.2: + resolution: {integrity: sha512-6Xi7aHHzKwxZsem4mCKoqP6YwUG3HamaHHAlz1hTNQPCqXhARFpSXnkC9TWlahHY5CG6hSL5XexNjxK8irVErg==} + dev: false + + /chokidar@3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /clsx@2.1.0: + resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} + engines: {node: '>=6'} + dev: true + + /code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + '@types/estree': 1.0.5 + acorn: 8.11.3 + estree-walker: 3.0.3 + periscopic: 3.1.0 + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + dev: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + + /cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + dev: true + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.0.2 + + /cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: true + + /dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + /detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /devalue@4.3.2: + resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==} + dev: true + + /didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dev: true + + /dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dev: true + + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: true + + /electron-to-chromium@1.4.648: + resolution: {integrity: sha512-EmFMarXeqJp9cUKu/QEciEApn0S/xRcpZWuAm32U7NgoZCimjsilKXHRO9saeEW55eHZagIDg6XTUOv32w9pjg==} + dev: true + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: true + + /es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + dev: true + + /esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + dev: true + + /escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + dev: true + + /esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + dev: true + + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.5 + + /fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fastq@1.17.0: + resolution: {integrity: sha512-zGygtijUMT7jnk3h26kUms3BkSDp4IfIKjmnqI2tvx6nuBfiF1UqOxbnLfzdv+apBy+53oaImsKtMw/xYbW+1w==} + dependencies: + reusify: 1.0.4 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /focus-trap@7.5.4: + resolution: {integrity: sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==} + dependencies: + tabbable: 6.2.0 + dev: false + + /foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + dev: true + + /formsnap@0.4.3(svelte@4.2.9)(sveltekit-superforms@1.13.4)(zod@3.22.4): + resolution: {integrity: sha512-PWVq78XVUHhAU1tcVGKeGamk6B4Opkk1uVNRW2YofiQpnA5Bry1c3TQjB9cVDw5u4oAwmDvIoAzVHlrAIgc+tw==} + peerDependencies: + svelte: ^4.0.0 + sveltekit-superforms: ^1.7.1 + zod: ^3.22.2 + dependencies: + svelte: 4.2.9 + sveltekit-superforms: 1.13.4(@sveltejs/kit@2.5.0)(svelte@4.2.9)(zod@3.22.4) + zod: 3.22.4 + dev: true + + /fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.3 + minipass: 7.0.4 + path-scurry: 1.10.1 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + dev: true + + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /hasown@2.0.0: + resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + dev: true + + /highlight.js@11.9.0: + resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==} + engines: {node: '>=12.0.0'} + dev: true + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + dev: true + + /import-meta-resolve@4.0.0: + resolution: {integrity: sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + dependencies: + hasown: 2.0.0 + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-reference@3.0.2: + resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + dependencies: + '@types/estree': 1.0.5 + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + + /jiti@1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} + hasBin: true + dev: true + + /kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + dev: true + + /klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + dev: true + + /lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + dev: true + + /lilconfig@3.0.0: + resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} + engines: {node: '>=14'} + dev: true + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: true + + /locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + /lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + dev: true + + /lucide-svelte@0.292.0(svelte@4.2.9): + resolution: {integrity: sha512-bnTpg9pbm6pQDc+YiLK2yxtRFk2Cc+hbzwjAPaV85k56x10CJ9LsXjon6wRrlNTSdxJR7GOsRjz0A5ZNu3Z7dg==} + peerDependencies: + svelte: '>=3 <5' + dependencies: + svelte: 4.2.9 + dev: false + + /magic-string@0.30.5: + resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + + /mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} + engines: {node: '>=16 || 14 >=14.17'} + dev: true + + /mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + + /moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + dev: false + + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: true + + /mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + dev: true + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + dev: true + + /nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /nanoid@5.0.4: + resolution: {integrity: sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + + /node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + dev: true + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: true + + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /path-scurry@1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 10.2.0 + minipass: 7.0.4 + dev: true + + /periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + dependencies: + '@types/estree': 1.0.5 + estree-walker: 3.0.3 + is-reference: 3.0.2 + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + dev: true + + /pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + dev: true + + /postcss-import@15.1.0(postcss@8.4.33): + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss: 8.4.33 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + dev: true + + /postcss-js@4.0.1(postcss@8.4.33): + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.33 + dev: true + + /postcss-load-config@4.0.2(postcss@8.4.33): + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 3.0.0 + postcss: 8.4.33 + yaml: 2.3.4 + dev: true + + /postcss-nested@6.0.1(postcss@8.4.33): + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.33 + postcss-selector-parser: 6.0.15 + dev: true + + /postcss-selector-parser@6.0.15: + resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==} + engines: {node: '>=4'} + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + dev: true + + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: true + + /postcss@8.4.33: + resolution: {integrity: sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /prism-svelte@0.5.0: + resolution: {integrity: sha512-db91Bf3pRGKDPz1lAqLFSJXeW13mulUJxhycysFpfXV5MIK7RgWWK2E5aPAa71s8TCzQUXxF5JOV42/iOs6QkA==} + dev: false + + /prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + dev: false + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /radix-icons-svelte@1.2.1: + resolution: {integrity: sha512-svmiMd0ocpdTm9cvAz0klcZpnh639lVctj6psQiawd4pYalVzOG4cX+JizAgRckyTAsRVdzObP7D2EBrSfdghA==} + dev: true + + /read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + dependencies: + pify: 2.3.0 + dev: true + + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + dev: true + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + dev: true + + /resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /rollup@4.9.6: + resolution: {integrity: sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.9.6 + '@rollup/rollup-android-arm64': 4.9.6 + '@rollup/rollup-darwin-arm64': 4.9.6 + '@rollup/rollup-darwin-x64': 4.9.6 + '@rollup/rollup-linux-arm-gnueabihf': 4.9.6 + '@rollup/rollup-linux-arm64-gnu': 4.9.6 + '@rollup/rollup-linux-arm64-musl': 4.9.6 + '@rollup/rollup-linux-riscv64-gnu': 4.9.6 + '@rollup/rollup-linux-x64-gnu': 4.9.6 + '@rollup/rollup-linux-x64-musl': 4.9.6 + '@rollup/rollup-win32-arm64-msvc': 4.9.6 + '@rollup/rollup-win32-ia32-msvc': 4.9.6 + '@rollup/rollup-win32-x64-msvc': 4.9.6 + fsevents: 2.3.3 + dev: true + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + dependencies: + mri: 1.2.0 + dev: true + + /sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.24 + mrmime: 2.0.0 + totalist: 3.0.1 + dev: true + + /sorcery@0.11.0: + resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==} + hasBin: true + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + buffer-crc32: 0.2.13 + minimist: 1.2.8 + sander: 0.5.1 + dev: true + + /source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.0.1 + dev: true + + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + commander: 4.1.1 + glob: 10.3.10 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + dev: true + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-check@3.6.3(postcss-load-config@4.0.2)(postcss@8.4.33)(svelte@4.2.9): + resolution: {integrity: sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==} + hasBin: true + peerDependencies: + svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + dependencies: + '@jridgewell/trace-mapping': 0.3.22 + chokidar: 3.5.3 + fast-glob: 3.3.2 + import-fresh: 3.3.0 + picocolors: 1.0.0 + sade: 1.8.1 + svelte: 4.2.9 + svelte-preprocess: 5.1.3(postcss-load-config@4.0.2)(postcss@8.4.33)(svelte@4.2.9)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + dev: true + + /svelte-headless-table@0.17.7(svelte@4.2.9): + resolution: {integrity: sha512-GRQEM0c4pXfFs6W+LGbsvrBbDqBaMxxibsWq8Q8o4ve4dTHIC9WsbuKMP3jRHl+iC9jd4K/TXJfLJHtLzuKSQA==} + peerDependencies: + svelte: ^3 || ^4 + dependencies: + svelte: 4.2.9 + svelte-keyed: 1.1.7(svelte@4.2.9) + svelte-render: 1.6.1(svelte@4.2.9) + svelte-subscribe: 1.0.6(svelte@4.2.9) + dev: true + + /svelte-highlight@7.4.8: + resolution: {integrity: sha512-KjXI+RwpBiDvwgZX9M0T6JpmAPhLjC4Gcks5qIDrFd7iO/pEBFR3B6qeMUTFIhxipHEPp/rC4/0enAvCQtHp+A==} + dependencies: + highlight.js: 11.9.0 + dev: true + + /svelte-hmr@0.15.3(svelte@4.2.9): + resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: ^3.19.0 || ^4.0.0 + dependencies: + svelte: 4.2.9 + dev: true + + /svelte-keyed@1.1.7(svelte@4.2.9): + resolution: {integrity: sha512-d3VIwBza12PIQ0mXf8js+r4xoSFiQikDobbi6hx03oQzZx0BImXCBcdsZeYEN7VrNswXcjIrhc9qqjlxcro49w==} + peerDependencies: + svelte: ^3.49.0 || ^4 + dependencies: + svelte: 4.2.9 + dev: true + + /svelte-legos@0.2.2(svelte@4.2.9): + resolution: {integrity: sha512-HTVkCIqhrxdy+OpXjxGr/4xIJEGv4d2cRQwTjm0SYfLw/YF1I1l/TQR59nb2WvjccnO8TNFNTvAWP5pgXQnU+w==} + peerDependencies: + svelte: ^4.0.0 + dependencies: + canvas-confetti: 1.9.2 + prism-svelte: 0.5.0 + prismjs: 1.29.0 + svelte: 4.2.9 + dev: false + + /svelte-preprocess@5.1.3(postcss-load-config@4.0.2)(postcss@8.4.33)(svelte@4.2.9)(typescript@5.3.3): + resolution: {integrity: sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==} + engines: {node: '>= 16.0.0', pnpm: ^8.0.0} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@types/pug': 2.0.10 + detect-indent: 6.1.0 + magic-string: 0.30.5 + postcss: 8.4.33 + postcss-load-config: 4.0.2(postcss@8.4.33) + sorcery: 0.11.0 + strip-indent: 3.0.0 + svelte: 4.2.9 + typescript: 5.3.3 + dev: true + + /svelte-render@1.6.1(svelte@4.2.9): + resolution: {integrity: sha512-pn580Z6DtxIDrXqQaGR/7z8tdHasgURn1AG5tt4ym1PfE6qFjT27NpN6vIg5kvDl1ewK9pYeTfIWPw4pPXqyuw==} + peerDependencies: + svelte: ^3 || ^4 + dependencies: + svelte: 4.2.9 + svelte-subscribe: 1.0.6(svelte@4.2.9) + dev: true + + /svelte-routing@2.11.0: + resolution: {integrity: sha512-oNJz2A8g5ZqBDuxUWMJLpU9XXGZ40Fz5uRvrGlpENs5C2QWK5m7YKiGINssN9yI/22f9wi4F5oTTkDaTyryolw==} + dev: true + + /svelte-sonner@0.3.11(svelte@4.2.9): + resolution: {integrity: sha512-TkjgDC7zr0waky81Z9CShXMD+4NQ7UASuRx0BhgQo8ZTDQQYk8X8MzJa3zVtZVa6RYJEiahHBXx8Zt/Ie9G5hg==} + peerDependencies: + svelte: '>=3 <5' + dependencies: + svelte: 4.2.9 + dev: false + + /svelte-subscribe@1.0.6(svelte@4.2.9): + resolution: {integrity: sha512-legaLPjOGAN3G6xUa8+1ms3qo3lE4nfypZe1CedyOEoUUwWPfmzJHuBXtwdro4xUf4kezUbzjtyK1UOeZDksog==} + peerDependencies: + svelte: ^3 || ^4 + dependencies: + svelte: 4.2.9 + dev: true + + /svelte@4.2.9: + resolution: {integrity: sha512-hsoB/WZGEPFXeRRLPhPrbRz67PhP6sqYgvwcAs+gWdSQSvNDw+/lTeUJSWe5h2xC97Fz/8QxAOqItwBzNJPU8w==} + engines: {node: '>=16'} + dependencies: + '@ampproject/remapping': 2.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.22 + '@types/estree': 1.0.5 + acorn: 8.11.3 + aria-query: 5.3.0 + axobject-query: 4.0.0 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.2 + locate-character: 3.0.0 + magic-string: 0.30.5 + periscopic: 3.1.0 + + /sveltekit-superforms@1.13.4(@sveltejs/kit@2.5.0)(svelte@4.2.9)(zod@3.22.4): + resolution: {integrity: sha512-rM2+Ictaw7OAIorCLmvg82orci/mtO9ZouI4emtx8SyYngx9aED+eNZlHPLufgB6D7geL2a+hMSFtM3zmMQixQ==} + peerDependencies: + '@sveltejs/kit': 1.x || 2.x + svelte: 3.x || 4.x + zod: 3.x + dependencies: + '@sveltejs/kit': 2.5.0(@sveltejs/vite-plugin-svelte@2.5.3)(svelte@4.2.9)(vite@5.0.12) + devalue: 4.3.2 + klona: 2.0.6 + svelte: 4.2.9 + zod: 3.22.4 + dev: true + + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + + /tailwind-merge@1.14.0: + resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==} + dev: true + + /tailwind-merge@2.2.1: + resolution: {integrity: sha512-o+2GTLkthfa5YUt4JxPfzMIpQzZ3adD1vLVkvKE1Twl9UAhGsEbIZhHHZVRttyW177S8PDJI3bTQNaebyofK3Q==} + dependencies: + '@babel/runtime': 7.23.9 + dev: true + + /tailwind-variants@0.1.20(tailwindcss@3.4.1): + resolution: {integrity: sha512-AMh7x313t/V+eTySKB0Dal08RHY7ggYK0MSn/ad8wKWOrDUIzyiWNayRUm2PIJ4VRkvRnfNuyRuKbLV3EN+ewQ==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwindcss: '*' + dependencies: + tailwind-merge: 1.14.0 + tailwindcss: 3.4.1 + dev: true + + /tailwindcss@3.4.1: + resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.5.3 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.33 + postcss-import: 15.1.0(postcss@8.4.33) + postcss-js: 4.0.1(postcss@8.4.33) + postcss-load-config: 4.0.2(postcss@8.4.33) + postcss-nested: 6.0.1(postcss@8.4.33) + postcss-selector-parser: 6.0.15 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + dev: true + + /thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + dependencies: + thenify: 3.3.1 + dev: true + + /thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + dependencies: + any-promise: 1.3.0 + dev: true + + /tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + dev: true + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + dev: true + + /ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + dev: true + + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + + /typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /update-browserslist-db@1.0.13(browserslist@4.22.3): + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.22.3 + escalade: 3.1.1 + picocolors: 1.0.0 + dev: true + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + + /vite@5.0.12: + resolution: {integrity: sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.19.12 + postcss: 8.4.33 + rollup: 4.9.6 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitefu@0.2.5(vite@5.0.12): + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + dependencies: + vite: 5.0.12 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true + + /yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} + engines: {node: '>= 14'} + dev: true + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} diff --git a/internal/server/admin/web/postcss.config.cjs b/admin/src/web/postcss.config.cjs similarity index 100% rename from internal/server/admin/web/postcss.config.cjs rename to admin/src/web/postcss.config.cjs diff --git a/internal/server/admin/web/public/vite.svg b/admin/src/web/public/vite.svg similarity index 100% rename from internal/server/admin/web/public/vite.svg rename to admin/src/web/public/vite.svg diff --git a/internal/server/admin/web/src/App.svelte b/admin/src/web/src/App.svelte similarity index 92% rename from internal/server/admin/web/src/App.svelte rename to admin/src/web/src/App.svelte index b576075d..ee020a58 100644 --- a/internal/server/admin/web/src/App.svelte +++ b/admin/src/web/src/App.svelte @@ -14,7 +14,7 @@ - + diff --git a/internal/server/admin/web/src/app.pcss b/admin/src/web/src/app.pcss similarity index 100% rename from internal/server/admin/web/src/app.pcss rename to admin/src/web/src/app.pcss diff --git a/internal/server/admin/web/src/lib/components/ApiError.svelte b/admin/src/web/src/lib/components/ApiError.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ApiError.svelte rename to admin/src/web/src/lib/components/ApiError.svelte diff --git a/internal/server/admin/web/src/lib/components/ConnectionStatus.svelte b/admin/src/web/src/lib/components/ConnectionStatus.svelte similarity index 82% rename from internal/server/admin/web/src/lib/components/ConnectionStatus.svelte rename to admin/src/web/src/lib/components/ConnectionStatus.svelte index 01c9e7fc..fe83e049 100644 --- a/internal/server/admin/web/src/lib/components/ConnectionStatus.svelte +++ b/admin/src/web/src/lib/components/ConnectionStatus.svelte @@ -2,10 +2,10 @@ import { Badge } from "$lib/components/ui/badge"; import type { ConnectionStatus } from "$lib/types"; - export let Status: ConnectionStatus; + export let status: ConnectionStatus; -{#if Status === "closed"} +{#if status === "closed"} closed {:else} active diff --git a/internal/server/admin/web/src/lib/components/ConnectionType.svelte b/admin/src/web/src/lib/components/ConnectionType.svelte similarity index 84% rename from internal/server/admin/web/src/lib/components/ConnectionType.svelte rename to admin/src/web/src/lib/components/ConnectionType.svelte index a2b0f8d3..3d604578 100644 --- a/internal/server/admin/web/src/lib/components/ConnectionType.svelte +++ b/admin/src/web/src/lib/components/ConnectionType.svelte @@ -2,10 +2,10 @@ import { Badge } from "$lib/components/ui/badge"; import type { ConnectionType } from "$lib/types"; - export let Type: ConnectionType; + export let type: ConnectionType; -{#if Type === "http"} +{#if type === "http"} HTTP {:else} TCP diff --git a/internal/server/admin/web/src/lib/components/DateField.svelte b/admin/src/web/src/lib/components/DateField.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/DateField.svelte rename to admin/src/web/src/lib/components/DateField.svelte diff --git a/internal/server/admin/web/src/lib/components/ErrorText.svelte b/admin/src/web/src/lib/components/ErrorText.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ErrorText.svelte rename to admin/src/web/src/lib/components/ErrorText.svelte diff --git a/internal/server/admin/web/src/lib/components/Pagination.svelte b/admin/src/web/src/lib/components/Pagination.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/Pagination.svelte rename to admin/src/web/src/lib/components/Pagination.svelte diff --git a/internal/server/admin/web/src/lib/components/copyToClipboard.svelte b/admin/src/web/src/lib/components/copyToClipboard.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/copyToClipboard.svelte rename to admin/src/web/src/lib/components/copyToClipboard.svelte diff --git a/internal/server/admin/web/src/lib/components/data-table-skeleton.svelte b/admin/src/web/src/lib/components/data-table-skeleton.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/data-table-skeleton.svelte rename to admin/src/web/src/lib/components/data-table-skeleton.svelte diff --git a/internal/server/admin/web/src/lib/components/data-table.svelte b/admin/src/web/src/lib/components/data-table.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/data-table.svelte rename to admin/src/web/src/lib/components/data-table.svelte diff --git a/internal/server/admin/web/src/lib/components/error.svelte b/admin/src/web/src/lib/components/error.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/error.svelte rename to admin/src/web/src/lib/components/error.svelte diff --git a/internal/server/admin/web/src/lib/components/newteam.svelte b/admin/src/web/src/lib/components/newteam.svelte similarity index 93% rename from internal/server/admin/web/src/lib/components/newteam.svelte rename to admin/src/web/src/lib/components/newteam.svelte index ef929a95..3a9b0695 100644 --- a/internal/server/admin/web/src/lib/components/newteam.svelte +++ b/admin/src/web/src/lib/components/newteam.svelte @@ -18,18 +18,18 @@ } isUpdating = true; try { - const res = await fetch("/api/team", { + const res = await fetch("/api/v1/team/", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - Name: teamName, + name: teamName, }), }); if (res.ok) { const data = await res.json(); - location.href = `/${data.Slug}/overview`; + location.href = `/${data.slug}/overview`; } else { teamNameError = (await res.json()).message; toast.error("Something went wrong"); diff --git a/internal/server/admin/web/src/lib/components/settings/emailSettingsCard.svelte b/admin/src/web/src/lib/components/settings/emailSettingsCard.svelte similarity index 89% rename from internal/server/admin/web/src/lib/components/settings/emailSettingsCard.svelte rename to admin/src/web/src/lib/components/settings/emailSettingsCard.svelte index ed697fd6..caefce7c 100644 --- a/internal/server/admin/web/src/lib/components/settings/emailSettingsCard.svelte +++ b/admin/src/web/src/lib/components/settings/emailSettingsCard.svelte @@ -70,14 +70,14 @@ }; let settingsUnSubscriber = settings.subscribe((settings) => { - addMemberEmailTemplate = settings?.AddMemberEmailTemplate || ""; - addMemberEmailSubject = settings?.AddMemberEmailSubject || ""; - smtpEnabled = settings?.SmtpEnabled || false; - smtpHost = settings?.SmtpHost || ""; - smtpPort = settings?.SmtpPort || 587; - smtpUsername = settings?.SmtpUsername || ""; - smtpPassword = settings?.SmtpPassword || ""; - fromAddress = settings?.FromAddress || ""; + addMemberEmailTemplate = settings?.add_user_email_body || ""; + addMemberEmailSubject = settings?.add_user_email_subject || ""; + smtpEnabled = settings?.smtp_enabled || false; + smtpHost = settings?.smtp_host || ""; + smtpPort = settings?.smtp_port || 587; + smtpUsername = settings?.smtp_username || ""; + smtpPassword = settings?.smtp_password || ""; + fromAddress = settings?.from_address || ""; }); const updateEmailSettings = async () => { @@ -85,20 +85,20 @@ if (!validateForm()) return; isUpdating = true; try { - const res = await fetch("/api/setting/email/update", { + const res = await fetch("/api/v1/settings/", { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - SmtpEnabled: smtpEnabled, - SmtpHost: smtpHost, - SmtpPort: smtpPort, - SmtpUsername: smtpUsername, - SmtpPassword: smtpPassword, - FromAddress: fromAddress, - addMemberEmailSubject: addMemberEmailSubject, - addMemberEmailTemplate: addMemberEmailTemplate, + smtp_enabled: smtpEnabled, + smtp_host: smtpHost, + smtp_port: smtpPort, + smtp_username: smtpUsername, + smtp_password: smtpPassword, + from_address: fromAddress, + add_user_email_subject: addMemberEmailSubject, + add_user_email_body: addMemberEmailTemplate, }), }); if (res.ok) { diff --git a/admin/src/web/src/lib/components/sidebarlink.svelte b/admin/src/web/src/lib/components/sidebarlink.svelte new file mode 100644 index 00000000..7d5e9943 --- /dev/null +++ b/admin/src/web/src/lib/components/sidebarlink.svelte @@ -0,0 +1,17 @@ + + +
+ +
+ +
+ +
diff --git a/internal/server/admin/web/src/lib/components/team-selector.svelte b/admin/src/web/src/lib/components/team-selector.svelte similarity index 59% rename from internal/server/admin/web/src/lib/components/team-selector.svelte rename to admin/src/web/src/lib/components/team-selector.svelte index 0e1ce49b..269bb2ae 100644 --- a/internal/server/admin/web/src/lib/components/team-selector.svelte +++ b/admin/src/web/src/lib/components/team-selector.svelte @@ -1,28 +1,36 @@ @@ -42,14 +50,14 @@ Your teams - {#each teams as team} - + {#each $currentUserTeams as team} + {team.Name} - {team.Name} + {team.name} {/each} diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte b/admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte rename to admin/src/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert-dialog/index.ts b/admin/src/web/src/lib/components/ui/alert-dialog/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert-dialog/index.ts rename to admin/src/web/src/lib/components/ui/alert-dialog/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/alert/alert-description.svelte b/admin/src/web/src/lib/components/ui/alert/alert-description.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert/alert-description.svelte rename to admin/src/web/src/lib/components/ui/alert/alert-description.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert/alert-title.svelte b/admin/src/web/src/lib/components/ui/alert/alert-title.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert/alert-title.svelte rename to admin/src/web/src/lib/components/ui/alert/alert-title.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert/alert.svelte b/admin/src/web/src/lib/components/ui/alert/alert.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert/alert.svelte rename to admin/src/web/src/lib/components/ui/alert/alert.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/alert/index.ts b/admin/src/web/src/lib/components/ui/alert/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/alert/index.ts rename to admin/src/web/src/lib/components/ui/alert/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/avatar/avatar-fallback.svelte b/admin/src/web/src/lib/components/ui/avatar/avatar-fallback.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/avatar/avatar-fallback.svelte rename to admin/src/web/src/lib/components/ui/avatar/avatar-fallback.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/avatar/avatar-image.svelte b/admin/src/web/src/lib/components/ui/avatar/avatar-image.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/avatar/avatar-image.svelte rename to admin/src/web/src/lib/components/ui/avatar/avatar-image.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/avatar/avatar.svelte b/admin/src/web/src/lib/components/ui/avatar/avatar.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/avatar/avatar.svelte rename to admin/src/web/src/lib/components/ui/avatar/avatar.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/avatar/index.ts b/admin/src/web/src/lib/components/ui/avatar/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/avatar/index.ts rename to admin/src/web/src/lib/components/ui/avatar/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/badge/badge.svelte b/admin/src/web/src/lib/components/ui/badge/badge.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/badge/badge.svelte rename to admin/src/web/src/lib/components/ui/badge/badge.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/badge/index.ts b/admin/src/web/src/lib/components/ui/badge/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/badge/index.ts rename to admin/src/web/src/lib/components/ui/badge/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/button/button.svelte b/admin/src/web/src/lib/components/ui/button/button.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/button/button.svelte rename to admin/src/web/src/lib/components/ui/button/button.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/button/index.ts b/admin/src/web/src/lib/components/ui/button/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/button/index.ts rename to admin/src/web/src/lib/components/ui/button/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/card/card-content.svelte b/admin/src/web/src/lib/components/ui/card/card-content.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/card/card-content.svelte rename to admin/src/web/src/lib/components/ui/card/card-content.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/card/card-description.svelte b/admin/src/web/src/lib/components/ui/card/card-description.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/card/card-description.svelte rename to admin/src/web/src/lib/components/ui/card/card-description.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/card/card-footer.svelte b/admin/src/web/src/lib/components/ui/card/card-footer.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/card/card-footer.svelte rename to admin/src/web/src/lib/components/ui/card/card-footer.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/card/card-header.svelte b/admin/src/web/src/lib/components/ui/card/card-header.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/card/card-header.svelte rename to admin/src/web/src/lib/components/ui/card/card-header.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/card/card-title.svelte b/admin/src/web/src/lib/components/ui/card/card-title.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/card/card-title.svelte rename to admin/src/web/src/lib/components/ui/card/card-title.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/card/card.svelte b/admin/src/web/src/lib/components/ui/card/card.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/card/card.svelte rename to admin/src/web/src/lib/components/ui/card/card.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/card/index.ts b/admin/src/web/src/lib/components/ui/card/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/card/index.ts rename to admin/src/web/src/lib/components/ui/card/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/checkbox/checkbox.svelte b/admin/src/web/src/lib/components/ui/checkbox/checkbox.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/checkbox/checkbox.svelte rename to admin/src/web/src/lib/components/ui/checkbox/checkbox.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/checkbox/index.ts b/admin/src/web/src/lib/components/ui/checkbox/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/checkbox/index.ts rename to admin/src/web/src/lib/components/ui/checkbox/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte rename to admin/src/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/dropdown-menu/index.ts b/admin/src/web/src/lib/components/ui/dropdown-menu/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/dropdown-menu/index.ts rename to admin/src/web/src/lib/components/ui/dropdown-menu/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/input/index.ts b/admin/src/web/src/lib/components/ui/input/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/input/index.ts rename to admin/src/web/src/lib/components/ui/input/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/input/input.svelte b/admin/src/web/src/lib/components/ui/input/input.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/input/input.svelte rename to admin/src/web/src/lib/components/ui/input/input.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/label/index.ts b/admin/src/web/src/lib/components/ui/label/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/label/index.ts rename to admin/src/web/src/lib/components/ui/label/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/label/label.svelte b/admin/src/web/src/lib/components/ui/label/label.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/label/label.svelte rename to admin/src/web/src/lib/components/ui/label/label.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/index.ts b/admin/src/web/src/lib/components/ui/pagination/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/index.ts rename to admin/src/web/src/lib/components/ui/pagination/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/pagination-content.svelte b/admin/src/web/src/lib/components/ui/pagination/pagination-content.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/pagination-content.svelte rename to admin/src/web/src/lib/components/ui/pagination/pagination-content.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte b/admin/src/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte rename to admin/src/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/pagination-item.svelte b/admin/src/web/src/lib/components/ui/pagination/pagination-item.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/pagination-item.svelte rename to admin/src/web/src/lib/components/ui/pagination/pagination-item.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/pagination-link.svelte b/admin/src/web/src/lib/components/ui/pagination/pagination-link.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/pagination-link.svelte rename to admin/src/web/src/lib/components/ui/pagination/pagination-link.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/pagination-next-button.svelte b/admin/src/web/src/lib/components/ui/pagination/pagination-next-button.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/pagination-next-button.svelte rename to admin/src/web/src/lib/components/ui/pagination/pagination-next-button.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/pagination-prev-button.svelte b/admin/src/web/src/lib/components/ui/pagination/pagination-prev-button.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/pagination-prev-button.svelte rename to admin/src/web/src/lib/components/ui/pagination/pagination-prev-button.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/pagination/pagination.svelte b/admin/src/web/src/lib/components/ui/pagination/pagination.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/pagination/pagination.svelte rename to admin/src/web/src/lib/components/ui/pagination/pagination.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/select/index.ts b/admin/src/web/src/lib/components/ui/select/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/select/index.ts rename to admin/src/web/src/lib/components/ui/select/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/select/select-content.svelte b/admin/src/web/src/lib/components/ui/select/select-content.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/select/select-content.svelte rename to admin/src/web/src/lib/components/ui/select/select-content.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/select/select-item.svelte b/admin/src/web/src/lib/components/ui/select/select-item.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/select/select-item.svelte rename to admin/src/web/src/lib/components/ui/select/select-item.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/select/select-label.svelte b/admin/src/web/src/lib/components/ui/select/select-label.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/select/select-label.svelte rename to admin/src/web/src/lib/components/ui/select/select-label.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/select/select-separator.svelte b/admin/src/web/src/lib/components/ui/select/select-separator.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/select/select-separator.svelte rename to admin/src/web/src/lib/components/ui/select/select-separator.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/select/select-trigger.svelte b/admin/src/web/src/lib/components/ui/select/select-trigger.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/select/select-trigger.svelte rename to admin/src/web/src/lib/components/ui/select/select-trigger.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/separator/index.ts b/admin/src/web/src/lib/components/ui/separator/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/separator/index.ts rename to admin/src/web/src/lib/components/ui/separator/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/separator/separator.svelte b/admin/src/web/src/lib/components/ui/separator/separator.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/separator/separator.svelte rename to admin/src/web/src/lib/components/ui/separator/separator.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/skeleton/index.ts b/admin/src/web/src/lib/components/ui/skeleton/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/skeleton/index.ts rename to admin/src/web/src/lib/components/ui/skeleton/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/skeleton/skeleton.svelte b/admin/src/web/src/lib/components/ui/skeleton/skeleton.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/skeleton/skeleton.svelte rename to admin/src/web/src/lib/components/ui/skeleton/skeleton.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/switch/index.ts b/admin/src/web/src/lib/components/ui/switch/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/switch/index.ts rename to admin/src/web/src/lib/components/ui/switch/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/switch/switch.svelte b/admin/src/web/src/lib/components/ui/switch/switch.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/switch/switch.svelte rename to admin/src/web/src/lib/components/ui/switch/switch.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/index.ts b/admin/src/web/src/lib/components/ui/table/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/index.ts rename to admin/src/web/src/lib/components/ui/table/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/table/table-body.svelte b/admin/src/web/src/lib/components/ui/table/table-body.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table-body.svelte rename to admin/src/web/src/lib/components/ui/table/table-body.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/table-caption.svelte b/admin/src/web/src/lib/components/ui/table/table-caption.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table-caption.svelte rename to admin/src/web/src/lib/components/ui/table/table-caption.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/table-cell.svelte b/admin/src/web/src/lib/components/ui/table/table-cell.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table-cell.svelte rename to admin/src/web/src/lib/components/ui/table/table-cell.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/table-footer.svelte b/admin/src/web/src/lib/components/ui/table/table-footer.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table-footer.svelte rename to admin/src/web/src/lib/components/ui/table/table-footer.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/table-head.svelte b/admin/src/web/src/lib/components/ui/table/table-head.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table-head.svelte rename to admin/src/web/src/lib/components/ui/table/table-head.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/table-header.svelte b/admin/src/web/src/lib/components/ui/table/table-header.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table-header.svelte rename to admin/src/web/src/lib/components/ui/table/table-header.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/table-row.svelte b/admin/src/web/src/lib/components/ui/table/table-row.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table-row.svelte rename to admin/src/web/src/lib/components/ui/table/table-row.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/table/table.svelte b/admin/src/web/src/lib/components/ui/table/table.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/table/table.svelte rename to admin/src/web/src/lib/components/ui/table/table.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/textarea/index.ts b/admin/src/web/src/lib/components/ui/textarea/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/textarea/index.ts rename to admin/src/web/src/lib/components/ui/textarea/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/textarea/textarea.svelte b/admin/src/web/src/lib/components/ui/textarea/textarea.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/textarea/textarea.svelte rename to admin/src/web/src/lib/components/ui/textarea/textarea.svelte diff --git a/internal/server/admin/web/src/lib/components/ui/tooltip/index.ts b/admin/src/web/src/lib/components/ui/tooltip/index.ts similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/tooltip/index.ts rename to admin/src/web/src/lib/components/ui/tooltip/index.ts diff --git a/internal/server/admin/web/src/lib/components/ui/tooltip/tooltip-content.svelte b/admin/src/web/src/lib/components/ui/tooltip/tooltip-content.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/ui/tooltip/tooltip-content.svelte rename to admin/src/web/src/lib/components/ui/tooltip/tooltip-content.svelte diff --git a/internal/server/admin/web/src/lib/components/users/avatar.svelte b/admin/src/web/src/lib/components/users/avatar.svelte similarity index 100% rename from internal/server/admin/web/src/lib/components/users/avatar.svelte rename to admin/src/web/src/lib/components/users/avatar.svelte diff --git a/internal/server/admin/web/src/lib/components/users/invite-user.svelte b/admin/src/web/src/lib/components/users/invite-user.svelte similarity index 70% rename from internal/server/admin/web/src/lib/components/users/invite-user.svelte rename to admin/src/web/src/lib/components/users/invite-user.svelte index 8cfa820a..c5d84e11 100644 --- a/internal/server/admin/web/src/lib/components/users/invite-user.svelte +++ b/admin/src/web/src/lib/components/users/invite-user.svelte @@ -7,10 +7,13 @@ import * as Select from "$lib/components/ui/select"; import { toast } from "svelte-sonner"; - import { users } from "$lib/store"; + import { currentUser, users } from "$lib/store"; import { getContext } from "svelte"; import ApiError from "../ApiError.svelte"; + import { Checkbox } from "$lib/components/ui/checkbox"; + let set_superuser = false; + const roles = [ { value: "member", label: "Member" }, { value: "admin", label: "Admin" }, @@ -21,34 +24,49 @@ let error = ""; + const setSuperuser = () => { + console.log("test"); + if (set_superuser) { + role = roles[1]; + } + }; + + $: set_superuser, setSuperuser(); + export let open: boolean = false; let isLoading = false; - let team = getContext("team"); + let team = getContext("team") as string; const add_member = async () => { error = ""; isLoading = true; try { - const res = await fetch(`/api/${team}/user/add`, { + const res = await fetch(`/api/v1/team/add`, { method: "POST", headers: { "Content-Type": "application/json", + "x-team-slug": team, }, - body: JSON.stringify({ Email: email, Role: role.value }), + body: JSON.stringify({ + email: email, + role: role.value, + set_superuser: set_superuser, + }), }); if (res.ok) { const data = await res.json(); if (data !== null) { users.update((users) => { - return [...users, { ...data, Role: role.value }]; + return [...users, { ...data, role: role.value }]; }); toast.success(`${email} added to team`); } role = roles[0]; email = ""; + set_superuser = false; open = false; } else { error = (await res.json()).message; @@ -83,7 +101,7 @@
- + @@ -99,10 +117,21 @@
+ {#if $currentUser?.user.is_superuser} +
+ + +
+ {/if} - + Cancel - {#if $currentUser?.IsSuperUser} + {#if $currentUser?.user.is_superuser} { - urlParams.set(key, value); - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.pushState({}, "", newUrl); - }; + import { updateQueryParam } from "$lib/utils"; let checked = false; const urlParams = new URLSearchParams(window.location.search); let connectionType = urlParams.get("type") || "recent"; let pageNo = writable(1); - let pageNoStr = urlParams.get("pageNo") || "1"; + let pageNoStr = urlParams.get("page") || "1"; pageNo.set(parseInt(pageNoStr, 10) || 1); $: if (checked) { - connectionType = "active"; - $pageNo = 1; + if (connectionType === "recent") { + connectionType = "active"; + pageNo.set(1); + } } else { - connectionType = "recent"; - $pageNo = 1; + if (connectionType === "active") { + connectionType = "recent"; + $pageNo = 1; + } } - $: updateQueryParam("type", connectionType); - $: updateQueryParam("pageNo", $pageNo.toString()); + $: updateQueryParam(urlParams, "type", connectionType); + $: updateQueryParam(urlParams, "page", $pageNo.toString()); $: getConnections(connectionType, $pageNo.toString()); - let team = getContext("team"); - let pagination = { - pageNo: 1, - pageSize: 10, - total: 0, - }; + let team = getContext("team") as string; + + let totalItems = 0; const getConnections = async ( type: string = "recent", @@ -54,11 +50,16 @@ connectionsLoading.set(true); try { const response = await fetch( - `/api/${team}/connection?type=${type}&pageNo=${pageNo}` + `/api/v1/connections/?type=${type}&page=${pageNo}`, + { + headers: { + "x-team-slug": team, + }, + } ); const responseData = await response.json(); connections.set(responseData["data"] || []); - pagination = responseData["pagination"]; + totalItems = responseData.count; } catch (err) { console.error(err); } finally { @@ -72,55 +73,55 @@ table.column({ header: "Type", accessor: (item: Connection) => item, - cell: ({ value: { Type } }: { value: { Type: string } }) => - createRender(ConnectionType, { Type }), + cell: ({ value: { type } }: { value: { type: string } }) => + createRender(ConnectionType, { type }), }), table.column({ header: "Port", accessor: (item: Connection) => { - const { Port } = item; - return Port ? Port : "-"; + const { port } = item; + return port ? port : "-"; }, }), table.column({ header: "Subdomain", accessor: (item: Connection) => { - const { Subdomain } = item; - return Subdomain ? Subdomain : "-"; + const { subdomain } = item; + return subdomain ? subdomain : "-"; }, }), table.column({ accessor: (item: Connection) => item, header: "Status", - cell: ({ value: { Status } }: { value: { Status: string } }) => - createRender(ConnectionStatus, { Status }), + cell: ({ value: { status } }: { value: { status: string } }) => + createRender(ConnectionStatus, { status }), }), table.column({ accessor: (item: Connection) => item, header: "Created at", - cell: ({ value: { CreatedAt } }: { value: { CreatedAt: string } }) => - createRender(DateField, { Date: CreatedAt }), + cell: (item: any) => + createRender(DateField, { Date: item.value.created_at }), }), table.column({ accessor: (item: Connection) => { - const { StartedAt, ClosedAt, Status } = item; - if (Status === "active") { + const { started_at, closed_at, status } = item; + if (status === "active") { return "-"; } - const startedAt = new Date(StartedAt as string); - const closedAt = new Date(ClosedAt as string); + const startedAt = new Date(started_at as string); + const closedAt = new Date(closed_at as string); const diff = closedAt.getTime() - startedAt.getTime(); return humanizeTimeMs(diff); }, header: "Duration", }), table.column({ - accessor: (item: any) => { - const { Email, FirstName, LastName } = item; - if (FirstName) { - return `${FirstName} ${LastName}`; + accessor: (item: Connection) => { + const { email, first_name, last_name } = item.created_by.user; + if (first_name) { + return `${first_name} ${last_name}`; } - return Email; + return email; }, header: "Created by", }), @@ -138,11 +139,7 @@
- +
diff --git a/internal/server/admin/web/src/pages/app/myaccount.svelte b/admin/src/web/src/pages/app/myaccount.svelte similarity index 77% rename from internal/server/admin/web/src/pages/app/myaccount.svelte rename to admin/src/web/src/pages/app/myaccount.svelte index fcfb7a7f..6d74c439 100644 --- a/internal/server/admin/web/src/pages/app/myaccount.svelte +++ b/admin/src/web/src/pages/app/myaccount.svelte @@ -3,19 +3,18 @@ import { Label } from "$lib/components/ui/label"; import { Button } from "$lib/components/ui/button"; import * as Card from "$lib/components/ui/card"; - import { currentTeamUser, currentUser } from "$lib/store"; + import { currentUser } from "$lib/store"; import { toast } from "svelte-sonner"; import { Reload } from "radix-icons-svelte"; import { getContext } from "svelte"; - let team = getContext("team"); + let team = getContext("team") as string; let firstName: string = "", lastName: string = ""; - currentUser.subscribe((user) => { - firstName = user?.FirstName || ""; - lastName = user?.LastName || ""; - console.log(user); + currentUser.subscribe((currentTeamUser) => { + firstName = currentTeamUser?.user.first_name || ""; + lastName = currentTeamUser?.user.last_name || ""; }); let isUpdating = false, @@ -24,18 +23,24 @@ const updateProfile = async () => { isUpdating = true; try { - const res = await fetch("/api/user/me/update", { + const res = await fetch("/api/v1/user/me/update", { method: "PATCH", headers: { "Content-Type": "application/json", + "x-team-slug": team, }, body: JSON.stringify({ - firstName, - lastName, + first_name: firstName, + last_name: lastName, }), }); if (res.ok) { - currentUser.set(await res.json()); + const data = await res.json(); + // @ts-ignore + $currentUser = { + ...$currentUser, + user: { ...$currentUser?.user, ...data }, + }; toast.success("Profile updated"); } else { toast.error("Something went wrong"); @@ -48,21 +53,27 @@ }; const copySecretToClipboard = () => { - navigator.clipboard.writeText($currentTeamUser?.SecretKey as string); + navigator.clipboard.writeText($currentUser?.secret_key as string); toast.success("Secret key copied to clipboard"); }; const rotateSecretKey = async () => { isRotatingSecretKey = true; try { - const res = await fetch(`/api/${team}/user/me/rotate-secret-key`, { + const res = await fetch(`/api/v1/user/me/rotate-secret-key`, { method: "PATCH", headers: { "Content-Type": "application/json", + "x-team-slug": team, }, }); if (res.ok) { - currentTeamUser.set(await res.json()); + const secret_key = (await res.json()).secret_key; + // @ts-ignore + $currentUser = { + ...$currentUser, + secret_key: secret_key, + }; toast.success("New secret key generated"); } else { toast.error("Something went wrong"); @@ -125,7 +136,7 @@ diff --git a/internal/server/admin/web/src/pages/app/new-team.svelte b/admin/src/web/src/pages/app/new-team.svelte similarity index 100% rename from internal/server/admin/web/src/pages/app/new-team.svelte rename to admin/src/web/src/pages/app/new-team.svelte diff --git a/internal/server/admin/web/src/pages/app/notfound.svelte b/admin/src/web/src/pages/app/notfound.svelte similarity index 100% rename from internal/server/admin/web/src/pages/app/notfound.svelte rename to admin/src/web/src/pages/app/notfound.svelte diff --git a/internal/server/admin/web/src/pages/app/overview.svelte b/admin/src/web/src/pages/app/overview.svelte similarity index 95% rename from internal/server/admin/web/src/pages/app/overview.svelte rename to admin/src/web/src/pages/app/overview.svelte index a968a331..9241cff1 100644 --- a/internal/server/admin/web/src/pages/app/overview.svelte +++ b/admin/src/web/src/pages/app/overview.svelte @@ -4,7 +4,7 @@ import yaml from "svelte-highlight/languages/yaml"; import { toast } from "svelte-sonner"; import "svelte-highlight/styles/stackoverflow-light.css"; - import { serverAddress, currentTeamUser } from "$lib/store"; + import { serverAddress, currentUser } from "$lib/store"; import { onMount } from "svelte"; const editConfigCommand = "portr config edit"; @@ -16,11 +16,11 @@ $: config = ` serverUrl: ${$serverAddress?.AdminUrl} sshUrl: ${$serverAddress?.SshUrl} -secretKey: ${$currentTeamUser?.SecretKey} +secretKey: ${$currentUser?.secret_key} tunnels: - name: portr subdomain: portr - port: 4321 + port: 4321 `.trim(); const copyCodeToClipboard = (code: string) => { diff --git a/internal/server/admin/web/src/pages/app/settings.svelte b/admin/src/web/src/pages/app/settings.svelte similarity index 81% rename from internal/server/admin/web/src/pages/app/settings.svelte rename to admin/src/web/src/pages/app/settings.svelte index aeab5adc..bf58f27f 100644 --- a/internal/server/admin/web/src/pages/app/settings.svelte +++ b/admin/src/web/src/pages/app/settings.svelte @@ -4,8 +4,9 @@ import { onMount } from "svelte"; const getSettings = async () => { - const res = await fetch("/api/setting/all"); + const res = await fetch("/api/v1/settings/"); settings.set(await res.json()); + console.log($settings); }; onMount(() => { diff --git a/internal/server/admin/web/src/pages/app/users.svelte b/admin/src/web/src/pages/app/users.svelte similarity index 79% rename from internal/server/admin/web/src/pages/app/users.svelte rename to admin/src/web/src/pages/app/users.svelte index 02432819..d2f963bc 100644 --- a/internal/server/admin/web/src/pages/app/users.svelte +++ b/admin/src/web/src/pages/app/users.svelte @@ -2,7 +2,7 @@ import Members from "$lib/components/users/members.svelte"; import { Button } from "$lib/components/ui/button"; import InviteUser from "$lib/components/users/invite-user.svelte"; - import { currentTeamUser } from "$lib/store"; + import { currentUser } from "$lib/store"; let addMemberModalOpen = false; @@ -13,7 +13,7 @@
Add member diff --git a/internal/server/admin/web/src/pages/home.svelte b/admin/src/web/src/pages/home.svelte similarity index 85% rename from internal/server/admin/web/src/pages/home.svelte rename to admin/src/web/src/pages/home.svelte index 62d1153d..1c3d1e0c 100644 --- a/internal/server/admin/web/src/pages/home.svelte +++ b/admin/src/web/src/pages/home.svelte @@ -31,6 +31,8 @@ const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code") as string; + const next = urlParams.get("next") as string; + console.log(next); let message: string = ""; let messageType: string = "success"; @@ -40,9 +42,9 @@ } const checkIfSuperuserSignup = async () => { - const resp = await fetch("/auth/github/is-superuser-signup"); + const resp = await fetch("/api/v1/auth/is-first-signup"); const data = await resp.json(); - isSuperUserSignup = data.isSuperUserSignup; + isSuperUserSignup = data.is_first_signup; }; onMount(() => { @@ -55,10 +57,14 @@ class="w-full max-w-sm p-6 m-auto mx-auto rounded-md dark:bg-gray-800 py-8 border" >
- +
- diff --git a/internal/server/admin/web/src/pages/notfound.svelte b/admin/src/web/src/pages/notfound.svelte similarity index 100% rename from internal/server/admin/web/src/pages/notfound.svelte rename to admin/src/web/src/pages/notfound.svelte diff --git a/internal/server/admin/web/src/pages/setup.svelte b/admin/src/web/src/pages/setup.svelte similarity index 100% rename from internal/server/admin/web/src/pages/setup.svelte rename to admin/src/web/src/pages/setup.svelte diff --git a/internal/server/admin/web/src/vite-env.d.ts b/admin/src/web/src/vite-env.d.ts similarity index 100% rename from internal/server/admin/web/src/vite-env.d.ts rename to admin/src/web/src/vite-env.d.ts diff --git a/internal/server/admin/web/svelte.config.js b/admin/src/web/svelte.config.js similarity index 100% rename from internal/server/admin/web/svelte.config.js rename to admin/src/web/svelte.config.js diff --git a/internal/server/admin/web/tailwind.config.js b/admin/src/web/tailwind.config.js similarity index 100% rename from internal/server/admin/web/tailwind.config.js rename to admin/src/web/tailwind.config.js diff --git a/internal/server/admin/web/tsconfig.json b/admin/src/web/tsconfig.json similarity index 100% rename from internal/server/admin/web/tsconfig.json rename to admin/src/web/tsconfig.json diff --git a/internal/server/admin/web/tsconfig.node.json b/admin/src/web/tsconfig.node.json similarity index 100% rename from internal/server/admin/web/tsconfig.node.json rename to admin/src/web/tsconfig.node.json diff --git a/internal/server/admin/web/vite.config.ts b/admin/src/web/vite.config.ts similarity index 100% rename from internal/server/admin/web/vite.config.ts rename to admin/src/web/vite.config.ts diff --git a/admin/t.py b/admin/t.py new file mode 100644 index 00000000..e69de29b diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 0caddffa..00000000 Binary files a/bun.lockb and /dev/null differ diff --git a/cmd/portr/config.go b/cmd/portr/config.go deleted file mode 100644 index 20d0fd5f..00000000 --- a/cmd/portr/config.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/amalshaji/portr/internal/client/config" - "github.com/labstack/gommon/color" - "github.com/urfave/cli/v2" -) - -func configCmd() *cli.Command { - return &cli.Command{ - Name: "config", - Usage: "Edit the portr config file", - Subcommands: []*cli.Command{ - { - Name: "edit", - Usage: "Edit the default config file", - Action: func(c *cli.Context) error { - return config.EditConfig() - }, - }, - { - Name: "validate", - Usage: "Validate the config file", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Usage: "Config file", - Value: config.DefaultConfigPath, - }, - }, - Action: func(c *cli.Context) error { - config, err := config.Load(c.String("config")) - if err != nil { - return err - } - err = config.ValidateConfig() - if err != nil { - return err - } - - fmt.Println(color.Green("Config file is valid")) - return nil - }, - }, - }, - } -} diff --git a/cmd/portrd/main.go b/cmd/portrd/main.go deleted file mode 100644 index acd2f375..00000000 --- a/cmd/portrd/main.go +++ /dev/null @@ -1,153 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "os/signal" - "syscall" - - "github.com/amalshaji/portr/internal/server/admin" - "github.com/amalshaji/portr/internal/server/admin/service" - "github.com/amalshaji/portr/internal/server/config" - "github.com/amalshaji/portr/internal/server/cron" - "github.com/amalshaji/portr/internal/server/db" - - "github.com/amalshaji/portr/internal/server/proxy" - "github.com/amalshaji/portr/internal/server/smtp" - sshd "github.com/amalshaji/portr/internal/server/ssh" - "github.com/urfave/cli/v2" -) - -const VERSION = "0.0.1-beta" - -type ServiceToRun int - -const ( - ADMIN_SERVER ServiceToRun = iota + 1 - TUNNEL_SERVER = iota + 1 - ALL_SERVERS = iota + 1 -) - -func main() { - app := &cli.App{ - Name: "portrd", - Usage: "portr server", - Version: VERSION, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Usage: "config file", - Value: "config.yaml", - }, - }, - Commands: []*cli.Command{ - { - Name: "start", - Usage: "Specify the server to start", - Subcommands: []*cli.Command{ - { - Name: "admin", - Usage: "Start the admin server", - Action: func(c *cli.Context) error { - start(c.String("config"), ADMIN_SERVER) - return nil - }, - }, - { - Name: "tunnel", - Usage: "Start the tunnel server", - Action: func(c *cli.Context) error { - start(c.String("config"), TUNNEL_SERVER) - return nil - }, - }, - { - Name: "all", - Usage: "Start both admin and tunnel servers", - Action: func(c *cli.Context) error { - start(c.String("config"), ALL_SERVERS) - return nil - }, - }, - }, - }, - { - Name: "migrate", - Usage: "Run database migrations", - Action: func(c *cli.Context) error { - migrate(c.String("config")) - return nil - }, - }, - }, - } - - if err := app.Run(os.Args); err != nil { - log.Fatal(err) - } -} - -func start(configFilePath string, toRun ServiceToRun) { - config, err := config.Load(configFilePath) - if err != nil { - log.Fatal(err) - } - - _db := db.New(&config.Database) - _db.Connect() - migrator := db.NewMigrator(_db, &config.Database) - - if config.Database.AutoMigrate { - if err := migrator.Migrate(); err != nil { - log.Fatal(err) - } - _db.PopulateDefaultSettings(context.Background()) - } - - smtp := smtp.New(&config.Admin) - - service := service.New(_db, config, smtp) - - proxyServer := proxy.New(config) - sshServer := sshd.New(&config.Ssh, proxyServer, service) - adminServer := admin.New(config, service) - cron := cron.New(_db, config) - - if toRun == TUNNEL_SERVER || toRun == ALL_SERVERS { - go proxyServer.Start() - defer proxyServer.Shutdown(context.TODO()) - - go sshServer.Start() - defer sshServer.Shutdown(context.TODO()) - } - - if toRun == ADMIN_SERVER || toRun == ALL_SERVERS { - go adminServer.Start() - defer adminServer.Shutdown() - - go cron.Start() - defer cron.Shutdown() - } - - done := make(chan os.Signal, 1) - signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - - <-done -} - -func migrate(configFilePath string) { - config, err := config.Load(configFilePath) - if err != nil { - log.Fatal(err) - } - - _db := db.New(&config.Database) - _db.Connect() - migrator := db.NewMigrator(_db, &config.Database) - if err := migrator.Migrate(); err != nil { - log.Fatal(err) - } - _db.PopulateDefaultSettings(context.Background()) -} diff --git a/configs/server.yaml b/configs/server.yaml deleted file mode 100644 index 2675e69b..00000000 --- a/configs/server.yaml +++ /dev/null @@ -1,17 +0,0 @@ -admin: - port: 8000 - useVite: true - oauth: - clientId: $GITHUB_CLIENT_ID # this is replaced by the environment var - clientSecret: $GITHUB_CLIENT_SECRET -ssh: - port: 2222 -proxy: - port: 8001 -database: - url: $DATABASE_URL - driver: sqlite3 - autoMigrate: true -useLocalhost: true -debug: true -domain: $DOMAIN diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 00000000..5a34d2d1 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,35 @@ +services: + admin: + build: + context: admin + ports: + - 8000:8000 + depends_on: + - postgres + env_file: .env + + tunnel: + build: + context: tunnel + command: ["start"] + ports: + - 2222:2222 + - 8001:8001 + depends_on: + - admin + - postgres + env_file: .env + + postgres: + image: postgres:16.2 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: {} diff --git a/docker/docker-compose.yaml b/docker-compose.yaml similarity index 57% rename from docker/docker-compose.yaml rename to docker-compose.yaml index ff511e28..ee886cc3 100644 --- a/docker/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,27 +8,40 @@ services: restart: unless-stopped network_mode: "host" - portr: + admin: build: # use an image - context: . - dockerfile: docker/Dockerfile - command: ["start", "all"] - volumes: - - ./server.yaml:/app/config.yaml - - ./data:/app/data/ - - ./keys:/app/keys/ + context: admin network_mode: "host" - env_file: .env + env_file: admin/.env restart: unless-stopped labels: caddy_0: $DOMAIN caddy_0.reverse_proxy: "{{upstreams http 8000}}" caddy_0.encode: gzip + + tunnel: + build: + # use an image + context: tunnel + network_mode: "host" + env_file: tunnel/.env + restart: unless-stopped + labels: caddy_1: "*.$DOMAIN" caddy_1.reverse_proxy: "{{upstreams http 8001}}" caddy_1.tls.dns: "cloudflare $CLOUDFLARE_API_TOKEN" caddy_1.encode: gzip + postgres: + image: postgres:16.2 + environment: + POSTGRES_USER: $POSTGRES_USER + POSTGRES_PASSWORD: $POSTGRES_PASSWORD + POSTGRES_DB: $POSTGRES_DB + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + volumes: - caddy_data: {} + postgres_data: {} diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index d3670229..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,34 +0,0 @@ -FROM oven/bun:1 as frontend-builder - -WORKDIR /app - -COPY internal/server/admin/web . - -RUN bun i && bun run build - -FROM golang:1.22.0 AS builder - -WORKDIR /app - -COPY go.mod go.sum /app/ - -RUN go mod download - -COPY . /app/ - -RUN CGO_ENABLED=1 go build -ldflags="-s -w -linkmode external -extldflags \"-static\"" -o portrd ./cmd/portrd - -FROM alpine:3.19.1 as final - -WORKDIR /app - -COPY --from=builder /app/portrd /app/ -COPY --from=builder /app/internal/server/admin/templates/ /app/internal/server/admin/templates/ -COPY --from=builder /app/internal/server/admin/static/ /app/internal/server/admin/static/ -COPY --from=frontend-builder /app/dist/ ./internal/server/admin/web/dist/ - -VOLUME [ "/app/configs" ] -VOLUME [ "/app/data" ] - -ENTRYPOINT ["./portrd", "--config", "/app/config.yaml"] - diff --git a/docker/docker-compose.dev.yaml b/docker/docker-compose.dev.yaml deleted file mode 100644 index d042377f..00000000 --- a/docker/docker-compose.dev.yaml +++ /dev/null @@ -1,15 +0,0 @@ -services: - portr: - build: - context: .. - dockerfile: docker/Dockerfile - command: ["start", "all"] - volumes: - - ../configs/server.yaml:/app/config.yaml - - ../data:/app/data/ - - ../keys:/app/keys/ - ports: - - 8000:8000 - - 8001:8001 - - 2222:2222 - restart: unless-stopped diff --git a/go.mod b/go.mod deleted file mode 100644 index 1f60ffa0..00000000 --- a/go.mod +++ /dev/null @@ -1,71 +0,0 @@ -module github.com/amalshaji/portr - -go 1.22.0 - -require ( - github.com/amacneil/dbmate/v2 v2.10.0 - github.com/briandowns/spinner v1.23.0 - github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 - github.com/emersion/go-smtp v0.19.0 - github.com/glebarez/sqlite v1.10.0 - github.com/gliderlabs/ssh v0.3.6 - github.com/go-resty/resty/v2 v2.11.0 - github.com/gofiber/fiber/v2 v2.52.0 - github.com/gofiber/template/django/v3 v3.1.9 - github.com/gookit/validate v1.5.1 - github.com/labstack/gommon v0.4.2 - github.com/matoous/go-nanoid/v2 v2.0.0 - github.com/mattn/go-sqlite3 v1.14.19 - github.com/oklog/ulid/v2 v2.1.0 - github.com/urfave/cli/v2 v2.26.0 - github.com/valyala/fasttemplate v1.2.2 - golang.org/x/crypto v0.17.0 - golang.org/x/oauth2 v0.15.0 - gopkg.in/yaml.v3 v3.0.1 - gorm.io/datatypes v1.2.0 - gorm.io/gorm v1.25.5 -) - -require ( - github.com/andybalholm/brotli v1.0.6 // indirect - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.7.0 // indirect - github.com/flosch/pongo2/v6 v6.0.0 // indirect - github.com/glebarez/go-sqlite v1.22.0 // indirect - github.com/go-sql-driver/mysql v1.7.1 // indirect - github.com/gofiber/template v1.8.2 // indirect - github.com/gofiber/utils v1.1.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.5.0 // indirect - github.com/gookit/filter v1.2.0 // indirect - github.com/gookit/goutil v0.6.14 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.17.4 // indirect - github.com/lib/pq v1.10.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.4 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/tcplisten v1.0.0 // indirect - github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.32.0 // indirect - gorm.io/driver/mysql v1.5.2 // indirect - modernc.org/libc v1.38.0 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.7.2 // indirect - modernc.org/sqlite v1.28.0 // indirect -) diff --git a/internal/server/admin/admin.go b/internal/server/admin/admin.go deleted file mode 100644 index 8a82a2ec..00000000 --- a/internal/server/admin/admin.go +++ /dev/null @@ -1,157 +0,0 @@ -package admin - -import ( - "context" - "errors" - "fmt" - "log" - "log/slog" - "net/http" - "time" - - "github.com/amalshaji/portr/internal/server/admin/handler" - "github.com/amalshaji/portr/internal/server/admin/service" - - "github.com/amalshaji/portr/internal/server/config" - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/utils" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/logger" - "github.com/gofiber/fiber/v2/middleware/recover" - "github.com/gofiber/template/django/v3" -) - -type AdminServer struct { - app *fiber.App - config *config.AdminConfig - log *slog.Logger -} - -func New(config *config.Config, service *service.Service) *AdminServer { - engine := django.New("./internal/server/admin/templates", ".html") - engine.SetAutoEscape(false) - - app := fiber.New(fiber.Config{ - DisableStartupMessage: true, - Views: engine, - }) - - app.Use(logger.New()) - app.Use(recover.New()) - - ctx := context.Background() - - if !config.Admin.UseVite { - app.Static("/", "./internal/server/admin/web/dist") - } - - app.Static("/static", "./internal/server/admin/static", fiber.Static{ - Compress: true, - }) - - clientPages := []string{"/connections", "/overview", "/settings", "/users", "/my-account", "/new-team"} - - app.Use(func(c *fiber.Ctx) error { - token := c.Cookies("portr-session") - user, _ := service.GetUserBySession(ctx, token) - - c.Locals("user", user) - return c.Next() - }) - - // set teamUser in locals - gleanTeamUser := func(c *fiber.Ctx) error { - user := c.Locals("user").(*db.UserWithTeams) - if user == nil { - return c.Next() - } - teamName := c.Params("teamName") - teamUser, _ := service.GetTeamUser(ctx, user.ID, teamName) - c.Locals("teamUser", teamUser) - return c.Next() - } - - handler := handler.New(config, service) - - githubAuthGroup := app.Group("/auth/github") - handler.RegisterGithubAuthRoutes(githubAuthGroup) - - connectionForClientGroup := app.Group("/api/") - handler.RegisterConnectionRoutesForClient(connectionForClientGroup) - - apiGroup := app.Group("/api/", apiAuthMiddleware) - handler.RegisterUserRoutes(apiGroup) - handler.RegisterSettingsRoutes(apiGroup, superUserPermissionRequired) - handler.RegisterTeamRoutes(apiGroup, superUserPermissionRequired) - - handler.RegisterClientConfigRoutes(app, apiAuthMiddleware) - - teamApiGroup := app.Group("/api/:teamName", gleanTeamUser, apiTeamAuthMiddleware) - handler.RegisterTeamUserRoutes(teamApiGroup, adminPermissionRequired) - handler.RegisterConnectionRoutes(teamApiGroup) - - // handle initial setup - app.Use(func(c *fiber.Ctx) error { - user := c.Locals("user").(*db.UserWithTeams) - if user != nil && len(user.Teams) == 0 && c.Path() != "/setup" { - return c.Redirect("/setup") - } - if user != nil && len(user.Teams) > 0 && c.Path() == "/setup" { - return c.Redirect(fmt.Sprintf("/%s/overview", user.Teams[0].Name)) - } - return c.Next() - }) - - // server index templates for all routes - // should be explicit? - rootTemplateView := func(c *fiber.Ctx) error { - return c.Render("index", fiber.Map{ - "UseVite": config.Admin.UseVite, - "ViteTags": getViteTags(), - }) - } - - app.Get("/", func(c *fiber.Ctx) error { - user := c.Locals("user").(*db.UserWithTeams) - if user != nil { - if len(user.Teams) == 0 { - return c.Redirect("/setup") - } - return c.Redirect(fmt.Sprintf("/%s/overview", user.Teams[0].Name)) - } - - return rootTemplateView(c) - }) - - app.Get("/setup", rootViewAuthMiddleware, rootTemplateView) - - for _, page := range clientPages { - app.Get("/:teamName"+page, gleanTeamUser, teamViewAuthMiddleware, rootTemplateView) - } - - return &AdminServer{ - app: app, - config: &config.Admin, - log: utils.GetLogger(), - } -} - -func (s *AdminServer) Start() { - s.log.Info("starting admin server", "port", s.config.ListenAddress()) - - if err := s.app.Listen(s.config.ListenAddress()); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("failed to start admin server: %v", err) - } -} - -func (s *AdminServer) Shutdown() { - s.log.Info("stopping admin server") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - - defer func() { cancel() }() - - if err := s.app.ShutdownWithContext(ctx); err != nil { - s.log.Error("failed to stop proxy server", "error", err) - } -} diff --git a/internal/server/admin/handler/auth.go b/internal/server/admin/handler/auth.go deleted file mode 100644 index 45ee6ef2..00000000 --- a/internal/server/admin/handler/auth.go +++ /dev/null @@ -1,77 +0,0 @@ -package handler - -import ( - "errors" - "time" - - "github.com/amalshaji/portr/internal/server/admin/service" - "github.com/amalshaji/portr/internal/utils" - "github.com/gofiber/fiber/v2" -) - -func (h *Handler) StartGithubAuth(c *fiber.Ctx) error { - state := utils.GenerateOAuthState() - oauth2Client := h.service.GetOauth2Client() - url := oauth2Client.AuthCodeURL(state) - - c.Cookie(&fiber.Cookie{ - Name: "portr-oauth-state", - Value: state, - HTTPOnly: true, - Path: "/", - Expires: time.Now().Add(10 * time.Minute), - SameSite: "Lax", - }) - return c.Redirect(url) -} - -func (h *Handler) GithubAuthCallback(c *fiber.Ctx) error { - state := c.Cookies("portr-oauth-state") - if state == "" { - h.log.Error("malformed oauth flow", "error", "missing state in cookie") - return c.Redirect("/?code=github-oauth-error") - } - - c.ClearCookie("portr-oauth-state") - - code := c.Query("code") - if code == "" { - h.log.Error("malformed oauth flow", "error", "missing code in query params") - return c.Redirect("/?code=github-oauth-error") - } - - oauth2Client := h.service.GetOauth2Client() - - token, err := oauth2Client.Exchange(c.Context(), code) - if err != nil { - h.log.Error("error while getting access token", "error", err) - return c.Redirect("/?code=github-oauth-error") - } - - user, err := h.service.GetOrCreateUserForGithubLogin(c.Context(), token.AccessToken) - if err != nil { - h.log.Error("error while creating user", "error", err) - if errors.Is(err, service.ErrUserNotFound) { - return c.Redirect("/?code=user-not-found") - } else if errors.Is(err, service.ErrDomainNotAllowed) { - return c.Redirect("/?code=domain-not-allowed") - } else if errors.Is(err, service.ErrPrivateEmail) { - return c.Redirect("/?code=private-email") - } - } - - session, _ := h.service.LoginUser(c.Context(), user) - c.Cookie(&fiber.Cookie{ - Name: "portr-session", - Value: session.Token, - HTTPOnly: true, - Path: "/", - Expires: time.Now().Add(24 * time.Hour), - SameSite: "Lax", - }) - return c.Redirect("/") -} - -func (h *Handler) IsSuperUserSignup(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"isSuperUserSignup": h.service.IsSuperUserSignUp(c.Context())}) -} diff --git a/internal/server/admin/handler/config.go b/internal/server/admin/handler/config.go deleted file mode 100644 index 132ff678..00000000 --- a/internal/server/admin/handler/config.go +++ /dev/null @@ -1,48 +0,0 @@ -package handler - -import ( - "fmt" - "os" - - "github.com/gofiber/fiber/v2" -) - -func (h *Handler) ValidateClientConfig(c *fiber.Ctx) error { - var payload struct { - Key string `json:"key"` - } - if err := c.BodyParser(&payload); err != nil { - h.log.Error("failed to parse payload", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"message": "invalid payload"}) - } - - err := h.service.ValidateClientConfig(c.Context(), payload.Key) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"message": "failed to validate client config"}) - } - - content, err := os.ReadFile(h.config.Ssh.KeysDir + "/id_rsa") - if err != nil { - h.log.Error("failed to locate credentials", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"message": "failed to locate credentials"}) - } - - return c.Send(content) -} - -func (h *Handler) GetServerAddress(c *fiber.Ctx) error { - AdminUrl := h.config.Admin.Host + ":" + fmt.Sprint(h.config.Admin.Port) - sshHost := h.config.Ssh.Host - - if !h.config.UseLocalHost { - AdminUrl = h.config.Domain - sshHost = h.config.Domain - } - - sshUrl := sshHost + ":" + fmt.Sprint(h.config.Ssh.Port) - - return c.JSON(fiber.Map{ - "AdminUrl": AdminUrl, - "SshUrl": sshUrl, - }) -} diff --git a/internal/server/admin/handler/connection.go b/internal/server/admin/handler/connection.go deleted file mode 100644 index 32df2a6a..00000000 --- a/internal/server/admin/handler/connection.go +++ /dev/null @@ -1,88 +0,0 @@ -package handler - -import ( - "strconv" - - "github.com/amalshaji/portr/internal/constants" - "github.com/amalshaji/portr/internal/server/admin/service" - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/utils" - "github.com/gofiber/fiber/v2" -) - -func (h *Handler) ListConnections(c *fiber.Ctx) error { - connection_type := c.Query("type") - - pageNoStr := c.Query("pageNo") - pageNo, err := strconv.Atoi(pageNoStr) - if err != nil { - pageNo = 1 - } - - var output any - - teamUser := c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow) - if connection_type == "active" { - activeConnections := h.service.ListActiveConnections(c.Context(), teamUser.TeamID, int64(pageNo)) - count := h.service.GetActiveConnectionCount(c.Context(), teamUser.TeamID) - output = PaginatedResponse[db.GetActiveConnectionsForTeamRow]{ - Data: activeConnections, - Pagination: Pagination{ - Page: pageNo, - PageSize: service.DefaultPageSize, - Total: int(count), - }, - } - } else { - recentConnections := h.service.ListRecentConnections(c.Context(), teamUser.TeamID, int64(pageNo)) - count := h.service.GetRecentConnectionCount(c.Context(), teamUser.TeamID) - output = PaginatedResponse[db.GetRecentConnectionsForTeamRow]{ - Data: recentConnections, - Pagination: Pagination{ - Page: pageNo, - PageSize: service.DefaultPageSize, - Total: int(count), - }, - } - } - - return c.JSON(output) -} - -func (h *Handler) CreateConnection(c *fiber.Ctx) error { - subdomain := c.Get("X-Subdomain") - connectionType := c.Get("X-Connection-Type") - - var err error - - if connectionType == "" { - return utils.ErrBadRequest(c, "connection type is required") - } - - if connectionType == string(constants.Http) { - if subdomain == "" { - return utils.ErrBadRequest(c, "subdomain is required") - } - } - - secretKey := c.Get("X-SecretKey") - if secretKey == "" { - return utils.ErrBadRequest(c, "secret key is required") - } - - var connection db.Connection - - if connectionType == string(constants.Http) { - connection, err = h.service.RegisterNewHttpConnection(c.Context(), subdomain, secretKey) - } else { - connection, err = h.service.RegisterNewTcpConnection(c.Context(), secretKey) - } - - if err != nil { - return utils.ErrBadRequest(c, err.Error()) - } - - return c.JSON(fiber.Map{ - "connectionId": connection.ID, - }) -} diff --git a/internal/server/admin/handler/handler.go b/internal/server/admin/handler/handler.go deleted file mode 100644 index b5f61351..00000000 --- a/internal/server/admin/handler/handler.go +++ /dev/null @@ -1,71 +0,0 @@ -package handler - -import ( - "log/slog" - - "github.com/amalshaji/portr/internal/server/admin/service" - "github.com/amalshaji/portr/internal/server/config" - "github.com/amalshaji/portr/internal/utils" - "github.com/gofiber/fiber/v2" -) - -type Handler struct { - config *config.Config - service *service.Service - log *slog.Logger -} - -func New(config *config.Config, service *service.Service) *Handler { - return &Handler{config: config, service: service, log: utils.GetLogger()} -} - -func (h *Handler) RegisterTeamUserRoutes(group fiber.Router, permissionHandler fiber.Handler) { - userGroup := group.Group("/user") - userGroup.Get("/", h.ListTeamUsers) - userGroup.Post("/add", h.AddMember) - userGroup.Get("/me", h.MeInTeam) - userGroup.Patch("/me/rotate-secret-key", h.RotateSecretKey) -} - -func (h *Handler) RegisterUserRoutes(group fiber.Router) { - currentUserGroup := group.Group("/user") - currentUserGroup.Get("/me", h.Me) - currentUserGroup.Patch("/me/update", h.MeUpdate) - currentUserGroup.Post("/me/logout", h.Logout) -} - -func (h *Handler) RegisterConnectionRoutes(group fiber.Router) { - connectionGroup := group.Group("/connection") - connectionGroup.Get("/", h.ListConnections) -} - -func (h *Handler) RegisterConnectionRoutesForClient(group fiber.Router) { - connectionGroup := group.Group("/connection") - connectionGroup.Post("/create", h.CreateConnection) -} - -func (h *Handler) RegisterGithubAuthRoutes(group fiber.Router) { - group.Get("/", h.StartGithubAuth) - group.Get("/callback", h.GithubAuthCallback) - group.Get("/is-superuser-signup", h.IsSuperUserSignup) -} - -func (h *Handler) RegisterSettingsRoutes(group fiber.Router, permissionHandler fiber.Handler) { - settingsGroup := group.Group("/setting", permissionHandler) - settingsGroup.Get("/all", h.ListSettings) - settingsGroup.Patch("/email/update", h.UpdateEmailSettings) -} - -func (h *Handler) RegisterClientConfigRoutes(app *fiber.App, authMiddleware fiber.Handler) { - configGroup := app.Group("/config") - configGroup.Post("/validate", h.ValidateClientConfig) - configGroup.Get("/address", authMiddleware, h.GetServerAddress) -} - -func (h *Handler) RegisterTeamRoutes( - group fiber.Router, - permissionHandler fiber.Handler, -) { - teamGroup := group.Group("/team", permissionHandler) - teamGroup.Post("/", h.CreateTeam) -} diff --git a/internal/server/admin/handler/pagination.go b/internal/server/admin/handler/pagination.go deleted file mode 100644 index b6e4c023..00000000 --- a/internal/server/admin/handler/pagination.go +++ /dev/null @@ -1,12 +0,0 @@ -package handler - -type Pagination struct { - Page int `json:"page"` - PageSize int `json:"pageSize"` - Total int `json:"total"` -} - -type PaginatedResponse[T any] struct { - Data []T `json:"data"` - Pagination Pagination `json:"pagination"` -} diff --git a/internal/server/admin/handler/settings.go b/internal/server/admin/handler/settings.go deleted file mode 100644 index 742b621e..00000000 --- a/internal/server/admin/handler/settings.go +++ /dev/null @@ -1,27 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/amalshaji/portr/internal/server/admin/service" - "github.com/gofiber/fiber/v2" -) - -func (h *Handler) ListSettings(c *fiber.Ctx) error { - return c.JSON(h.service.ListSettings(c.Context())) -} - -func (h *Handler) UpdateEmailSettings(c *fiber.Ctx) error { - var updatePayload service.UpdateEmailSettingsInput - if err := c.BodyParser(&updatePayload); err != nil { - return c.Status(http.StatusBadRequest).JSON(fiber.Map{"message": "invalid payload"}) - } - if err := updatePayload.Validate(); err != nil { - return c.Status(http.StatusBadRequest).JSON(fiber.Map{"message": err.Error()}) - } - result, err := h.service.UpdateEmailSettings(c.Context(), updatePayload) - if err != nil { - return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"message": err.Error()}) - } - return c.JSON(result) -} diff --git a/internal/server/admin/handler/team.go b/internal/server/admin/handler/team.go deleted file mode 100644 index 0dd3b5ca..00000000 --- a/internal/server/admin/handler/team.go +++ /dev/null @@ -1,34 +0,0 @@ -package handler - -import ( - "github.com/amalshaji/portr/internal/server/admin/service" - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/utils" - "github.com/gofiber/fiber/v2" -) - -func (h *Handler) CreateTeam(c *fiber.Ctx) error { - var createTeamInput service.CreateTeamInput - if err := utils.BodyParser(c, &createTeamInput); err != nil { - return utils.ErrBadRequest(c, err.Error()) - } - user := c.Locals("user").(*db.UserWithTeams) - team, err := h.service.CreateFirstTeam(c.Context(), createTeamInput, user.ID) - if err != nil { - return utils.ErrInternalServerError(c, err.Error()) - } - return c.JSON(team) -} - -func (h *Handler) AddMember(c *fiber.Ctx) error { - var payload service.AddMemberInput - if err := utils.BodyParser(c, &payload); err != nil { - return utils.ErrBadRequest(c, err) - } - teamUser := c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow) - result, err := h.service.AddMember(c.Context(), payload, teamUser.TeamID, teamUser.ID) - if err != nil { - return utils.ErrBadRequest(c, err.Error()) - } - return c.JSON(result) -} diff --git a/internal/server/admin/handler/user.go b/internal/server/admin/handler/user.go deleted file mode 100644 index 021a81be..00000000 --- a/internal/server/admin/handler/user.go +++ /dev/null @@ -1,57 +0,0 @@ -package handler - -import ( - "net/http" - - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/gofiber/fiber/v2" -) - -func (h *Handler) ListTeamUsers(c *fiber.Ctx) error { - teamUser := c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow) - return c.JSON(h.service.ListTeamUsers(c.Context(), teamUser.TeamID)) -} - -func (h *Handler) Me(c *fiber.Ctx) error { - return c.JSON(c.Locals("user").(*db.UserWithTeams)) -} - -func (h *Handler) MeInTeam(c *fiber.Ctx) error { - return c.JSON(c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow)) -} - -func (h *Handler) MeUpdate(c *fiber.Ctx) error { - var updatePayload struct { - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - } - if err := c.BodyParser(&updatePayload); err != nil { - return c.Status(http.StatusBadRequest).JSON(fiber.Map{"message": "invalid payload"}) - } - userFromLocals := c.Locals("user").(*db.UserWithTeams) - result, err := h.service.UpdateUser(c.Context(), userFromLocals.ID, updatePayload.FirstName, updatePayload.LastName) - if err != nil { - return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"message": "failed to update profile info"}) - } - return c.JSON(result) -} - -func (h *Handler) Logout(c *fiber.Ctx) error { - // expire all keys! - c.ClearCookie() - err := h.service.Logout(c.Context(), c.Cookies("portr-session")) - if err != nil { - h.log.Error("error while logging out", "error", err) - return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"message": "internal server error"}) - } - return c.SendStatus(http.StatusOK) -} - -func (h *Handler) RotateSecretKey(c *fiber.Ctx) error { - result, err := h.service.RotateSecretKey(c.Context(), c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow).ID) - if err != nil { - h.log.Error("error while logging out", "error", err) - return c.Status(http.StatusBadRequest).JSON(fiber.Map{"message": err.Error()}) - } - return c.JSON(result) -} diff --git a/internal/server/admin/middleware.go b/internal/server/admin/middleware.go deleted file mode 100644 index c6742387..00000000 --- a/internal/server/admin/middleware.go +++ /dev/null @@ -1,68 +0,0 @@ -package admin - -import ( - "fmt" - "slices" - - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/gofiber/fiber/v2" -) - -var rootViewAuthMiddleware = func(c *fiber.Ctx) error { - user := c.Locals("user").(*db.UserWithTeams) - if user == nil { - return c.Redirect("/") - } - return c.Next() -} - -var teamViewAuthMiddleware = func(c *fiber.Ctx) error { - user := c.Locals("user").(*db.UserWithTeams) - if user == nil { - return c.Redirect("/") - } - - teamUser := c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow) - if teamUser == nil { - return c.Redirect(fmt.Sprintf("/%s/overview", user.Teams[0].Slug)) - } - - return c.Next() -} - -var apiAuthMiddleware = func(c *fiber.Ctx) error { - user := c.Locals("user").(*db.UserWithTeams) - if user == nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "unauthorized"}) - } - return c.Next() -} - -var apiTeamAuthMiddleware = func(c *fiber.Ctx) error { - teamUser := c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow) - if teamUser == nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "unauthorized"}) - } - return c.Next() -} - -// Make sure to run these after running auth middlewares -var adminPermissionRequired = func(c *fiber.Ctx) error { - user := c.Locals("teamUser").(*db.GetTeamMemberByUserIdAndTeamSlugRow) - if !slices.Contains([]string{"admin", "superuser"}, user.Role) { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "message": "you need admin permissions to perform this action", - }) - } - return c.Next() -} - -var superUserPermissionRequired = func(c *fiber.Ctx) error { - user := c.Locals("user").(*db.UserWithTeams) - if !user.IsSuperUser { - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "message": "you need superuser permissions to perform this action", - }) - } - return c.Next() -} diff --git a/internal/server/admin/script.go b/internal/server/admin/script.go deleted file mode 100644 index 9986bf65..00000000 --- a/internal/server/admin/script.go +++ /dev/null @@ -1,46 +0,0 @@ -package admin - -import ( - "encoding/json" - "log" - "os" -) - -const viteDistDir = "./internal/server/admin/web/dist" - -type manifest struct { - IndexHTML struct { - CSS []string `json:"css"` - File string `json:"file"` - IsEntry bool `json:"isEntry"` - Src string `json:"src"` - } `json:"index.html"` -} - -func getViteTags() string { - manifestFileContents, err := os.ReadFile(viteDistDir + "/static/.vite/manifest.json") - if err != nil { - log.Fatal(err) - } - - var manifest manifest - if err := json.Unmarshal(manifestFileContents, &manifest); err != nil { - log.Fatal(err) - } - - var tags string - - csses := manifest.IndexHTML.CSS - if len(csses) > 0 { - for _, css := range csses { - tags += "" - } - } - - file := manifest.IndexHTML.File - if file != "" { - tags += "" - } - - return tags -} diff --git a/internal/server/admin/service/auth.go b/internal/server/admin/service/auth.go deleted file mode 100644 index a73b40db..00000000 --- a/internal/server/admin/service/auth.go +++ /dev/null @@ -1,140 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/utils" - "github.com/go-resty/resty/v2" - "golang.org/x/oauth2" -) - -const GITHUB_REDIRECT_URI = "/auth/github/callback" - -func (s *Service) GetOauth2Client() oauth2.Config { - return oauth2.Config{ - ClientID: s.config.Admin.OAuth.ClientID, - ClientSecret: s.config.Admin.OAuth.ClientSecret, - RedirectURL: s.config.AdminUrl() + GITHUB_REDIRECT_URI, - Endpoint: oauth2.Endpoint{ - AuthURL: "https://github.com/login/oauth/authorize", - TokenURL: "https://github.com/login/oauth/access_token", - }, - Scopes: []string{"user:email"}, - } -} - -func (s *Service) GetAccessToken(code, state string) (string, error) { - requestBodyMap := map[string]string{ - "client_id": s.config.Admin.OAuth.ClientID, - "client_secret": s.config.Admin.OAuth.ClientSecret, - "code": code, - } - - var response = struct { - AccessToken string `json:"access_token"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` - }{} - - client := resty.New() - resp, err := client.R(). - SetHeader("Accept", "application/json"). - SetBody(requestBodyMap). - SetResult(response). - Post("https://github.com/login/oauth/access_token") - if err != nil { - return "", err - } - if resp.StatusCode() != http.StatusOK { - return "", fmt.Errorf("github api returned status code %d", resp.StatusCode()) - } - - return response.AccessToken, nil -} - -type GithubUserDetails struct { - Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` -} - -func (s *Service) GetGithubUserDetails(accessToken string) (GithubUserDetails, error) { - var result GithubUserDetails - - client := resty.New() - resp, err := client.R(). - SetResult(&result). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", accessToken)). - SetHeader("Accept", "application/vnd.github+json"). - SetHeader("X-GitHub-Api-Version", "2022-11-28"). - Get("https://api.github.com/user") - if err != nil { - return GithubUserDetails{}, err - } - if resp.StatusCode() != http.StatusOK { - return GithubUserDetails{}, fmt.Errorf("github api returned status code %d", resp.StatusCode()) - } - - return result, nil -} - -type GithubUserEmails struct { - Email string `json:"email"` - Verified bool `json:"verified"` - Primary bool `json:"primary"` - Visibility string `json:"visibility"` -} - -func (s *Service) GetGithubUserEmails(accessToken string) (*[]GithubUserEmails, error) { - url := "https://api.github.com/user/emails" - client := &http.Client{} - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) - req.Header.Add("Accept", "application/vnd.github+json") - req.Header.Add("X-GitHub-Api-Version", "2022-11-28") - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("github api returned status code %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var result []GithubUserEmails - err = json.Unmarshal(body, &result) - if err != nil { - return nil, err - } - - return &result, nil -} - -func (s *Service) LoginUser(ctx context.Context, user *db.User) (db.Session, error) { - sessionToken := utils.GenerateSessionToken() - return s.db.Queries.CreateSession(ctx, db.CreateSessionParams{ - Token: sessionToken, - UserID: user.ID, - }) -} - -func (s *Service) IsSuperUserSignUp(ctx context.Context) bool { - count, _ := s.db.Queries.GetUsersCount(ctx) - return count == 0 -} diff --git a/internal/server/admin/service/config.go b/internal/server/admin/service/config.go deleted file mode 100644 index 89678b3e..00000000 --- a/internal/server/admin/service/config.go +++ /dev/null @@ -1,20 +0,0 @@ -package service - -import ( - "context" - "database/sql" - "errors" - "fmt" -) - -func (s *Service) ValidateClientConfig(ctx context.Context, key string) error { - _, err := s.db.Queries.GetTeamUserBySecretKey(ctx, key) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.log.Error("invalid secret key", "key", key) - return fmt.Errorf("invalid secret key") - } - return fmt.Errorf("error while validating secret key: %w", err) - } - return nil -} diff --git a/internal/server/admin/service/connection.go b/internal/server/admin/service/connection.go deleted file mode 100644 index ff57ca9a..00000000 --- a/internal/server/admin/service/connection.go +++ /dev/null @@ -1,190 +0,0 @@ -package service - -import ( - "context" - "database/sql" - "errors" - "fmt" - - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/oklog/ulid/v2" -) - -func (s *Service) ListActiveConnections(ctx context.Context, teamID, pageNo int64) []db.GetActiveConnectionsForTeamRow { - result, err := s.db.Queries.GetActiveConnectionsForTeam(ctx, db.GetActiveConnectionsForTeamParams{ - TeamID: teamID, - Offset: (pageNo - 1) * int64(DefaultPageSize), - }) - if err != nil { - s.log.Error("error while fetching active connections", "error", err) - return []db.GetActiveConnectionsForTeamRow{} - } - return result -} - -func (s *Service) GetActiveConnectionCount(ctx context.Context, teamID int64) int64 { - result, err := s.db.Queries.GetActiveConnectionCountForTeam(ctx, teamID) - if err != nil { - s.log.Error("error while fetching active connection count", "error", err) - return 0 - } - return result -} - -func (s *Service) ListRecentConnections(ctx context.Context, teamID, pageSize int64) []db.GetRecentConnectionsForTeamRow { - result, err := s.db.Queries.GetRecentConnectionsForTeam(ctx, db.GetRecentConnectionsForTeamParams{ - TeamID: teamID, - Offset: (pageSize - 1) * int64(DefaultPageSize), - }) - if err != nil { - s.log.Error("error while fetching active connections", "error", err) - return []db.GetRecentConnectionsForTeamRow{} - } - return result -} - -func (s *Service) GetRecentConnectionCount(ctx context.Context, teamID int64) int64 { - result, err := s.db.Queries.GetRecentConnectionCountForTeam(ctx, teamID) - if err != nil { - s.log.Error("error while fetching recent connection count", "error", err) - return 0 - } - return result -} - -func (s *Service) RegisterNewHttpConnection( - ctx context.Context, - subdomain string, - secretKey string, -) (db.Connection, error) { - teamUserResult, err := s.db.Queries.GetTeamUserBySecretKey(ctx, secretKey) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return db.Connection{}, fmt.Errorf("invalid secret key") - } - return db.Connection{}, err - } - - item, err := s.db.Queries.GetReservedOrActiveConnectionForSubdomain( - ctx, - db.GetReservedOrActiveConnectionForSubdomainParams{ - Subdomain: subdomain, - SecretKey: secretKey, - }) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - // do nothing - } else { - return db.Connection{}, err - } - } - - // do a better check - if item.ID != "" { - return db.Connection{}, fmt.Errorf("subdomain is in use") - } - - result, err := s.db.Queries.CreateNewHttpConnection(ctx, db.CreateNewHttpConnectionParams{ - ID: ulid.Make().String(), - Subdomain: subdomain, - TeamMemberID: teamUserResult.ID, - TeamID: teamUserResult.TeamID, - }) - if err != nil { - return db.Connection{}, err - } - return result, nil -} - -func (s *Service) RegisterNewTcpConnection( - ctx context.Context, - secretKey string, -) (db.Connection, error) { - teamUserResult, err := s.db.Queries.GetTeamUserBySecretKey(ctx, secretKey) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return db.Connection{}, fmt.Errorf("invalid secret key") - } - return db.Connection{}, err - } - - result, err := s.db.Queries.CreateNewTcpConnection(ctx, db.CreateNewTcpConnectionParams{ - ID: ulid.Make().String(), - Port: nil, - TeamMemberID: teamUserResult.ID, - TeamID: teamUserResult.TeamID, - }) - if err != nil { - return db.Connection{}, err - } - return result, nil -} - -func (s *Service) GetReservedConnectionForSubdomain( - ctx context.Context, - subdomain, - secretKey string, -) (string, error) { - result, err := s.db.Queries.GetReservedOrActiveConnectionForSubdomain( - ctx, - db.GetReservedOrActiveConnectionForSubdomainParams{ - Subdomain: subdomain, - SecretKey: secretKey, - }) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return "", fmt.Errorf("unregistered subdomain") - } - return "", err - } - return result.ID, nil -} - -func (s *Service) GetReservedOrActiveConnectionById( - ctx context.Context, - id string, -) (db.GetReservedOrActiveConnectionByIdRow, error) { - result, err := s.db.Queries.GetReservedOrActiveConnectionById(ctx, id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return db.GetReservedOrActiveConnectionByIdRow{}, fmt.Errorf("unregistered connection") - } - return db.GetReservedOrActiveConnectionByIdRow{}, err - } - return result, nil -} - -func (s *Service) GetReservedConnectionForPort( - ctx context.Context, - port uint32, - secretKey string, -) (string, error) { - result, err := s.db.Queries.GetReservedOrActiveConnectionForPort( - ctx, - db.GetReservedOrActiveConnectionForPortParams{ - Port: port, - SecretKey: secretKey, - }) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return "", fmt.Errorf("unregistered port") - } - return "", err - } - return result.ID, nil -} - -func (s *Service) MarkConnectionAsClosed(ctx context.Context, connectionId string) error { - return s.db.Queries.MarkConnectionAsClosed(ctx, connectionId) -} - -func (s *Service) MarkConnectionAsActive(ctx context.Context, connectionId string) error { - return s.db.Queries.MarkConnectionAsActive(ctx, connectionId) -} - -func (s *Service) AddPortToConnection(ctx context.Context, connectionId string, port uint32) error { - return s.db.Queries.AddPortToConnection(ctx, db.AddPortToConnectionParams{ - Port: port, - ID: connectionId, - }) -} diff --git a/internal/server/admin/service/constants.go b/internal/server/admin/service/constants.go deleted file mode 100644 index 726275e2..00000000 --- a/internal/server/admin/service/constants.go +++ /dev/null @@ -1,3 +0,0 @@ -package service - -var DefaultPageSize = 10 diff --git a/internal/server/admin/service/service.go b/internal/server/admin/service/service.go deleted file mode 100644 index 49be8537..00000000 --- a/internal/server/admin/service/service.go +++ /dev/null @@ -1,21 +0,0 @@ -package service - -import ( - "log/slog" - - "github.com/amalshaji/portr/internal/server/config" - "github.com/amalshaji/portr/internal/server/db" - "github.com/amalshaji/portr/internal/server/smtp" - "github.com/amalshaji/portr/internal/utils" -) - -type Service struct { - db *db.Db - config *config.Config - smtp *smtp.Smtp - log *slog.Logger -} - -func New(db *db.Db, config *config.Config, smtp *smtp.Smtp) *Service { - return &Service{db: db, config: config, smtp: smtp, log: utils.GetLogger()} -} diff --git a/internal/server/admin/service/settings.go b/internal/server/admin/service/settings.go deleted file mode 100644 index a53f1ef5..00000000 --- a/internal/server/admin/service/settings.go +++ /dev/null @@ -1,29 +0,0 @@ -package service - -import ( - "context" - - db "github.com/amalshaji/portr/internal/server/db/models" -) - -func (s *Service) ListSettings(ctx context.Context) db.GlobalSetting { - settings, _ := s.db.Queries.GetGlobalSettings(ctx) - return settings -} - -func (s *Service) UpdateEmailSettings(ctx context.Context, updateSettingsInput UpdateEmailSettingsInput) (db.GlobalSetting, error) { - err := s.db.Queries.UpdateGlobalSettings(ctx, db.UpdateGlobalSettingsParams{ - SmtpEnabled: updateSettingsInput.SmtpEnabled, - SmtpHost: updateSettingsInput.SmtpHost, - SmtpPort: updateSettingsInput.SmtpPort, - SmtpUsername: updateSettingsInput.SmtpUsername, - SmtpPassword: updateSettingsInput.SmtpPassword, - FromAddress: updateSettingsInput.FromAddress, - AddMemberEmailSubject: updateSettingsInput.AddMemberEmailSubject, - AddMemberEmailTemplate: updateSettingsInput.AddMemberEmailTemplate, - }) - if err != nil { - return db.GlobalSetting{}, err - } - return s.ListSettings(ctx), nil -} diff --git a/internal/server/admin/service/team.go b/internal/server/admin/service/team.go deleted file mode 100644 index dc9c8cda..00000000 --- a/internal/server/admin/service/team.go +++ /dev/null @@ -1,120 +0,0 @@ -package service - -import ( - "context" - "database/sql" - "errors" - - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/server/smtp" - "github.com/amalshaji/portr/internal/utils" - "github.com/valyala/fasttemplate" -) - -type CreateTeamInput struct { - Name string `validate:"required|min_len:4"` -} - -func (s *Service) CreateTeam(ctx context.Context, createTeamInput CreateTeamInput) (db.Team, error) { - return s.db.Queries.CreateTeam(ctx, db.CreateTeamParams{ - Name: createTeamInput.Name, - Slug: utils.Slugify(createTeamInput.Name), - }) -} - -func (s *Service) CreateFirstTeam(ctx context.Context, createTeamInput CreateTeamInput, userID int64) (*db.Team, error) { - tx, _ := s.db.Conn.Begin() - defer tx.Rollback() - - team, err := s.CreateTeam(ctx, createTeamInput) - if err != nil { - if utils.IsSqliteUniqueConstraintError(err) { - return nil, errors.New("team name already exists") - } - return nil, err - } - - _, err = s.CreateTeamUser(ctx, userID, team.ID, "admin") - if err != nil { - return nil, err - } - - tx.Commit() - return &team, nil -} - -func (s *Service) sendAddMemberNotification(ctx context.Context, user *db.User, role string, teamId int64, settings *db.GlobalSetting) error { - team, _ := s.db.Queries.GetTeamById(ctx, teamId) - - context := map[string]interface{}{ - "appUrl": s.config.AdminUrl(), - "email": user.Email, - "role": role, - "teamName": team.Name, - } - - t := fasttemplate.New(settings.AddMemberEmailSubject.(string), "{{", "}}") - renderedSubject := t.ExecuteString(context) - - t = fasttemplate.New(settings.AddMemberEmailTemplate.(string), "{{", "}}") - renderedText := t.ExecuteString(context) - - smtpInput := smtp.SendEmailInput{ - From: settings.FromAddress.(string), - To: user.Email, - Subject: renderedSubject, - Body: renderedText, - } - - if err := s.smtp.SendEmail(smtpInput, settings); err != nil { - s.log.Error("failed to send invite notification", "error", err) - return err - } - - return nil -} - -func (s *Service) AddMember( - ctx context.Context, - addMemberInput AddMemberInput, - addedToTeamId, - addByTeamUserId int64, -) (*db.User, error) { - _, err := s.db.Queries.GetTeamMemberByEmail(ctx, db.GetTeamMemberByEmailParams{ - Email: addMemberInput.Email, - TeamID: addedToTeamId, - }) - if err == nil { - return nil, errors.New("user already part of the team") - } - - user, err := s.db.Queries.GetUserByEmail(ctx, addMemberInput.Email) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - // create user - newUser, err := s.CreateUser(ctx, GithubUserDetails{ - Email: addMemberInput.Email, - AvatarUrl: "", - }, "", false) - if err != nil { - s.log.Error("error while creating user", "error", err) - return nil, err - } - user = *newUser - } else { - s.log.Error("error while getting user", "error", err) - return nil, err - } - } - - s.CreateTeamUser(ctx, user.ID, addedToTeamId, addMemberInput.Role) - - settings := s.ListSettings(ctx) - if settings.SmtpEnabled { - go func() { - s.sendAddMemberNotification(ctx, &user, addMemberInput.Role, addedToTeamId, &settings) - }() - } - - return &user, nil -} diff --git a/internal/server/admin/service/types.go b/internal/server/admin/service/types.go deleted file mode 100644 index 6b07bc7b..00000000 --- a/internal/server/admin/service/types.go +++ /dev/null @@ -1,62 +0,0 @@ -package service - -import "errors" - -var ( - ErrSmtpHostRequired = errors.New("smtp host is required") - ErrSmtpPortRequired = errors.New("smtp port is required") - ErrSmtpUsernameRequired = errors.New("smtp username is required") - ErrSmtpPasswordRequired = errors.New("smtp password is required") - ErrSmtpFromAddressRequired = errors.New("smtp from address is required") - ErrSmtpInviteEmailSubjectRequired = errors.New("smtp invite email subject is required") - ErrSmtpInviteEmailTemplateRequired = errors.New("smtp invite email template is required") -) - -type UpdateSignupSettingsInput struct { - SignupRequiresInvite bool - AllowRandomUserSignup bool - RandomUserSignupAllowedDomains string -} - -type UpdateEmailSettingsInput struct { - SmtpEnabled bool - SmtpHost string - SmtpPort int32 - SmtpUsername string - SmtpPassword string - FromAddress string - AddMemberEmailTemplate string - AddMemberEmailSubject string -} - -func (u UpdateEmailSettingsInput) Validate() error { - if u.SmtpEnabled { - if u.SmtpHost == "" { - return ErrSmtpHostRequired - } - if u.SmtpPort == 0 { - return ErrSmtpPortRequired - } - if u.SmtpUsername == "" { - return ErrSmtpUsernameRequired - } - if u.SmtpPassword == "" { - return ErrSmtpPasswordRequired - } - if u.FromAddress == "" { - return ErrSmtpFromAddressRequired - } - if u.AddMemberEmailSubject == "" { - return ErrSmtpInviteEmailSubjectRequired - } - if u.AddMemberEmailTemplate == "" { - return ErrSmtpInviteEmailTemplateRequired - } - } - return nil -} - -type AddMemberInput struct { - Email string `validate:"required|email"` - Role string `validate:"required"` -} diff --git a/internal/server/admin/service/user.go b/internal/server/admin/service/user.go deleted file mode 100644 index f2eebf67..00000000 --- a/internal/server/admin/service/user.go +++ /dev/null @@ -1,201 +0,0 @@ -package service - -import ( - "context" - "database/sql" - "errors" - "fmt" - - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/utils" -) - -var ( - ErrUserNotFound = fmt.Errorf("user not found") - ErrDomainNotAllowed = fmt.Errorf("domain not allowed") - ErrPrivateEmail = fmt.Errorf("private email") -) - -func (s *Service) ListTeamUsers(ctx context.Context, teamID int64) []db.GetTeamMembersRow { - teamUsers, _ := s.db.Queries.GetTeamMembers(ctx, teamID) - return teamUsers -} - -func (s *Service) GetUserBySession(ctx context.Context, token string) (*db.UserWithTeams, error) { - result, err := s.db.Queries.GetUserBySession(ctx, token) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.log.Error("invalid session token", "token", token) - return nil, fmt.Errorf("invalid session token") - } - } - // optimize this, single query - teams, _ := s.db.Queries.GetTeamsOfUser(ctx, result.ID) - return &db.UserWithTeams{ - GetUserBySessionRow: result, - Teams: teams, - }, nil -} - -type UserWithTeamsUpdateResponse struct { - db.GetUserByIdRow - Teams []db.Team -} - -func (s *Service) GetUserById(ctx context.Context, userID int64) (UserWithTeamsUpdateResponse, error) { - result, err := s.db.Queries.GetUserById(ctx, userID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.log.Error("invalid session token", "userID", userID) - return UserWithTeamsUpdateResponse{}, fmt.Errorf("invalid session token") - } - } - // optimize this, single query - teams, _ := s.db.Queries.GetTeamsOfUser(ctx, result.ID) - return UserWithTeamsUpdateResponse{ - GetUserByIdRow: result, - Teams: teams, - }, nil -} - -func (s *Service) GetTeamUser( - ctx context.Context, - userID int64, - teamName string, -) (*db.GetTeamMemberByUserIdAndTeamSlugRow, error) { - teamUser, err := s.db.Queries.GetTeamMemberByUserIdAndTeamSlug(ctx, db.GetTeamMemberByUserIdAndTeamSlugParams{ - ID: userID, - Slug: teamName, - }) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.log.Error("teamUser not found", "user", userID, "team", teamName) - return nil, fmt.Errorf("teamUser not found") - } - } - return &teamUser, nil -} - -func (s *Service) CreateUser( - ctx context.Context, - githubUserDetails GithubUserDetails, - accessToken string, - isSuperUser bool, -) ( - *db.User, error, -) { - user := db.User{ - Email: githubUserDetails.Email, - IsSuperUser: isSuperUser, - GithubAccessToken: accessToken, - GithubAvatarUrl: githubUserDetails.AvatarUrl, - } - user, err := s.db.Queries.CreateUser(ctx, db.CreateUserParams{ - Email: user.Email, - IsSuperUser: user.IsSuperUser, - GithubAccessToken: user.GithubAccessToken, - GithubAvatarUrl: user.GithubAvatarUrl, - }) - if err != nil { - s.log.Error("error while creating user", "error", err) - return nil, err - } - return &user, nil -} - -func (s *Service) GetOrCreateUserForGithubLogin(ctx context.Context, accessToken string) (*db.User, error) { - userDetails, err := s.GetGithubUserDetails(accessToken) - if err != nil { - s.log.Error("error while getting user details", "error", err) - return nil, fmt.Errorf("error while creating user") - } - - if userDetails.Email == "" { - // no emails in user api - // get all emails from the emails api - email, err := s.GetGithubUserEmails(accessToken) - if err != nil { - s.log.Error("error while getting user emails", "error", err) - return nil, fmt.Errorf("error while creating user") - } - - // get the primary email - for _, e := range *email { - if e.Verified && e.Primary { - userDetails.Email = e.Email - break - } - } - - if userDetails.Email == "" { - // no primary email found - s.log.Error("no primary email found", "error", err) - return nil, fmt.Errorf("failed to fetch email from github") - } - - } - - count, _ := s.db.Queries.GetUsersCount(ctx) - if count == 0 { - // This is the first user, make it super user - return s.CreateUser(ctx, userDetails, accessToken, true) - } - - user, err := s.db.Queries.GetUserByEmail(ctx, userDetails.Email) - if err != nil && errors.Is(err, sql.ErrNoRows) { - return nil, ErrUserNotFound - } - - err = s.db.Queries.UpdateUser(ctx, db.UpdateUserParams{ - ID: user.ID, - GithubAccessToken: accessToken, - GithubAvatarUrl: userDetails.AvatarUrl, - }) - if err != nil { - return nil, err - } - - return &user, nil -} - -func (s *Service) CreateTeamUser(ctx context.Context, userID, teamID int64, role string) (*db.TeamMember, error) { - teamUser, err := s.db.Queries.CreateTeamMember(ctx, db.CreateTeamMemberParams{ - TeamID: teamID, - UserID: userID, - Role: role, - SecretKey: utils.GenerateSecretKeyForUser(), - }) - if err != nil { - return nil, err - } - return &teamUser, nil -} - -func (s *Service) Logout(ctx context.Context, token string) error { - return s.db.Queries.DeleteSession(ctx, token) -} - -func (s *Service) UpdateUser(ctx context.Context, userID int64, firstName, lastName string) (UserWithTeamsUpdateResponse, error) { - err := s.db.Queries.UpdateUser(ctx, db.UpdateUserParams{ - ID: userID, - FirstName: firstName, - LastName: lastName, - }) - if err != nil { - return UserWithTeamsUpdateResponse{}, err - } - - return s.GetUserById(ctx, userID) -} - -func (s *Service) RotateSecretKey(ctx context.Context, teamUserID int64) (db.GetTeamMemberByIdRow, error) { - secretKey := utils.GenerateSecretKeyForUser() - err := s.db.Queries.UpdateSecretKey(ctx, db.UpdateSecretKeyParams{ - ID: teamUserID, - SecretKey: secretKey, - }) - if err != nil { - return db.GetTeamMemberByIdRow{}, err - } - return s.db.Queries.GetTeamMemberById(ctx, teamUserID) -} diff --git a/internal/server/admin/static/logo.png b/internal/server/admin/static/logo.png deleted file mode 100644 index 28f378a7..00000000 Binary files a/internal/server/admin/static/logo.png and /dev/null differ diff --git a/internal/server/admin/web/src/lib/components/sidebarlink.svelte b/internal/server/admin/web/src/lib/components/sidebarlink.svelte deleted file mode 100644 index 9dde9340..00000000 --- a/internal/server/admin/web/src/lib/components/sidebarlink.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -
- -
- -
- -
diff --git a/internal/server/admin/web/src/lib/components/users/members.svelte b/internal/server/admin/web/src/lib/components/users/members.svelte deleted file mode 100644 index 2dc411b0..00000000 --- a/internal/server/admin/web/src/lib/components/users/members.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/internal/server/admin/web/src/lib/types.d.ts b/internal/server/admin/web/src/lib/types.d.ts deleted file mode 100644 index f1010e44..00000000 --- a/internal/server/admin/web/src/lib/types.d.ts +++ /dev/null @@ -1,84 +0,0 @@ -export type Team = { - ID: number; - CreatedAt: string; - UpdatedAt: string | null; - DeletedAt: string | null; - Name: string; - Slug: string; -}; - -export type User = { - ID: number; - CreatedAt: string; - UpdatedAt: string | null; - DeletedAt: string | null; - Email: string; - FirstName: string | null; - LastName: string | null; - GithubAvatarUrl: string | null; - IsSuperUser: boolean; - Teams: Team[]; -}; - -export type TeamUser = { - ID: number; - CreatedAt: string; - UpdatedAt: string | null; - DeletedAt: string | null; - TeamID: number; - Team: Team; - UserID: number; - User: User; - Role: "superuser" | "admin" | "member"; - SecretKey: string; -}; - -type BaseSettings = { - AllowRandomUserSignup: boolean; - RandomUserSignupAllowedDomains: string; - SignupRequiresInvite: boolean; -}; - -export type SettingsForSignup = BaseSettings; - -export type Settings = BaseSettings & { - SmtpEnabled: boolean; - SmtpHost: string; - SmtpPort: number; - SmtpUsername: string; - SmtpPassword: string; - FromAddress: string; - AddMemberEmailSubject: string; - AddMemberEmailTemplate: string; -}; - -export type ConnectionStatus = "reserved" | "active" | "closed"; - -export type ConnectionType = "http" | "tcp"; - -export type Connection = { - ID: number; - Type: ConnectionType; - Port: number; - Subdomain: string; - CreatedAt: string; - StartedAt: string | null; - ClosedAt: string | null; - Status: ConnectionStatus; - UserID: number; - User: User; -}; - -export type Invite = { - Email: string; - Role: "admin" | "member"; - Status: "pending" | "accepted" | "expired"; - InvitedByEmail: string; - InvitedByFirstName: string; - InvitedByLastName: string; -}; - -export type ServerAddress = { - AdminUrl: string; - SshUrl: string; -}; diff --git a/internal/server/admin/web/src/lib/utils.ts b/internal/server/admin/web/src/lib/utils.ts deleted file mode 100644 index 230a1fbd..00000000 --- a/internal/server/admin/web/src/lib/utils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; -import { cubicOut } from "svelte/easing"; -import type { TransitionConfig } from "svelte/transition"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -type FlyAndScaleParams = { - y?: number; - x?: number; - start?: number; - duration?: number; -}; - -export const flyAndScale = ( - node: Element, - params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } -): TransitionConfig => { - const style = getComputedStyle(node); - const transform = style.transform === "none" ? "" : style.transform; - - const scaleConversion = ( - valueA: number, - scaleA: [number, number], - scaleB: [number, number] - ) => { - const [minA, maxA] = scaleA; - const [minB, maxB] = scaleB; - - const percentage = (valueA - minA) / (maxA - minA); - const valueB = percentage * (maxB - minB) + minB; - - return valueB; - }; - - const styleToString = ( - style: Record - ): string => { - return Object.keys(style).reduce((str, key) => { - if (style[key] === undefined) return str; - return str + `${key}:${style[key]};`; - }, ""); - }; - - return { - duration: params.duration ?? 200, - delay: 0, - css: (t) => { - const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); - const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); - const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); - - return styleToString({ - transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, - opacity: t - }); - }, - easing: cubicOut - }; -}; \ No newline at end of file diff --git a/internal/server/config/config.go b/internal/server/config/config.go deleted file mode 100644 index b0d876e8..00000000 --- a/internal/server/config/config.go +++ /dev/null @@ -1,155 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strings" - - "gopkg.in/yaml.v3" -) - -type OAuth struct { - ClientID string `yaml:"clientId"` - ClientSecret string `yaml:"clientSecret"` -} - -type AdminConfig struct { - Host string - Port int - UseVite bool `yaml:"useVite"` - OAuth OAuth `yaml:"oauth"` -} - -func (a AdminConfig) Address() string { - return a.Host + ":" + fmt.Sprint(a.Port) -} - -func (a AdminConfig) ListenAddress() string { - return ":" + fmt.Sprint(a.Port) -} - -type SshConfig struct { - Host string - Port int - KeysDir string -} - -func (s SshConfig) Address() string { - return s.Host + ":" + fmt.Sprint(s.Port) -} - -type ProxyConfig struct { - Host string - Port int -} - -func (p ProxyConfig) Address() string { - return p.Host + ":" + fmt.Sprint(p.Port) -} - -type DatabaseConfig struct { - Url string `yaml:"url"` - Driver string `yaml:"driver"` - AutoMigrate bool `yaml:"autoMigrate"` -} - -type Config struct { - Admin AdminConfig `yaml:"admin"` - Ssh SshConfig `yaml:"ssh"` - Proxy ProxyConfig `yaml:"proxy"` - Domain string `yaml:"domain"` - UseLocalHost bool `yaml:"useLocalhost"` - Debug bool `yaml:"debug"` - Database DatabaseConfig `yaml:"database"` -} - -func new() *Config { - return &Config{ - Admin: AdminConfig{ - Host: "localhost", - Port: 8000, - UseVite: false, - OAuth: OAuth{ - ClientID: "", - ClientSecret: "", - }, - }, - Ssh: SshConfig{ - Host: "localhost", - Port: 2222, - KeysDir: "./keys", - }, - Proxy: ProxyConfig{ - Host: "localhost", - Port: 8001, - }, - Domain: "", - UseLocalHost: false, - Debug: false, - Database: DatabaseConfig{ - Url: "./data/db.sqlite", - Driver: "sqlite3", - AutoMigrate: false, - }, - } -} - -func (c *Config) HttpTunnelUrl(subdomain string) string { - if !c.UseLocalHost { - return "https://" + subdomain + "." + c.Domain - } - return "http://" + subdomain + "." + c.Proxy.Address() -} - -func (c *Config) TcpTunnelUrl(port int64) string { - if !c.UseLocalHost { - return c.Domain + ":" + fmt.Sprint(port) - } - return "localhost:" + fmt.Sprint(port) -} - -func (c *Config) setDefaults() { - if c.UseLocalHost { - c.Domain = c.Admin.Address() - } -} - -func (c Config) Protocol() string { - if !c.UseLocalHost { - return "https" - } - return "http" -} - -func (c Config) AdminUrl() string { - if !c.UseLocalHost { - return "https://" + c.Domain - } - return "http://" + c.Admin.Address() -} - -func (c Config) ExtractSubdomain(url string) string { - withoutProtocol := strings.ReplaceAll(url, c.Protocol()+"://", "") - if !c.UseLocalHost { - return strings.ReplaceAll(withoutProtocol, "."+c.Domain, "") - } - return strings.ReplaceAll(withoutProtocol, "."+c.Proxy.Address(), "") -} - -func Load(path string) (*Config, error) { - c := new() - - bytes, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - err = yaml.Unmarshal([]byte(os.ExpandEnv(string(bytes))), c) - if err != nil { - return nil, err - } - - c.setDefaults() - - return c, nil -} diff --git a/internal/server/cron/tasks.go b/internal/server/cron/tasks.go deleted file mode 100644 index 51770658..00000000 --- a/internal/server/cron/tasks.go +++ /dev/null @@ -1,42 +0,0 @@ -package cron - -import ( - "context" - "time" -) - -type CronFunc func(*Cron) - -type Job struct { - Name string - Interval time.Duration - Function CronFunc -} - -var crons = []Job{ - { - Name: "Delete expired sessions", - Interval: 6 * time.Hour, - Function: func(c *Cron) { - if err := c.db.Queries.DeleteExpiredSessions(context.Background()); err != nil { - c.logger.Error("error deleting expired sessions", "error", err) - } - }, - }, - { - Name: "Delete unclaimed connections", - Interval: 10 * time.Second, - Function: func(c *Cron) { - if err := c.db.Queries.DeleteUnclaimedConnections(context.Background()); err != nil { - c.logger.Error("error deleting unclaimed connections", "error", err) - } - }, - }, - { - Name: "Ping active connections", - Interval: 10 * time.Second, - Function: func(c *Cron) { - c.pingActiveConnections(context.Background()) - }, - }, -} diff --git a/internal/server/db/db.go b/internal/server/db/db.go deleted file mode 100644 index 6f21a32a..00000000 --- a/internal/server/db/db.go +++ /dev/null @@ -1,66 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" - "log" - - "github.com/amalshaji/portr/internal/server/config" - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/utils" - _ "github.com/mattn/go-sqlite3" -) - -type Db struct { - Conn *sql.DB - Queries *db.Queries - config *config.DatabaseConfig -} - -func New(config *config.DatabaseConfig) *Db { - return &Db{ - config: config, - } -} - -var ( - DefaultSmtpEnabled = false - DefaultAddMemberEmailSubject = utils.Trim("You've been added to team {{teamName}} on Portr!") - DefaultAddMemberEmailTemplate = utils.Trim(`Hello {{email}} - -You've been added to team "{{teamName}}" on Portr. - -Get started by signing in with your github account at {{appUrl}}`) -) - -func (d *Db) Connect() { - var err error - - d.Conn, err = sql.Open(d.config.Driver, d.config.Url) - if err != nil { - log.Fatal(err) - } - - d.Queries = db.New(d.Conn) -} - -func (d *Db) PopulateDefaultSettings(ctx context.Context) { - _, err := d.Queries.GetGlobalSettings(ctx) - - // Populate default settings - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - _, err = d.Queries.CreateGlobalSettings(ctx, db.CreateGlobalSettingsParams{ - SmtpEnabled: DefaultSmtpEnabled, - AddMemberEmailSubject: DefaultAddMemberEmailSubject, - AddMemberEmailTemplate: DefaultAddMemberEmailTemplate, - }) - if err != nil { - log.Fatal(err) - } - } else { - log.Fatal(err) - } - } -} diff --git a/internal/server/db/migrations/20231230090812_create_all_tables.sql b/internal/server/db/migrations/20231230090812_create_all_tables.sql deleted file mode 100644 index 7c115ee2..00000000 --- a/internal/server/db/migrations/20231230090812_create_all_tables.sql +++ /dev/null @@ -1,80 +0,0 @@ --- migrate:up -CREATE TABLE - IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - first_name TEXT NULL, - last_name TEXT NULL, - is_super_user BOOLEAN NOT NULL DEFAULT false, - github_access_token TEXT NULL, - github_avatar_url TEXT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - -CREATE TABLE - IF NOT EXISTS teams ( - id INTEGER PRIMARY KEY, - NAME TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - -CREATE TABLE - IF NOT EXISTS team_members ( - id INTEGER PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users (id), - team_id INTEGER NOT NULL REFERENCES teams (id), - secret_key TEXT NOT NULL UNIQUE, - role TEXT NOT NULL, - added_by_user_id INTEGER NULL REFERENCES users (id), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, team_id) - ); - -CREATE TABLE - IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users (id), - token TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - -CREATE TABLE - IF NOT EXISTS connections ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL DEFAULT 'http', -- http, tcp - subdomain TEXT NULL, - port INTEGER NULL, - status TEXT NOT NULL DEFAULT 'reserved', -- reserved, active, closed - team_member_id INTEGER NOT NULL REFERENCES team_members (id), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - started_at TIMESTAMP NULL, - closed_at TIMESTAMP NULL, - team_id INTEGER NULL REFERENCES teams (id) - ); - -CREATE TABLE - IF NOT EXISTS global_settings ( - id INTEGER PRIMARY KEY, - smtp_enabled BOOLEAN NOT NULL DEFAULT false, - smtp_host TEXT NULL, - smtp_port INTEGER NULL, - smtp_username TEXT NULL, - smtp_password TEXT NULL, - from_address TEXT NULL, - add_member_email_subject TEXT NULL, - add_member_email_template TEXT NULL - ); - --- migrate:down -DROP TABLE IF EXISTS global_settings; - -DROP TABLE IF EXISTS connections; - -DROP TABLE IF EXISTS sessions; - -DROP TABLE IF EXISTS team_members; - -DROP TABLE IF EXISTS teams; - -DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/internal/server/db/migrator.go b/internal/server/db/migrator.go deleted file mode 100644 index 1c8a937e..00000000 --- a/internal/server/db/migrator.go +++ /dev/null @@ -1,37 +0,0 @@ -package db - -import ( - "embed" - "net/url" - - "github.com/amacneil/dbmate/v2/pkg/dbmate" - _ "github.com/amacneil/dbmate/v2/pkg/driver/sqlite" - "github.com/amalshaji/portr/internal/server/config" -) - -//go:embed migrations/*.sql -var fs embed.FS - -type Migrator struct { - db *Db - config *config.DatabaseConfig -} - -func NewMigrator(db *Db, config *config.DatabaseConfig) *Migrator { - return &Migrator{db: db, config: config} -} - -func (m *Migrator) Migrate() error { - // dbmate requires it in this format - dbUrl, _ := url.Parse(m.config.Driver + ":" + m.config.Url) - _db := dbmate.New(dbUrl) - _db.FS = fs - _db.MigrationsDir = []string{"./migrations"} - _db.SchemaFile = "./internal/server/db/schema.sql" - - if err := _db.CreateAndMigrate(); err != nil { - return err - } - - return nil -} diff --git a/internal/server/db/models/db.go b/internal/server/db/models/db.go deleted file mode 100644 index bdb151c1..00000000 --- a/internal/server/db/models/db.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.25.0 - -package db - -import ( - "context" - "database/sql" -) - -type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) - PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx *sql.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/internal/server/db/models/models.go b/internal/server/db/models/models.go deleted file mode 100644 index aafdf309..00000000 --- a/internal/server/db/models/models.go +++ /dev/null @@ -1,69 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.25.0 - -package db - -import ( - "time" -) - -type Connection struct { - ID string - Type string - Subdomain interface{} - Port interface{} - Status string - TeamMemberID int64 - CreatedAt time.Time - StartedAt interface{} - ClosedAt interface{} - TeamID interface{} -} - -type GlobalSetting struct { - ID int64 - SmtpEnabled bool - SmtpHost interface{} - SmtpPort interface{} - SmtpUsername interface{} - SmtpPassword interface{} - FromAddress interface{} - AddMemberEmailSubject interface{} - AddMemberEmailTemplate interface{} -} - -type Session struct { - ID int64 - UserID int64 - Token string - CreatedAt time.Time -} - -type Team struct { - ID int64 - Name string - Slug string - CreatedAt time.Time -} - -type TeamMember struct { - ID int64 - UserID int64 - TeamID int64 - SecretKey string - Role string - AddedByUserID interface{} - CreatedAt time.Time -} - -type User struct { - ID int64 - Email string - FirstName interface{} - LastName interface{} - IsSuperUser bool - GithubAccessToken interface{} - GithubAvatarUrl interface{} - CreatedAt time.Time -} diff --git a/internal/server/db/models/query.sql.go b/internal/server/db/models/query.sql.go deleted file mode 100644 index b5c3b08a..00000000 --- a/internal/server/db/models/query.sql.go +++ /dev/null @@ -1,1300 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.25.0 -// source: query.sql - -package db - -import ( - "context" - "time" -) - -const addPortToConnection = `-- name: AddPortToConnection :exec -UPDATE connections -SET - port = ? -WHERE - id = ? -` - -type AddPortToConnectionParams struct { - Port interface{} - ID string -} - -func (q *Queries) AddPortToConnection(ctx context.Context, arg AddPortToConnectionParams) error { - _, err := q.db.ExecContext(ctx, addPortToConnection, arg.Port, arg.ID) - return err -} - -const createGlobalSettings = `-- name: CreateGlobalSettings :one -INSERT INTO - global_settings ( - smtp_enabled, - add_member_email_subject, - add_member_email_template - ) -VALUES - (?, ?, ?) RETURNING id, smtp_enabled, smtp_host, smtp_port, smtp_username, smtp_password, from_address, add_member_email_subject, add_member_email_template -` - -type CreateGlobalSettingsParams struct { - SmtpEnabled bool - AddMemberEmailSubject interface{} - AddMemberEmailTemplate interface{} -} - -func (q *Queries) CreateGlobalSettings(ctx context.Context, arg CreateGlobalSettingsParams) (GlobalSetting, error) { - row := q.db.QueryRowContext(ctx, createGlobalSettings, arg.SmtpEnabled, arg.AddMemberEmailSubject, arg.AddMemberEmailTemplate) - var i GlobalSetting - err := row.Scan( - &i.ID, - &i.SmtpEnabled, - &i.SmtpHost, - &i.SmtpPort, - &i.SmtpUsername, - &i.SmtpPassword, - &i.FromAddress, - &i.AddMemberEmailSubject, - &i.AddMemberEmailTemplate, - ) - return i, err -} - -const createNewHttpConnection = `-- name: CreateNewHttpConnection :one -INSERT INTO - connections (id, type, subdomain, team_member_id, team_id) -VALUES - (?, "http", ?, ?, ?) RETURNING id, type, subdomain, port, status, team_member_id, created_at, started_at, closed_at, team_id -` - -type CreateNewHttpConnectionParams struct { - ID string - Subdomain interface{} - TeamMemberID int64 - TeamID interface{} -} - -func (q *Queries) CreateNewHttpConnection(ctx context.Context, arg CreateNewHttpConnectionParams) (Connection, error) { - row := q.db.QueryRowContext(ctx, createNewHttpConnection, - arg.ID, - arg.Subdomain, - arg.TeamMemberID, - arg.TeamID, - ) - var i Connection - err := row.Scan( - &i.ID, - &i.Type, - &i.Subdomain, - &i.Port, - &i.Status, - &i.TeamMemberID, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.TeamID, - ) - return i, err -} - -const createNewTcpConnection = `-- name: CreateNewTcpConnection :one -INSERT INTO - connections (id, type, port, team_member_id, team_id) -VALUES - (?, "tcp", ?, ?, ?) RETURNING id, type, subdomain, port, status, team_member_id, created_at, started_at, closed_at, team_id -` - -type CreateNewTcpConnectionParams struct { - ID string - Port interface{} - TeamMemberID int64 - TeamID interface{} -} - -func (q *Queries) CreateNewTcpConnection(ctx context.Context, arg CreateNewTcpConnectionParams) (Connection, error) { - row := q.db.QueryRowContext(ctx, createNewTcpConnection, - arg.ID, - arg.Port, - arg.TeamMemberID, - arg.TeamID, - ) - var i Connection - err := row.Scan( - &i.ID, - &i.Type, - &i.Subdomain, - &i.Port, - &i.Status, - &i.TeamMemberID, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.TeamID, - ) - return i, err -} - -const createSession = `-- name: CreateSession :one -INSERT INTO - sessions (token, user_id) -VALUES - (?, ?) RETURNING id, user_id, token, created_at -` - -type CreateSessionParams struct { - Token string - UserID int64 -} - -func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { - row := q.db.QueryRowContext(ctx, createSession, arg.Token, arg.UserID) - var i Session - err := row.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.CreatedAt, - ) - return i, err -} - -const createTeam = `-- name: CreateTeam :one -INSERT INTO - teams (name, slug) -VALUES - (?, ?) RETURNING id, name, slug, created_at -` - -type CreateTeamParams struct { - Name string - Slug string -} - -func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) { - row := q.db.QueryRowContext(ctx, createTeam, arg.Name, arg.Slug) - var i Team - err := row.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.CreatedAt, - ) - return i, err -} - -const createTeamMember = `-- name: CreateTeamMember :one -INSERT INTO - team_members (user_id, team_id, role, secret_key) -VALUES - (?, ?, ?, ?) RETURNING id, user_id, team_id, secret_key, role, added_by_user_id, created_at -` - -type CreateTeamMemberParams struct { - UserID int64 - TeamID int64 - Role string - SecretKey string -} - -func (q *Queries) CreateTeamMember(ctx context.Context, arg CreateTeamMemberParams) (TeamMember, error) { - row := q.db.QueryRowContext(ctx, createTeamMember, - arg.UserID, - arg.TeamID, - arg.Role, - arg.SecretKey, - ) - var i TeamMember - err := row.Scan( - &i.ID, - &i.UserID, - &i.TeamID, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt, - ) - return i, err -} - -const createUser = `-- name: CreateUser :one -INSERT INTO - users ( - email, - first_name, - last_name, - is_super_user, - github_access_token, - github_avatar_url - ) -VALUES - (?, ?, ?, ?, ?, ?) RETURNING id, email, first_name, last_name, is_super_user, github_access_token, github_avatar_url, created_at -` - -type CreateUserParams struct { - Email string - FirstName interface{} - LastName interface{} - IsSuperUser bool - GithubAccessToken interface{} - GithubAvatarUrl interface{} -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { - row := q.db.QueryRowContext(ctx, createUser, - arg.Email, - arg.FirstName, - arg.LastName, - arg.IsSuperUser, - arg.GithubAccessToken, - arg.GithubAvatarUrl, - ) - var i User - err := row.Scan( - &i.ID, - &i.Email, - &i.FirstName, - &i.LastName, - &i.IsSuperUser, - &i.GithubAccessToken, - &i.GithubAvatarUrl, - &i.CreatedAt, - ) - return i, err -} - -const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec -DELETE FROM sessions -WHERE - strftime ('%s', 'now') - strftime ('%s', created_at) > 24 * 60 * 60 -` - -func (q *Queries) DeleteExpiredSessions(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, deleteExpiredSessions) - return err -} - -const deleteSession = `-- name: DeleteSession :exec -DELETE FROM sessions -WHERE - token = ? -` - -func (q *Queries) DeleteSession(ctx context.Context, token string) error { - _, err := q.db.ExecContext(ctx, deleteSession, token) - return err -} - -const deleteUnclaimedConnections = `-- name: DeleteUnclaimedConnections :exec -DELETE FROM connections -WHERE - status = 'reserved' - AND strftime ('%s', 'now') - strftime ('%s', created_at) > 10 -` - -func (q *Queries) DeleteUnclaimedConnections(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, deleteUnclaimedConnections) - return err -} - -const getActiveConnectionCountForTeam = `-- name: GetActiveConnectionCountForTeam :one -SELECT - count(*) -FROM - connections -WHERE - connections.team_id = ? - AND status = 'active' -` - -func (q *Queries) GetActiveConnectionCountForTeam(ctx context.Context, teamID interface{}) (int64, error) { - row := q.db.QueryRowContext(ctx, getActiveConnectionCountForTeam, teamID) - var count int64 - err := row.Scan(&count) - return count, err -} - -const getActiveConnectionsForTeam = `-- name: GetActiveConnectionsForTeam :many -SELECT - connections.id, - connections.type, - connections.port, - connections.subdomain, - connections.created_at, - connections.started_at, - connections.closed_at, - connections.status, - users.email, - users.first_name, - users.last_name, - users.github_avatar_url -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id - JOIN users ON users.id = team_members.user_id -WHERE - connections.team_id = ? - AND status = 'active' -ORDER BY - connections.id DESC -LIMIT - 10 -OFFSET - ? -` - -type GetActiveConnectionsForTeamParams struct { - TeamID interface{} - Offset int64 -} - -type GetActiveConnectionsForTeamRow struct { - ID string - Type string - Port interface{} - Subdomain interface{} - CreatedAt time.Time - StartedAt interface{} - ClosedAt interface{} - Status string - Email string - FirstName interface{} - LastName interface{} - GithubAvatarUrl interface{} -} - -func (q *Queries) GetActiveConnectionsForTeam(ctx context.Context, arg GetActiveConnectionsForTeamParams) ([]GetActiveConnectionsForTeamRow, error) { - rows, err := q.db.QueryContext(ctx, getActiveConnectionsForTeam, arg.TeamID, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetActiveConnectionsForTeamRow - for rows.Next() { - var i GetActiveConnectionsForTeamRow - if err := rows.Scan( - &i.ID, - &i.Type, - &i.Port, - &i.Subdomain, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.Status, - &i.Email, - &i.FirstName, - &i.LastName, - &i.GithubAvatarUrl, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAllActiveConnections = `-- name: GetAllActiveConnections :many -SELECT - id, type, subdomain, port, status, team_member_id, created_at, started_at, closed_at, team_id -FROM - connections -WHERE - status = 'active' -` - -func (q *Queries) GetAllActiveConnections(ctx context.Context) ([]Connection, error) { - rows, err := q.db.QueryContext(ctx, getAllActiveConnections) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Connection - for rows.Next() { - var i Connection - if err := rows.Scan( - &i.ID, - &i.Type, - &i.Subdomain, - &i.Port, - &i.Status, - &i.TeamMemberID, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.TeamID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getGlobalSettings = `-- name: GetGlobalSettings :one -SELECT - id, smtp_enabled, smtp_host, smtp_port, smtp_username, smtp_password, from_address, add_member_email_subject, add_member_email_template -FROM - global_settings -LIMIT - 1 -` - -func (q *Queries) GetGlobalSettings(ctx context.Context) (GlobalSetting, error) { - row := q.db.QueryRowContext(ctx, getGlobalSettings) - var i GlobalSetting - err := row.Scan( - &i.ID, - &i.SmtpEnabled, - &i.SmtpHost, - &i.SmtpPort, - &i.SmtpUsername, - &i.SmtpPassword, - &i.FromAddress, - &i.AddMemberEmailSubject, - &i.AddMemberEmailTemplate, - ) - return i, err -} - -const getRecentConnectionCountForTeam = `-- name: GetRecentConnectionCountForTeam :one -SELECT - count(*) -FROM - connections -WHERE - connections.team_id = ? - AND status != 'reserved' -` - -func (q *Queries) GetRecentConnectionCountForTeam(ctx context.Context, teamID interface{}) (int64, error) { - row := q.db.QueryRowContext(ctx, getRecentConnectionCountForTeam, teamID) - var count int64 - err := row.Scan(&count) - return count, err -} - -const getRecentConnectionsForTeam = `-- name: GetRecentConnectionsForTeam :many -SELECT - connections.id, - connections.type, - connections.port, - connections.subdomain, - connections.created_at, - connections.started_at, - connections.closed_at, - connections.status, - users.email, - users.first_name, - users.last_name, - users.github_avatar_url -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id - JOIN users ON users.id = team_members.user_id -WHERE - connections.team_id = ? - AND status != 'reserved' -ORDER BY - connections.id DESC -LIMIT - 10 -OFFSET - ? -` - -type GetRecentConnectionsForTeamParams struct { - TeamID interface{} - Offset int64 -} - -type GetRecentConnectionsForTeamRow struct { - ID string - Type string - Port interface{} - Subdomain interface{} - CreatedAt time.Time - StartedAt interface{} - ClosedAt interface{} - Status string - Email string - FirstName interface{} - LastName interface{} - GithubAvatarUrl interface{} -} - -func (q *Queries) GetRecentConnectionsForTeam(ctx context.Context, arg GetRecentConnectionsForTeamParams) ([]GetRecentConnectionsForTeamRow, error) { - rows, err := q.db.QueryContext(ctx, getRecentConnectionsForTeam, arg.TeamID, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetRecentConnectionsForTeamRow - for rows.Next() { - var i GetRecentConnectionsForTeamRow - if err := rows.Scan( - &i.ID, - &i.Type, - &i.Port, - &i.Subdomain, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.Status, - &i.Email, - &i.FirstName, - &i.LastName, - &i.GithubAvatarUrl, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getReservedOrActiveConnectionById = `-- name: GetReservedOrActiveConnectionById :one -SELECT - connections.id, type, subdomain, port, status, team_member_id, connections.created_at, started_at, closed_at, connections.team_id, team_members.id, user_id, team_members.team_id, secret_key, role, added_by_user_id, team_members.created_at -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id -WHERE - connections.id = ? - AND status IN ('active', 'reserved') -LIMIT - 1 -` - -type GetReservedOrActiveConnectionByIdRow struct { - ID string - Type string - Subdomain interface{} - Port interface{} - Status string - TeamMemberID int64 - CreatedAt time.Time - StartedAt interface{} - ClosedAt interface{} - TeamID int64 - ID_2 int64 - UserID int64 - TeamID_2 int64 - SecretKey string - Role string - AddedByUserID interface{} - CreatedAt_2 time.Time -} - -func (q *Queries) GetReservedOrActiveConnectionById(ctx context.Context, id string) (GetReservedOrActiveConnectionByIdRow, error) { - row := q.db.QueryRowContext(ctx, getReservedOrActiveConnectionById, id) - var i GetReservedOrActiveConnectionByIdRow - err := row.Scan( - &i.ID, - &i.Type, - &i.Subdomain, - &i.Port, - &i.Status, - &i.TeamMemberID, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.TeamID, - &i.ID_2, - &i.UserID, - &i.TeamID_2, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt_2, - ) - return i, err -} - -const getReservedOrActiveConnectionForPort = `-- name: GetReservedOrActiveConnectionForPort :one -SELECT - connections.id, type, subdomain, port, status, team_member_id, connections.created_at, started_at, closed_at, connections.team_id, team_members.id, user_id, team_members.team_id, secret_key, role, added_by_user_id, team_members.created_at -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id -WHERE - port = ? - AND team_members.secret_key = ? - AND status IN ('active', 'reserved') -LIMIT - 1 -` - -type GetReservedOrActiveConnectionForPortParams struct { - Port interface{} - SecretKey string -} - -type GetReservedOrActiveConnectionForPortRow struct { - ID string - Type string - Subdomain interface{} - Port interface{} - Status string - TeamMemberID int64 - CreatedAt time.Time - StartedAt interface{} - ClosedAt interface{} - TeamID int64 - ID_2 int64 - UserID int64 - TeamID_2 int64 - SecretKey string - Role string - AddedByUserID interface{} - CreatedAt_2 time.Time -} - -func (q *Queries) GetReservedOrActiveConnectionForPort(ctx context.Context, arg GetReservedOrActiveConnectionForPortParams) (GetReservedOrActiveConnectionForPortRow, error) { - row := q.db.QueryRowContext(ctx, getReservedOrActiveConnectionForPort, arg.Port, arg.SecretKey) - var i GetReservedOrActiveConnectionForPortRow - err := row.Scan( - &i.ID, - &i.Type, - &i.Subdomain, - &i.Port, - &i.Status, - &i.TeamMemberID, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.TeamID, - &i.ID_2, - &i.UserID, - &i.TeamID_2, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt_2, - ) - return i, err -} - -const getReservedOrActiveConnectionForSubdomain = `-- name: GetReservedOrActiveConnectionForSubdomain :one -SELECT - connections.id, type, subdomain, port, status, team_member_id, connections.created_at, started_at, closed_at, connections.team_id, team_members.id, user_id, team_members.team_id, secret_key, role, added_by_user_id, team_members.created_at -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id -WHERE - subdomain = ? - AND team_members.secret_key = ? - AND status IN ('active', 'reserved') -LIMIT - 1 -` - -type GetReservedOrActiveConnectionForSubdomainParams struct { - Subdomain interface{} - SecretKey string -} - -type GetReservedOrActiveConnectionForSubdomainRow struct { - ID string - Type string - Subdomain interface{} - Port interface{} - Status string - TeamMemberID int64 - CreatedAt time.Time - StartedAt interface{} - ClosedAt interface{} - TeamID int64 - ID_2 int64 - UserID int64 - TeamID_2 int64 - SecretKey string - Role string - AddedByUserID interface{} - CreatedAt_2 time.Time -} - -func (q *Queries) GetReservedOrActiveConnectionForSubdomain(ctx context.Context, arg GetReservedOrActiveConnectionForSubdomainParams) (GetReservedOrActiveConnectionForSubdomainRow, error) { - row := q.db.QueryRowContext(ctx, getReservedOrActiveConnectionForSubdomain, arg.Subdomain, arg.SecretKey) - var i GetReservedOrActiveConnectionForSubdomainRow - err := row.Scan( - &i.ID, - &i.Type, - &i.Subdomain, - &i.Port, - &i.Status, - &i.TeamMemberID, - &i.CreatedAt, - &i.StartedAt, - &i.ClosedAt, - &i.TeamID, - &i.ID_2, - &i.UserID, - &i.TeamID_2, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt_2, - ) - return i, err -} - -const getTeamById = `-- name: GetTeamById :one -SELECT - id, name, slug, created_at -FROM - teams -WHERE - id = ? -LIMIT - 1 -` - -func (q *Queries) GetTeamById(ctx context.Context, id int64) (Team, error) { - row := q.db.QueryRowContext(ctx, getTeamById, id) - var i Team - err := row.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.CreatedAt, - ) - return i, err -} - -const getTeamMemberByEmail = `-- name: GetTeamMemberByEmail :one -SELECT - team_members.id, user_id, team_id, secret_key, role, added_by_user_id, team_members.created_at, users.id, email, first_name, last_name, is_super_user, github_access_token, github_avatar_url, users.created_at -FROM - team_members - JOIN users ON users.id = team_members.user_id -WHERE - users.email = ? - AND team_members.team_id = ? -LIMIT - 1 -` - -type GetTeamMemberByEmailParams struct { - Email string - TeamID int64 -} - -type GetTeamMemberByEmailRow struct { - ID int64 - UserID int64 - TeamID int64 - SecretKey string - Role string - AddedByUserID interface{} - CreatedAt time.Time - ID_2 int64 - Email string - FirstName interface{} - LastName interface{} - IsSuperUser bool - GithubAccessToken interface{} - GithubAvatarUrl interface{} - CreatedAt_2 time.Time -} - -func (q *Queries) GetTeamMemberByEmail(ctx context.Context, arg GetTeamMemberByEmailParams) (GetTeamMemberByEmailRow, error) { - row := q.db.QueryRowContext(ctx, getTeamMemberByEmail, arg.Email, arg.TeamID) - var i GetTeamMemberByEmailRow - err := row.Scan( - &i.ID, - &i.UserID, - &i.TeamID, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt, - &i.ID_2, - &i.Email, - &i.FirstName, - &i.LastName, - &i.IsSuperUser, - &i.GithubAccessToken, - &i.GithubAvatarUrl, - &i.CreatedAt_2, - ) - return i, err -} - -const getTeamMemberById = `-- name: GetTeamMemberById :one -SELECT - team_members.id, team_members.user_id, team_members.team_id, team_members.secret_key, team_members.role, team_members.added_by_user_id, team_members.created_at, - users.id, users.email, users.first_name, users.last_name, users.is_super_user, users.github_access_token, users.github_avatar_url, users.created_at -FROM - team_members - JOIN users ON users.id = team_members.user_id -WHERE - team_members.id = ? -LIMIT - 1 -` - -type GetTeamMemberByIdRow struct { - ID int64 - UserID int64 - TeamID int64 - SecretKey string - Role string - AddedByUserID interface{} - CreatedAt time.Time - ID_2 int64 - Email string - FirstName interface{} - LastName interface{} - IsSuperUser bool - GithubAccessToken interface{} - GithubAvatarUrl interface{} - CreatedAt_2 time.Time -} - -func (q *Queries) GetTeamMemberById(ctx context.Context, id int64) (GetTeamMemberByIdRow, error) { - row := q.db.QueryRowContext(ctx, getTeamMemberById, id) - var i GetTeamMemberByIdRow - err := row.Scan( - &i.ID, - &i.UserID, - &i.TeamID, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt, - &i.ID_2, - &i.Email, - &i.FirstName, - &i.LastName, - &i.IsSuperUser, - &i.GithubAccessToken, - &i.GithubAvatarUrl, - &i.CreatedAt_2, - ) - return i, err -} - -const getTeamMemberByUserIdAndTeamSlug = `-- name: GetTeamMemberByUserIdAndTeamSlug :one -SELECT - team_members.id, team_members.user_id, team_members.team_id, team_members.secret_key, team_members.role, team_members.added_by_user_id, team_members.created_at, - users.id, users.email, users.first_name, users.last_name, users.is_super_user, users.github_access_token, users.github_avatar_url, users.created_at -FROM - team_members - JOIN users ON users.id = team_members.user_id - JOIN teams ON teams.id = team_members.team_id -WHERE - users.id = ? - AND teams.slug = ? -LIMIT - 1 -` - -type GetTeamMemberByUserIdAndTeamSlugParams struct { - ID int64 - Slug string -} - -type GetTeamMemberByUserIdAndTeamSlugRow struct { - ID int64 - UserID int64 - TeamID int64 - SecretKey string - Role string - AddedByUserID interface{} - CreatedAt time.Time - ID_2 int64 - Email string - FirstName interface{} - LastName interface{} - IsSuperUser bool - GithubAccessToken interface{} - GithubAvatarUrl interface{} - CreatedAt_2 time.Time -} - -func (q *Queries) GetTeamMemberByUserIdAndTeamSlug(ctx context.Context, arg GetTeamMemberByUserIdAndTeamSlugParams) (GetTeamMemberByUserIdAndTeamSlugRow, error) { - row := q.db.QueryRowContext(ctx, getTeamMemberByUserIdAndTeamSlug, arg.ID, arg.Slug) - var i GetTeamMemberByUserIdAndTeamSlugRow - err := row.Scan( - &i.ID, - &i.UserID, - &i.TeamID, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt, - &i.ID_2, - &i.Email, - &i.FirstName, - &i.LastName, - &i.IsSuperUser, - &i.GithubAccessToken, - &i.GithubAvatarUrl, - &i.CreatedAt_2, - ) - return i, err -} - -const getTeamMembers = `-- name: GetTeamMembers :many -SELECT - users.email, - team_members.role, - users.github_avatar_url -FROM - team_members - JOIN users ON users.id = team_members.user_id -WHERE - team_id = ? -` - -type GetTeamMembersRow struct { - Email string - Role string - GithubAvatarUrl interface{} -} - -func (q *Queries) GetTeamMembers(ctx context.Context, teamID int64) ([]GetTeamMembersRow, error) { - rows, err := q.db.QueryContext(ctx, getTeamMembers, teamID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTeamMembersRow - for rows.Next() { - var i GetTeamMembersRow - if err := rows.Scan(&i.Email, &i.Role, &i.GithubAvatarUrl); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTeamUserBySecretKey = `-- name: GetTeamUserBySecretKey :one -SELECT - id, user_id, team_id, secret_key, role, added_by_user_id, created_at -FROM - team_members -WHERE - secret_key = ? -LIMIT - 1 -` - -func (q *Queries) GetTeamUserBySecretKey(ctx context.Context, secretKey string) (TeamMember, error) { - row := q.db.QueryRowContext(ctx, getTeamUserBySecretKey, secretKey) - var i TeamMember - err := row.Scan( - &i.ID, - &i.UserID, - &i.TeamID, - &i.SecretKey, - &i.Role, - &i.AddedByUserID, - &i.CreatedAt, - ) - return i, err -} - -const getTeamsOfUser = `-- name: GetTeamsOfUser :many -SELECT - teams.id, teams.name, teams.slug, teams.created_at -FROM - team_members - JOIN teams ON teams.id = team_members.team_id -WHERE - team_members.user_id = ? -` - -func (q *Queries) GetTeamsOfUser(ctx context.Context, userID int64) ([]Team, error) { - rows, err := q.db.QueryContext(ctx, getTeamsOfUser, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Team - for rows.Next() { - var i Team - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Slug, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getUserByEmail = `-- name: GetUserByEmail :one -SELECT - id, email, first_name, last_name, is_super_user, github_access_token, github_avatar_url, created_at -FROM - users -WHERE - email = ? -LIMIT - 1 -` - -func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { - row := q.db.QueryRowContext(ctx, getUserByEmail, email) - var i User - err := row.Scan( - &i.ID, - &i.Email, - &i.FirstName, - &i.LastName, - &i.IsSuperUser, - &i.GithubAccessToken, - &i.GithubAvatarUrl, - &i.CreatedAt, - ) - return i, err -} - -const getUserById = `-- name: GetUserById :one -SELECT - users.id, - users.email, - users.created_at, - users.first_name, - users.last_name, - users.github_avatar_url, - users.is_super_user -FROM - users -WHERE - id = ? -LIMIT - 1 -` - -type GetUserByIdRow struct { - ID int64 - Email string - CreatedAt time.Time - FirstName interface{} - LastName interface{} - GithubAvatarUrl interface{} - IsSuperUser bool -} - -func (q *Queries) GetUserById(ctx context.Context, id int64) (GetUserByIdRow, error) { - row := q.db.QueryRowContext(ctx, getUserById, id) - var i GetUserByIdRow - err := row.Scan( - &i.ID, - &i.Email, - &i.CreatedAt, - &i.FirstName, - &i.LastName, - &i.GithubAvatarUrl, - &i.IsSuperUser, - ) - return i, err -} - -const getUserBySession = `-- name: GetUserBySession :one -SELECT - users.id, - users.email, - users.created_at, - users.first_name, - users.last_name, - users.github_avatar_url, - users.is_super_user -FROM - users - JOIN sessions ON sessions.user_id = users.id -WHERE - sessions.token = ? -LIMIT - 1 -` - -type GetUserBySessionRow struct { - ID int64 - Email string - CreatedAt time.Time - FirstName interface{} - LastName interface{} - GithubAvatarUrl interface{} - IsSuperUser bool -} - -func (q *Queries) GetUserBySession(ctx context.Context, token string) (GetUserBySessionRow, error) { - row := q.db.QueryRowContext(ctx, getUserBySession, token) - var i GetUserBySessionRow - err := row.Scan( - &i.ID, - &i.Email, - &i.CreatedAt, - &i.FirstName, - &i.LastName, - &i.GithubAvatarUrl, - &i.IsSuperUser, - ) - return i, err -} - -const getUsersCount = `-- name: GetUsersCount :one -SELECT - COUNT(*) -FROM - users -` - -func (q *Queries) GetUsersCount(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, getUsersCount) - var count int64 - err := row.Scan(&count) - return count, err -} - -const markConnectionAsActive = `-- name: MarkConnectionAsActive :exec -UPDATE connections -SET - status = 'active', - started_at = CURRENT_TIMESTAMP -WHERE - id = ? -` - -func (q *Queries) MarkConnectionAsActive(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, markConnectionAsActive, id) - return err -} - -const markConnectionAsClosed = `-- name: MarkConnectionAsClosed :exec -UPDATE connections -SET - status = 'closed', - closed_at = CURRENT_TIMESTAMP -WHERE - id = ? -` - -func (q *Queries) MarkConnectionAsClosed(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, markConnectionAsClosed, id) - return err -} - -const updateGlobalSettings = `-- name: UpdateGlobalSettings :exec -UPDATE global_settings -SET - smtp_enabled = ?, - smtp_host = ?, - smtp_port = ?, - smtp_username = ?, - smtp_password = ?, - from_address = ?, - add_member_email_subject = ?, - add_member_email_template = ? -` - -type UpdateGlobalSettingsParams struct { - SmtpEnabled bool - SmtpHost interface{} - SmtpPort interface{} - SmtpUsername interface{} - SmtpPassword interface{} - FromAddress interface{} - AddMemberEmailSubject interface{} - AddMemberEmailTemplate interface{} -} - -func (q *Queries) UpdateGlobalSettings(ctx context.Context, arg UpdateGlobalSettingsParams) error { - _, err := q.db.ExecContext(ctx, updateGlobalSettings, - arg.SmtpEnabled, - arg.SmtpHost, - arg.SmtpPort, - arg.SmtpUsername, - arg.SmtpPassword, - arg.FromAddress, - arg.AddMemberEmailSubject, - arg.AddMemberEmailTemplate, - ) - return err -} - -const updateSecretKey = `-- name: UpdateSecretKey :exec -UPDATE team_members -SET - secret_key = ? -WHERE - id = ? -` - -type UpdateSecretKeyParams struct { - SecretKey string - ID int64 -} - -func (q *Queries) UpdateSecretKey(ctx context.Context, arg UpdateSecretKeyParams) error { - _, err := q.db.ExecContext(ctx, updateSecretKey, arg.SecretKey, arg.ID) - return err -} - -const updateUser = `-- name: UpdateUser :exec -UPDATE users -SET - first_name = COALESCE(?, first_name), - last_name = COALESCE(?, last_name), - github_access_token = COALESCE(?, github_access_token), - github_avatar_url = COALESCE(?, github_avatar_url) -WHERE - id = ? -` - -type UpdateUserParams struct { - FirstName interface{} - LastName interface{} - GithubAccessToken interface{} - GithubAvatarUrl interface{} - ID int64 -} - -func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { - _, err := q.db.ExecContext(ctx, updateUser, - arg.FirstName, - arg.LastName, - arg.GithubAccessToken, - arg.GithubAvatarUrl, - arg.ID, - ) - return err -} diff --git a/internal/server/db/models/types.go b/internal/server/db/models/types.go deleted file mode 100644 index 1822d2d2..00000000 --- a/internal/server/db/models/types.go +++ /dev/null @@ -1,6 +0,0 @@ -package db - -type UserWithTeams struct { - GetUserBySessionRow - Teams []Team -} diff --git a/internal/server/db/schema.sql b/internal/server/db/schema.sql deleted file mode 100644 index 07898ee3..00000000 --- a/internal/server/db/schema.sql +++ /dev/null @@ -1,59 +0,0 @@ -CREATE TABLE IF NOT EXISTS "schema_migrations" (version varchar(128) primary key); -CREATE TABLE users ( - id INTEGER PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - first_name TEXT NULL, - last_name TEXT NULL, - is_super_user BOOLEAN NOT NULL DEFAULT false, - github_access_token TEXT NULL, - github_avatar_url TEXT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); -CREATE TABLE teams ( - id INTEGER PRIMARY KEY, - NAME TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); -CREATE TABLE team_members ( - id INTEGER PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users (id), - team_id INTEGER NOT NULL REFERENCES teams (id), - secret_key TEXT NOT NULL UNIQUE, - role TEXT NOT NULL, - added_by_user_id INTEGER NULL REFERENCES users (id), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, team_id) - ); -CREATE TABLE sessions ( - id INTEGER PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users (id), - token TEXT NOT NULL UNIQUE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); -CREATE TABLE connections ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL DEFAULT 'http', -- http, tcp - subdomain TEXT NULL, - port INTEGER NULL, - status TEXT NOT NULL DEFAULT 'reserved', -- reserved, active, closed - team_member_id INTEGER NOT NULL REFERENCES team_members (id), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - started_at TIMESTAMP NULL, - closed_at TIMESTAMP NULL, - team_id INTEGER NULL REFERENCES teams (id) - ); -CREATE TABLE global_settings ( - id INTEGER PRIMARY KEY, - smtp_enabled BOOLEAN NOT NULL DEFAULT false, - smtp_host TEXT NULL, - smtp_port INTEGER NULL, - smtp_username TEXT NULL, - smtp_password TEXT NULL, - from_address TEXT NULL, - add_member_email_subject TEXT NULL, - add_member_email_template TEXT NULL - ); --- Dbmate schema migrations -INSERT INTO "schema_migrations" (version) VALUES - ('20231230090812'); diff --git a/internal/server/smtp/smtp.go b/internal/server/smtp/smtp.go deleted file mode 100644 index dbf022fc..00000000 --- a/internal/server/smtp/smtp.go +++ /dev/null @@ -1,40 +0,0 @@ -package smtp - -import ( - "fmt" - "log/slog" - "strings" - - "github.com/amalshaji/portr/internal/server/config" - db "github.com/amalshaji/portr/internal/server/db/models" - "github.com/amalshaji/portr/internal/utils" - "github.com/emersion/go-sasl" - "github.com/emersion/go-smtp" -) - -type Smtp struct { - config *config.AdminConfig - log *slog.Logger -} - -func New(config *config.AdminConfig) *Smtp { - return &Smtp{config: config, log: utils.GetLogger()} -} - -type SendEmailInput struct { - From string - To string - Subject string - Body string -} - -func (s *Smtp) SendEmail(input SendEmailInput, settings *db.GlobalSetting) error { - auth := sasl.NewPlainClient("", settings.SmtpUsername.(string), settings.SmtpPassword.(string)) - message := fmt.Sprintf("Subject: %s\n\n%s", input.Subject, input.Body) - return smtp.SendMail( - settings.SmtpHost.(string)+":"+fmt.Sprint(settings.SmtpPort.(int64)), - auth, input.From, - []string{input.To}, - strings.NewReader(message), - ) -} diff --git a/package.json b/package.json deleted file mode 100644 index 56f38750..00000000 --- a/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "portr", - "private": true, - "workspaces": [ - "internal/server/admin/*" - ] -} diff --git a/query.sql b/query.sql deleted file mode 100644 index b0b7aba9..00000000 --- a/query.sql +++ /dev/null @@ -1,375 +0,0 @@ --- name: GetUserBySession :one -SELECT - users.id, - users.email, - users.created_at, - users.first_name, - users.last_name, - users.github_avatar_url, - users.is_super_user -FROM - users - JOIN sessions ON sessions.user_id = users.id -WHERE - sessions.token = ? -LIMIT - 1; - --- name: GetTeamMemberByEmail :one -SELECT - * -FROM - team_members - JOIN users ON users.id = team_members.user_id -WHERE - users.email = ? - AND team_members.team_id = ? -LIMIT - 1; - --- name: GetTeamMemberByUserIdAndTeamSlug :one -SELECT - team_members.*, - users.* -FROM - team_members - JOIN users ON users.id = team_members.user_id - JOIN teams ON teams.id = team_members.team_id -WHERE - users.id = ? - AND teams.slug = ? -LIMIT - 1; - --- name: CreateTeam :one -INSERT INTO - teams (name, slug) -VALUES - (?, ?) RETURNING *; - --- name: CreateTeamMember :one -INSERT INTO - team_members (user_id, team_id, role, secret_key) -VALUES - (?, ?, ?, ?) RETURNING *; - --- name: CreateSession :one -INSERT INTO - sessions (token, user_id) -VALUES - (?, ?) RETURNING *; - --- name: GetUsersCount :one -SELECT - COUNT(*) -FROM - users; - --- name: GetTeamUserBySecretKey :one -SELECT - * -FROM - team_members -WHERE - secret_key = ? -LIMIT - 1; - --- name: GetActiveConnectionsForTeam :many -SELECT - connections.id, - connections.type, - connections.port, - connections.subdomain, - connections.created_at, - connections.started_at, - connections.closed_at, - connections.status, - users.email, - users.first_name, - users.last_name, - users.github_avatar_url -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id - JOIN users ON users.id = team_members.user_id -WHERE - connections.team_id = ? - AND status = 'active' -ORDER BY - connections.id DESC -LIMIT - 10 -OFFSET - ?; - --- name: GetActiveConnectionCountForTeam :one -SELECT - count(*) -FROM - connections -WHERE - connections.team_id = ? - AND status = 'active'; - --- name: GetRecentConnectionsForTeam :many -SELECT - connections.id, - connections.type, - connections.port, - connections.subdomain, - connections.created_at, - connections.started_at, - connections.closed_at, - connections.status, - users.email, - users.first_name, - users.last_name, - users.github_avatar_url -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id - JOIN users ON users.id = team_members.user_id -WHERE - connections.team_id = ? - AND status != 'reserved' -ORDER BY - connections.id DESC -LIMIT - 10 -OFFSET - ?; - --- name: GetRecentConnectionCountForTeam :one -SELECT - count(*) -FROM - connections -WHERE - connections.team_id = ? - AND status != 'reserved'; - --- name: CreateNewHttpConnection :one -INSERT INTO - connections (id, type, subdomain, team_member_id, team_id) -VALUES - (?, "http", ?, ?, ?) RETURNING *; - --- name: CreateNewTcpConnection :one -INSERT INTO - connections (id, type, port, team_member_id, team_id) -VALUES - (?, "tcp", ?, ?, ?) RETURNING *; - --- name: MarkConnectionAsActive :exec -UPDATE connections -SET - status = 'active', - started_at = CURRENT_TIMESTAMP -WHERE - id = ?; - --- name: MarkConnectionAsClosed :exec -UPDATE connections -SET - status = 'closed', - closed_at = CURRENT_TIMESTAMP -WHERE - id = ?; - --- name: GetGlobalSettings :one -SELECT - * -FROM - global_settings -LIMIT - 1; - --- name: CreateGlobalSettings :one -INSERT INTO - global_settings ( - smtp_enabled, - add_member_email_subject, - add_member_email_template - ) -VALUES - (?, ?, ?) RETURNING *; - --- name: UpdateGlobalSettings :exec -UPDATE global_settings -SET - smtp_enabled = ?, - smtp_host = ?, - smtp_port = ?, - smtp_username = ?, - smtp_password = ?, - from_address = ?, - add_member_email_subject = ?, - add_member_email_template = ?; - --- name: GetTeamMembers :many -SELECT - users.email, - team_members.role, - users.github_avatar_url -FROM - team_members - JOIN users ON users.id = team_members.user_id -WHERE - team_id = ?; - --- name: CreateUser :one -INSERT INTO - users ( - email, - first_name, - last_name, - is_super_user, - github_access_token, - github_avatar_url - ) -VALUES - (?, ?, ?, ?, ?, ?) RETURNING *; - --- name: DeleteSession :exec -DELETE FROM sessions -WHERE - token = ?; - --- name: UpdateSecretKey :exec -UPDATE team_members -SET - secret_key = ? -WHERE - id = ?; - --- name: GetUserByEmail :one -SELECT - * -FROM - users -WHERE - email = ? -LIMIT - 1; - --- name: GetTeamsOfUser :many -SELECT - teams.* -FROM - team_members - JOIN teams ON teams.id = team_members.team_id -WHERE - team_members.user_id = ?; - --- name: GetTeamMemberById :one -SELECT - team_members.*, - users.* -FROM - team_members - JOIN users ON users.id = team_members.user_id -WHERE - team_members.id = ? -LIMIT - 1; - --- name: GetUserById :one -SELECT - users.id, - users.email, - users.created_at, - users.first_name, - users.last_name, - users.github_avatar_url, - users.is_super_user -FROM - users -WHERE - id = ? -LIMIT - 1; - --- name: GetTeamById :one -SELECT - * -FROM - teams -WHERE - id = ? -LIMIT - 1; - --- name: UpdateUser :exec -UPDATE users -SET - first_name = COALESCE(?, first_name), - last_name = COALESCE(?, last_name), - github_access_token = COALESCE(?, github_access_token), - github_avatar_url = COALESCE(?, github_avatar_url) -WHERE - id = ?; - --- name: DeleteExpiredSessions :exec -DELETE FROM sessions -WHERE - strftime ('%s', 'now') - strftime ('%s', created_at) > 24 * 60 * 60; - --- name: DeleteUnclaimedConnections :exec -DELETE FROM connections -WHERE - status = 'reserved' - AND strftime ('%s', 'now') - strftime ('%s', created_at) > 10; - --- name: GetReservedOrActiveConnectionById :one -SELECT - * -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id -WHERE - connections.id = ? - AND status IN ('active', 'reserved') -LIMIT - 1; - --- name: AddPortToConnection :exec -UPDATE connections -SET - port = ? -WHERE - id = ?; - --- name: GetReservedOrActiveConnectionForSubdomain :one -SELECT - * -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id -WHERE - subdomain = ? - AND team_members.secret_key = ? - AND status IN ('active', 'reserved') -LIMIT - 1; - --- name: GetReservedOrActiveConnectionForPort :one -SELECT - * -FROM - connections - JOIN team_members ON team_members.id = connections.team_member_id -WHERE - port = ? - AND team_members.secret_key = ? - AND status IN ('active', 'reserved') -LIMIT - 1; - --- name: GetAllActiveConnections :many -SELECT - * -FROM - connections -WHERE - status = 'active'; \ No newline at end of file diff --git a/sqlc.yaml b/sqlc.yaml deleted file mode 100644 index 76fbd28f..00000000 --- a/sqlc.yaml +++ /dev/null @@ -1,9 +0,0 @@ -version: "2" -sql: - - engine: "sqlite" - queries: "query.sql" - schema: "internal/server/db/migrations" - gen: - go: - package: "db" - out: "internal/server/db/models" diff --git a/.air.toml b/tunnel/.air.toml similarity index 79% rename from .air.toml rename to tunnel/.air.toml index 081d2e5e..91466fe3 100644 --- a/.air.toml +++ b/tunnel/.air.toml @@ -4,18 +4,10 @@ tmp_dir = "tmp" [build] args_bin = [] -bin = "./tmp/main -c configs/server.yaml start all" +bin = "./tmp/main start" cmd = "go build -o ./tmp/main cmd/portrd/main.go" delay = 1000 -exclude_dir = [ - "assets", - "tmp", - "vendor", - "testdata", - "node_modules", - "internal/server/admin/web", - "postgres-data", -] +exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false diff --git a/tunnel/.dockerignore b/tunnel/.dockerignore new file mode 100644 index 00000000..57fdeab7 --- /dev/null +++ b/tunnel/.dockerignore @@ -0,0 +1,3 @@ +tmp +portr +.env diff --git a/tunnel/.gitignore b/tunnel/.gitignore new file mode 100644 index 00000000..0b95e230 --- /dev/null +++ b/tunnel/.gitignore @@ -0,0 +1,2 @@ +keys +.env diff --git a/tunnel/Dockerfile b/tunnel/Dockerfile new file mode 100644 index 00000000..8287cac0 --- /dev/null +++ b/tunnel/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.22.0 AS builder + +WORKDIR /app + +COPY go.mod go.sum /app/ + +RUN go mod download + +COPY . /app/ + +RUN CGO_ENABLED=1 go build -ldflags="-s -w -linkmode external -extldflags \"-static\"" -o portrd ./cmd/portrd + +FROM alpine:3.19.1 as final + +WORKDIR /app + +COPY --from=builder /app/portrd /app/ + +ENTRYPOINT ["./portrd"] diff --git a/tunnel/cmd/portr/config.go b/tunnel/cmd/portr/config.go new file mode 100644 index 00000000..ecc9a8b5 --- /dev/null +++ b/tunnel/cmd/portr/config.go @@ -0,0 +1,22 @@ +package main + +import ( + "github.com/amalshaji/portr/internal/client/config" + "github.com/urfave/cli/v2" +) + +func configCmd() *cli.Command { + return &cli.Command{ + Name: "config", + Usage: "Edit the portr config file", + Subcommands: []*cli.Command{ + { + Name: "edit", + Usage: "Edit the default config file", + Action: func(c *cli.Context) error { + return config.EditConfig() + }, + }, + }, + } +} diff --git a/cmd/portr/http.go b/tunnel/cmd/portr/http.go similarity index 100% rename from cmd/portr/http.go rename to tunnel/cmd/portr/http.go diff --git a/cmd/portr/main.go b/tunnel/cmd/portr/main.go similarity index 100% rename from cmd/portr/main.go rename to tunnel/cmd/portr/main.go diff --git a/cmd/portr/start.go b/tunnel/cmd/portr/start.go similarity index 100% rename from cmd/portr/start.go rename to tunnel/cmd/portr/start.go diff --git a/cmd/portr/tcp.go b/tunnel/cmd/portr/tcp.go similarity index 100% rename from cmd/portr/tcp.go rename to tunnel/cmd/portr/tcp.go diff --git a/tunnel/cmd/portrd/main.go b/tunnel/cmd/portrd/main.go new file mode 100644 index 00000000..dc1c757f --- /dev/null +++ b/tunnel/cmd/portrd/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + + "github.com/amalshaji/portr/internal/server/config" + "github.com/amalshaji/portr/internal/server/cron" + "github.com/amalshaji/portr/internal/server/db" + "github.com/amalshaji/portr/internal/server/proxy" + "github.com/amalshaji/portr/internal/server/service" + sshd "github.com/amalshaji/portr/internal/server/ssh" + "github.com/urfave/cli/v2" +) + +const VERSION = "0.0.1-beta" + +func main() { + app := &cli.App{ + Name: "portrd", + Usage: "portr server", + Version: VERSION, + Commands: []*cli.Command{ + { + Name: "start", + Usage: "Start the tunnel server", + Action: func(c *cli.Context) error { + start(c.String("config")) + return nil + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func start(configFilePath string) { + config := config.Load(configFilePath) + + _db := db.New(&config.Database) + _db.Connect() + + service := service.New(_db) + + proxyServer := proxy.New(config) + sshServer := sshd.New(&config.Ssh, proxyServer, service) + cron := cron.New(_db, config, service) + + go proxyServer.Start() + defer proxyServer.Shutdown(context.TODO()) + + go sshServer.Start() + defer sshServer.Shutdown(context.TODO()) + + go cron.Start() + defer cron.Shutdown() + + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + <-done +} diff --git a/configs/client.yaml b/tunnel/configs/client.yaml similarity index 77% rename from configs/client.yaml rename to tunnel/configs/client.yaml index a0fa7320..7adcc502 100644 --- a/configs/client.yaml +++ b/tunnel/configs/client.yaml @@ -1,7 +1,7 @@ serverUrl: localhost:8000 sshUrl: localhost:2222 tunnelUrl: localhost:8001 -secretKey: 57b4L4I4XcU-Wcu548rnqaahGbyykszmdD72y-dVI9 +secretKey: Zq5Zk5gP5qHGg28nixeqRIOMM9Zo3EUgWnFAEnJnwC useLocalhost: true debug: true tunnels: diff --git a/tunnel/configs/server.yaml b/tunnel/configs/server.yaml new file mode 100644 index 00000000..324a961b --- /dev/null +++ b/tunnel/configs/server.yaml @@ -0,0 +1,10 @@ +ssh: + port: $SSH_PORT +proxy: + port: $PROXY_PORT +database: + url: $DB_URL + driver: $DB_DRIVER +useLocalhost: $USE_LOCALHOST +debug: $DEBUG +domain: $DOMAIN diff --git a/tunnel/go.mod b/tunnel/go.mod new file mode 100644 index 00000000..ac39579d --- /dev/null +++ b/tunnel/go.mod @@ -0,0 +1,58 @@ +module github.com/amalshaji/portr + +go 1.22.0 + +require ( + github.com/briandowns/spinner v1.23.0 + github.com/gliderlabs/ssh v0.3.6 + github.com/go-resty/resty/v2 v2.11.0 + github.com/gofiber/fiber/v2 v2.52.1 + github.com/gookit/validate v1.5.2 + github.com/labstack/gommon v0.4.2 + github.com/matoous/go-nanoid/v2 v2.0.0 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/oklog/ulid/v2 v2.1.0 + github.com/urfave/cli/v2 v2.27.1 + github.com/valyala/fasttemplate v1.2.2 + golang.org/x/crypto v0.19.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/datatypes v1.2.0 + gorm.io/driver/postgres v1.5.6 + gorm.io/driver/sqlite v1.5.5 + gorm.io/gorm v1.25.7 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gookit/filter v1.2.1 // indirect + github.com/gookit/goutil v0.6.15 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect + github.com/jackc/pgx/v5 v5.5.3 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.7 // indirect + github.com/kr/text v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.52.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + gorm.io/driver/mysql v1.5.4 // indirect +) diff --git a/go.sum b/tunnel/go.sum similarity index 68% rename from go.sum rename to tunnel/go.sum index 5cc756e3..53ce882a 100644 --- a/go.sum +++ b/tunnel/go.sum @@ -1,7 +1,7 @@ -github.com/amacneil/dbmate/v2 v2.10.0 h1:bGp1sL/tijenf/BhQBw/t0LmiYvBTi+LXn6uBiHwLII= -github.com/amacneil/dbmate/v2 v2.10.0/go.mod h1:sZI+Tv+Bx1S2eJ6Cg0cz2UHhaTyJLLkQYc8c8qklgVc= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= @@ -11,21 +11,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= -github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/emersion/go-smtp v0.19.0 h1:iVCDtR2/JY3RpKoaZ7u6I/sb52S3EzfNHO1fAWVHgng= -github.com/emersion/go-smtp v0.19.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= -github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= -github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= -github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= -github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc= -github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/gliderlabs/ssh v0.3.6 h1:ZzjlDa05TcFRICb3anf/dSPN3ewz1Zx6CMLPWgkm3b8= github.com/gliderlabs/ssh v0.3.6/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= @@ -35,55 +24,55 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= -github.com/gofiber/template v1.8.2 h1:PIv9s/7Uq6m+Fm2MDNd20pAFFKt5wWs7ZBd8iV9pWwk= -github.com/gofiber/template v1.8.2/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= -github.com/gofiber/template/django/v3 v3.1.9 h1:/Qfmh9P3W7N1rqd4HHu/Y+9oR5MmPtnSw15nceUunz8= -github.com/gofiber/template/django/v3 v3.1.9/go.mod h1:HCxbI5202tCyeRGuvCm8DgOOWffDSi6TF3K6AJSxDmo= -github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= -github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= +github.com/gofiber/fiber/v2 v2.52.1 h1:1RoU2NS+b98o1L77sdl5mboGPiW+0Ypsi5oLmcYlgHI= +github.com/gofiber/fiber/v2 v2.52.1/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/gookit/filter v1.2.0 h1:r7E01dHVkysb5WgzooiGsfblHGShEZCeGcyYM+5IpYU= github.com/gookit/filter v1.2.0/go.mod h1:bXs9RcB4Blxwny970opiwABeIEqQ/gzOMmHBhKwBdms= +github.com/gookit/filter v1.2.1 h1:37XivkBm2E5qe1KaGdJ5ZfF5l9NYdGWfLEeQadJD8O4= +github.com/gookit/filter v1.2.1/go.mod h1:rxynQFr793x+XDwnRmJFEb53zDw0Zqx3OD7TXWoR9mQ= github.com/gookit/goutil v0.6.14 h1:96elyOG4BvVoDaiT7vx1vHPrVyEtFfYlPPBODR0/FGQ= github.com/gookit/goutil v0.6.14/go.mod h1:YyDBddefmjS+mU2PDPgCcjVzTDM5WgExiDv5ZA/b8I8= +github.com/gookit/goutil v0.6.15 h1:mMQ0ElojNZoyPD0eVROk5QXJPh2uKR4g06slgPDF5Jo= +github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY= github.com/gookit/validate v1.5.1 h1:rPp64QZQJM+fysGFAhKpvekQAav4Ok6sjfTs9ZtxcpA= github.com/gookit/validate v1.5.1/go.mod h1:SskOHUQokzMNt6T3r7N+N/4me/6fxDx+tmoXf/3ZQog= +github.com/gookit/validate v1.5.2 h1:i5I2OQ7WYHFRPRATGu9QarR9snnNHydvwSuHXaRWAV0= +github.com/gookit/validate v1.5.2/go.mod h1:yuPy2WwDlwGRa06fFJ5XIO8QEwhRnTC2LmxmBa5SE14= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= -github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s= +github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/matoous/go-nanoid v1.5.0 h1:VRorl6uCngneC4oUQqOYtO3S0H5QKFtKuKycFG3euek= github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0= github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g= @@ -94,8 +83,11 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= @@ -103,23 +95,31 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI= github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= +github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= +github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= @@ -129,13 +129,11 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 h1:qXafrlZL1WsJW5OokjraLLRURHiw0OzKHD/RNdspp4w= -github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04/go.mod h1:FiwNQxz6hGoNFBC4nIx+CxZhI3nne5RmIOlT/MXcSD4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -146,13 +144,13 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= -golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -163,19 +161,18 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= @@ -188,13 +185,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= -google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -205,20 +195,18 @@ gorm.io/datatypes v1.2.0 h1:5YT+eokWdIxhJgWHdrb2zYUimyk0+TaFth+7a0ybzco= gorm.io/datatypes v1.2.0/go.mod h1:o1dh0ZvjIjhH/bngTpypG6lVRJ5chTBxE09FH/71k04= gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= -gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= -gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/mysql v1.5.4 h1:igQmHfKcbaTVyAIHNhhB888vvxh8EdQ2uSUT0LPcBso= +gorm.io/driver/mysql v1.5.4/go.mod h1:9rYxJph/u9SWkWc9yY4XJ1F/+xO0S/ChOmbk3+Z5Tvs= +gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU= +gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= +gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0= gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig= +gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= -gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= -gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -modernc.org/libc v1.38.0 h1:o4Lpk0zNDSdsjfEXnF1FGXWQ9PDi1NOdWcLP5n13FGo= -modernc.org/libc v1.38.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= -modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= -modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= -modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/internal/client/client/client.go b/tunnel/internal/client/client/client.go similarity index 100% rename from internal/client/client/client.go rename to tunnel/internal/client/client/client.go diff --git a/internal/client/config/config.go b/tunnel/internal/client/config/config.go similarity index 99% rename from internal/client/config/config.go rename to tunnel/internal/client/config/config.go index ea21b86b..e4cc4854 100644 --- a/internal/client/config/config.go +++ b/tunnel/internal/client/config/config.go @@ -204,7 +204,7 @@ func (c Config) ValidateConfig() error { client := resty.New() - resp, err := client.R().SetBody(payloadMap).Post(c.GetAdminAddress() + "/config/validate") + resp, err := client.R().SetBody(payloadMap).Post(c.GetAdminAddress() + "/internal/config/validate") if err != nil { return err } diff --git a/internal/client/db/db.go b/tunnel/internal/client/db/db.go similarity index 95% rename from internal/client/db/db.go rename to tunnel/internal/client/db/db.go index 4956343a..f7cf2a6a 100644 --- a/internal/client/db/db.go +++ b/tunnel/internal/client/db/db.go @@ -4,8 +4,8 @@ import ( "log" "os" - "github.com/glebarez/sqlite" "gorm.io/datatypes" + "gorm.io/driver/sqlite" "gorm.io/gorm" ) diff --git a/internal/client/ssh/ssh.go b/tunnel/internal/client/ssh/ssh.go similarity index 89% rename from internal/client/ssh/ssh.go rename to tunnel/internal/client/ssh/ssh.go index 10d6a695..a990f915 100644 --- a/internal/client/ssh/ssh.go +++ b/tunnel/internal/client/ssh/ssh.go @@ -49,52 +49,37 @@ func New(config config.ClientConfig, db *db.Db) *SshClient { } } -func (s *SshClient) getSshSigner() ssh.Signer { - homeDir, _ := os.UserHomeDir() - pemBytes, err := os.ReadFile(homeDir + "/.portr/keys/id_rsa") - if err != nil { - if s.config.Debug { - s.log.Error("failed to read ssh key", "error", err) - } - log.Fatal(ErrLocalSetupIncomplete) - } - - signer, err := ssh.ParsePrivateKey(pemBytes) - if err != nil { - if s.config.Debug { - s.log.Error("failed to parse ssh key", "error", err) - } - log.Fatal(ErrLocalSetupIncomplete) - } - return signer -} - func (s *SshClient) createNewConnection() (string, error) { client := resty.New() var reqErr struct { - Message string `json:"message"` + Detail any `json:"detail"` } var response struct { - ConnectionId string `json:"connectionId"` + ConnectionId string `json:"connection_id"` } + payload := map[string]any{ + "connection_type": string(s.config.Tunnel.Type), + "secret_key": s.config.SecretKey, + "subdomain": nil, + } request := client.R(). - SetHeader("X-Connection-Type", string(s.config.Tunnel.Type)). - SetHeader("X-SecretKey", s.config.SecretKey). SetError(&reqErr). SetResult(&response) if s.config.Tunnel.Type == constants.Http { - request = request.SetHeader("X-Subdomain", s.config.Tunnel.Subdomain) + payload["subdomain"] = s.config.Tunnel.Subdomain } - resp, err := request.Post(s.config.GetServerAddr() + "/api/connection/create") + resp, err := request.SetBody(payload).Post(s.config.GetServerAddr() + "/api/v1/connections/") + if err != nil { return "", err } if resp.StatusCode() != 200 { - return "", fmt.Errorf(reqErr.Message) + s.log.Error("failed to create new connection", "error", reqErr) + return "", fmt.Errorf("failed to create new connection") } return response.ConnectionId, nil } @@ -107,12 +92,10 @@ func (s *SshClient) startListenerForClient() error { return err } - signer := s.getSshSigner() - sshConfig := &ssh.ClientConfig{ - User: connectionId, + User: fmt.Sprintf("%s:%s", connectionId, s.config.SecretKey), Auth: []ssh.AuthMethod{ - ssh.PublicKeys(signer), + ssh.Password(""), }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } diff --git a/internal/constants/constants.go b/tunnel/internal/constants/constants.go similarity index 100% rename from internal/constants/constants.go rename to tunnel/internal/constants/constants.go diff --git a/tunnel/internal/server/config/config.go b/tunnel/internal/server/config/config.go new file mode 100644 index 00000000..5dd6a25f --- /dev/null +++ b/tunnel/internal/server/config/config.go @@ -0,0 +1,126 @@ +package config + +import ( + "fmt" + "log" + "os" + "strconv" + "strings" +) + +type SshConfig struct { + Host string + Port int + KeysDir string +} + +func (s SshConfig) Address() string { + return s.Host + ":" + fmt.Sprint(s.Port) +} + +type ProxyConfig struct { + Host string + Port int +} + +func (p ProxyConfig) Address() string { + return p.Host + ":" + fmt.Sprint(p.Port) +} + +type DatabaseConfig struct { + Url string + Driver string + AutoMigrate bool +} + +type Config struct { + Ssh SshConfig + Proxy ProxyConfig + Domain string + UseLocalHost bool + Debug bool + Database DatabaseConfig +} + +func new() *Config { + sshPortStr := os.Getenv("SSH_PORT") + if sshPortStr == "" { + sshPortStr = "2222" + } + sshPort, err := strconv.Atoi(sshPortStr) + if err != nil { + log.Fatal(err) + } + + proxyPortStr := os.Getenv("PROXY_PORT") + if proxyPortStr == "" { + proxyPortStr = "8001" + } + proxyPort, err := strconv.Atoi(proxyPortStr) + if err != nil { + log.Fatal(err) + } + + domain := os.Getenv("DOMAIN") + if domain == "" { + domain = "localhost:8000" + } + + dbUrl := os.Getenv("DB_URL") + if dbUrl == "" { + log.Fatal("DB_URL is required") + } + + dbDriver := strings.Split(os.Getenv("DB_URL"), "://")[0] + + return &Config{ + Ssh: SshConfig{ + Host: "localhost", + Port: sshPort, + }, + Proxy: ProxyConfig{ + Host: "localhost", + Port: proxyPort, + }, + Domain: domain, + UseLocalHost: os.Getenv("USE_LOCALHOST") == "true", + Debug: os.Getenv("DEBUG") == "true", + Database: DatabaseConfig{ + Url: dbUrl, + Driver: dbDriver, + }, + } +} + +func (c *Config) HttpTunnelUrl(subdomain string) string { + if !c.UseLocalHost { + return "https://" + subdomain + "." + c.Domain + } + return "http://" + subdomain + "." + c.Proxy.Address() +} + +func (c *Config) TcpTunnelUrl(port uint32) string { + if !c.UseLocalHost { + return c.Domain + ":" + fmt.Sprint(port) + } + return "localhost:" + fmt.Sprint(port) +} + +func (c Config) Protocol() string { + if !c.UseLocalHost { + return "https" + } + return "http" +} + +func (c Config) ExtractSubdomain(url string) string { + withoutProtocol := strings.ReplaceAll(url, c.Protocol()+"://", "") + if !c.UseLocalHost { + return strings.ReplaceAll(withoutProtocol, "."+c.Domain, "") + } + return strings.ReplaceAll(withoutProtocol, "."+c.Proxy.Address(), "") +} + +func Load(path string) *Config { + return new() +} diff --git a/internal/server/cron/cron.go b/tunnel/internal/server/cron/cron.go similarity index 77% rename from internal/server/cron/cron.go rename to tunnel/internal/server/cron/cron.go index 2449a919..e127183f 100644 --- a/internal/server/cron/cron.go +++ b/tunnel/internal/server/cron/cron.go @@ -8,6 +8,7 @@ import ( "github.com/amalshaji/portr/internal/server/config" "github.com/amalshaji/portr/internal/server/db" + "github.com/amalshaji/portr/internal/server/service" "github.com/amalshaji/portr/internal/utils" ) @@ -15,11 +16,12 @@ type Cron struct { db *db.Db logger *slog.Logger config *config.Config + service *service.Service cancelFunc context.CancelFunc } -func New(db *db.Db, config *config.Config) *Cron { - return &Cron{db: db, config: config, logger: utils.GetLogger()} +func New(db *db.Db, config *config.Config, service *service.Service) *Cron { + return &Cron{db: db, config: config, service: service, logger: utils.GetLogger()} } func (c *Cron) Start() { diff --git a/internal/server/cron/ping.go b/tunnel/internal/server/cron/ping.go similarity index 61% rename from internal/server/cron/ping.go rename to tunnel/internal/server/cron/ping.go index 8687bf5e..3faca723 100644 --- a/internal/server/cron/ping.go +++ b/tunnel/internal/server/cron/ping.go @@ -6,15 +6,15 @@ import ( "net" "time" - models "github.com/amalshaji/portr/internal/server/db/models" + "github.com/amalshaji/portr/internal/server/db" "github.com/go-resty/resty/v2" ) var ErrInactiveTunnel = fmt.Errorf("inactive tunnel") -func (c *Cron) pingHttpConnection(connection models.Connection) error { +func (c *Cron) pingHttpConnection(connection db.Connection) error { client := resty.New().R() - resp, err := client.Get(c.config.HttpTunnelUrl(connection.Subdomain.(string))) + resp, err := client.Get(c.config.HttpTunnelUrl(*connection.Subdomain)) // don't care about the error, just care about the response if err != nil { return nil @@ -25,10 +25,10 @@ func (c *Cron) pingHttpConnection(connection models.Connection) error { return nil } -func (c *Cron) pingTcpConnection(connection models.Connection) error { +func (c *Cron) pingTcpConnection(connection db.Connection) error { timeout := time.Second * 5 - conn, err := net.DialTimeout("tcp", c.config.TcpTunnelUrl(connection.Port.(int64)), timeout) + conn, err := net.DialTimeout("tcp", c.config.TcpTunnelUrl(*connection.Port), timeout) if err != nil { return ErrInactiveTunnel } @@ -38,14 +38,16 @@ func (c *Cron) pingTcpConnection(connection models.Connection) error { func (c *Cron) pingActiveConnections(ctx context.Context) { var err error - connections, err := c.db.Queries.GetAllActiveConnections(ctx) + connections := c.service.GetAllActiveConnections(ctx) if err != nil { c.logger.Error("error getting active connections", "error", err) return } + c.logger.Info("pinging active connections", "count", len(connections)) + for _, connection := range connections { - go func(connection models.Connection) { + go func(connection db.Connection) { if connection.Type == "http" { err = c.pingHttpConnection(connection) } else { @@ -53,7 +55,7 @@ func (c *Cron) pingActiveConnections(ctx context.Context) { } if err != nil { - c.db.Queries.MarkConnectionAsClosed(ctx, connection.ID) + c.service.MarkConnectionAsClosed(ctx, connection.ID) } }(connection) } diff --git a/tunnel/internal/server/cron/tasks.go b/tunnel/internal/server/cron/tasks.go new file mode 100644 index 00000000..32ca9738 --- /dev/null +++ b/tunnel/internal/server/cron/tasks.go @@ -0,0 +1,42 @@ +package cron + +import ( + "context" + "time" +) + +type CronFunc func(*Cron) + +type Job struct { + Name string + Interval time.Duration + Function CronFunc +} + +var crons = []Job{ + // { + // Name: "Delete expired sessions", + // Interval: 6 * time.Hour, + // Function: func(c *Cron) { + // if err := c.db.Queries.DeleteExpiredSessions(context.Background()); err != nil { + // c.logger.Error("error deleting expired sessions", "error", err) + // } + // }, + // }, + // { + // Name: "Delete unclaimed connections", + // Interval: 10 * time.Second, + // Function: func(c *Cron) { + // if err := c.db.Queries.DeleteUnclaimedConnections(context.Background()); err != nil { + // c.logger.Error("error deleting unclaimed connections", "error", err) + // } + // }, + // }, + { + Name: "Ping active connections", + Interval: 10 * time.Second, + Function: func(c *Cron) { + c.pingActiveConnections(context.Background()) + }, + }, +} diff --git a/tunnel/internal/server/db/db.go b/tunnel/internal/server/db/db.go new file mode 100644 index 00000000..e77ebc02 --- /dev/null +++ b/tunnel/internal/server/db/db.go @@ -0,0 +1,39 @@ +package db + +import ( + "log" + + "github.com/amalshaji/portr/internal/server/config" + _ "github.com/mattn/go-sqlite3" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type Db struct { + Conn *gorm.DB + config *config.DatabaseConfig +} + +func New(config *config.DatabaseConfig) *Db { + return &Db{ + config: config, + } +} + +func (d *Db) Connect() { + var err error + + switch d.config.Driver { + case "sqlite3", "sqlite": + d.Conn, err = gorm.Open(sqlite.Open(d.config.Url), &gorm.Config{}) + case "postgres", "postgresql": + d.Conn, err = gorm.Open(postgres.Open(d.config.Url), &gorm.Config{}) + default: + log.Fatalf("unsupported database driver: %s", d.config.Driver) + } + + if err != nil { + log.Fatal(err) + } +} diff --git a/tunnel/internal/server/db/models.go b/tunnel/internal/server/db/models.go new file mode 100644 index 00000000..5be34a2c --- /dev/null +++ b/tunnel/internal/server/db/models.go @@ -0,0 +1,36 @@ +package db + +import ( + "time" +) + +type Connection struct { + ID string `gorm:"primarykey"` + Type string + Subdomain *string + Port *uint32 + Status string + CreatedAt time.Time + StartedAt *time.Time + ClosedAt *time.Time + CreatedByID uint + CreatedBy TeamUser +} + +func (Connection) TableName() string { + return "connection" +} + +type TeamUser struct { + ID uint `gorm:"primarykey"` + CreatedAt time.Time + UpdatedAt time.Time + SecretKey string + Role string + TeamID uint32 + UserID uint32 +} + +func (TeamUser) TableName() string { + return "team_users" +} diff --git a/internal/server/proxy/proxy.go b/tunnel/internal/server/proxy/proxy.go similarity index 100% rename from internal/server/proxy/proxy.go rename to tunnel/internal/server/proxy/proxy.go diff --git a/tunnel/internal/server/service/service.go b/tunnel/internal/server/service/service.go new file mode 100644 index 00000000..aa0b6593 --- /dev/null +++ b/tunnel/internal/server/service/service.go @@ -0,0 +1,58 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/amalshaji/portr/internal/server/db" + "github.com/amalshaji/portr/internal/utils" + "gorm.io/gorm" +) + +type Service struct { + db *db.Db + logger *slog.Logger +} + +func New(db *db.Db) *Service { + return &Service{db: db, logger: utils.GetLogger()} +} + +func (s *Service) GetReservedConnectionById(ctx context.Context, connectionId string) (*db.Connection, error) { + var connection db.Connection + + err := s.db.Conn.Preload("CreatedBy").Where("status = 'reserved' AND id = ?", connectionId).First(&connection).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("connection not found") + } + return nil, err + } + + return &connection, nil +} + +func (s *Service) AddPortToConnection(ctx context.Context, connectionId string, port uint32) error { + connection, err := s.GetReservedConnectionById(ctx, fmt.Sprint(connectionId)) + if err != nil { + return err + } + connection.Port = &port + return s.db.Conn.Save(connection).Error +} + +func (s *Service) MarkConnectionAsActive(ctx context.Context, connectionId string) error { + return s.db.Conn.Model(&db.Connection{}).Where("id = ?", connectionId).Update("status", "active").Error +} + +func (s *Service) MarkConnectionAsClosed(ctx context.Context, connectionId string) error { + return s.db.Conn.Model(&db.Connection{}).Where("id = ?", connectionId).Update("status", "closed").Error +} + +func (s *Service) GetAllActiveConnections(ctx context.Context) []db.Connection { + var connections []db.Connection + s.db.Conn.Where("status = ?", "active").Find(&connections) + return connections +} diff --git a/internal/server/ssh/sshd.go b/tunnel/internal/server/ssh/sshd.go similarity index 76% rename from internal/server/ssh/sshd.go rename to tunnel/internal/server/ssh/sshd.go index 22eb38da..fdf44438 100644 --- a/internal/server/ssh/sshd.go +++ b/tunnel/internal/server/ssh/sshd.go @@ -6,13 +6,14 @@ import ( "fmt" "log" "log/slog" - "os" + "strings" "time" "github.com/amalshaji/portr/internal/constants" - "github.com/amalshaji/portr/internal/server/admin/service" + "github.com/amalshaji/portr/internal/server/config" "github.com/amalshaji/portr/internal/server/proxy" + "github.com/amalshaji/portr/internal/server/service" "github.com/amalshaji/portr/internal/utils" "github.com/gliderlabs/ssh" ) @@ -38,35 +39,35 @@ func (s *SshServer) GetServerAddr() string { return ":" + fmt.Sprint(s.config.Port) } -func (s *SshServer) getSshPublicKey() ssh.PublicKey { - publicKey, err := os.ReadFile(s.config.KeysDir + "/id_rsa.pub") - if err != nil { - log.Fatalf("could not read public key, make sure the keys are present in the %s folder", s.config.KeysDir) - } - key, _, _, _, _ := ssh.ParseAuthorizedKey(publicKey) - return key -} - func (s *SshServer) Start() { forwardHandler := &ssh.ForwardedTCPHandler{} - keyFromDisk := s.getSshPublicKey() - server := ssh.Server{ Addr: s.GetServerAddr(), Handler: ssh.Handler(func(sh ssh.Session) { select {} }), ReversePortForwardingCallback: ssh.ReversePortForwardingCallback(func(ctx ssh.Context, host string, port uint32) bool { - connectionId := ctx.User() - proxyTarget := fmt.Sprintf("%s:%d", host, port) + userSplit := strings.Split(ctx.User(), ":") + if len(userSplit) != 2 { + return false + } - reservedConnection, err := s.service.GetReservedOrActiveConnectionById(ctx, connectionId) + connectionId, secretKey := userSplit[0], userSplit[1] + + reservedConnection, err := s.service.GetReservedConnectionById(ctx, connectionId) if err != nil { s.log.Error("failed to get reserved connection", "error", err) return false } + if reservedConnection.CreatedBy.SecretKey != secretKey { + s.log.Error("connection not created by the user") + return false + } + + proxyTarget := fmt.Sprintf("%s:%d", host, port) + if reservedConnection.Type == string(constants.Tcp) { err = s.service.AddPortToConnection(ctx, reservedConnection.ID, port) if err != nil { @@ -82,7 +83,7 @@ func (s *SshServer) Start() { } if reservedConnection.Type == string(constants.Http) { - err = s.proxy.AddRoute(reservedConnection.Subdomain.(string), proxyTarget) + err = s.proxy.AddRoute(*reservedConnection.Subdomain, proxyTarget) if err != nil { s.log.Error("failed to add route", "error", err) return false @@ -96,8 +97,8 @@ func (s *SshServer) Start() { s.log.Error("failed to mark connection as closed", "error", err) } - if reservedConnection.Subdomain == string(constants.Http) { - err := s.proxy.RemoveRoute(reservedConnection.Subdomain.(string)) + if *reservedConnection.Subdomain == string(constants.Http) { + err := s.proxy.RemoveRoute(*reservedConnection.Subdomain) if err != nil { s.log.Error("failed to remove route", "error", err) } @@ -111,8 +112,8 @@ func (s *SshServer) Start() { "tcpip-forward": forwardHandler.HandleSSHRequest, "cancel-tcpip-forward": forwardHandler.HandleSSHRequest, }, - PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { - return ssh.KeysEqual(key, keyFromDisk) + PasswordHandler: func(ctx ssh.Context, password string) bool { + return true }, } diff --git a/internal/utils/error-templates/local-server-not-online.html b/tunnel/internal/utils/error-templates/local-server-not-online.html similarity index 100% rename from internal/utils/error-templates/local-server-not-online.html rename to tunnel/internal/utils/error-templates/local-server-not-online.html diff --git a/internal/utils/error-templates/unregistered-subdomain.html b/tunnel/internal/utils/error-templates/unregistered-subdomain.html similarity index 100% rename from internal/utils/error-templates/unregistered-subdomain.html rename to tunnel/internal/utils/error-templates/unregistered-subdomain.html diff --git a/internal/utils/error.go b/tunnel/internal/utils/error.go similarity index 100% rename from internal/utils/error.go rename to tunnel/internal/utils/error.go diff --git a/internal/utils/http.go b/tunnel/internal/utils/http.go similarity index 100% rename from internal/utils/http.go rename to tunnel/internal/utils/http.go diff --git a/internal/utils/id.go b/tunnel/internal/utils/id.go similarity index 100% rename from internal/utils/id.go rename to tunnel/internal/utils/id.go diff --git a/internal/utils/loading.go b/tunnel/internal/utils/loading.go similarity index 100% rename from internal/utils/loading.go rename to tunnel/internal/utils/loading.go diff --git a/internal/utils/log.go b/tunnel/internal/utils/log.go similarity index 100% rename from internal/utils/log.go rename to tunnel/internal/utils/log.go diff --git a/internal/utils/port.go b/tunnel/internal/utils/port.go similarity index 100% rename from internal/utils/port.go rename to tunnel/internal/utils/port.go diff --git a/internal/utils/random.go b/tunnel/internal/utils/random.go similarity index 100% rename from internal/utils/random.go rename to tunnel/internal/utils/random.go diff --git a/internal/utils/request.go b/tunnel/internal/utils/request.go similarity index 100% rename from internal/utils/request.go rename to tunnel/internal/utils/request.go diff --git a/internal/utils/string.go b/tunnel/internal/utils/string.go similarity index 100% rename from internal/utils/string.go rename to tunnel/internal/utils/string.go