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 %}