From 7e0e1948df0435af80749f2651aa161d9401511e Mon Sep 17 00:00:00 2001 From: amalshaji Date: Sun, 25 Feb 2024 21:00:35 +0530 Subject: [PATCH] split tunnel and admin, rewrite admin in py --- .gitignore | 2 + .pre-commit-config.yaml | 30 + admin/.dockerignore | 7 + admin/.gitignore | 4 + admin/.python-version | 1 + admin/Dockerfile | 36 + admin/README.md | 3 + admin/pyproject.toml | 46 + admin/requirements-dev.lock | 131 ++ admin/requirements.lock | 92 + admin/requirements.txt | 90 + admin/scripts/pre-deploy.py | 14 + admin/scripts/start.sh | 4 + admin/src/portr_admin/__init__.py | 2 + admin/src/portr_admin/apis/__init__.py | 5 + admin/src/portr_admin/apis/pagination.py | 28 + admin/src/portr_admin/apis/security.py | 57 + admin/src/portr_admin/apis/v1/__init__.py | 13 + admin/src/portr_admin/apis/v1/auth.py | 111 + admin/src/portr_admin/apis/v1/connection.py | 53 + admin/src/portr_admin/apis/v1/settings.py | 34 + admin/src/portr_admin/apis/v1/team.py | 50 + admin/src/portr_admin/apis/v1/user.py | 45 + admin/src/portr_admin/beats.py | 19 + admin/src/portr_admin/config.py | 25 + admin/src/portr_admin/conftest.py | 12 + admin/src/portr_admin/db.py | 22 + admin/src/portr_admin/enums.py | 8 + admin/src/portr_admin/main.py | 150 ++ admin/src/portr_admin/model_mixins.py | 0 admin/src/portr_admin/models/__init__.py | 16 + admin/src/portr_admin/models/auth.py | 20 + admin/src/portr_admin/models/connection.py | 39 + admin/src/portr_admin/models/settings.py | 14 + admin/src/portr_admin/models/user.py | 63 + admin/src/portr_admin/py.typed | 0 admin/src/portr_admin/schemas/__init__.py | 0 admin/src/portr_admin/schemas/connection.py | 23 + admin/src/portr_admin/schemas/settings.py | 12 + admin/src/portr_admin/schemas/team.py | 19 + admin/src/portr_admin/schemas/user.py | 44 + admin/src/portr_admin/services/__init__.py | 0 admin/src/portr_admin/services/auth.py | 12 + admin/src/portr_admin/services/connection.py | 21 + admin/src/portr_admin/services/settings.py | 28 + admin/src/portr_admin/services/team.py | 38 + admin/src/portr_admin/services/user.py | 53 + .../src/portr_admin}/static/favicon.svg | 0 .../src/portr_admin}/static/logo.svg | 0 .../src/portr_admin}/templates/index.html | 4 +- admin/src/portr_admin/tests/__init__.py | 30 + .../portr_admin/tests/api_tests/__init__.py | 0 .../portr_admin/tests/api_tests/test_auth.py | 50 + .../tests/api_tests/test_connection.py | 83 + admin/src/portr_admin/tests/factories.py | 47 + .../tests/service_tests/__init__.py | 0 .../service_tests/test_connection_service.py | 54 + admin/src/portr_admin/utils/__init__.py | 0 admin/src/portr_admin/utils/exception.py | 11 + admin/src/portr_admin/utils/github_auth.py | 66 + admin/src/portr_admin/utils/token.py | 18 + admin/src/portr_admin/utils/vite.py | 26 + .../server/admin => admin/src}/web/.gitignore | 0 .../admin => admin/src}/web/components.json | 0 .../server/admin => admin/src}/web/index.html | 0 .../admin => admin/src}/web/package.json | 2 +- admin/src/web/pnpm-lock.yaml | 2020 +++++++++++++++++ .../src}/web/postcss.config.cjs | 0 .../admin => admin/src}/web/public/vite.svg | 0 .../admin => admin/src}/web/src/App.svelte | 2 +- .../admin => admin/src}/web/src/app.pcss | 0 .../web/src/lib/components/ApiError.svelte | 0 .../lib/components/ConnectionStatus.svelte | 4 +- .../src/lib/components/ConnectionType.svelte | 4 +- .../web/src/lib/components/DateField.svelte | 0 .../web/src/lib/components/ErrorText.svelte | 0 .../web/src/lib/components/Pagination.svelte | 0 .../src/lib/components/copyToClipboard.svelte | 0 .../lib/components/data-table-skeleton.svelte | 0 .../web/src/lib/components/data-table.svelte | 0 .../src}/web/src/lib/components/error.svelte | 0 .../web/src/lib/components/newteam.svelte | 6 +- .../settings/emailSettingsCard.svelte | 34 +- .../web/src/lib/components/sidebarlink.svelte | 17 + .../src/lib/components/team-selector.svelte | 40 +- .../alert-dialog/alert-dialog-action.svelte | 0 .../alert-dialog/alert-dialog-cancel.svelte | 0 .../alert-dialog/alert-dialog-content.svelte | 0 .../alert-dialog-description.svelte | 0 .../alert-dialog/alert-dialog-footer.svelte | 0 .../alert-dialog/alert-dialog-header.svelte | 0 .../alert-dialog/alert-dialog-overlay.svelte | 0 .../alert-dialog/alert-dialog-portal.svelte | 0 .../ui/alert-dialog/alert-dialog-title.svelte | 0 .../lib/components/ui/alert-dialog/index.ts | 0 .../ui/alert/alert-description.svelte | 0 .../components/ui/alert/alert-title.svelte | 0 .../src/lib/components/ui/alert/alert.svelte | 0 .../web/src/lib/components/ui/alert/index.ts | 0 .../ui/avatar/avatar-fallback.svelte | 0 .../components/ui/avatar/avatar-image.svelte | 0 .../lib/components/ui/avatar/avatar.svelte | 0 .../web/src/lib/components/ui/avatar/index.ts | 0 .../src/lib/components/ui/badge/badge.svelte | 0 .../web/src/lib/components/ui/badge/index.ts | 0 .../lib/components/ui/button/button.svelte | 0 .../web/src/lib/components/ui/button/index.ts | 0 .../components/ui/card/card-content.svelte | 0 .../ui/card/card-description.svelte | 0 .../lib/components/ui/card/card-footer.svelte | 0 .../lib/components/ui/card/card-header.svelte | 0 .../lib/components/ui/card/card-title.svelte | 0 .../src/lib/components/ui/card/card.svelte | 0 .../web/src/lib/components/ui/card/index.ts | 0 .../components/ui/checkbox/checkbox.svelte | 0 .../src/lib/components/ui/checkbox/index.ts | 0 .../dropdown-menu-checkbox-item.svelte | 0 .../dropdown-menu-content.svelte | 0 .../dropdown-menu/dropdown-menu-item.svelte | 0 .../dropdown-menu/dropdown-menu-label.svelte | 0 .../dropdown-menu-radio-group.svelte | 0 .../dropdown-menu-radio-item.svelte | 0 .../dropdown-menu-separator.svelte | 0 .../dropdown-menu-shortcut.svelte | 0 .../dropdown-menu-sub-content.svelte | 0 .../dropdown-menu-sub-trigger.svelte | 0 .../lib/components/ui/dropdown-menu/index.ts | 0 .../web/src/lib/components/ui/input/index.ts | 0 .../src/lib/components/ui/input/input.svelte | 0 .../web/src/lib/components/ui/label/index.ts | 0 .../src/lib/components/ui/label/label.svelte | 0 .../src/lib/components/ui/pagination/index.ts | 0 .../ui/pagination/pagination-content.svelte | 0 .../ui/pagination/pagination-ellipsis.svelte | 0 .../ui/pagination/pagination-item.svelte | 0 .../ui/pagination/pagination-link.svelte | 0 .../pagination/pagination-next-button.svelte | 0 .../pagination/pagination-prev-button.svelte | 0 .../ui/pagination/pagination.svelte | 0 .../web/src/lib/components/ui/select/index.ts | 0 .../ui/select/select-content.svelte | 0 .../components/ui/select/select-item.svelte | 0 .../components/ui/select/select-label.svelte | 0 .../ui/select/select-separator.svelte | 0 .../ui/select/select-trigger.svelte | 0 .../src/lib/components/ui/separator/index.ts | 0 .../components/ui/separator/separator.svelte | 0 .../src/lib/components/ui/skeleton/index.ts | 0 .../components/ui/skeleton/skeleton.svelte | 0 .../web/src/lib/components/ui/switch/index.ts | 0 .../lib/components/ui/switch/switch.svelte | 0 .../web/src/lib/components/ui/table/index.ts | 0 .../lib/components/ui/table/table-body.svelte | 0 .../components/ui/table/table-caption.svelte | 0 .../lib/components/ui/table/table-cell.svelte | 0 .../components/ui/table/table-footer.svelte | 0 .../lib/components/ui/table/table-head.svelte | 0 .../components/ui/table/table-header.svelte | 0 .../lib/components/ui/table/table-row.svelte | 0 .../src/lib/components/ui/table/table.svelte | 0 .../src/lib/components/ui/textarea/index.ts | 0 .../components/ui/textarea/textarea.svelte | 0 .../src/lib/components/ui/tooltip/index.ts | 0 .../ui/tooltip/tooltip-content.svelte | 0 .../src/lib/components/users/avatar.svelte | 0 .../lib/components/users/invite-user.svelte | 43 +- .../src/lib/components/users/invites.svelte | 0 .../src/lib/components/users/members.svelte | 86 + .../lib/components/users/user-email.svelte | 14 + .../src}/web/src/lib/humanize.ts | 0 .../src}/web/src/lib/services/user.ts | 0 .../admin => admin/src}/web/src/lib/store.ts | 9 +- admin/src/web/src/lib/types.d.ts | 86 + admin/src/web/src/lib/utils.ts | 72 + .../admin => admin/src}/web/src/main.ts | 0 .../src}/web/src/pages/app/app.svelte | 31 +- .../src}/web/src/pages/app/connections.svelte | 87 +- .../src}/web/src/pages/app/myaccount.svelte | 39 +- .../src}/web/src/pages/app/new-team.svelte | 0 .../src}/web/src/pages/app/notfound.svelte | 0 .../src}/web/src/pages/app/overview.svelte | 6 +- .../src}/web/src/pages/app/settings.svelte | 3 +- .../src}/web/src/pages/app/users.svelte | 4 +- .../src}/web/src/pages/home.svelte | 14 +- .../src}/web/src/pages/notfound.svelte | 0 .../src}/web/src/pages/setup.svelte | 0 .../admin => admin/src}/web/src/vite-env.d.ts | 0 .../admin => admin/src}/web/svelte.config.js | 0 .../src}/web/tailwind.config.js | 0 .../admin => admin/src}/web/tsconfig.json | 0 .../src}/web/tsconfig.node.json | 0 .../admin => admin/src}/web/vite.config.ts | 0 admin/t.py | 0 bun.lockb | Bin 100576 -> 0 bytes cmd/portr/config.go | 50 - cmd/portrd/main.go | 153 -- configs/server.yaml | 17 - docker-compose.dev.yaml | 35 + ...docker-compose.yaml => docker-compose.yaml | 33 +- docker/Dockerfile | 34 - docker/docker-compose.dev.yaml | 15 - go.mod | 71 - internal/server/admin/admin.go | 157 -- internal/server/admin/handler/auth.go | 77 - internal/server/admin/handler/config.go | 48 - internal/server/admin/handler/connection.go | 88 - internal/server/admin/handler/handler.go | 71 - internal/server/admin/handler/pagination.go | 12 - internal/server/admin/handler/settings.go | 27 - internal/server/admin/handler/team.go | 34 - internal/server/admin/handler/user.go | 57 - internal/server/admin/middleware.go | 68 - internal/server/admin/script.go | 46 - internal/server/admin/service/auth.go | 140 -- internal/server/admin/service/config.go | 20 - internal/server/admin/service/connection.go | 190 -- internal/server/admin/service/constants.go | 3 - internal/server/admin/service/service.go | 21 - internal/server/admin/service/settings.go | 29 - internal/server/admin/service/team.go | 120 - internal/server/admin/service/types.go | 62 - internal/server/admin/service/user.go | 201 -- internal/server/admin/static/logo.png | Bin 58783 -> 0 bytes .../web/src/lib/components/sidebarlink.svelte | 17 - .../src/lib/components/users/members.svelte | 52 - internal/server/admin/web/src/lib/types.d.ts | 84 - internal/server/admin/web/src/lib/utils.ts | 62 - internal/server/config/config.go | 155 -- internal/server/cron/tasks.go | 42 - internal/server/db/db.go | 66 - .../20231230090812_create_all_tables.sql | 80 - internal/server/db/migrator.go | 37 - internal/server/db/models/db.go | 31 - internal/server/db/models/models.go | 69 - internal/server/db/models/query.sql.go | 1300 ----------- internal/server/db/models/types.go | 6 - internal/server/db/schema.sql | 59 - internal/server/smtp/smtp.go | 40 - package.json | 7 - query.sql | 375 --- sqlc.yaml | 9 - .air.toml => tunnel/.air.toml | 12 +- tunnel/.dockerignore | 3 + tunnel/.gitignore | 2 + tunnel/Dockerfile | 19 + tunnel/cmd/portr/config.go | 22 + {cmd => tunnel/cmd}/portr/http.go | 0 {cmd => tunnel/cmd}/portr/main.go | 0 {cmd => tunnel/cmd}/portr/start.go | 0 {cmd => tunnel/cmd}/portr/tcp.go | 0 tunnel/cmd/portrd/main.go | 68 + {configs => tunnel/configs}/client.yaml | 2 +- tunnel/configs/server.yaml | 10 + tunnel/go.mod | 58 + go.sum => tunnel/go.sum | 132 +- .../internal}/client/client/client.go | 0 .../internal}/client/config/config.go | 2 +- {internal => tunnel/internal}/client/db/db.go | 2 +- .../internal}/client/ssh/ssh.go | 45 +- .../internal}/constants/constants.go | 0 tunnel/internal/server/config/config.go | 126 + .../internal}/server/cron/cron.go | 6 +- .../internal}/server/cron/ping.go | 18 +- tunnel/internal/server/cron/tasks.go | 42 + tunnel/internal/server/db/db.go | 39 + tunnel/internal/server/db/models.go | 36 + .../internal}/server/proxy/proxy.go | 0 tunnel/internal/server/service/service.go | 58 + .../internal}/server/ssh/sshd.go | 43 +- .../local-server-not-online.html | 0 .../unregistered-subdomain.html | 0 {internal => tunnel/internal}/utils/error.go | 0 {internal => tunnel/internal}/utils/http.go | 0 {internal => tunnel/internal}/utils/id.go | 0 .../internal}/utils/loading.go | 0 {internal => tunnel/internal}/utils/log.go | 0 {internal => tunnel/internal}/utils/port.go | 0 {internal => tunnel/internal}/utils/random.go | 0 .../internal}/utils/request.go | 0 {internal => tunnel/internal}/utils/string.go | 0 280 files changed, 5024 insertions(+), 4599 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 admin/.dockerignore create mode 100644 admin/.gitignore create mode 100644 admin/.python-version create mode 100644 admin/Dockerfile create mode 100644 admin/README.md create mode 100644 admin/pyproject.toml create mode 100644 admin/requirements-dev.lock create mode 100644 admin/requirements.lock create mode 100644 admin/requirements.txt create mode 100644 admin/scripts/pre-deploy.py create mode 100755 admin/scripts/start.sh create mode 100644 admin/src/portr_admin/__init__.py create mode 100644 admin/src/portr_admin/apis/__init__.py create mode 100644 admin/src/portr_admin/apis/pagination.py create mode 100644 admin/src/portr_admin/apis/security.py create mode 100644 admin/src/portr_admin/apis/v1/__init__.py create mode 100644 admin/src/portr_admin/apis/v1/auth.py create mode 100644 admin/src/portr_admin/apis/v1/connection.py create mode 100644 admin/src/portr_admin/apis/v1/settings.py create mode 100644 admin/src/portr_admin/apis/v1/team.py create mode 100644 admin/src/portr_admin/apis/v1/user.py create mode 100644 admin/src/portr_admin/beats.py create mode 100644 admin/src/portr_admin/config.py create mode 100644 admin/src/portr_admin/conftest.py create mode 100644 admin/src/portr_admin/db.py create mode 100644 admin/src/portr_admin/enums.py create mode 100644 admin/src/portr_admin/main.py create mode 100644 admin/src/portr_admin/model_mixins.py create mode 100644 admin/src/portr_admin/models/__init__.py create mode 100644 admin/src/portr_admin/models/auth.py create mode 100644 admin/src/portr_admin/models/connection.py create mode 100644 admin/src/portr_admin/models/settings.py create mode 100644 admin/src/portr_admin/models/user.py create mode 100644 admin/src/portr_admin/py.typed create mode 100644 admin/src/portr_admin/schemas/__init__.py create mode 100644 admin/src/portr_admin/schemas/connection.py create mode 100644 admin/src/portr_admin/schemas/settings.py create mode 100644 admin/src/portr_admin/schemas/team.py create mode 100644 admin/src/portr_admin/schemas/user.py create mode 100644 admin/src/portr_admin/services/__init__.py create mode 100644 admin/src/portr_admin/services/auth.py create mode 100644 admin/src/portr_admin/services/connection.py create mode 100644 admin/src/portr_admin/services/settings.py create mode 100644 admin/src/portr_admin/services/team.py create mode 100644 admin/src/portr_admin/services/user.py rename {internal/server/admin => admin/src/portr_admin}/static/favicon.svg (100%) rename {internal/server/admin => admin/src/portr_admin}/static/logo.svg (100%) rename {internal/server/admin => admin/src/portr_admin}/templates/index.html (91%) create mode 100644 admin/src/portr_admin/tests/__init__.py create mode 100644 admin/src/portr_admin/tests/api_tests/__init__.py create mode 100644 admin/src/portr_admin/tests/api_tests/test_auth.py create mode 100644 admin/src/portr_admin/tests/api_tests/test_connection.py create mode 100644 admin/src/portr_admin/tests/factories.py create mode 100644 admin/src/portr_admin/tests/service_tests/__init__.py create mode 100644 admin/src/portr_admin/tests/service_tests/test_connection_service.py create mode 100644 admin/src/portr_admin/utils/__init__.py create mode 100644 admin/src/portr_admin/utils/exception.py create mode 100644 admin/src/portr_admin/utils/github_auth.py create mode 100644 admin/src/portr_admin/utils/token.py create mode 100644 admin/src/portr_admin/utils/vite.py rename {internal/server/admin => admin/src}/web/.gitignore (100%) rename {internal/server/admin => admin/src}/web/components.json (100%) rename {internal/server/admin => admin/src}/web/index.html (100%) rename {internal/server/admin => admin/src}/web/package.json (97%) create mode 100644 admin/src/web/pnpm-lock.yaml rename {internal/server/admin => admin/src}/web/postcss.config.cjs (100%) rename {internal/server/admin => admin/src}/web/public/vite.svg (100%) rename {internal/server/admin => admin/src}/web/src/App.svelte (92%) rename {internal/server/admin => admin/src}/web/src/app.pcss (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ApiError.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ConnectionStatus.svelte (82%) rename {internal/server/admin => admin/src}/web/src/lib/components/ConnectionType.svelte (84%) rename {internal/server/admin => admin/src}/web/src/lib/components/DateField.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ErrorText.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/Pagination.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/copyToClipboard.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/data-table-skeleton.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/data-table.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/error.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/newteam.svelte (93%) rename {internal/server/admin => admin/src}/web/src/lib/components/settings/emailSettingsCard.svelte (89%) create mode 100644 admin/src/web/src/lib/components/sidebarlink.svelte rename {internal/server/admin => admin/src}/web/src/lib/components/team-selector.svelte (59%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert-dialog/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert/alert-description.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert/alert-title.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert/alert.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/alert/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/avatar/avatar-fallback.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/avatar/avatar-image.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/avatar/avatar.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/avatar/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/badge/badge.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/badge/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/button/button.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/button/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/card/card-content.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/card/card-description.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/card/card-footer.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/card/card-header.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/card/card-title.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/card/card.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/card/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/checkbox/checkbox.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/checkbox/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/dropdown-menu/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/input/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/input/input.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/label/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/label/label.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/pagination-content.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/pagination-ellipsis.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/pagination-item.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/pagination-link.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/pagination-next-button.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/pagination-prev-button.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/pagination/pagination.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/select/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/select/select-content.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/select/select-item.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/select/select-label.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/select/select-separator.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/select/select-trigger.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/separator/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/separator/separator.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/skeleton/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/skeleton/skeleton.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/switch/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/switch/switch.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table-body.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table-caption.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table-cell.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table-footer.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table-head.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table-header.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table-row.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/table/table.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/textarea/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/textarea/textarea.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/tooltip/index.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/ui/tooltip/tooltip-content.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/users/avatar.svelte (100%) rename {internal/server/admin => admin/src}/web/src/lib/components/users/invite-user.svelte (70%) rename {internal/server/admin => admin/src}/web/src/lib/components/users/invites.svelte (100%) create mode 100644 admin/src/web/src/lib/components/users/members.svelte create mode 100644 admin/src/web/src/lib/components/users/user-email.svelte rename {internal/server/admin => admin/src}/web/src/lib/humanize.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/services/user.ts (100%) rename {internal/server/admin => admin/src}/web/src/lib/store.ts (75%) create mode 100644 admin/src/web/src/lib/types.d.ts create mode 100644 admin/src/web/src/lib/utils.ts rename {internal/server/admin => admin/src}/web/src/main.ts (100%) rename {internal/server/admin => admin/src}/web/src/pages/app/app.svelte (86%) rename {internal/server/admin => admin/src}/web/src/pages/app/connections.svelte (63%) rename {internal/server/admin => admin/src}/web/src/pages/app/myaccount.svelte (77%) rename {internal/server/admin => admin/src}/web/src/pages/app/new-team.svelte (100%) rename {internal/server/admin => admin/src}/web/src/pages/app/notfound.svelte (100%) rename {internal/server/admin => admin/src}/web/src/pages/app/overview.svelte (95%) rename {internal/server/admin => admin/src}/web/src/pages/app/settings.svelte (81%) rename {internal/server/admin => admin/src}/web/src/pages/app/users.svelte (79%) rename {internal/server/admin => admin/src}/web/src/pages/home.svelte (85%) rename {internal/server/admin => admin/src}/web/src/pages/notfound.svelte (100%) rename {internal/server/admin => admin/src}/web/src/pages/setup.svelte (100%) rename {internal/server/admin => admin/src}/web/src/vite-env.d.ts (100%) rename {internal/server/admin => admin/src}/web/svelte.config.js (100%) rename {internal/server/admin => admin/src}/web/tailwind.config.js (100%) rename {internal/server/admin => admin/src}/web/tsconfig.json (100%) rename {internal/server/admin => admin/src}/web/tsconfig.node.json (100%) rename {internal/server/admin => admin/src}/web/vite.config.ts (100%) create mode 100644 admin/t.py delete mode 100755 bun.lockb delete mode 100644 cmd/portr/config.go delete mode 100644 cmd/portrd/main.go delete mode 100644 configs/server.yaml create mode 100644 docker-compose.dev.yaml rename docker/docker-compose.yaml => docker-compose.yaml (57%) delete mode 100644 docker/Dockerfile delete mode 100644 docker/docker-compose.dev.yaml delete mode 100644 go.mod delete mode 100644 internal/server/admin/admin.go delete mode 100644 internal/server/admin/handler/auth.go delete mode 100644 internal/server/admin/handler/config.go delete mode 100644 internal/server/admin/handler/connection.go delete mode 100644 internal/server/admin/handler/handler.go delete mode 100644 internal/server/admin/handler/pagination.go delete mode 100644 internal/server/admin/handler/settings.go delete mode 100644 internal/server/admin/handler/team.go delete mode 100644 internal/server/admin/handler/user.go delete mode 100644 internal/server/admin/middleware.go delete mode 100644 internal/server/admin/script.go delete mode 100644 internal/server/admin/service/auth.go delete mode 100644 internal/server/admin/service/config.go delete mode 100644 internal/server/admin/service/connection.go delete mode 100644 internal/server/admin/service/constants.go delete mode 100644 internal/server/admin/service/service.go delete mode 100644 internal/server/admin/service/settings.go delete mode 100644 internal/server/admin/service/team.go delete mode 100644 internal/server/admin/service/types.go delete mode 100644 internal/server/admin/service/user.go delete mode 100644 internal/server/admin/static/logo.png delete mode 100644 internal/server/admin/web/src/lib/components/sidebarlink.svelte delete mode 100644 internal/server/admin/web/src/lib/components/users/members.svelte delete mode 100644 internal/server/admin/web/src/lib/types.d.ts delete mode 100644 internal/server/admin/web/src/lib/utils.ts delete mode 100644 internal/server/config/config.go delete mode 100644 internal/server/cron/tasks.go delete mode 100644 internal/server/db/db.go delete mode 100644 internal/server/db/migrations/20231230090812_create_all_tables.sql delete mode 100644 internal/server/db/migrator.go delete mode 100644 internal/server/db/models/db.go delete mode 100644 internal/server/db/models/models.go delete mode 100644 internal/server/db/models/query.sql.go delete mode 100644 internal/server/db/models/types.go delete mode 100644 internal/server/db/schema.sql delete mode 100644 internal/server/smtp/smtp.go delete mode 100644 package.json delete mode 100644 query.sql delete mode 100644 sqlc.yaml rename .air.toml => tunnel/.air.toml (79%) create mode 100644 tunnel/.dockerignore create mode 100644 tunnel/.gitignore create mode 100644 tunnel/Dockerfile create mode 100644 tunnel/cmd/portr/config.go rename {cmd => tunnel/cmd}/portr/http.go (100%) rename {cmd => tunnel/cmd}/portr/main.go (100%) rename {cmd => tunnel/cmd}/portr/start.go (100%) rename {cmd => tunnel/cmd}/portr/tcp.go (100%) create mode 100644 tunnel/cmd/portrd/main.go rename {configs => tunnel/configs}/client.yaml (77%) create mode 100644 tunnel/configs/server.yaml create mode 100644 tunnel/go.mod rename go.sum => tunnel/go.sum (68%) rename {internal => tunnel/internal}/client/client/client.go (100%) rename {internal => tunnel/internal}/client/config/config.go (99%) rename {internal => tunnel/internal}/client/db/db.go (95%) rename {internal => tunnel/internal}/client/ssh/ssh.go (89%) rename {internal => tunnel/internal}/constants/constants.go (100%) create mode 100644 tunnel/internal/server/config/config.go rename {internal => tunnel/internal}/server/cron/cron.go (77%) rename {internal => tunnel/internal}/server/cron/ping.go (61%) create mode 100644 tunnel/internal/server/cron/tasks.go create mode 100644 tunnel/internal/server/db/db.go create mode 100644 tunnel/internal/server/db/models.go rename {internal => tunnel/internal}/server/proxy/proxy.go (100%) create mode 100644 tunnel/internal/server/service/service.go rename {internal => tunnel/internal}/server/ssh/sshd.go (76%) rename {internal => tunnel/internal}/utils/error-templates/local-server-not-online.html (100%) rename {internal => tunnel/internal}/utils/error-templates/unregistered-subdomain.html (100%) rename {internal => tunnel/internal}/utils/error.go (100%) rename {internal => tunnel/internal}/utils/http.go (100%) rename {internal => tunnel/internal}/utils/id.go (100%) rename {internal => tunnel/internal}/utils/loading.go (100%) rename {internal => tunnel/internal}/utils/log.go (100%) rename {internal => tunnel/internal}/utils/port.go (100%) rename {internal => tunnel/internal}/utils/random.go (100%) rename {internal => tunnel/internal}/utils/request.go (100%) rename {internal => tunnel/internal}/utils/string.go (100%) 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 0caddffa1d77c7027638fe9bebc3a0013f22541c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 100576 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 S%vp(jB-!7e 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 28f378a70d4509ed371111a821b42abdc4d8d48a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58783 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 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