From c8202a5c82942edde47f256d7f6b88162045872f Mon Sep 17 00:00:00 2001 From: texm Date: Tue, 25 Apr 2023 14:33:14 +0800 Subject: [PATCH] init from gitlab --- .gitignore | 11 + .gitlab-ci.yml | 23 + Dockerfile | 20 + Justfile | 164 + README.md | 4 +- TODO | 0 bootstrap.sh | 98 + go.mod | 58 + go.sum | 323 ++ internal/env/env.go | 41 + internal/env/mock.go | 42 + internal/models/auth.go | 22 + internal/models/dokku.go | 28 + internal/models/github.go | 21 + internal/models/server.go | 25 + internal/server/api/apps/builds.go | 103 + internal/server/api/apps/config.go | 38 + internal/server/api/apps/domains.go | 97 + internal/server/api/apps/management.go | 415 +++ internal/server/api/apps/networks.go | 84 + internal/server/api/apps/processes.go | 190 ++ internal/server/api/apps/routes.go | 70 + internal/server/api/apps/services.go | 60 + internal/server/api/apps/setup.go | 229 ++ internal/server/api/apps/storage.go | 107 + internal/server/api/auth/auth.go | 47 + internal/server/api/auth/github.go | 76 + internal/server/api/auth/password.go | 58 + internal/server/api/auth/routes.go | 16 + internal/server/api/command.go | 34 + internal/server/api/github.go | 58 + internal/server/api/routes.go | 26 + internal/server/api/routes_test.go | 57 + internal/server/api/services/backups.go | 209 ++ internal/server/api/services/management.go | 167 ++ internal/server/api/services/routes.go | 34 + internal/server/api/services/services.go | 220 ++ internal/server/api/settings/networks.go | 46 + internal/server/api/settings/registry.go | 75 + internal/server/api/settings/routes.go | 33 + internal/server/api/settings/settings.go | 156 + internal/server/api/setup/github.go | 111 + internal/server/api/setup/password.go | 101 + internal/server/api/setup/routes.go | 20 + internal/server/api/setup/setup.go | 65 + internal/server/auth.go | 43 + internal/server/auth/auth.go | 151 + internal/server/auth/cookies.go | 43 + internal/server/auth/github.go | 16 + internal/server/auth/none.go | 14 + internal/server/auth/password.go | 31 + internal/server/bootstrap.go | 60 + internal/server/commands/commands.go | 159 + internal/server/commands/execution.go | 72 + internal/server/config.go | 25 + internal/server/db/database.go | 38 + internal/server/db/logger.go | 70 + internal/server/dokku/dokku.go | 28 + internal/server/dokku/sync.go | 158 + internal/server/dto/apps.go | 325 ++ internal/server/dto/auth.go | 65 + internal/server/dto/command.go | 38 + internal/server/dto/dto.go | 117 + internal/server/dto/services.go | 114 + internal/server/dto/settings.go | 78 + internal/server/dto/setup.go | 6 + internal/server/github/app.go | 86 + internal/server/github/sync.go | 148 + internal/server/github/user.go | 56 + internal/server/middleware/auth.go | 75 + internal/server/middleware/middleware.go | 10 + internal/server/middleware/recover.go | 31 + internal/server/middleware/request_logger.go | 87 + internal/server/middleware/security.go | 30 + internal/server/middleware/setup.go | 59 + internal/server/middleware/static.go | 29 + internal/server/rand.go | 13 + internal/server/router.go | 67 + internal/server/secrets.go | 47 + internal/server/server.go | 75 + internal/server/setup.go | 72 + internal/server/websocket.go | 35 + ui/package-lock.json | 2626 +++++++++++++++++ ui/package.json | 32 + ui/postcss.config.cjs | 6 + ui/src/app.css | 7 + ui/src/app.html | 13 + ui/src/components/Header.svelte | 49 + .../commands/CommandExecution.svelte | 47 + .../commands/CommandExecutionWindow.svelte | 88 + ui/src/components/commands/Terminal.svelte | 94 + ui/src/components/common/Alert.svelte | 27 + ui/src/components/common/Card.svelte | 14 + ui/src/components/common/Cards.svelte | 3 + ui/src/components/common/CenterCard.svelte | 7 + ui/src/components/common/Code.svelte | 10 + .../common/ConfirmationModal.svelte | 53 + ui/src/components/common/ContentPage.svelte | 27 + ui/src/components/common/Error.svelte | 37 + ui/src/components/common/Icon.svelte | 114 + ui/src/components/common/KVEditor.svelte | 145 + ui/src/components/common/Loader.svelte | 105 + ui/src/components/common/Logs.svelte | 20 + ui/src/components/common/Modal.svelte | 45 + .../components/common/QueryDataWrapper.svelte | 15 + ui/src/components/common/Sidebar.svelte | 57 + ui/src/components/common/Steps.svelte | 92 + ui/src/components/common/Todo.svelte | 3 + .../components/common/devicons/Docker.svelte | 15 + .../components/common/devicons/Github.svelte | 18 + ui/src/components/common/devicons/Go.svelte | 19 + .../common/devicons/Javascript.svelte | 15 + .../components/common/devicons/MongoDB.svelte | 92 + .../components/common/devicons/MySQL.svelte | 15 + .../common/devicons/Postgres.svelte | 22 + .../components/common/devicons/Python.svelte | 15 + .../components/common/devicons/Redis.svelte | 36 + ui/src/components/common/devicons/Ruby.svelte | 17 + .../components/common/devicons/SQLite.svelte | 35 + .../components/dashboard/DashboardCard.svelte | 58 + .../dashboard/DashboardCardList.svelte | 59 + ui/src/components/links/LinkCard.svelte | 91 + ui/src/components/links/LinkModal.svelte | 51 + .../links/link-configs/Generic.svelte | 32 + .../components/processes/ProcessCard.svelte | 119 + .../processes/ProcessDeploymentEditor.svelte | 72 + .../processes/ProcessResource.svelte | 55 + .../processes/ProcessResourceEditor.svelte | 97 + .../processes/ProcessResourceView.svelte | 54 + .../processes/ProcessScaleSelector.svelte | 72 + .../processes/ProcessesOverview.svelte | 64 + .../service-pages/redis/RedisIndex.svelte | 1 + ui/src/components/totp/TotpSetupButton.svelte | 134 + ui/src/lib/api.js | 6 + ui/src/lib/apis/apps.js | 287 ++ ui/src/lib/apis/auth.js | 40 + ui/src/lib/apis/client.js | 14 + ui/src/lib/apis/exec.js | 29 + ui/src/lib/apis/services.js | 137 + ui/src/lib/apis/settings.js | 88 + ui/src/lib/apis/setup.js | 60 + ui/src/lib/auth.js | 95 + ui/src/lib/stores.js | 87 + ui/src/routes/+layout.js | 38 + ui/src/routes/+layout.svelte | 82 + ui/src/routes/+page.svelte | 37 + ui/src/routes/app/+page.svelte | 5 + ui/src/routes/app/[name]/+layout.svelte | 61 + ui/src/routes/app/[name]/+page.svelte | 74 + ui/src/routes/app/[name]/AppHeader.svelte | 111 + .../app/[name]/AppHeaderIconButton.svelte | 21 + ui/src/routes/app/[name]/builds/+page.svelte | 27 + .../app/[name]/builds/BuildDirectory.svelte | 88 + .../app/[name]/builds/BuilderSelection.svelte | 49 + .../app/[name]/builds/DeployChecks.svelte | 60 + .../[name]/builds/ProcessDeployChecks.svelte | 62 + .../app/[name]/builds/SetupConfig.svelte | 54 + ui/src/routes/app/[name]/cron/+page.svelte | 5 + ui/src/routes/app/[name]/domains/+page.svelte | 75 + .../app/[name]/domains/AddDomainModal.svelte | 39 + .../app/[name]/domains/DomainListItem.svelte | 49 + .../app/[name]/environment/+page.svelte | 47 + ui/src/routes/app/[name]/logs/+page.svelte | 22 + ui/src/routes/app/[name]/network/+page.svelte | 58 + .../app/[name]/network/AppNetworks.svelte | 154 + .../app/[name]/network/NetworkSelect.svelte | 40 + .../routes/app/[name]/services/+page.svelte | 135 + .../routes/app/[name]/settings/+page.svelte | 16 + .../[name]/settings/DestroyAppButton.svelte | 50 + .../[name]/settings/RenameAppButton.svelte | 63 + ui/src/routes/app/[name]/setup/+page.svelte | 79 + .../app/[name]/setup/configs/Archive.svelte | 62 + .../[name]/setup/configs/DockerImage.svelte | 23 + .../app/[name]/setup/configs/GitNew.svelte | 35 + .../app/[name]/setup/configs/GitSync.svelte | 75 + .../app/[name]/setup/steps/Configure.svelte | 30 + .../app/[name]/setup/steps/Confirm.svelte | 40 + .../app/[name]/setup/steps/Select.svelte | 26 + ui/src/routes/app/[name]/storage/+page.svelte | 74 + .../[name]/storage/CreateStorageModal.svelte | 84 + .../app/[name]/storage/StorageMount.svelte | 77 + .../routes/app/[name]/terminal/+page.svelte | 74 + ui/src/routes/app/new/+page.svelte | 55 + ui/src/routes/login/+layout.svelte | 7 + ui/src/routes/login/+page.js | 13 + ui/src/routes/login/github/+page.svelte | 41 + .../routes/login/github/callback/+page.svelte | 52 + ui/src/routes/login/password/+page.svelte | 87 + ui/src/routes/logout/+page.js | 12 + ui/src/routes/logout/+page.svelte | 5 + ui/src/routes/service/+page.svelte | 5 + ui/src/routes/service/[name]/+layout.js | 13 + ui/src/routes/service/[name]/+layout.svelte | 69 + ui/src/routes/service/[name]/+page.svelte | 25 + .../service/[name]/ServiceHeader.svelte | 82 + .../routes/service/[name]/apps/+page.svelte | 125 + .../service/[name]/backups/+page.svelte | 61 + .../service/[name]/backups/BackupAuth.svelte | 124 + .../[name]/backups/BackupBucket.svelte | 41 + .../[name]/backups/BackupButton.svelte | 31 + .../[name]/backups/BackupEncryption.svelte | 95 + .../[name]/backups/BackupSchedule.svelte | 83 + .../routes/service/[name]/logs/+page.svelte | 30 + .../service/[name]/settings/+page.svelte | 19 + .../[name]/settings/CloneServiceButton.svelte | 69 + .../settings/DestroyServiceButton.svelte | 53 + ui/src/routes/service/new/+page.svelte | 49 + .../new/configs/DefaultServiceConfig.svelte | 82 + .../new/configs/LitestreamConfig.svelte | 5 + .../ServiceConfigEnvVarsOption.svelte | 24 + .../ServiceConfigOverrideOption.svelte | 53 + .../service/new/steps/ConfigureService.svelte | 54 + .../routes/service/new/steps/Confirm.svelte | 28 + .../service/new/steps/SelectService.svelte | 45 + ui/src/routes/settings/+layout.js | 4 + ui/src/routes/settings/+layout.svelte | 26 + ui/src/routes/settings/+page.svelte | 46 + ui/src/routes/settings/docker/+page.svelte | 35 + .../docker/DockerRegistryButton.svelte | 86 + ui/src/routes/settings/domains/+page.svelte | 82 + .../settings/domains/DomainListItem.svelte | 53 + ui/src/routes/settings/git/+page.svelte | 11 + .../routes/settings/git/GitAuthButton.svelte | 81 + .../settings/git/GitRemoveAuthButton.svelte | 54 + ui/src/routes/settings/plugins/+page.svelte | 29 + ui/src/routes/settings/plugins/Plugin.svelte | 15 + ui/src/routes/settings/scheduler/+page.svelte | 28 + .../scheduler/DockerLocalSettings.svelte | 0 ui/src/routes/settings/users/+page.svelte | 44 + ui/src/routes/settings/users/SSHKey.svelte | 34 + ui/src/routes/settings/users/UserCard.svelte | 25 + ui/src/routes/setup/+layout.js | 12 + ui/src/routes/setup/+layout.svelte | 7 + ui/src/routes/setup/+page.svelte | 64 + ui/src/routes/setup/github/+page.js | 8 + ui/src/routes/setup/github/+page.svelte | 78 + .../routes/setup/github/created/+page.svelte | 40 + ui/src/routes/setup/github/install/+page.js | 10 + .../setup/github/installed/+page.svelte | 44 + ui/src/routes/setup/password/+page.svelte | 94 + ui/static/favicon.png | Bin 0 -> 1571 bytes ui/svelte.config.js | 14 + ui/tailwind.config.cjs | 29 + ui/vite.config.js | 30 + web/.gitignore | 10 + web/.npmrc | 1 + web/package-lock.json | 1980 +++++++++++++ web/package.json | 25 + web/postcss.config.cjs | 6 + web/src/app.css | 3 + web/src/app.html | 12 + web/src/components/Hint.svelte | 6 + web/src/components/Navbar.svelte | 30 + web/src/routes/+layout.js | 4 + web/src/routes/+layout.svelte | 9 + web/src/routes/+page.svelte | 19 + web/src/routes/Features.svelte | 22 + web/src/routes/Hero.svelte | 33 + web/src/routes/Icons.svelte | 67 + web/src/routes/Screenshots.svelte | 15 + web/src/routes/docs/+layout.svelte | 87 + web/src/routes/docs/+page.js | 6 + web/src/routes/docs/ProgressButtons.svelte | 35 + web/src/routes/docs/apps/+page.svelte | 42 + web/src/routes/docs/installation/+page.svelte | 89 + web/src/routes/docs/intro/+page.svelte | 38 + web/src/routes/docs/services/+page.svelte | 50 + web/src/routes/docs/settings/+page.svelte | 1 + web/src/routes/docs/setup/+page.svelte | 49 + web/src/routes/docs/support/+page.svelte | 10 + web/src/routes/docs/uninstall/+page.svelte | 31 + web/src/routes/docs/upgrading/+page.svelte | 22 + web/static/.nojekyll | 0 web/static/favicon.png | Bin 0 -> 1571 bytes web/static/images/app-overview.webp | Bin 0 -> 15112 bytes web/static/images/dashboard-dark.webp | Bin 0 -> 10426 bytes web/static/images/dashboard-light.webp | Bin 0 -> 12130 bytes web/static/images/service-overview.webp | Bin 0 -> 11580 bytes web/svelte.config.js | 22 + web/tailwind.config.cjs | 13 + web/vite.config.js | 14 + 281 files changed, 19861 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 Justfile create mode 100644 TODO create mode 100644 bootstrap.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/env/env.go create mode 100644 internal/env/mock.go create mode 100644 internal/models/auth.go create mode 100644 internal/models/dokku.go create mode 100644 internal/models/github.go create mode 100644 internal/models/server.go create mode 100644 internal/server/api/apps/builds.go create mode 100644 internal/server/api/apps/config.go create mode 100644 internal/server/api/apps/domains.go create mode 100644 internal/server/api/apps/management.go create mode 100644 internal/server/api/apps/networks.go create mode 100644 internal/server/api/apps/processes.go create mode 100644 internal/server/api/apps/routes.go create mode 100644 internal/server/api/apps/services.go create mode 100644 internal/server/api/apps/setup.go create mode 100644 internal/server/api/apps/storage.go create mode 100644 internal/server/api/auth/auth.go create mode 100644 internal/server/api/auth/github.go create mode 100644 internal/server/api/auth/password.go create mode 100644 internal/server/api/auth/routes.go create mode 100644 internal/server/api/command.go create mode 100644 internal/server/api/github.go create mode 100644 internal/server/api/routes.go create mode 100644 internal/server/api/routes_test.go create mode 100644 internal/server/api/services/backups.go create mode 100644 internal/server/api/services/management.go create mode 100644 internal/server/api/services/routes.go create mode 100644 internal/server/api/services/services.go create mode 100644 internal/server/api/settings/networks.go create mode 100644 internal/server/api/settings/registry.go create mode 100644 internal/server/api/settings/routes.go create mode 100644 internal/server/api/settings/settings.go create mode 100644 internal/server/api/setup/github.go create mode 100644 internal/server/api/setup/password.go create mode 100644 internal/server/api/setup/routes.go create mode 100644 internal/server/api/setup/setup.go create mode 100644 internal/server/auth.go create mode 100644 internal/server/auth/auth.go create mode 100644 internal/server/auth/cookies.go create mode 100644 internal/server/auth/github.go create mode 100644 internal/server/auth/none.go create mode 100644 internal/server/auth/password.go create mode 100644 internal/server/bootstrap.go create mode 100644 internal/server/commands/commands.go create mode 100644 internal/server/commands/execution.go create mode 100644 internal/server/config.go create mode 100644 internal/server/db/database.go create mode 100644 internal/server/db/logger.go create mode 100644 internal/server/dokku/dokku.go create mode 100644 internal/server/dokku/sync.go create mode 100644 internal/server/dto/apps.go create mode 100644 internal/server/dto/auth.go create mode 100644 internal/server/dto/command.go create mode 100644 internal/server/dto/dto.go create mode 100644 internal/server/dto/services.go create mode 100644 internal/server/dto/settings.go create mode 100644 internal/server/dto/setup.go create mode 100644 internal/server/github/app.go create mode 100644 internal/server/github/sync.go create mode 100644 internal/server/github/user.go create mode 100644 internal/server/middleware/auth.go create mode 100644 internal/server/middleware/middleware.go create mode 100644 internal/server/middleware/recover.go create mode 100644 internal/server/middleware/request_logger.go create mode 100644 internal/server/middleware/security.go create mode 100644 internal/server/middleware/setup.go create mode 100644 internal/server/middleware/static.go create mode 100644 internal/server/rand.go create mode 100644 internal/server/router.go create mode 100644 internal/server/secrets.go create mode 100644 internal/server/server.go create mode 100644 internal/server/setup.go create mode 100644 internal/server/websocket.go create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/postcss.config.cjs create mode 100644 ui/src/app.css create mode 100644 ui/src/app.html create mode 100644 ui/src/components/Header.svelte create mode 100644 ui/src/components/commands/CommandExecution.svelte create mode 100644 ui/src/components/commands/CommandExecutionWindow.svelte create mode 100644 ui/src/components/commands/Terminal.svelte create mode 100644 ui/src/components/common/Alert.svelte create mode 100644 ui/src/components/common/Card.svelte create mode 100644 ui/src/components/common/Cards.svelte create mode 100644 ui/src/components/common/CenterCard.svelte create mode 100644 ui/src/components/common/Code.svelte create mode 100644 ui/src/components/common/ConfirmationModal.svelte create mode 100644 ui/src/components/common/ContentPage.svelte create mode 100644 ui/src/components/common/Error.svelte create mode 100644 ui/src/components/common/Icon.svelte create mode 100644 ui/src/components/common/KVEditor.svelte create mode 100644 ui/src/components/common/Loader.svelte create mode 100644 ui/src/components/common/Logs.svelte create mode 100644 ui/src/components/common/Modal.svelte create mode 100644 ui/src/components/common/QueryDataWrapper.svelte create mode 100644 ui/src/components/common/Sidebar.svelte create mode 100644 ui/src/components/common/Steps.svelte create mode 100644 ui/src/components/common/Todo.svelte create mode 100644 ui/src/components/common/devicons/Docker.svelte create mode 100644 ui/src/components/common/devicons/Github.svelte create mode 100644 ui/src/components/common/devicons/Go.svelte create mode 100644 ui/src/components/common/devicons/Javascript.svelte create mode 100644 ui/src/components/common/devicons/MongoDB.svelte create mode 100644 ui/src/components/common/devicons/MySQL.svelte create mode 100644 ui/src/components/common/devicons/Postgres.svelte create mode 100644 ui/src/components/common/devicons/Python.svelte create mode 100644 ui/src/components/common/devicons/Redis.svelte create mode 100644 ui/src/components/common/devicons/Ruby.svelte create mode 100644 ui/src/components/common/devicons/SQLite.svelte create mode 100644 ui/src/components/dashboard/DashboardCard.svelte create mode 100644 ui/src/components/dashboard/DashboardCardList.svelte create mode 100644 ui/src/components/links/LinkCard.svelte create mode 100644 ui/src/components/links/LinkModal.svelte create mode 100644 ui/src/components/links/link-configs/Generic.svelte create mode 100644 ui/src/components/processes/ProcessCard.svelte create mode 100644 ui/src/components/processes/ProcessDeploymentEditor.svelte create mode 100644 ui/src/components/processes/ProcessResource.svelte create mode 100644 ui/src/components/processes/ProcessResourceEditor.svelte create mode 100644 ui/src/components/processes/ProcessResourceView.svelte create mode 100644 ui/src/components/processes/ProcessScaleSelector.svelte create mode 100644 ui/src/components/processes/ProcessesOverview.svelte create mode 100644 ui/src/components/service-pages/redis/RedisIndex.svelte create mode 100644 ui/src/components/totp/TotpSetupButton.svelte create mode 100644 ui/src/lib/api.js create mode 100644 ui/src/lib/apis/apps.js create mode 100644 ui/src/lib/apis/auth.js create mode 100644 ui/src/lib/apis/client.js create mode 100644 ui/src/lib/apis/exec.js create mode 100644 ui/src/lib/apis/services.js create mode 100644 ui/src/lib/apis/settings.js create mode 100644 ui/src/lib/apis/setup.js create mode 100644 ui/src/lib/auth.js create mode 100644 ui/src/lib/stores.js create mode 100644 ui/src/routes/+layout.js create mode 100644 ui/src/routes/+layout.svelte create mode 100644 ui/src/routes/+page.svelte create mode 100644 ui/src/routes/app/+page.svelte create mode 100644 ui/src/routes/app/[name]/+layout.svelte create mode 100644 ui/src/routes/app/[name]/+page.svelte create mode 100644 ui/src/routes/app/[name]/AppHeader.svelte create mode 100644 ui/src/routes/app/[name]/AppHeaderIconButton.svelte create mode 100644 ui/src/routes/app/[name]/builds/+page.svelte create mode 100644 ui/src/routes/app/[name]/builds/BuildDirectory.svelte create mode 100644 ui/src/routes/app/[name]/builds/BuilderSelection.svelte create mode 100644 ui/src/routes/app/[name]/builds/DeployChecks.svelte create mode 100644 ui/src/routes/app/[name]/builds/ProcessDeployChecks.svelte create mode 100644 ui/src/routes/app/[name]/builds/SetupConfig.svelte create mode 100644 ui/src/routes/app/[name]/cron/+page.svelte create mode 100644 ui/src/routes/app/[name]/domains/+page.svelte create mode 100644 ui/src/routes/app/[name]/domains/AddDomainModal.svelte create mode 100644 ui/src/routes/app/[name]/domains/DomainListItem.svelte create mode 100644 ui/src/routes/app/[name]/environment/+page.svelte create mode 100644 ui/src/routes/app/[name]/logs/+page.svelte create mode 100644 ui/src/routes/app/[name]/network/+page.svelte create mode 100644 ui/src/routes/app/[name]/network/AppNetworks.svelte create mode 100644 ui/src/routes/app/[name]/network/NetworkSelect.svelte create mode 100644 ui/src/routes/app/[name]/services/+page.svelte create mode 100644 ui/src/routes/app/[name]/settings/+page.svelte create mode 100644 ui/src/routes/app/[name]/settings/DestroyAppButton.svelte create mode 100644 ui/src/routes/app/[name]/settings/RenameAppButton.svelte create mode 100644 ui/src/routes/app/[name]/setup/+page.svelte create mode 100644 ui/src/routes/app/[name]/setup/configs/Archive.svelte create mode 100644 ui/src/routes/app/[name]/setup/configs/DockerImage.svelte create mode 100644 ui/src/routes/app/[name]/setup/configs/GitNew.svelte create mode 100644 ui/src/routes/app/[name]/setup/configs/GitSync.svelte create mode 100644 ui/src/routes/app/[name]/setup/steps/Configure.svelte create mode 100644 ui/src/routes/app/[name]/setup/steps/Confirm.svelte create mode 100644 ui/src/routes/app/[name]/setup/steps/Select.svelte create mode 100644 ui/src/routes/app/[name]/storage/+page.svelte create mode 100644 ui/src/routes/app/[name]/storage/CreateStorageModal.svelte create mode 100644 ui/src/routes/app/[name]/storage/StorageMount.svelte create mode 100644 ui/src/routes/app/[name]/terminal/+page.svelte create mode 100644 ui/src/routes/app/new/+page.svelte create mode 100644 ui/src/routes/login/+layout.svelte create mode 100644 ui/src/routes/login/+page.js create mode 100644 ui/src/routes/login/github/+page.svelte create mode 100644 ui/src/routes/login/github/callback/+page.svelte create mode 100644 ui/src/routes/login/password/+page.svelte create mode 100644 ui/src/routes/logout/+page.js create mode 100644 ui/src/routes/logout/+page.svelte create mode 100644 ui/src/routes/service/+page.svelte create mode 100644 ui/src/routes/service/[name]/+layout.js create mode 100644 ui/src/routes/service/[name]/+layout.svelte create mode 100644 ui/src/routes/service/[name]/+page.svelte create mode 100644 ui/src/routes/service/[name]/ServiceHeader.svelte create mode 100644 ui/src/routes/service/[name]/apps/+page.svelte create mode 100644 ui/src/routes/service/[name]/backups/+page.svelte create mode 100644 ui/src/routes/service/[name]/backups/BackupAuth.svelte create mode 100644 ui/src/routes/service/[name]/backups/BackupBucket.svelte create mode 100644 ui/src/routes/service/[name]/backups/BackupButton.svelte create mode 100644 ui/src/routes/service/[name]/backups/BackupEncryption.svelte create mode 100644 ui/src/routes/service/[name]/backups/BackupSchedule.svelte create mode 100644 ui/src/routes/service/[name]/logs/+page.svelte create mode 100644 ui/src/routes/service/[name]/settings/+page.svelte create mode 100644 ui/src/routes/service/[name]/settings/CloneServiceButton.svelte create mode 100644 ui/src/routes/service/[name]/settings/DestroyServiceButton.svelte create mode 100644 ui/src/routes/service/new/+page.svelte create mode 100644 ui/src/routes/service/new/configs/DefaultServiceConfig.svelte create mode 100644 ui/src/routes/service/new/configs/LitestreamConfig.svelte create mode 100644 ui/src/routes/service/new/configs/config-components/ServiceConfigEnvVarsOption.svelte create mode 100644 ui/src/routes/service/new/configs/config-components/ServiceConfigOverrideOption.svelte create mode 100644 ui/src/routes/service/new/steps/ConfigureService.svelte create mode 100644 ui/src/routes/service/new/steps/Confirm.svelte create mode 100644 ui/src/routes/service/new/steps/SelectService.svelte create mode 100644 ui/src/routes/settings/+layout.js create mode 100644 ui/src/routes/settings/+layout.svelte create mode 100644 ui/src/routes/settings/+page.svelte create mode 100644 ui/src/routes/settings/docker/+page.svelte create mode 100644 ui/src/routes/settings/docker/DockerRegistryButton.svelte create mode 100644 ui/src/routes/settings/domains/+page.svelte create mode 100644 ui/src/routes/settings/domains/DomainListItem.svelte create mode 100644 ui/src/routes/settings/git/+page.svelte create mode 100644 ui/src/routes/settings/git/GitAuthButton.svelte create mode 100644 ui/src/routes/settings/git/GitRemoveAuthButton.svelte create mode 100644 ui/src/routes/settings/plugins/+page.svelte create mode 100644 ui/src/routes/settings/plugins/Plugin.svelte create mode 100644 ui/src/routes/settings/scheduler/+page.svelte create mode 100644 ui/src/routes/settings/scheduler/DockerLocalSettings.svelte create mode 100644 ui/src/routes/settings/users/+page.svelte create mode 100644 ui/src/routes/settings/users/SSHKey.svelte create mode 100644 ui/src/routes/settings/users/UserCard.svelte create mode 100644 ui/src/routes/setup/+layout.js create mode 100644 ui/src/routes/setup/+layout.svelte create mode 100644 ui/src/routes/setup/+page.svelte create mode 100644 ui/src/routes/setup/github/+page.js create mode 100644 ui/src/routes/setup/github/+page.svelte create mode 100644 ui/src/routes/setup/github/created/+page.svelte create mode 100644 ui/src/routes/setup/github/install/+page.js create mode 100644 ui/src/routes/setup/github/installed/+page.svelte create mode 100644 ui/src/routes/setup/password/+page.svelte create mode 100644 ui/static/favicon.png create mode 100644 ui/svelte.config.js create mode 100644 ui/tailwind.config.cjs create mode 100644 ui/vite.config.js create mode 100644 web/.gitignore create mode 100644 web/.npmrc create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.cjs create mode 100644 web/src/app.css create mode 100644 web/src/app.html create mode 100644 web/src/components/Hint.svelte create mode 100644 web/src/components/Navbar.svelte create mode 100644 web/src/routes/+layout.js create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/Features.svelte create mode 100644 web/src/routes/Hero.svelte create mode 100644 web/src/routes/Icons.svelte create mode 100644 web/src/routes/Screenshots.svelte create mode 100644 web/src/routes/docs/+layout.svelte create mode 100644 web/src/routes/docs/+page.js create mode 100644 web/src/routes/docs/ProgressButtons.svelte create mode 100644 web/src/routes/docs/apps/+page.svelte create mode 100644 web/src/routes/docs/installation/+page.svelte create mode 100644 web/src/routes/docs/intro/+page.svelte create mode 100644 web/src/routes/docs/services/+page.svelte create mode 100644 web/src/routes/docs/settings/+page.svelte create mode 100644 web/src/routes/docs/setup/+page.svelte create mode 100644 web/src/routes/docs/support/+page.svelte create mode 100644 web/src/routes/docs/uninstall/+page.svelte create mode 100644 web/src/routes/docs/upgrading/+page.svelte create mode 100644 web/static/.nojekyll create mode 100644 web/static/favicon.png create mode 100644 web/static/images/app-overview.webp create mode 100644 web/static/images/dashboard-dark.webp create mode 100644 web/static/images/dashboard-light.webp create mode 100644 web/static/images/service-overview.webp create mode 100644 web/svelte.config.js create mode 100644 web/tailwind.config.cjs create mode 100644 web/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..910d051 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store + +ui/node_modules/ +ui/.svelte-kit +ui/dist + +.env +.test_keyfile +test.db +shokku +.idea diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..11be423 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,23 @@ +docker-build: + image: docker:latest + stage: build + services: + - docker:dind + before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - | + if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then + tag="" + echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'" + else + tag=":$CI_COMMIT_REF_SLUG" + echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag" + fi + - docker build --pull -t "$CI_REGISTRY_IMAGE${tag}" . + - docker push "$CI_REGISTRY_IMAGE${tag}" + rules: + - if: $CI_COMMIT_BRANCH + exists: + - Dockerfile + when: manual \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..666d0fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:alpine AS npm_builder +WORKDIR /app +COPY ui ./ui +RUN npm --prefix /app/ui install +RUN npm --prefix /app/ui run build + +FROM golang:alpine AS go_builder +WORKDIR /app +RUN apk update && apk add --no-cache git +COPY cmd ./cmd +COPY go.mod ./go.mod +COPY internal ./internal +COPY --from=npm_builder /app/ui/dist ./cmd/shokku/dist +RUN go get -d -v ./... +RUN go install -v ./... +RUN GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/shokku ./cmd/shokku + +FROM gcr.io/distroless/static:nonroot +COPY --from=go_builder /go/bin/shokku /go/bin/shokku +ENTRYPOINT ["/go/bin/shokku"] \ No newline at end of file diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..6db7800 --- /dev/null +++ b/Justfile @@ -0,0 +1,164 @@ +set shell := ["bash", "-uc"] + +ui_npm := 'npm --prefix ui' +web_npm := 'npm --prefix web' +shokku_image := 'texm/shokku:latest' +dokku_image := 'dokku/dokku:0.30.2' +dokku_container_name := 'shokku-dokku-dev' +dokku_data_mount_dir := '/tmp/var/lib/dokku' +dokku_ssh_host := '127.0.0.1' +dokku_ssh_port := '3022' +db_path := 'test.db' + +host_keyfile := ".test_keyfile" +container_keyfile := '/home/dokku/.ssh/authorized_keys' + +reflex_script := " +# Run dev server +-s -r '\\.go$' -R '^ui/' -R '^\\.git$' -- just dev-backend + +# Run ui dev server +# Exclude everything since vite will hot reload +-s -R '^.*' -- just dev-ui" + +_default: + @just --list + +### +# Public recipes +### + +# clean up resources from the backend and docker +@clean: _clean-backend _clean-docker + +# setup dependencies, bootstrap backend, create dokku container +@setup: clean _install-dependencies _setup-dokku _setup-backend + +# run the development environment (ui and backend) +@dev: + -echo -n '{{reflex_script}}' | reflex -d 'fancy' -c '-' + +@dev-backend: _touch-cmd-dist _setup-dokku + just _run-with-env go run ./cmd/shokku + +@dev-ui: + -{{ui_npm}} run dev + +# build a static /cmd/shokku binary +@build: + {{ui_npm}} run build + mv ./ui/dist ./cmd/shokku + # GOOS=linux GOARCH=amd64 go build -o shokku ./cmd/shokku + -go build -o shokku ./cmd/shokku + -rm -r ./cmd/shokku/dist + +@format: + go fmt ./... + {{ui_npm}} run prettier + +# run the website development server +@dev-web: + {{web_npm}} install + {{web_npm}} run dev + +# build the docker image and tag as latest +build-docker: + docker build -t "{{shokku_image}}" . + +# run the docker image 'texm/shokku:latest' with environment vars set +run-docker: + @docker run -d \ + -e DOKKU_SSH_HOST='{{dokku_ssh_host}}' \ + -e DOKKU_SSH_PORT='{{dokku_ssh_port}}' \ + -e DB_PATH='{{db_path}}' \ + {{shokku_image}} + +### +# Private helper recipes +### + +## dependencies +@_install-dependencies: + go install github.com/cespare/reflex@latest + {{ui_npm}} install > /dev/null + go mod tidy +## + +## helpers +@_touch-cmd-dist: + mkdir -p ./cmd/shokku/dist && touch ./cmd/shokku/dist/bleh + +@_run-with-env +CMD: + DOKKU_SSH_HOST='{{dokku_ssh_host}}' \ + DOKKU_SSH_PORT='{{dokku_ssh_port}}' \ + DB_PATH='{{db_path}}' \ + DEBUG_MODE=true \ + {{CMD}} +## + +## builds & cleaning +@_clean-backend: + go clean + -rm -r ./cmd/shokku/dist &> /dev/null + -rm {{db_path}} &> /dev/null + -rm {{host_keyfile}} &> /dev/null + +@_clean-docker: + -docker rm -f {{dokku_container_name}} &> /dev/null + -sudo rm -r {{dokku_data_mount_dir}} &> /dev/null +## + +## Setup + +@_setup-backend: _touch-cmd-dist + just _run-with-env go run ./cmd/shokku bootstrap > {{host_keyfile}} + just _add-dokku-ssh-key; + +_setup-dokku: + #!/bin/bash + if [[ -z $(docker ps -a | grep '{{dokku_container_name}}') ]]; then + just _run-dokku-container; + just _install-dokku-plugins; + elif [[ `docker container inspect -f '{{{{.State.Running}}' '{{dokku_container_name}}'` == 'false' ]]; then + docker start {{dokku_container_name}}; + fi + +@_run-dokku-container: + docker run -d \ + --env DOKKU_HOSTNAME=dokku.me \ + --env DOKKU_HOST_ROOT={{dokku_data_mount_dir}}/home/dokku \ + --env DOKKU_LIB_HOST_ROOT={{dokku_data_mount_dir}}/var/lib/dokku \ + --name {{dokku_container_name}} \ + --publish {{dokku_ssh_port}}:22 \ + --volume {{dokku_data_mount_dir}}:/mnt/dokku \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + {{dokku_image}} > /dev/null + + if [[ "{{ os() }}" == 'macos' ]]; then \ + docker exec shokku-dokku-dev bash -c \ + 'groupmod -g 99 systemd-timesync && groupmod -g 101 docker'; fi + +@_install-dokku-plugins: + echo "installing dokku plugins" + for plugin in redis postgres mongo mysql letsencrypt; do \ + echo "installing $plugin"; \ + docker exec {{dokku_container_name}} bash -c \ + "dokku plugin:install https://github.com/dokku/dokku-$plugin.git" \ + > /dev/null; \ + done + + # to fix plugins: https://github.com/dokku/dokku/issues/5004 + # -docker exec {{dokku_container_name}} bash -c "rm -r /mnt/dokku/services" 2&> /dev/null + # -docker exec {{dokku_container_name}} bash -c "mv /var/lib/dokku/services /mnt/dokku/" + # -docker exec {{dokku_container_name}} bash -c "ln -s /mnt/dokku/services/ /var/lib/dokku/services" + +@_add-dokku-ssh-key: + docker exec {{dokku_container_name}} bash -c \ + 'cd /home/ \ + && rm -f {{container_keyfile}} \ + && touch {{container_keyfile}} \ + && chown dokku:dokku {{container_keyfile}}' + + docker exec {{dokku_container_name}} bash -c \ + "echo '$(cat {{host_keyfile}})' | dokku ssh-keys:add admin" > /dev/null +## \ No newline at end of file diff --git a/README.md b/README.md index 93e5790..0a2112b 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# shokku \ No newline at end of file +# shokku + +a web frontend for dokku \ No newline at end of file diff --git a/TODO b/TODO new file mode 100644 index 0000000..e69de29 diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100644 index 0000000..d91785d --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,98 @@ +#!/bin/bash +set -e + +# This script installs and sets up shokku with a few prerequisites: +# dokku must be installed +# a global domain must be set +# letsencrypt must be configured and enabled with global email set + +DOKKU_STORAGE_DIR="/var/lib/dokku/data/storage/" +SHOKKU_DATA_DIR="$DOKKU_STORAGE_DIR/shokku" +SHOKKU_APP_DATA_MOUNT_PATH="$SHOKKU_DATA_DIR:/data" +SHOKKU_VERSION=${SHOKKU_VERSION:-"latest"} +SHOKKU_IMAGE="registry.gitlab.com/texm/shokku:$SHOKKU_VERSION" + +SHOKKU_DOKKU_USER="shokkuadmin" +DISTROLESS_NONROOT_UID="65532" + +clean-shokku() { + echo "=> checking for existing resources" + + if dokku apps:exists shokku &> /dev/null; then + echo "==> destroying old dokku app" + dokku apps:destroy --force shokku &> /dev/null + fi + + if dokku ssh-keys:list "$SHOKKU_DOKKU_USER" &> /dev/null; then + echo "==> removing existing dokku ssh key" + dokku ssh-keys:remove $SHOKKU_DOKKU_USER; + fi +} + +create-shokku-app() { + clean-shokku + echo "=> pulling version '$SHOKKU_VERSION'" + HOST_SSH_PORT=$(grep "Port " /etc/ssh/sshd_config | awk '{ print $2 }') + docker pull "$SHOKKU_IMAGE" &> /dev/null + SHOKKU_IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$SHOKKU_IMAGE") + + echo "=> creating & configuring app" + dokku apps:create shokku &> /dev/null + dokku docker-options:add shokku deploy \ + "--add-host=host.docker.internal:host-gateway" &> /dev/null + dokku config:set shokku \ + DOKKU_SSH_HOST='host.docker.internal' \ + DOKKU_SSH_PORT="$HOST_SSH_PORT" &> /dev/null + + echo "==> creating storage" + dokku storage:ensure-directory shokku --chown false &> /dev/null + dokku storage:mount shokku "$SHOKKU_APP_DATA_MOUNT_PATH" &> /dev/null + chown -R "$DISTROLESS_NONROOT_UID":"$DISTROLESS_NONROOT_UID" "$SHOKKU_DATA_DIR" &> /dev/null + + echo "==> bootstrapping" + dokku config:set shokku DOKKU_SKIP_DEPLOY=true &> /dev/null + dokku git:from-image shokku "$SHOKKU_IMAGE_DIGEST" &> /dev/null + + shokku_ssh_key=$(dokku run shokku bootstrap) &> /dev/null + echo "$shokku_ssh_key" | dokku ssh-keys:add "$SHOKKU_DOKKU_USER" &> /dev/null + + echo "==> deploying" + dokku config:unset shokku DOKKU_SKIP_DEPLOY &> /dev/null + + echo "==> enabling letsencrypt" + dokku letsencrypt:enable shokku &> /dev/null +} + +main() { + if [[ "$(id -u)" != "0" ]]; then + echo "This script must be run as root" 1>&2 + exit 1 + fi + + if ! command -v dokku &> /dev/null; then + echo "Please install dokku first using the instructions at https://dokku.com" 1>&2 + exit + fi + + if ! dokku plugin:installed letsencrypt; then + echo "Please setup letsencrypt using the instructions at https://dokku.com/docs/deployment/application-deployment/#setting-up-ssl" 1>&2 + exit + fi + + for plugin in redis postgres mongo mysql; do + if ! dokku plugin:installed $plugin; then + echo "=> Installing plugin $plugin" + dokku plugin:install https://github.com/dokku/dokku-$plugin.git $plugin &> /dev/null + fi + done + + create-shokku-app + + shokku_app_domain=$(dokku domains:report shokku --domains-app-vhosts) + shokku_setup_key=$(dokku logs -q shokku | grep setup_key | jq ".setup_key") + + echo "=> shokku installed and running " + echo "--- proceed with setup using key $shokku_setup_key at https://$shokku_app_domain ---" +} + +main diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e285e96 --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module gitlab.com/texm/shokku + +go 1.18 + +require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.1.0 + github.com/glebarez/sqlite v1.5.0 + github.com/go-playground/validator/v10 v10.11.0 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/google/go-github/v48 v48.0.0 + github.com/gorilla/websocket v1.5.0 + github.com/labstack/echo/v4 v4.8.0 + github.com/pquerna/otp v1.4.0 + github.com/rs/zerolog v1.27.0 + github.com/sethvargo/go-envconfig v0.8.2 + github.com/stretchr/testify v1.7.1 + github.com/texm/dokku-go v0.0.0-20230313121650-17f2109811a5 + golang.org/x/crypto v0.7.0 + golang.org/x/oauth2 v0.2.0 + gorm.io/gorm v1.24.0 +) + +require ( + github.com/boombuler/barcode v1.0.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/glebarez/go-sqlite v1.19.1 // indirect + github.com/go-playground/locales v0.14.0 // indirect + github.com/go-playground/universal-translator v0.18.0 // indirect + github.com/golang-jwt/jwt/v4 v4.4.2 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-github/v45 v45.2.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/labstack/gommon v0.3.1 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.1 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/text v0.8.0 // indirect + golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/grpc v1.41.0 // indirect + google.golang.org/protobuf v1.28.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + modernc.org/libc v1.19.0 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.4.0 // indirect + modernc.org/sqlite v1.19.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..766e7c1 --- /dev/null +++ b/go.sum @@ -0,0 +1,323 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.4.17 h1:iT12IBVClFevaf8PuVyi3UmZOVh4OqnaLxDTW2O6j3w= +github.com/Microsoft/hcsshim v0.8.23 h1:47MSwtKGXet80aIn+7h4YI6fwPmwIghAnsx2aOUrG2M= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/bradleyfalzon/ghinstallation/v2 v2.1.0 h1:5+NghM1Zred9Z078QEZtm28G/kfDfZN/92gkDlLwGVA= +github.com/bradleyfalzon/ghinstallation/v2 v2.1.0/go.mod h1:Xg3xPRN5Mcq6GDqeUVhFbjEWMb4JHCyWEeeBGEYQoTU= +github.com/cenkalti/backoff/v4 v4.1.2 h1:6Yo7N8UP2K6LWZnW94DLVSSrbobcWdVzAYOisuDPIFo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/containerd/cgroups v1.0.1 h1:iJnMvco9XGvKUvNQkv88bE4uJXxRQH18efbKo9w5vHQ= +github.com/containerd/containerd v1.5.9 h1:rs6Xg1gtIxaeyG+Smsb/0xaSDu1VgFhOCKBXxMxbsF4= +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/docker v20.10.11+incompatible h1:OqzI/g/W54LczvhnccGqniFoQghHx3pklbLuhfXpqGo= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/glebarez/go-sqlite v1.19.1 h1:o2XhjyR8CQ2m84+bVz10G0cabmG0tY4sIMiCbrcUTrY= +github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M= +github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk= +github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY= +github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2BOGlCyvTqsp/xIw= +github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI= +github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28= +github.com/google/go-github/v48 v48.0.0 h1:9H5fWVXFK6ZsRriyPbjtnFAkJnoj0WKFtTYfpCRrTm8= +github.com/google/go-github/v48 v48.0.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +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/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.8.0 h1:wdc6yKVaHxkNOEdz4cRZs1pQkwSXPiRjq69yWP4QQS8= +github.com/labstack/echo/v4 v4.8.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks= +github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o= +github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/sys/mount v0.2.0 h1:WhCW5B355jtxndN5ovugJlMFJawbUODuW8fSnEH6SSM= +github.com/moby/sys/mountinfo v0.5.0 h1:2Ks8/r6lopsxWi9m58nlwjaeSzUX9iiL1vj5qB/9ObI= +github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= +github.com/opencontainers/runc v1.0.2 h1:opHZMaswlyxz1OuGpBE53Dwe4/xF7EZTY0A2L/FpCOg= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs= +github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U= +github.com/sethvargo/go-envconfig v0.8.2 h1:DDUVuG21RMgeB/bn4leclUI/837y6cQCD4w8hb5797k= +github.com/sethvargo/go-envconfig v0.8.2/go.mod h1:Iz1Gy1Sf3T64TQlJSvee81qDhf7YIlt8GMUX6yyNFs0= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +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.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/testcontainers/testcontainers-go v0.13.0 h1:OUujSlEGsXVo/ykPVZk3KanBNGN0TYb/7oKIPVn15JA= +github.com/texm/dokku-go v0.0.0-20230313121650-17f2109811a5 h1:aqhKgNTHPa99dEGAGFjBIjQyEtP33sDd9eNrd8MnVBU= +github.com/texm/dokku-go v0.0.0-20230313121650-17f2109811a5/go.mod h1:SmmgKvqbYdG9PJK+pwwZt9ZASXWXuNqtXRmSVINsM3o= +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/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.2.0 h1:GtQkldQ9m7yvzCL1V+LrYow3Khe0eJH0w7RbX/VbaIU= +golang.org/x/oauth2 v0.2.0/go.mod h1:Cwn6afJ8jrQwYMxQDTpISoXmXW9I6qF6vDeuuoX3Ibs= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a h1:pOwg4OoaRYScjmR4LlLgdtnyoHYTSAVhhqe5uPdpII8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E= +google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +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.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74= +gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= +modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= +modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= +modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0= +modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= +modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= +modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0= +modernc.org/libc v1.19.0 h1:bXyVhGQg6KIClTr8FMVIDPl7jtbcs7aS5WP7vLDaxPs= +modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk= +modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4= +modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 0000000..306958e --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,41 @@ +package env + +import ( + "github.com/labstack/echo/v4" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/server/auth" + "gorm.io/gorm" +) + +const Version = "0.0.1" + +type Env struct { + Version string + Router *echo.Echo + Dokku *dokku.SSHClient + DB *gorm.DB + Auth auth.Authenticator + + DebugMode bool + SetupCompleted bool +} + +func New(debugMode bool) *Env { + return &Env{ + DebugMode: debugMode, + Version: Version, + } +} + +type handlerFunc func(env *Env, c echo.Context) error + +func (e *Env) H(f handlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { return f(e, c) } +} + +func (e *Env) Shutdown() error { + if err := e.Dokku.Close(); err != nil { + return err + } + return nil +} diff --git a/internal/env/mock.go b/internal/env/mock.go new file mode 100644 index 0000000..431043f --- /dev/null +++ b/internal/env/mock.go @@ -0,0 +1,42 @@ +package env + +import ( + "github.com/glebarez/sqlite" + "github.com/labstack/echo/v4" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/server/dto" + "gorm.io/gorm" +) + +func NewTestingEnvironment() *Env { + router := echo.New() + router.Validator = dto.NewRequestValidator() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + panic(err.Error()) + } + + dokkuClient := &mockDokkuClient{} + + return &Env{ + Router: router, + DB: db, + DebugMode: true, + Dokku: &dokkuClient.SSHClient, + } +} + +type mockDokkuClient struct { + dokku.SSHClient + + returnVal string +} + +func (mc *mockDokkuClient) Exec(cmd string) (string, error) { + return cmd, nil +} + +func (mc *mockDokkuClient) SetReturnValue(val string) { + mc.returnVal = val +} diff --git a/internal/models/auth.go b/internal/models/auth.go new file mode 100644 index 0000000..9ca2cb9 --- /dev/null +++ b/internal/models/auth.go @@ -0,0 +1,22 @@ +package models + +import "gorm.io/gorm" + +type User struct { + gorm.Model + Name string `gorm:"unique"` + Source string + SSHKeys []SSHKey + + PasswordHash []byte + TotpEnabled bool + TotpSecret string + RecoveryCode string +} + +type SSHKey struct { + gorm.Model + UserID uint + GithubID int64 `gorm:"unique"` + Key string +} diff --git a/internal/models/dokku.go b/internal/models/dokku.go new file mode 100644 index 0000000..50459af --- /dev/null +++ b/internal/models/dokku.go @@ -0,0 +1,28 @@ +package models + +import "gorm.io/gorm" + +type App struct { + gorm.Model + Name string + IsSetup bool + SetupMethod string +} + +type AppSetupConfig struct { + gorm.Model + AppID uint + DeployBranch string + RepoURL string + RepoGitRef string + Image string +} + +type Service struct { + gorm.Model + Name string + Type string + BackupAuthSet bool + BackupEncryptionSet bool + BackupBucket string +} diff --git a/internal/models/github.go b/internal/models/github.go new file mode 100644 index 0000000..8a9871b --- /dev/null +++ b/internal/models/github.go @@ -0,0 +1,21 @@ +package models + +import ( + "gorm.io/gorm" +) + +type GithubApp struct { + gorm.Model + AppId int64 + NodeId string + ClientId string + Name string + Slug string + ClientSecret string + WebhookSecret string + PEM string +} + +func (GithubApp) TableName() string { + return "github_app" +} diff --git a/internal/models/server.go b/internal/models/server.go new file mode 100644 index 0000000..1e5c38e --- /dev/null +++ b/internal/models/server.go @@ -0,0 +1,25 @@ +package models + +import ( + "gitlab.com/texm/shokku/internal/server/auth" + "gorm.io/gorm" + "time" +) + +type Server struct { + gorm.Model + IsSetup bool + SetupKey string + AuthMethod auth.Method + LastSync time.Time +} + +func (Server) TableName() string { + return "server" +} + +type ServerSecrets struct { + gorm.Model + DokkuSSHKeyGob []byte + SigningKey []byte +} diff --git a/internal/server/api/apps/builds.go b/internal/server/api/apps/builds.go new file mode 100644 index 0000000..36bd93b --- /dev/null +++ b/internal/server/api/apps/builds.go @@ -0,0 +1,103 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetAppBuilder(e *env.Env, c echo.Context) error { + var req dto.GetAppBuilderRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + report, err := e.Dokku.GetAppBuilderReport(req.Name) + if err != nil { + return fmt.Errorf("getting app builder: %w", err) + } + + selectedBuilder := report.ComputedSelectedBuilder + if selectedBuilder == "" { + selectedBuilder = "auto" + } + + return c.JSON(http.StatusOK, dto.GetAppBuilderResponse{ + Selected: selectedBuilder, + }) +} + +func SetAppBuilder(e *env.Env, c echo.Context) error { + var req dto.SetAppBuilderRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + builders := map[string]dokku.AppBuilder{ + "auto": "", + "dockerfile": dokku.AppBuilderDockerfile, + "herokuish": dokku.AppBuilderHerokuish, + "null": dokku.AppBuilderNull, + "buildpack": dokku.AppBuilderPack, + "lambda": dokku.AppBuilderLambda, + } + chosenBuilder, supported := builders[req.Builder] + if !supported { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("unsupported builder '%s'", req.Builder)) + } + + err := e.Dokku.SetAppSelectedBuilder(req.Name, chosenBuilder) + if err != nil { + return fmt.Errorf("setting app builder: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func GetAppBuildDirectory(e *env.Env, c echo.Context) error { + var req dto.GetAppBuildDirectoryRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + report, err := e.Dokku.GetAppBuilderReport(req.Name) + if err != nil { + return fmt.Errorf("getting app build dir: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetAppBuildDirectoryResponse{ + Directory: report.ComputedBuildDir, + }) +} + +func SetAppBuildDirectory(e *env.Env, c echo.Context) error { + var req dto.SetAppBuildDirectoryRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + err := e.Dokku.SetAppBuilderProperty(req.Name, dokku.BuilderPropertyBuildDir, req.Directory) + if err != nil { + return fmt.Errorf("setting app build dir: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func ClearAppBuildDirectory(e *env.Env, c echo.Context) error { + var req dto.ClearAppBuildDirectoryRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + err := e.Dokku.SetAppBuilderProperty(req.Name, dokku.BuilderPropertyBuildDir, "") + if err != nil { + return fmt.Errorf("clearing app build dir: %w", err) + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/apps/config.go b/internal/server/api/apps/config.go new file mode 100644 index 0000000..43093d0 --- /dev/null +++ b/internal/server/api/apps/config.go @@ -0,0 +1,38 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetAppConfig(e *env.Env, c echo.Context) error { + var req dto.GetAppConfigRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + config, err := e.Dokku.GetAppConfig(req.Name) + if err != nil { + return fmt.Errorf("getting app config: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetAppConfigResponse{ + Config: config, + }) +} + +func SetAppConfig(e *env.Env, c echo.Context) error { + var req dto.SetAppConfigRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.SetAppConfigValues(req.Name, req.Config, false); err != nil { + return fmt.Errorf("setting app config: %w", err) + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/apps/domains.go b/internal/server/api/apps/domains.go new file mode 100644 index 0000000..74c1d10 --- /dev/null +++ b/internal/server/api/apps/domains.go @@ -0,0 +1,97 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetAppDomainsReport(e *env.Env, c echo.Context) error { + var req dto.GetAppDomainsReportRequest + if err := dto.BindRequest(c, &req); err != nil { + return echo.ErrBadRequest + } + + report, err := e.Dokku.GetAppDomainsReport(req.Name) + if err != nil { + return fmt.Errorf("getting app domains report: %w", err) + } + + if len(report.AppDomains) == 0 { + report.AppDomains = make([]string, 0) + } + + if len(report.GlobalDomains) == 0 { + report.GlobalDomains = make([]string, 0) + } + + return c.JSON(http.StatusOK, dto.GetAppDomainsReportResponse{ + Domains: report.AppDomains, + Enabled: report.AppEnabled, + }) +} + +func SetAppDomainsEnabled(e *env.Env, c echo.Context) error { + var req dto.SetAppDomainsEnabledRequest + if err := dto.BindRequest(c, &req); err != nil { + return echo.ErrBadRequest + } + + var err error + if req.Enabled { + err = e.Dokku.EnableAppDomains(req.Name) + } else { + err = e.Dokku.DisableAppDomains(req.Name) + } + if err != nil { + return fmt.Errorf("setting app domains enabled: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func GetAppLetsEncryptEnabled(e *env.Env, c echo.Context) error { + var req dto.GetAppLetsEncryptEnabledRequest + if err := dto.BindRequest(c, &req); err != nil { + return echo.ErrBadRequest + } + + return c.NoContent(http.StatusNotImplemented) +} + +func SetAppLetsEncryptEnabled(e *env.Env, c echo.Context) error { + var req dto.SetAppLetsEncryptEnabledRequest + if err := dto.BindRequest(c, &req); err != nil { + return echo.ErrBadRequest + } + + return c.NoContent(http.StatusNotImplemented) +} + +func AddAppDomain(e *env.Env, c echo.Context) error { + var req dto.AlterAppDomainRequest + if err := dto.BindRequest(c, &req); err != nil { + return echo.ErrBadRequest + } + + if err := e.Dokku.AddAppDomain(req.Name, req.Domain); err != nil { + return fmt.Errorf("adding app domain: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func RemoveAppDomain(e *env.Env, c echo.Context) error { + var req dto.AlterAppDomainRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.RemoveAppDomain(req.Name, req.Domain); err != nil { + return fmt.Errorf("removing app domain: %w", err) + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/apps/management.go b/internal/server/api/apps/management.go new file mode 100644 index 0000000..e39a8bf --- /dev/null +++ b/internal/server/api/apps/management.go @@ -0,0 +1,415 @@ +package apps + +import ( + "errors" + "fmt" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/commands" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/texm/dokku-go" +) + +const FilteredApp = "shokku" + +func lookupDBAppByName(e *env.Env, name string) (*models.App, error) { + dbApp := models.App{Name: name} + res := e.DB.Where("name = ?", name).Find(&dbApp) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, fmt.Errorf("no app found for %s", name) + } + return &dbApp, nil +} + +func GetAppsList(e *env.Env, c echo.Context) error { + appsList, err := e.Dokku.ListApps() + + apps := make([]dto.GetAppsListItem, 0) + for _, name := range appsList { + if name != FilteredApp { + apps = append(apps, dto.GetAppsListItem{ + Name: name, + // TODO: get type + Type: "", + }) + } + } + + if err != nil && !errors.Is(err, dokku.NoDeployedAppsError) { + return fmt.Errorf("getting apps overview: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetAppsListResponse{ + Apps: apps, + }) +} + +func GetAppsProcessReport(e *env.Env, c echo.Context) error { + allReports, err := e.Dokku.GetAllProcessReport() + if err != nil { + return fmt.Errorf("failed to get apps report: %w", err) + } + + apps := make([]dto.GetAppOverviewResponse, 0) + for name, psReport := range allReports { + if name == FilteredApp { + continue + } + + app, lookupErr := lookupDBAppByName(e, name) + if lookupErr != nil { + return fmt.Errorf("failed to lookup app %s: %w", name, lookupErr) + } + + apps = append(apps, dto.GetAppOverviewResponse{ + Name: name, + IsSetup: app.IsSetup, + SetupMethod: app.SetupMethod, + IsDeployed: psReport.Deployed, + IsRunning: psReport.Running, + NumProcesses: psReport.Processes, + CanScale: psReport.CanScale, + Restore: psReport.Restore, + }) + } + + return c.JSON(http.StatusOK, dto.GetAllAppsOverviewResponse{ + Apps: apps, + }) +} + +func GetAppOverview(e *env.Env, c echo.Context) error { + var req dto.GetAppOverviewRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + app, lookupErr := lookupDBAppByName(e, req.Name) + if lookupErr != nil { + return fmt.Errorf("failed to lookup app %s: %w", req.Name, lookupErr) + } + + psReport, psErr := e.Dokku.GetAppProcessReport(req.Name) + if psErr != nil { + return fmt.Errorf("getting apps process report: %w", psErr) + } + + gitReport, gitErr := e.Dokku.GitGetAppReport(req.Name) + if gitErr != nil { + return fmt.Errorf("getting app git report: %w", gitErr) + } + + return c.JSON(http.StatusOK, dto.GetAppOverviewResponse{ + IsSetup: app.IsSetup, + SetupMethod: app.SetupMethod, + IsDeployed: psReport.Deployed, + GitDeployBranch: gitReport.DeployBranch, + GitLastUpdated: gitReport.LastUpdatedAt, + IsRunning: psReport.Running, + }) +} + +func GetAppInfo(e *env.Env, c echo.Context) error { + var req dto.GetAppInfoRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + info, err := e.Dokku.GetAppReport(req.Name) + if err != nil { + return fmt.Errorf("getting app info: %w", err) + } + + res := &dto.GetAppInfoResponse{ + Info: dto.AppInfo{ + Name: req.Name, + Directory: info.Directory, + DeploySource: info.DeploySource, + DeploySourceMetadata: info.DeploySourceMetadata, + CreatedAt: time.UnixMilli(info.CreatedAtTimestamp), + IsLocked: info.IsLocked, + }, + } + + return c.JSON(http.StatusOK, res) +} + +func CreateApp(e *env.Env, c echo.Context) error { + var req dto.ManageAppRequest + if reqErr := dto.BindRequest(c, &req); reqErr != nil { + log.Debug(). + Err(reqErr.ToHTTP()). + Str("appName", req.Name). + Msg("bind err") + return reqErr.ToHTTP() + } + + _, lookupErr := lookupDBAppByName(e, req.Name) + if lookupErr == nil { + return echo.ErrBadRequest + } + + if createErr := e.Dokku.CreateApp(req.Name); createErr != nil { + return fmt.Errorf("creating app: %w", createErr) + } + + if dbErr := e.DB.Create(&models.App{Name: req.Name}).Error; dbErr != nil { + log.Error().Err(dbErr).Str("name", req.Name).Msg("failed to create db app") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} + +func DestroyApp(e *env.Env, c echo.Context) error { + var req dto.DestroyAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, dbErr := lookupDBAppByName(e, req.Name) + if dbErr != nil { + log.Error().Err(dbErr).Str("name", req.Name).Msg("failed to lookup app") + return echo.ErrNotFound + } + + if err := e.Dokku.DestroyApp(req.Name); err != nil { + return fmt.Errorf("destroying app: %w", err) + } + + // TODO: hard delete app + if err := e.DB.Delete(&dbApp).Error; err != nil { + log.Error().Err(err).Str("name", req.Name).Msg("failed to delete app") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} + +func RenameApp(e *env.Env, c echo.Context) error { + var req dto.RenameAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, dbErr := lookupDBAppByName(e, req.CurrentName) + if dbErr != nil { + log.Error().Err(dbErr).Str("name", req.CurrentName). + Msg("failed to lookup app") + return echo.ErrNotFound + } + + if _, newDbErr := lookupDBAppByName(e, req.NewName); newDbErr == nil { + return echo.ErrBadRequest + } + + dbApp.Name = req.NewName + if saveErr := e.DB.Save(&dbApp).Error; saveErr != nil { + log.Error().Err(saveErr). + Str("name", req.NewName). + Msg("failed to save db app") + } + + options := &dokku.AppManagementOptions{SkipDeploy: true} + if renameErr := e.Dokku.RenameApp(req.CurrentName, req.NewName, options); renameErr != nil { + return fmt.Errorf("renaming app: %w", renameErr) + } + + return c.NoContent(http.StatusOK) +} + +func StartApp(e *env.Env, c echo.Context) error { + var req dto.ManageAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + cmd := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.StartApp(req.Name, nil) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} + +func StopApp(e *env.Env, c echo.Context) error { + var req dto.ManageAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + cmd := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.StopApp(req.Name, nil) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} + +func RestartApp(e *env.Env, c echo.Context) error { + var req dto.ManageAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + cmd := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.RestartApp(req.Name, nil) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} + +func RebuildApp(e *env.Env, c echo.Context) error { + var req dto.ManageAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, appErr := lookupDBAppByName(e, req.Name) + if appErr != nil { + return echo.NewHTTPError(http.StatusBadRequest, "couldnt get app") + } + if !dbApp.IsSetup { + return echo.NewHTTPError(http.StatusBadRequest, "not setup") + } + + var cfg models.AppSetupConfig + cfgErr := e.DB.Where("app_id = ?", dbApp.ID).First(&cfg).Error + if cfgErr != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "no setup config") + } + method := setupMethod(dbApp.SetupMethod) + var cmd commands.AsyncDokkuCommand + if method == methodGit { + cmd = func() (*dokku.CommandOutputStream, error) { + return e.Dokku.RebuildApp(req.Name, nil) + } + } else if method == methodSyncRepo { + opts := &dokku.GitSyncOptions{ + Build: true, + GitRef: cfg.RepoGitRef, + } + cmd = func() (*dokku.CommandOutputStream, error) { + return e.Dokku.GitSyncAppRepo(req.Name, cfg.RepoURL, opts) + } + } else if method == methodDocker { + image := cfg.Image + cmd = func() (*dokku.CommandOutputStream, error) { + return e.Dokku.GitCreateFromImage(req.Name, image, nil) + } + } else { + log.Error(). + Str("method", dbApp.SetupMethod). + Str("name", dbApp.Name). + Msg("invalid app setup method") + return echo.NewHTTPError(http.StatusBadRequest, "invalid setup method") + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} + +func GetAppDeployChecks(e *env.Env, c echo.Context) error { + var req dto.GetAppDeployChecksRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + report, err := e.Dokku.GetAppDeployChecksReport(req.Name) + if err != nil { + return fmt.Errorf("getting app deploy checks: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetAppDeployChecksResponse{ + AllDisabled: report.AllDisabled, + AllSkipped: report.AllSkipped, + DisabledProcesses: report.DisabledProcesses, + SkippedProcesses: report.SkippedProcesses, + }) +} + +func SetAppDeployChecks(e *env.Env, c echo.Context) error { + var req dto.SetAppDeployChecksRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + var err error + switch req.State { + case "enabled": + err = e.Dokku.EnableAppDeployChecks(req.Name) + case "disabled": + err = e.Dokku.DisableAppDeployChecks(req.Name) + case "skipped": + err = e.Dokku.SetAppDeployChecksSkipped(req.Name) + default: + return echo.NewHTTPError(http.StatusBadRequest, "unknown state") + } + + if err != nil { + return fmt.Errorf("setting app deploy checks to %s: %w", req.State, err) + } + + return c.NoContent(http.StatusOK) +} + +func GetAppLogs(e *env.Env, c echo.Context) error { + var req dto.GetAppLogsRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + logs, err := e.Dokku.GetAppLogs(req.Name) + if err != nil { + if errors.Is(err, dokku.AppNotDeployedError) { + return c.JSON(http.StatusOK, dto.GetAppLogsResponse{}) + } + return fmt.Errorf("getting app logs: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetAppLogsResponse{ + Logs: strings.Split(logs, "\n"), + }) +} + +/* +func AppExecInProcess(e *env.Env, c echo.Context) error { + var req dto.AppExecInProcessRequest + if err := dto.BindRequest(c, &req); err != nil { + log.Debug().Str("err", err.String()).Interface("req", req).Msg("bind") + return err.ToHTTP() + } + + cmd := fmt.Sprintf(`enter %s %s %s`, req.AppName, req.ProcessName, req.Command) + + output, execErr := e.Dokku.Exec(cmd) + + res := dto.AppExecInProcessResponse{ + Output: output, + } + if execErr != nil { + res.Error = execErr.Error() + + var sshExitErr *dokku.ExitCodeError + if errors.As(execErr, &sshExitErr) { + res.Error = sshExitErr.Output() + } + } + + return c.JSON(http.StatusOK, res) +} +*/ diff --git a/internal/server/api/apps/networks.go b/internal/server/api/apps/networks.go new file mode 100644 index 0000000..0b015da --- /dev/null +++ b/internal/server/api/apps/networks.go @@ -0,0 +1,84 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetAppNetworksReport(e *env.Env, c echo.Context) error { + var req dto.GetAppNetworksReportRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + report, err := e.Dokku.GetAppNetworkReport(req.Name) + if err != nil { + return fmt.Errorf("getting app network report: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetAppNetworksReportResponse{ + AttachInitial: report.ComputedInitialNetwork, + AttachPostCreate: report.ComputedAttachPostCreate, + AttachPostDeploy: report.ComputedAttachPostDeploy, + BindAllInterfaces: report.ComputedBindAllInterfaces, + TLD: report.ComputedTLD, + WebListeners: report.WebListeners, + }) +} + +func SetAppNetworks(e *env.Env, c echo.Context) error { + var req dto.SetAppNetworksRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if req.Initial != nil { + err := e.Dokku.SetAppNetworkProperty(req.Name, + dokku.NetworkPropertyInitialNetwork, *req.Initial) + if err != nil { + return fmt.Errorf("setting initial network: %w", err) + } + } + + if req.PostCreate != nil { + err := e.Dokku.SetAppNetworkProperty(req.Name, + dokku.NetworkPropertyAttachPostCreate, *req.PostCreate) + if err != nil { + return fmt.Errorf("setting postcreate network: %w", err) + } + } + + if req.PostDeploy != nil { + err := e.Dokku.SetAppNetworkProperty(req.Name, + dokku.NetworkPropertyAttachPostDeploy, *req.PostDeploy) + if err != nil { + return fmt.Errorf("setting postdeploy network: %w", err) + } + } + + if req.BindAllInterfaces != nil { + bindAll := "false" + if *req.BindAllInterfaces { + bindAll = "true" + } + err := e.Dokku.SetAppNetworkProperty(req.Name, + dokku.NetworkPropertyBindAllInterfaces, bindAll) + if err != nil { + return fmt.Errorf("setting bind-all-interfaces: %w", err) + } + } + + if req.TLD != nil { + err := e.Dokku.SetAppNetworkProperty(req.Name, + dokku.NetworkPropertyTLD, *req.TLD) + if err != nil { + return fmt.Errorf("setting TLD: %w", err) + } + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/apps/processes.go b/internal/server/api/apps/processes.go new file mode 100644 index 0000000..59e993d --- /dev/null +++ b/internal/server/api/apps/processes.go @@ -0,0 +1,190 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/commands" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetAppProcesses(e *env.Env, c echo.Context) error { + var req dto.GetAppProcessesRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + scales, err := e.Dokku.GetAppProcessScale(req.Name) + if err != nil { + return fmt.Errorf("getting app process scale: %w", err) + } + + processes := make([]string, len(scales)) + i := 0 + for processName := range scales { + processes[i] = processName + i++ + } + + return c.JSON(http.StatusOK, dto.GetAppProcessesResponse{ + Processes: processes, + }) +} + +func GetAppProcessReport(e *env.Env, c echo.Context) error { + var req dto.GetAppProcessReportRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + resourceReport, err := e.Dokku.GetAppResourceReport(req.Name) + if err != nil { + return fmt.Errorf("getting app resource report: %w", err) + } + + processScale, err := e.Dokku.GetAppProcessScale(req.Name) + if err != nil { + return fmt.Errorf("getting app process scale: %w", err) + } + + processMap := map[string]dto.AppProcessInfo{} + for processName, scale := range processScale { + appResources := dto.AppProcessInfo{ + Scale: scale, + } + if psSettings, ok := resourceReport.Processes[processName]; ok { + appResources.Resources = psSettings + } + processMap[processName] = appResources + } + + return c.JSON(http.StatusOK, dto.GetAppProcessReportResponse{ + ResourceDefaults: resourceReport.Defaults, + Processes: processMap, + }) +} + +func SetAppProcessResources(e *env.Env, c echo.Context) error { + var req dto.SetAppProcessResourcesRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + limits := req.ResourceLimits + reservations := req.ResourceReservations + + if limits.CPU != nil { + err := e.Dokku.SetAppProcessResourceLimit(req.Name, req.Process, + dokku.ResourceCPU, *limits.CPU) + if err != nil { + return fmt.Errorf("setting app cpu limit: %w", err) + } + } else { + err := e.Dokku.ClearAppProcessResourceLimit(req.Name, req.Process, dokku.ResourceCPU) + if err != nil { + return fmt.Errorf("clearing app cpu limit: %w", err) + } + } + + if limits.Memory != nil && limits.MemoryUnit != nil { + resSpec := dokku.ResourceSpec{ + Name: "memory", + Suffix: *limits.MemoryUnit, + } + err := e.Dokku.SetAppProcessResourceLimit(req.Name, req.Process, resSpec, *limits.Memory) + if err != nil { + return fmt.Errorf("setting app mem limit: %w", err) + } + } else { + err := e.Dokku.ClearAppProcessResourceLimit(req.Name, req.Process, dokku.ResourceMemoryBytes) + if err != nil { + return fmt.Errorf("clearing app mem limit: %w", err) + } + } + + if reservations.Memory != nil && reservations.MemoryUnit != nil { + resSpec := dokku.ResourceSpec{ + Name: "memory", + Suffix: *reservations.MemoryUnit, + } + err := e.Dokku.SetAppProcessResourceReservation(req.Name, req.Process, resSpec, + *reservations.Memory) + if err != nil { + return fmt.Errorf("setting app mem reservation: %w", err) + } + } else { + err := e.Dokku.ClearAppProcessResourceReservation(req.Name, req.Process, + dokku.ResourceMemoryBytes) + if err != nil { + return fmt.Errorf("clearing app mem reservation: %w", err) + } + } + + return c.NoContent(http.StatusOK) +} + +func GetAppProcessScale(e *env.Env, c echo.Context) error { + var req dto.GetAppProcessScaleRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + scale, err := e.Dokku.GetAppProcessScale(req.Name) + if err != nil { + return fmt.Errorf("getting app process scale: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetAppProcessScaleResponse{ + ProcessScale: scale, + }) +} + +func SetAppProcessDeployChecks(e *env.Env, c echo.Context) error { + var req dto.SetAppProcessDeployChecksRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + processes := []string{req.Process} + var err error + switch req.State { + case "enabled": + err = e.Dokku.EnableAppProcessesDeployChecks(req.Name, processes) + case "disabled": + err = e.Dokku.DisableAppProcessesDeployChecks(req.Name, processes) + case "skipped": + err = e.Dokku.SetAppProcessesDeployChecksSkipped(req.Name, processes) + default: + return echo.NewHTTPError(http.StatusBadRequest, "unknown state") + } + + if err != nil { + return fmt.Errorf("setting app deploy checks to " + req.State) + } + + return c.NoContent(http.StatusOK) +} + +func SetAppProcessScale(e *env.Env, c echo.Context) error { + var req dto.SetAppProcessScaleRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + _, err := e.Auth.GetUserFromContext(c) + if err != nil { + log.Error().Err(err).Msg("failed to retrieve user from context") + return echo.ErrInternalServerError + } + + cmd := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.SetAppProcessScale(req.Name, req.Process, req.Scale, req.SkipDeploy) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} diff --git a/internal/server/api/apps/routes.go b/internal/server/api/apps/routes.go new file mode 100644 index 0000000..27d030f --- /dev/null +++ b/internal/server/api/apps/routes.go @@ -0,0 +1,70 @@ +package apps + +import ( + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" +) + +func RegisterRoutes(e *env.Env, g *echo.Group) { + g.GET("/list", e.H(GetAppsList)) + g.GET("/report", e.H(GetAppsProcessReport)) + g.GET("/overview", e.H(GetAppOverview)) + + g.POST("/create", e.H(CreateApp)) + g.POST("/start", e.H(StartApp)) + g.POST("/stop", e.H(StopApp)) + g.POST("/restart", e.H(RestartApp)) + g.POST("/rebuild", e.H(RebuildApp)) + + setupGroup := g.Group("/setup") + setupGroup.GET("/status", e.H(GetAppSetupStatus)) + setupGroup.GET("/config", e.H(GetAppSetupConfig)) + setupGroup.POST("/new-repo", e.H(SetupAppNewRepo)) + setupGroup.POST("/sync-repo", e.H(SetupAppSyncRepo)) + setupGroup.POST("/pull-image", e.H(SetupAppPullImage)) + setupGroup.POST("/upload-archive", e.H(SetupAppUploadArchive)) + + g.POST("/destroy", e.H(DestroyApp)) + g.GET("/info", e.H(GetAppInfo)) + g.POST("/rename", e.H(RenameApp)) + g.GET("/services", e.H(GetAppServices)) + + g.GET("/deploy-checks", e.H(GetAppDeployChecks)) + g.POST("/deploy-checks", e.H(SetAppDeployChecks)) + + process := g.Group("/process") + process.GET("/list", e.H(GetAppProcesses)) + process.GET("/report", e.H(GetAppProcessReport)) + process.POST("/deploy-checks", e.H(SetAppProcessDeployChecks)) + process.POST("/resources", e.H(SetAppProcessResources)) + process.GET("/scale", e.H(GetAppProcessScale)) + process.POST("/scale", e.H(SetAppProcessScale)) + + g.GET("/letsencrypt", e.H(GetAppLetsEncryptEnabled)) + g.POST("/letsencrypt", e.H(SetAppLetsEncryptEnabled)) + + g.GET("/domains", e.H(GetAppDomainsReport)) + g.POST("/domains/state", e.H(SetAppDomainsEnabled)) + + g.POST("/domain", e.H(AddAppDomain)) + g.DELETE("/domain", e.H(RemoveAppDomain)) + + g.GET("/networks", e.H(GetAppNetworksReport)) + g.POST("/networks", e.H(SetAppNetworks)) + + g.GET("/logs", e.H(GetAppLogs)) + + g.GET("/config", e.H(GetAppConfig)) + g.POST("/config", e.H(SetAppConfig)) + + g.GET("/storage", e.H(GetAppStorage)) + g.POST("/storage/mount", e.H(MountAppStorage)) + g.POST("/storage/unmount", e.H(UnmountAppStorage)) + + g.GET("/builder", e.H(GetAppBuilder)) + g.POST("/builder", e.H(SetAppBuilder)) + + g.GET("/build-directory", e.H(GetAppBuildDirectory)) + g.POST("/build-directory", e.H(SetAppBuildDirectory)) + g.DELETE("/build-directory", e.H(ClearAppBuildDirectory)) +} diff --git a/internal/server/api/apps/services.go b/internal/server/api/apps/services.go new file mode 100644 index 0000000..7c74abd --- /dev/null +++ b/internal/server/api/apps/services.go @@ -0,0 +1,60 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" + "strings" +) + +var ( + dokkuErrPrefix = "! " + serviceTypes = []string{"redis", "postgres", "mysql", "mongo"} +) + +func splitDokkuListOutput(output string) ([]string, error) { + if strings.HasPrefix(output, dokkuErrPrefix) { + return nil, nil + } + if output == "" { + return []string{}, nil + } + return strings.Split(output, "\n"), nil +} + +func getAppServiceLinks(e *env.Env, appName string, serviceType string) ([]string, error) { + linksCmd := fmt.Sprintf("%s:app-links %s --quiet", serviceType, appName) + out, err := e.Dokku.Exec(linksCmd) + if err != nil { + return nil, err + } + return splitDokkuListOutput(out) +} + +func GetAppServices(e *env.Env, c echo.Context) error { + var req dto.GetAppServicesRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + serviceList := []dto.ServiceInfo{} + + for _, serviceType := range serviceTypes { + links, err := getAppServiceLinks(e, req.Name, serviceType) + if err != nil { + return fmt.Errorf("getting links for " + serviceType) + } + for _, name := range links { + serviceList = append(serviceList, dto.ServiceInfo{ + Name: name, + Type: serviceType, + }) + } + } + + return c.JSON(http.StatusOK, dto.ListServicesResponse{ + Services: serviceList, + }) +} diff --git a/internal/server/api/apps/setup.go b/internal/server/api/apps/setup.go new file mode 100644 index 0000000..353c7b1 --- /dev/null +++ b/internal/server/api/apps/setup.go @@ -0,0 +1,229 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/auth" + "gitlab.com/texm/shokku/internal/server/commands" + "gitlab.com/texm/shokku/internal/server/dto" + "gorm.io/gorm/clause" + "net/http" + "net/url" +) + +type setupMethod string + +const ( + methodGit = setupMethod("git") + methodSyncRepo = setupMethod("sync-repo") + methodDocker = setupMethod("docker") + methodArchive = setupMethod("archive") +) + +var ( + updateOnConflict = clause.OnConflict{ + DoUpdates: clause.AssignmentColumns([]string{"updated_at"}), + } +) + +func saveAppSetupMethod(e *env.Env, app *models.App, method setupMethod, cfg *models.AppSetupConfig) error { + app.IsSetup = true + app.SetupMethod = string(method) + if appErr := e.DB.Save(app).Error; appErr != nil { + log.Error().Err(appErr). + Str("app", app.Name). + Str("method", app.SetupMethod). + Msg("failed to save app table") + return fmt.Errorf("failed to save app table: %w", appErr) + } + + cfg.AppID = app.ID + + updateErr := e.DB.Clauses(updateOnConflict).Create(&cfg).Error + if updateErr != nil { + log.Error().Err(updateErr). + Str("app", app.Name). + Interface("cfg", cfg). + Msg("failed to update app setup config") + return fmt.Errorf("failed to save app setup config: %w", updateErr) + } + + return nil +} + +func GetAppSetupStatus(e *env.Env, c echo.Context) error { + var req dto.GetAppSetupStatusRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, appErr := lookupDBAppByName(e, req.Name) + if appErr != nil { + return echo.ErrNotFound + } + + return c.JSON(http.StatusOK, dto.GetAppSetupStatusResponse{ + IsSetup: dbApp.IsSetup, + Method: dbApp.SetupMethod, + }) +} + +func GetAppSetupConfig(e *env.Env, c echo.Context) error { + var req dto.GetAppSetupConfigRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, appErr := lookupDBAppByName(e, req.Name) + if appErr != nil { + return echo.ErrNotFound + } + + var cfg models.AppSetupConfig + e.DB.Where("app_id = ?", dbApp.ID).FirstOrInit(&cfg) + + return c.JSON(http.StatusOK, dto.GetAppSetupConfigResponse{ + IsSetup: dbApp.IsSetup, + Method: dbApp.SetupMethod, + DeploymentBranch: cfg.DeployBranch, + RepoURL: cfg.RepoURL, + RepoGitRef: cfg.RepoGitRef, + Image: cfg.Image, + }) +} + +func SetupAppNewRepo(e *env.Env, c echo.Context) error { + var req dto.SetupAppNewRepoRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, appErr := lookupDBAppByName(e, req.Name) + if appErr != nil { + return echo.ErrNotFound + } + + if initErr := e.Dokku.GitInitializeApp(req.Name); initErr != nil { + return fmt.Errorf("initialising git repo: %w", initErr) + } + + branch := req.DeploymentBranch + if branch == "" { + branch = "master" + } + + if branch != "master" { + branchErr := e.Dokku.GitSetAppProperty(req.Name, dokku.GitPropertyDeployBranch, req.DeploymentBranch) + if branchErr != nil { + return fmt.Errorf("setting git deploy branch: %w", branchErr) + } + } + + cfg := &models.AppSetupConfig{ + DeployBranch: branch, + } + if saveErr := saveAppSetupMethod(e, dbApp, methodGit, cfg); saveErr != nil { + return saveErr + } + + return c.NoContent(http.StatusOK) +} + +func SetupAppSyncRepo(e *env.Env, c echo.Context) error { + var req dto.SetupAppSyncRepoRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, appErr := lookupDBAppByName(e, req.Name) + if appErr != nil { + return echo.ErrNotFound + } + + parsedURL, urlErr := url.Parse(req.RepositoryURL) + if urlErr != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid repo url") + } + + if e.Auth.GetMethod() == auth.MethodGithub { + if parsedURL.Hostname() != "github.com" { + // return echo.ErrBadRequest + } + } + + syncOpts := &dokku.GitSyncOptions{ + Build: true, + GitRef: req.GitRef, + } + execFn := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.GitSyncAppRepo(req.Name, req.RepositoryURL, syncOpts) + } + + cfg := &models.AppSetupConfig{ + AppID: dbApp.ID, + RepoURL: req.RepositoryURL, + RepoGitRef: req.GitRef, + } + cb := func() error { + return saveAppSetupMethod(e, dbApp, methodSyncRepo, cfg) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(execFn, cb), + }) +} + +func SetupAppPullImage(e *env.Env, c echo.Context) error { + var req dto.SetupAppPullImageRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbApp, appErr := lookupDBAppByName(e, req.Name) + if appErr != nil { + return echo.ErrNotFound + } + + execFn := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.GitCreateFromImage(req.Name, req.Image, nil) + } + + cfg := &models.AppSetupConfig{ + AppID: dbApp.ID, + Image: req.Image, + } + cb := func() error { + return saveAppSetupMethod(e, dbApp, methodDocker, cfg) + } + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(execFn, cb), + }) +} + +func SetupAppUploadArchive(e *env.Env, c echo.Context) error { + var req dto.SetupAppUploadArchiveRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + // disable for now + if true { + return echo.ErrForbidden + } + + file, err := c.FormFile("archive") + if err != nil { + log.Error().Err(err).Msg("invalid form file") + return echo.ErrInternalServerError + } + + log.Info(). + Str("app", req.Name). + Msgf("got file: %+v", file.Header) + + return c.NoContent(http.StatusNotImplemented) +} diff --git a/internal/server/api/apps/storage.go b/internal/server/api/apps/storage.go new file mode 100644 index 0000000..a50aaa1 --- /dev/null +++ b/internal/server/api/apps/storage.go @@ -0,0 +1,107 @@ +package apps + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func isMountInSlice(mount dokku.StorageBindMount, mounts []dokku.StorageBindMount) bool { + for _, m := range mounts { + if m == mount { + return true + } + } + return false +} + +func GetAppStorage(e *env.Env, c echo.Context) error { + var req dto.GetAppStorageRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + report, err := e.Dokku.GetAppStorageReport(req.Name) + if err != nil { + return fmt.Errorf("getting app storage report: %w", err) + } + + var allMounts []dokku.StorageBindMount + allMounts = append(allMounts, report.RunMounts...) + allMounts = append(allMounts, report.DeployMounts...) + allMounts = append(allMounts, report.BuildMounts...) + + seenMap := map[string]bool{} + mounts := []dto.StorageMount{} + for _, dokkuMount := range allMounts { + if _, seen := seenMap[dokkuMount.String()]; seen { + continue + } + seenMap[dokkuMount.String()] = true + mounts = append(mounts, dto.StorageMount{ + HostDir: dokkuMount.HostDir, + ContainerDir: dokkuMount.ContainerDir, + IsBuildMount: isMountInSlice(dokkuMount, report.BuildMounts), + IsRunMount: isMountInSlice(dokkuMount, report.RunMounts), + IsDeployMount: isMountInSlice(dokkuMount, report.DeployMounts), + }) + } + + return c.JSON(http.StatusOK, dto.GetAppStorageResponse{ + Mounts: mounts, + }) +} + +func MountAppStorage(e *env.Env, c echo.Context) error { + var req dto.AlterAppStorageRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if req.StorageType == "New Storage" { + // TODO: actually chown properly + err := e.Dokku.EnsureStorageDirectory(req.Name, dokku.StorageChownOptionHerokuish) + if err != nil { + return fmt.Errorf("ensuring app storage dir: %w", err) + } + } + + mount := dokku.StorageBindMount{ + HostDir: req.HostDir, + ContainerDir: req.ContainerDir, + } + if err := e.Dokku.MountAppStorage(req.Name, mount); err != nil { + return fmt.Errorf("mounting app storage dir: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func UnmountAppStorage(e *env.Env, c echo.Context) error { + var req dto.AlterAppStorageRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + mount := dokku.StorageBindMount{ + HostDir: req.HostDir, + ContainerDir: req.ContainerDir, + } + if err := e.Dokku.UnmountAppStorage(req.Name, mount); err != nil { + return fmt.Errorf("unmounting app storage dir: %w", err) + } + + if req.RestartApp { + go func() { + if _, err := e.Dokku.RestartApp(req.Name, nil); err != nil { + log.Error().Err(err).Msg("error while restarting app") + } + }() + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/auth/auth.go b/internal/server/api/auth/auth.go new file mode 100644 index 0000000..02b72a5 --- /dev/null +++ b/internal/server/api/auth/auth.go @@ -0,0 +1,47 @@ +package auth + +import ( + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/auth" + "net/http" +) + +func HandleLogout(e *env.Env, c echo.Context) error { + e.Auth.ClearTokenCookies(c) + return c.NoContent(http.StatusOK) +} + +func HandleRefreshAuth(e *env.Env, c echo.Context) error { + user, err := e.Auth.GetUserFromContext(c) + if err != nil { + log.Error().Msg("failed to parse user from context") + return echo.ErrInternalServerError + } + + claims := auth.UserClaims{ + Name: user.Name, + } + token, err := e.Auth.NewToken(claims) + if err != nil { + log.Error().Err(err).Msg("failed to create jwt") + return echo.ErrInternalServerError + } + + e.Auth.SetTokenCookies(c, token) + + return c.NoContent(http.StatusOK) +} + +func HandleGetDetails(e *env.Env, c echo.Context) error { + user, err := e.Auth.GetUserFromContext(c) + if err != nil { + log.Error().Msg("failed to parse user from context") + return echo.ErrInternalServerError + } + + return c.JSON(http.StatusOK, echo.Map{ + "username": user.Name, + }) +} diff --git a/internal/server/api/auth/github.go b/internal/server/api/auth/github.go new file mode 100644 index 0000000..7c675e6 --- /dev/null +++ b/internal/server/api/auth/github.go @@ -0,0 +1,76 @@ +package auth + +import ( + "context" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/auth" + "gitlab.com/texm/shokku/internal/server/dto" + "gitlab.com/texm/shokku/internal/server/github" + "net/http" +) + +func GetGithubAuthInfo(e *env.Env, c echo.Context) error { + var ghApp models.GithubApp + if err := e.DB.First(&ghApp).Error; err != nil { + log.Error().Err(err).Msg("no github app in db") + return echo.ErrBadRequest + } + + return c.JSON(http.StatusOK, dto.GetGithubAuthInfoResponse{ + ClientID: ghApp.ClientId, + }) +} + +func CompleteGithubAuth(e *env.Env, c echo.Context) error { + var req dto.GithubAuthRequest + if err := c.Bind(&req); err != nil { + return echo.ErrBadRequest + } + + ctx := context.Background() + params := github.CodeExchangeParams{ + Code: req.Code, + Scopes: []string{}, + RedirectURL: req.RedirectURL, + } + client, err := github.ExchangeCode(ctx, e, params) + if err != nil { + log.Error().Err(err).Msg("failed to exchange code for client") + return echo.ErrBadRequest + } + + user, err := client.GetUser(ctx) + if err != nil { + log.Error().Err(err).Msg("failed to get user") + return echo.ErrBadRequest + } + + var count int64 + dbUser := models.User{Name: user.GetLogin()} + r := e.DB.Model(&dbUser).Where(&dbUser).Count(&count) + if r.Error != nil { + log.Error(). + Err(r.Error). + Str("name", user.GetLogin()). + Msg("user lookup error") + return echo.ErrInternalServerError + } + if count == 0 { + return echo.ErrForbidden + } + + claims := auth.UserClaims{ + Name: user.GetLogin(), + } + token, err := e.Auth.NewToken(claims) + if err != nil { + log.Error().Err(err).Msg("failed to create jwt") + return echo.ErrInternalServerError + } + e.Auth.SetTokenCookies(c, token) + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/auth/password.go b/internal/server/api/auth/password.go new file mode 100644 index 0000000..5f8c9c0 --- /dev/null +++ b/internal/server/api/auth/password.go @@ -0,0 +1,58 @@ +package auth + +import ( + "github.com/pquerna/otp/totp" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + auth "gitlab.com/texm/shokku/internal/server/auth" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" + + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/models" +) + +func HandlePasswordLogin(e *env.Env, c echo.Context) error { + var req dto.PasswordLoginRequest + if err := c.Bind(&req); err != nil { + return echo.ErrBadRequest + } + + var dbUser models.User + res := e.DB.Where("name = ?", req.Username).Take(&dbUser) + if res.Error != nil { + return echo.ErrForbidden + } + + pwAuth, ok := e.Auth.(*auth.PasswordAuthenticator) + if !ok { + log.Error().Msg("failed to cast authenticator to pw auth") + return echo.ErrInternalServerError + } + if !pwAuth.VerifyHash([]byte(req.Password), dbUser.PasswordHash) { + return echo.ErrForbidden + } + + if dbUser.TotpEnabled { + if !totp.Validate(req.TotpCode, dbUser.TotpSecret) { + return c.JSON(http.StatusOK, dto.PasswordLoginResponse{ + Success: true, + NeedsTotp: true, + }) + } + } + + claims := auth.UserClaims{ + Name: req.Username, + } + token, err := e.Auth.NewToken(claims) + if err != nil { + log.Error().Err(err).Msg("failed to create jwt") + return echo.ErrInternalServerError + } + e.Auth.SetTokenCookies(c, token) + + return c.JSON(http.StatusOK, dto.PasswordLoginResponse{ + Success: true, + }) +} diff --git a/internal/server/api/auth/routes.go b/internal/server/api/auth/routes.go new file mode 100644 index 0000000..412e161 --- /dev/null +++ b/internal/server/api/auth/routes.go @@ -0,0 +1,16 @@ +package auth + +import ( + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" +) + +func RegisterRoutes(e *env.Env, g *echo.Group, authG *echo.Group) { + g.POST("/login", e.H(HandlePasswordLogin)) + g.GET("/github", e.H(GetGithubAuthInfo)) + g.POST("/github/auth", e.H(CompleteGithubAuth)) + g.POST("/logout", e.H(HandleLogout)) + + authG.GET("/details", e.H(HandleGetDetails)) + authG.POST("/refresh", e.H(HandleRefreshAuth)) +} diff --git a/internal/server/api/command.go b/internal/server/api/command.go new file mode 100644 index 0000000..8618f76 --- /dev/null +++ b/internal/server/api/command.go @@ -0,0 +1,34 @@ +package api + +import ( + "errors" + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/commands" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetCommandExecutionStatus(e *env.Env, c echo.Context) error { + var req dto.GetCommandExecutionStatusRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + status, err := commands.GetExecutionStatus(req.ExecutionID) + if err != nil { + if errors.Is(err, commands.ErrNotPolled) { + return c.JSON(http.StatusOK, dto.CommandExecutionStatusResponse{ + Started: false, + }) + } + return echo.ErrNotFound + } + + return c.JSON(http.StatusOK, dto.CommandExecutionStatusResponse{ + CombinedOutput: status.CombinedOutput, + Started: true, + Finished: status.Finished, + Success: status.StreamError == nil, + }) +} diff --git a/internal/server/api/github.go b/internal/server/api/github.go new file mode 100644 index 0000000..102e843 --- /dev/null +++ b/internal/server/api/github.go @@ -0,0 +1,58 @@ +package api + +import ( + "github.com/google/go-github/v48/github" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "net/http" +) + +func ReceiveGithubWebhookEvent(e *env.Env, c echo.Context) error { + var ghApp models.GithubApp + if ghErr := e.DB.First(&ghApp).Error; ghErr != nil { + log.Error().Err(ghErr).Msg("failed to retrieve github app info") + return echo.ErrInternalServerError + } + + req := c.Request() + secret := []byte(ghApp.WebhookSecret) + payload, validationErr := github.ValidatePayload(req, secret) + if validationErr != nil { + return echo.ErrBadRequest + } + event, parseErr := github.ParseWebHook(github.WebHookType(req), payload) + if parseErr != nil { + log.Error().Err(parseErr).Msg("failed to parse webhook") + return echo.ErrInternalServerError + } + var err error + switch event := event.(type) { + case *github.MetaEvent: + err = processMetaEvent(e, event) + case *github.PushEvent: + err = processPushEvent(e, event) + default: + log.Error(). + Interface("type", event). + Msg("received unsupported webhook event") + return echo.ErrInternalServerError + } + + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err) + } + + return c.NoContent(http.StatusOK) +} + +func processMetaEvent(e *env.Env, event *github.MetaEvent) error { + log.Debug().Interface("event", event).Msg("got meta event") + + return nil +} + +func processPushEvent(e *env.Env, event *github.PushEvent) error { + return nil +} diff --git a/internal/server/api/routes.go b/internal/server/api/routes.go new file mode 100644 index 0000000..459347e --- /dev/null +++ b/internal/server/api/routes.go @@ -0,0 +1,26 @@ +package api + +import ( + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/api/apps" + "gitlab.com/texm/shokku/internal/server/api/auth" + "gitlab.com/texm/shokku/internal/server/api/services" + "gitlab.com/texm/shokku/internal/server/api/settings" + "gitlab.com/texm/shokku/internal/server/api/setup" +) + +func RegisterRoutes(e *env.Env, authMiddleware []echo.MiddlewareFunc) { + g := e.Router.Group("/api") + + setup.RegisterRoutes(e, g.Group("/setup")) + auth.RegisterRoutes(e, g.Group("/auth"), g.Group("/auth", authMiddleware...)) + + protectedApi := g.Group("", authMiddleware...) + apps.RegisterRoutes(e, protectedApi.Group("/apps")) + services.RegisterRoutes(e, protectedApi.Group("/services")) + settings.RegisterRoutes(e, protectedApi.Group("/settings")) + + protectedApi.GET("/exec/status", e.H(GetCommandExecutionStatus)) + g.POST("/github/events", e.H(ReceiveGithubWebhookEvent)) +} diff --git a/internal/server/api/routes_test.go b/internal/server/api/routes_test.go new file mode 100644 index 0000000..8c7caf2 --- /dev/null +++ b/internal/server/api/routes_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func createQueryContext(route string, params url.Values) (*env.Env, echo.Context) { + e := env.NewTestingEnvironment() + + uri := fmt.Sprintf("%s/?%s", route, params.Encode()) + req := httptest.NewRequest(http.MethodGet, uri, nil) + rec := httptest.NewRecorder() + + c := e.Router.NewContext(req, rec) + + e.Router.GET("/ping", e.H(pingRoute)) + + return e, c +} + +type pingRequest struct { + Foo string `query:"foo" validate:"alphanum"` +} + +func pingRoute(e *env.Env, c echo.Context) error { + var req pingRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + return c.NoContent(http.StatusOK) +} + +func TestPingRouteValidatesSuccess(t *testing.T) { + q := make(url.Values) + q.Set("foo", "bar") + e, c := createQueryContext("/ping", q) + + err := pingRoute(e, c) + assert.NoError(t, err) +} + +func TestPingRouteValidatesFail(t *testing.T) { + q := make(url.Values) + q.Set("foo", "bar!!") + e, c := createQueryContext("/ping", q) + + err := pingRoute(e, c) + assert.Error(t, err) +} diff --git a/internal/server/api/services/backups.go b/internal/server/api/services/backups.go new file mode 100644 index 0000000..d0977b1 --- /dev/null +++ b/internal/server/api/services/backups.go @@ -0,0 +1,209 @@ +package services + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/commands" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetServiceBackupReport(e *env.Env, c echo.Context) error { + var req dto.GetServiceBackupReportRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + + cmd := fmt.Sprintf("%s:backup-schedule-cat %s", dbSvc.Type, req.Name) + backupSchedule, err := e.Dokku.Exec(cmd) + if err != nil { + backupSchedule = "" + } + + report := dto.ServiceBackupReport{ + AuthSet: dbSvc.BackupAuthSet, + EncryptionSet: dbSvc.BackupEncryptionSet, + Bucket: dbSvc.BackupBucket, + Schedule: backupSchedule, + } + + return c.JSON(http.StatusOK, dto.GetServiceBackupReportResponse{ + Report: report, + }) +} + +func SetServiceBackupAuth(e *env.Env, c echo.Context) error { + var req dto.SetServiceBackupsAuthRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + + cfg := req.Config + args := fmt.Sprintf("%s %s %s %s %s", cfg.AccessKeyId, cfg.SecretKey, + cfg.Region, cfg.SignatureVersion, cfg.EndpointUrl) + cmd := fmt.Sprintf("%s:backup-auth %s %s", dbSvc.Type, req.Name, args) + if _, execErr := e.Dokku.Exec(cmd); execErr != nil { + return fmt.Errorf("setting backup auth: %w", execErr) + } + + dbSvc.BackupAuthSet = true + if err := e.DB.Save(&dbSvc).Error; err != nil { + log.Error().Err(err).Msg("error updating service backup auth") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} + +func SetServiceBackupBucket(e *env.Env, c echo.Context) error { + var req dto.SetServiceBackupsBucketRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + + dbSvc.BackupBucket = req.Bucket + if dbErr := e.DB.Save(&dbSvc).Error; dbErr != nil { + log.Error().Err(dbErr).Msg("error updating service backup bucket") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} + +func RunServiceBackup(e *env.Env, c echo.Context) error { + var req dto.RunServiceBackupRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + if dbSvc.BackupBucket == "" { + return echo.NewHTTPError(http.StatusBadRequest, "backup bucket not set") + } else if !dbSvc.BackupAuthSet { + return echo.NewHTTPError(http.StatusBadRequest, "backup auth not set") + } + + dokkuCmd := fmt.Sprintf("%s:backup %s %s", dbSvc.Type, dbSvc.Name, dbSvc.BackupBucket) + + cmd := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.ExecStreaming(dokkuCmd) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} + +func SetServiceBackupSchedule(e *env.Env, c echo.Context) error { + var req dto.SetServiceBackupsScheduleRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + if dbSvc.BackupBucket == "" { + return echo.NewHTTPError(http.StatusBadRequest, + "service backup bucket not set") + } + + cmd := fmt.Sprintf(`%s:backup-schedule %s "%s" %s`, dbSvc.Type, + req.Name, req.Schedule, dbSvc.BackupBucket) + if out, err := e.Dokku.Exec(cmd); err != nil { + log.Debug().Str("output", out).Msg("backup schedule output") + return fmt.Errorf("setting backup schedule: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func RemoveServiceBackupSchedule(e *env.Env, c echo.Context) error { + var req dto.ManageServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + cmd := fmt.Sprintf(`%s:backup-unschedule %s`, req.Type, req.Name) + if out, err := e.Dokku.Exec(cmd); err != nil { + log.Debug().Str("output", out).Msg("backup schedule output") + return fmt.Errorf("removing backup schedule: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func SetServiceBackupEncryption(e *env.Env, c echo.Context) error { + var req dto.SetServiceBackupsEncryptionRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + + cmd := fmt.Sprintf(`%s:backup-set-encryption %s %s`, dbSvc.Type, + req.Name, req.Passphrase) + if out, err := e.Dokku.Exec(cmd); err != nil { + log.Debug().Str("output", out).Msg("set backup encryption output") + return fmt.Errorf("setting backup encryption: %w", err) + } + + dbSvc.BackupEncryptionSet = true + if saveErr := e.DB.Save(&dbSvc).Error; saveErr != nil { + log.Error().Err(saveErr).Msg("error updating service backup encryption") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} + +func RemoveServiceBackupEncryption(e *env.Env, c echo.Context) error { + var req dto.ManageServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + + cmd := fmt.Sprintf(`%s:backup-unset-encryption %s`, req.Type, req.Name) + if out, err := e.Dokku.Exec(cmd); err != nil { + log.Debug().Str("output", out).Msg("unset backup encryption output") + return fmt.Errorf("removing backup encryption: %w", err) + } + + dbSvc.BackupEncryptionSet = false + if saveErr := e.DB.Save(&dbSvc).Error; saveErr != nil { + log.Error().Err(saveErr).Msg("error updating service backup encryption") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/services/management.go b/internal/server/api/services/management.go new file mode 100644 index 0000000..e3424e9 --- /dev/null +++ b/internal/server/api/services/management.go @@ -0,0 +1,167 @@ +package services + +import ( + "errors" + "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" + "strings" +) + +var ( + ErrDestroyingLinkedService = echo.NewHTTPError(http.StatusBadRequest, + "cannot destroy a linked service") + ErrServiceNameTaken = echo.NewHTTPError(http.StatusBadRequest, + "service name exists") +) + +func manageService(e *env.Env, c echo.Context, mgmt string, flags string) error { + var req dto.GenericServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + cmd := fmt.Sprintf("%s:%s %s %s", req.Type, mgmt, req.Name, flags) + if _, err := e.Dokku.Exec(cmd); err != nil { + return fmt.Errorf(mgmt+"ing service", err) + } + + return c.NoContent(http.StatusOK) +} + +func StartService(e *env.Env, c echo.Context) error { + return manageService(e, c, "start", "") +} +func StopService(e *env.Env, c echo.Context) error { + return manageService(e, c, "stop", "") +} +func RestartService(e *env.Env, c echo.Context) error { + return manageService(e, c, "restart", "") +} + +func DestroyService(e *env.Env, c echo.Context) error { + var req dto.GenericServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, lookupErr := lookupDBServiceByName(e, req.Name) + if lookupErr != nil { + return echo.ErrNotFound + } + + cmd := fmt.Sprintf("%s:destroy %s -f", req.Type, req.Name) + if _, err := e.Dokku.Exec(cmd); err != nil { + var dokkuErr *dokku.ExitCodeError + if errors.As(err, &dokkuErr) { + if strings.HasSuffix(dokkuErr.Output(), "Cannot delete linked service") { + return ErrDestroyingLinkedService + } + } + return fmt.Errorf("destroying service: %w", err) + } + + if err := e.DB.Delete(&dbSvc).Error; err != nil { + log.Error().Err(err).Interface("svc", dbSvc).Msg("error deleting service") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} + +// use short opt strings otherwise arg parse is funky +func maybeAppendStringOptionFlag(flags *[]string, opt string, cfgOption *string) { + if cfgOption != nil { + *flags = append(*flags, fmt.Sprintf("-%s '%s'", opt, *cfgOption)) + } +} + +func getServiceCreateFlags(req dto.GenericServiceCreationConfig) string { + flags := &[]string{} + + if req.CustomEnv != nil { + envVars := make([]string, len(*req.CustomEnv)) + for i, e := range *req.CustomEnv { + envVars[i] = fmt.Sprintf("%s=%s", e[0], e[1]) + } + envStr := strings.Join(envVars, ";") + *flags = append(*flags, fmt.Sprintf("-C \"%s\"", envStr)) + } + + maybeAppendStringOptionFlag(flags, "c", req.ConfigOptions) + maybeAppendStringOptionFlag(flags, "i", req.Image) + maybeAppendStringOptionFlag(flags, "m", req.MemoryLimit) + maybeAppendStringOptionFlag(flags, "p", req.Password) + maybeAppendStringOptionFlag(flags, "r", req.RootPassword) + maybeAppendStringOptionFlag(flags, "s", req.SharedMemorySize) + + return strings.Join(*flags, " ") +} + +func CreateNewGenericService(e *env.Env, c echo.Context) error { + var req dto.CreateGenericServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + log.Debug().Err(err.ToHTTP()).Interface("req", req).Msgf("req failed") + return err.ToHTTP() + } + + dbSvc := models.Service{ + Name: req.Name, + Type: req.ServiceType, + } + if e.DB.Where("name = ?", req.Name).Find(&dbSvc).RowsAffected != 0 { + return ErrServiceNameTaken + } + + e.DB.Save(&dbSvc) + + flags := getServiceCreateFlags(req.Config) + cmd := fmt.Sprintf("%s:create %s %s", req.ServiceType, req.Name, flags) + _, err := e.Dokku.Exec(cmd) + if err != nil { + return fmt.Errorf("creating %s service: %w", req.ServiceType, err) + } + + return c.NoContent(http.StatusOK) +} + +func CloneService(e *env.Env, c echo.Context) error { + var req dto.CloneServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, notFoundErr := lookupDBServiceByName(e, req.Name) + if notFoundErr != nil { + return echo.NewHTTPError(http.StatusNotFound, "service not found") + } + + _, notFoundErr = lookupDBServiceByName(e, req.NewName) + if notFoundErr == nil { + return echo.NewHTTPError(http.StatusBadRequest, "new service name exists") + } + + cmd := fmt.Sprintf("%s:clone %s %s", dbSvc.Type, req.Name, req.NewName) + _, err := e.Dokku.Exec(cmd) + if err != nil { + return fmt.Errorf("cloning %s service: %w", dbSvc.Type, err) + } + + newSvc := models.Service{ + Name: req.NewName, + Type: dbSvc.Type, + } + if err := e.DB.Save(&newSvc).Error; err != nil { + log.Error().Err(err). + Str("name", req.NewName).Str("type", dbSvc.Type). + Msg("failed to save cloned service to db") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/services/routes.go b/internal/server/api/services/routes.go new file mode 100644 index 0000000..7fd8dc5 --- /dev/null +++ b/internal/server/api/services/routes.go @@ -0,0 +1,34 @@ +package services + +import ( + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" +) + +func RegisterRoutes(e *env.Env, g *echo.Group) { + g.GET("/list", e.H(ListServices)) + g.GET("/info", e.H(GetServiceInfo)) + g.GET("/type", e.H(GetServiceType)) + g.GET("/logs", e.H(GetServiceLogs)) + + g.POST("/create", e.H(CreateNewGenericService)) + g.POST("/clone", e.H(CloneService)) + g.POST("/start", e.H(StartService)) + g.POST("/stop", e.H(StopService)) + g.POST("/restart", e.H(RestartService)) + g.POST("/destroy", e.H(DestroyService)) + + g.POST("/link", e.H(LinkGenericServiceToApp)) + g.POST("/unlink", e.H(UnlinkGenericServiceFromApp)) + g.GET("/linked-apps", e.H(GetServiceLinkedApps)) + + backups := g.Group("/backups") + backups.GET("/report", e.H(GetServiceBackupReport)) + backups.POST("/auth", e.H(SetServiceBackupAuth)) + backups.POST("/bucket", e.H(SetServiceBackupBucket)) + backups.POST("/run", e.H(RunServiceBackup)) + backups.POST("/schedule", e.H(SetServiceBackupSchedule)) + backups.DELETE("/schedule", e.H(RemoveServiceBackupSchedule)) + backups.POST("/encryption", e.H(SetServiceBackupEncryption)) + backups.DELETE("/encryption", e.H(RemoveServiceBackupEncryption)) +} diff --git a/internal/server/api/services/services.go b/internal/server/api/services/services.go new file mode 100644 index 0000000..11a9376 --- /dev/null +++ b/internal/server/api/services/services.go @@ -0,0 +1,220 @@ +package services + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/commands" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" + "strings" +) + +var ( + dokkuErrPrefix = "! " + serviceTypes = []string{"redis", "postgres", "mysql", "mongo"} +) + +func lookupDBServiceByName(e *env.Env, name string) (*models.Service, error) { + dbSvc := models.Service{ + Name: name, + } + res := e.DB.Where("name = ?", name).Find(&dbSvc) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, fmt.Errorf("no service found for %s", name) + } + return &dbSvc, nil +} + +func splitDokkuListOutput(output string) ([]string, error) { + if strings.HasPrefix(output, dokkuErrPrefix) { + return nil, nil + } + if output == "" { + return []string{}, nil + } + return strings.Split(output, "\n"), nil +} + +func getServiceAppLinks(e *env.Env, serviceName string, serviceType string) ([]string, error) { + linksCmd := fmt.Sprintf("%s:links %s --quiet", serviceType, serviceName) + out, err := e.Dokku.Exec(linksCmd) + if err != nil { + return nil, err + } + return splitDokkuListOutput(out) +} + +func getServiceList(e *env.Env, serviceType string) ([]string, error) { + listCmd := fmt.Sprintf("%s:list --quiet", serviceType) + out, err := e.Dokku.Exec(listCmd) + if err != nil { + return nil, err + } + if strings.Contains(out, "There are no") { + return []string{}, nil + } + return splitDokkuListOutput(out) +} + +func ListServices(e *env.Env, c echo.Context) error { + serviceList := []dto.ServiceInfo{} + + for _, serviceType := range serviceTypes { + services, err := getServiceList(e, serviceType) + if err != nil { + return fmt.Errorf("getting list for %s services: %w", serviceType, err) + } + for _, name := range services { + serviceList = append(serviceList, dto.ServiceInfo{ + Name: name, + Type: serviceType, + }) + } + } + + return c.JSON(http.StatusOK, dto.ListServicesResponse{ + Services: serviceList, + }) +} + +func GetServiceType(e *env.Env, c echo.Context) error { + var req dto.GetServiceTypeRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.Name) + if err != nil { + return echo.ErrNotFound + } + + return c.JSON(http.StatusOK, dto.GetServiceTypeResponse{ + Type: dbSvc.Type, + }) +} + +func GetServiceInfo(e *env.Env, c echo.Context) error { + var req dto.GenericServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + info := map[string]string{ + "version": "", "internal-ip": "", "status": "", // "dsn": "", + } + for key := range info { + cmd := fmt.Sprintf("%s:info %s --%s", req.Type, req.Name, key) + out, err := e.Dokku.Exec(cmd) + if err != nil { + return fmt.Errorf("getting service info: %w", err) + } + info[key] = out + } + + return c.JSON(http.StatusOK, dto.GetServiceInfoResponse{ + Info: info, + }) +} + +func getGenericLinkFlags(req dto.LinkGenericServiceToAppRequest) string { + var flags []string + if req.Alias != "" { + flag := fmt.Sprintf("--alias %s", req.Alias) + flags = append(flags, flag) + } + if req.QueryString != "" { + flag := fmt.Sprintf("--querystring %s", req.QueryString) + flags = append(flags, flag) + } + return strings.Join(flags, " ") +} + +func LinkGenericServiceToApp(e *env.Env, c echo.Context) error { + var req dto.LinkGenericServiceToAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.ServiceName) + if err != nil { + return echo.ErrNotFound + } + + flags := getGenericLinkFlags(req) + linkCmd := fmt.Sprintf("%s:link %s %s %s", + dbSvc.Type, dbSvc.Name, req.AppName, flags) + + cmd := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.ExecStreaming(linkCmd) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} + +func UnlinkGenericServiceFromApp(e *env.Env, c echo.Context) error { + var req dto.LinkGenericServiceToAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + dbSvc, err := lookupDBServiceByName(e, req.ServiceName) + if err != nil { + return echo.ErrNotFound + } + + unlinkCmd := fmt.Sprintf("%s:unlink %s %s", dbSvc.Type, + dbSvc.Name, req.AppName) + + cmd := func() (*dokku.CommandOutputStream, error) { + return e.Dokku.ExecStreaming(unlinkCmd) + } + + return c.JSON(http.StatusOK, dto.CommandExecutionResponse{ + ExecutionID: commands.RequestExecution(cmd, nil), + }) +} + +func GetServiceLinkedApps(e *env.Env, c echo.Context) error { + var req dto.GenericServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + log.Error().Err(err.ToHTTP()).Msg("error") + return err.ToHTTP() + } + + apps, err := getServiceAppLinks(e, req.Name, req.Type) + if err != nil { + return fmt.Errorf("getting linked apps: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetServiceLinkedAppsResponse{ + Apps: apps, + }) +} + +func GetServiceLogs(e *env.Env, c echo.Context) error { + var req dto.GenericServiceRequest + if err := dto.BindRequest(c, &req); err != nil { + log.Error().Err(err.ToHTTP()).Msg("error") + return err.ToHTTP() + } + + cmd := fmt.Sprintf("%s:logs %s", req.Type, req.Name) + out, err := e.Dokku.Exec(cmd) + if err != nil { + return fmt.Errorf("getting linked apps: %w", err) + } + + logs := strings.Split(out, "\n") + return c.JSON(http.StatusOK, dto.GetServiceLogsResponse{ + Logs: logs, + }) +} diff --git a/internal/server/api/settings/networks.go b/internal/server/api/settings/networks.go new file mode 100644 index 0000000..944c976 --- /dev/null +++ b/internal/server/api/settings/networks.go @@ -0,0 +1,46 @@ +package settings + +import ( + "fmt" + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func CreateNetwork(e *env.Env, c echo.Context) error { + var req dto.AlterNetworkRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.CreateNetwork(req.Network); err != nil { + return fmt.Errorf("error creating network: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func DestroyNetwork(e *env.Env, c echo.Context) error { + var req dto.AlterNetworkRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.DestroyNetwork(req.Network); err != nil { + return fmt.Errorf("error destroying network: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func ListNetworks(e *env.Env, c echo.Context) error { + networks, err := e.Dokku.ListNetworks() + if err != nil { + return fmt.Errorf("error listing networks: %w", err) + } + + return c.JSON(http.StatusOK, dto.ListNetworksResponse{ + Networks: networks, + }) +} diff --git a/internal/server/api/settings/registry.go b/internal/server/api/settings/registry.go new file mode 100644 index 0000000..36cda85 --- /dev/null +++ b/internal/server/api/settings/registry.go @@ -0,0 +1,75 @@ +package settings + +import ( + "errors" + "fmt" + "github.com/labstack/echo/v4" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func SetDockerRegistry(e *env.Env, c echo.Context) error { + var req dto.SetDockerRegistryRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.LoginDockerRegistry(req.Server, req.Username, req.Password); err != nil { + return echo.NewHTTPError(http.StatusForbidden, err.Error()) + } + + propErr := e.Dokku.SetAppDockerRegistryProperty("--global", dokku.DockerRegistryPropertyServer, req.Server) + if propErr != nil { + return echo.NewHTTPError(http.StatusInternalServerError, propErr.Error()) + } + + return c.NoContent(http.StatusOK) +} + +func GetDockerRegistryReport(e *env.Env, c echo.Context) error { + report, err := e.Dokku.GetDockerRegistryReport() + if err != nil && !errors.Is(err, dokku.NoDeployedAppsError) { + return fmt.Errorf("failed to get registry report: %w", err) + } + + var response dto.GetDockerRegistryReportResponse + for _, appReport := range report { + response.Server = appReport.GlobalServer + response.PushOnRelease = appReport.GlobalPushOnRelease + break + } + + return c.JSON(http.StatusOK, response) +} + +func AddGitAuth(e *env.Env, c echo.Context) error { + var req dto.AddGitAuthRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.GitSetAuth(req.Host, req.Username, req.Password); err != nil { + return echo.NewHTTPError(http.StatusForbidden, err.Error()) + } + + if err := e.Dokku.GitAllowHost(req.Host); err != nil { + return echo.NewHTTPError(http.StatusForbidden, err.Error()) + } + + return c.NoContent(http.StatusOK) +} + +func RemoveGitAuth(e *env.Env, c echo.Context) error { + var req dto.RemoveGitAuthRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.GitRemoveAuth(req.Host); err != nil { + return echo.NewHTTPError(http.StatusForbidden, err.Error()) + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/settings/routes.go b/internal/server/api/settings/routes.go new file mode 100644 index 0000000..cc9c0f6 --- /dev/null +++ b/internal/server/api/settings/routes.go @@ -0,0 +1,33 @@ +package settings + +import ( + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" +) + +func RegisterRoutes(e *env.Env, g *echo.Group) { + g.GET("/versions", e.H(GetVersions)) + + g.GET("/events", e.H(GetEventLogs)) + g.GET("/events/list", e.H(GetEventLogsList)) + g.POST("/events", e.H(SetEventLoggingEnabled)) + + g.GET("/users", e.H(GetUsers)) + g.GET("/ssh-keys", e.H(GetSSHKeys)) + + g.GET("/domains", e.H(GetGlobalDomains)) + g.POST("/domains", e.H(AddGlobalDomain)) + g.DELETE("/domains", e.H(RemoveGlobalDomain)) + + g.GET("/networks", e.H(ListNetworks)) + g.POST("/networks", e.H(CreateNetwork)) + g.DELETE("/networks", e.H(DestroyNetwork)) + + g.GET("/plugins", e.H(ListPlugins)) + + g.GET("/registry", e.H(GetDockerRegistryReport)) + g.POST("/registry", e.H(SetDockerRegistry)) + + g.POST("/git-auth", e.H(AddGitAuth)) + g.DELETE("/git-auth", e.H(RemoveGitAuth)) +} diff --git a/internal/server/api/settings/settings.go b/internal/server/api/settings/settings.go new file mode 100644 index 0000000..2258121 --- /dev/null +++ b/internal/server/api/settings/settings.go @@ -0,0 +1,156 @@ +package settings + +import ( + "fmt" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" + + "github.com/labstack/echo/v4" +) + +func GetVersions(e *env.Env, c echo.Context) error { + dokkuVersion, err := e.Dokku.GetDokkuVersion() + if err != nil { + return fmt.Errorf("getting dokku version: %w", err) + } + return c.JSON(http.StatusOK, &dto.GetVersionsResponse{ + Dokku: dokkuVersion, + Shokku: e.Version, + }) +} + +func GetUsers(e *env.Env, c echo.Context) error { + var dbUsers []models.User + res := e.DB.Model(models.User{}).Preload("SSHKeys").Find(&dbUsers) + if res.Error != nil { + return fmt.Errorf("querying db users: %w", res.Error) + } + + users := make([]dto.User, len(dbUsers)) + for i, dbUser := range dbUsers { + keys := make([]string, len(dbUser.SSHKeys)) + for j, key := range dbUser.SSHKeys { + keys[j] = key.Key + } + users[i] = dto.User{ + Name: dbUser.Name, + Source: dbUser.Source, + SSHKeys: keys, + } + } + + return c.JSON(http.StatusOK, dto.GetUsersResponse{ + Users: users, + }) +} + +func GetSSHKeys(e *env.Env, c echo.Context) error { + keys, err := e.Dokku.ListSSHKeys() + if err != nil { + return fmt.Errorf("listing ssh keys: %w", err) + } + return c.JSON(http.StatusOK, &dto.GetSSHKeysResponse{ + Keys: keys, + }) +} + +func GetGlobalDomains(e *env.Env, c echo.Context) error { + report, err := e.Dokku.GetGlobalDomainsReport() + if err != nil { + return fmt.Errorf("getting global domains report: %w", err) + } + + if len(report.Domains) == 0 { + report.Domains = make([]string, 0) + } + + return c.JSON(http.StatusOK, &dto.GetGlobalDomainsResponse{ + Domains: report.Domains, + Enabled: report.Enabled, + }) +} + +func AddGlobalDomain(e *env.Env, c echo.Context) error { + var req dto.AlterGlobalDomainRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + // TODO: domain verification etc + if err := e.Dokku.AddGlobalDomain(req.Domain); err != nil { + return fmt.Errorf("adding global domain: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func RemoveGlobalDomain(e *env.Env, c echo.Context) error { + var req dto.DeleteGlobalDomainRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + // TODO: domain verification etc + if err := e.Dokku.RemoveGlobalDomain(req.Domain); err != nil { + return fmt.Errorf("removing global domain: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func SetEventLoggingEnabled(e *env.Env, c echo.Context) error { + var req dto.SetEventLoggingEnabledRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := e.Dokku.SetEventLoggingEnabled(req.Enabled); err != nil { + return fmt.Errorf("setting event logging: %w", err) + } + + return c.NoContent(http.StatusOK) +} + +func GetEventLogsList(e *env.Env, c echo.Context) error { + events, err := e.Dokku.ListLoggedEvents() + if err != nil { + return fmt.Errorf("removing global domain: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetEventLogsListResponse{ + Events: events, + }) +} + +func GetEventLogs(e *env.Env, c echo.Context) error { + logs, err := e.Dokku.GetEventLogs() + if err != nil { + return fmt.Errorf("getting event logs: %w", err) + } + + return c.JSON(http.StatusOK, dto.GetEventLogsResponse{ + Logs: logs, + }) +} + +func ListPlugins(e *env.Env, c echo.Context) error { + plugins, err := e.Dokku.ListPlugins() + if err != nil { + return fmt.Errorf("listing plugins: %w", err) + } + info := make([]dto.PluginInfo, len(plugins)) + for i := 0; i < len(plugins); i++ { + p := plugins[i] + info[i] = dto.PluginInfo{ + Name: p.Name, + Version: p.Version, + Enabled: p.Enabled, + Description: p.Description, + } + } + return c.JSON(http.StatusOK, &dto.ListPluginsResponse{ + Plugins: info, + }) +} diff --git a/internal/server/api/setup/github.go b/internal/server/api/setup/github.go new file mode 100644 index 0000000..59dc86b --- /dev/null +++ b/internal/server/api/setup/github.go @@ -0,0 +1,111 @@ +package setup + +import ( + "context" + "database/sql" + "errors" + "fmt" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/auth" + "gitlab.com/texm/shokku/internal/server/dto" + "gitlab.com/texm/shokku/internal/server/github" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +const ( + githubAppInstallURL = "https://github.com/apps/%s/installations/new/permissions?target_id=%d" +) + +func GetGithubSetupStatus(e *env.Env, c echo.Context) error { + var ghApp models.GithubApp + r := e.DB.Find(&ghApp) + if r.Error != nil && !errors.Is(r.Error, sql.ErrNoRows) { + log.Error().Err(r.Error).Msg("Failed to lookup github app") + return echo.ErrInternalServerError + } + + return c.JSON(http.StatusOK, dto.GetGithubSetupStatus{ + AppCreated: r.RowsAffected > 0, + }) +} + +func CreateGithubApp(e *env.Env, c echo.Context) error { + var req dto.CreateGithubAppRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + cfg, err := github.CompleteAppManifest(e, req.Code) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err) + } + + return c.JSON(http.StatusOK, dto.CreateGithubAppResponse{ + Slug: cfg.GetSlug(), + }) +} + +func GetGithubAppInstallInfo(e *env.Env, c echo.Context) error { + ctx := context.Background() + + client, clientErr := github.GetAppClient(e) + if clientErr != nil { + log.Error().Err(clientErr).Msg("failed to get app client") + return echo.NewHTTPError(http.StatusBadRequest, clientErr) + } + + app, appErr := client.GetApp(ctx) + if appErr != nil { + log.Error().Err(appErr).Msg("failed to get github client app") + return echo.NewHTTPError(http.StatusBadRequest, appErr) + } + url := fmt.Sprintf(githubAppInstallURL, app.GetSlug(), app.Owner.GetID()) + + return c.JSON(http.StatusOK, dto.InstallGithubAppResponse{ + InstallURL: url, + }) +} + +func CompleteGithubSetup(e *env.Env, c echo.Context) error { + var req dto.CompleteGithubSetupRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + client, clientErr := github.GetAppClient(e) + if clientErr != nil { + log.Error().Err(clientErr).Msg("failed to get app client") + return echo.ErrBadRequest + } + + ctx := context.Background() + inst, _, instErr := client.Apps.GetInstallation(ctx, req.InstallationId) + if instErr != nil { + log.Error(). + Err(instErr). + Int64("id", req.InstallationId). + Msg("failed to get installation") + return echo.ErrBadRequest + } + + if err := setupServerWithAuthMethod(e, auth.MethodGithub); err != nil { + log.Error().Err(err).Msg("failed to setup github auth") + return echo.ErrInternalServerError + } + + go func() { + if err := github.SyncUsersToDB(e); err != nil { + log.Error().Err(err).Msg("failed to sync github users") + } + }() + + log.Debug(). + Int64("id", inst.GetID()). + Msg("installed github app") + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/setup/password.go b/internal/server/api/setup/password.go new file mode 100644 index 0000000..834c59d --- /dev/null +++ b/internal/server/api/setup/password.go @@ -0,0 +1,101 @@ +package setup + +import ( + "bytes" + "encoding/base64" + "fmt" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/auth" + "image/png" + "math/rand" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/pquerna/otp/totp" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/dto" +) + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func generateRandomString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} + +func GenerateTotp(e *env.Env, c echo.Context) error { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "shokku", + AccountName: "admin account", + }) + if err != nil { + return fmt.Errorf("failed to generate totp key: %w", err) + } + + var imgBuf bytes.Buffer + img, imgErr := key.Image(160, 160) + if imgErr != nil { + return fmt.Errorf("failed to generate totp image: %w", imgErr) + } + if encErr := png.Encode(&imgBuf, img); encErr != nil { + return fmt.Errorf("failed to encode totp image to png: %w", encErr) + } + return c.JSON(http.StatusOK, dto.GenerateTotpResponse{ + Secret: key.Secret(), + Image: base64.StdEncoding.EncodeToString(imgBuf.Bytes()), + RecoveryCode: generateRandomString(12), + }) +} + +func ConfirmTotp(e *env.Env, c echo.Context) error { + var req dto.ConfirmTotpRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + return c.JSON(http.StatusOK, dto.ConfirmTotpResponse{ + Valid: totp.Validate(req.Code, req.Secret), + }) +} + +func CompletePasswordSetup(e *env.Env, c echo.Context) error { + var req dto.CompletePasswordSetupRequest + if err := dto.BindRequest(c, &req); err != nil { + return err.ToHTTP() + } + + if err := setupServerWithAuthMethod(e, auth.MethodPassword); err != nil { + log.Error().Err(err).Msg("failed to setup github auth") + return echo.ErrInternalServerError + } + + pw, ok := e.Auth.(*auth.PasswordAuthenticator) + if !ok { + log.Error().Msg("failed to cast e.Auth to pw auth") + return echo.ErrInternalServerError + } + passwordHash, pwErr := pw.HashPassword([]byte(req.Password)) + if pwErr != nil { + log.Error().Err(pwErr).Msg("failed to hash password") + return echo.ErrInternalServerError + } + + user := models.User{ + Name: req.Username, + Source: "manual", + PasswordHash: passwordHash, + TotpEnabled: req.Enable2FA, + RecoveryCode: req.RecoveryCode, + TotpSecret: req.TotpSecret, + } + if err := e.DB.Save(&user).Error; err != nil { + log.Error().Err(err).Msg("failed to save initial password auth user") + return echo.ErrInternalServerError + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/server/api/setup/routes.go b/internal/server/api/setup/routes.go new file mode 100644 index 0000000..740f77f --- /dev/null +++ b/internal/server/api/setup/routes.go @@ -0,0 +1,20 @@ +package setup + +import ( + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" +) + +func RegisterRoutes(e *env.Env, g *echo.Group) { + g.GET("/status", e.H(GetStatus)) + g.GET("/verify-key", e.H(GetSetupKeyValid)) + + g.POST("/github/create-app", e.H(CreateGithubApp)) + g.GET("/github/install-info", e.H(GetGithubAppInstallInfo)) + g.GET("/github/status", e.H(GetGithubSetupStatus)) + g.POST("/github/completed", e.H(CompleteGithubSetup)) + + g.POST("/password", e.H(CompletePasswordSetup)) + g.POST("/totp/new", e.H(GenerateTotp)) + g.POST("/totp/confirm", e.H(ConfirmTotp)) +} diff --git a/internal/server/api/setup/setup.go b/internal/server/api/setup/setup.go new file mode 100644 index 0000000..cf2e5c9 --- /dev/null +++ b/internal/server/api/setup/setup.go @@ -0,0 +1,65 @@ +package setup + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/auth" + "gitlab.com/texm/shokku/internal/server/dto" + "net/http" +) + +func GetStatus(e *env.Env, c echo.Context) error { + method := string(e.Auth.GetMethod()) + log.Debug(). + Bool("is_setup", e.SetupCompleted). + Str("method", method). + Msg("get setup status") + return c.JSON(http.StatusOK, dto.GetSetupStatusResponse{ + IsSetup: e.SetupCompleted, + Method: method, + }) +} + +func GetSetupKeyValid(e *env.Env, c echo.Context) error { + return c.NoContent(http.StatusOK) +} + +func setupServerWithAuthMethod(e *env.Env, method auth.Method) error { + var state models.Server + e.DB.FirstOrCreate(&state) + + state.IsSetup = true + state.AuthMethod = method + if err := e.DB.Save(&state).Error; err != nil { + log.Error().Err(err).Msg("failed to save setup state") + return echo.ErrInternalServerError + } + + newAuth, authErr := createAuthenticator(e, method) + if authErr != nil { + log.Error().Err(authErr).Msg("failed to init new authenticator") + return echo.ErrInternalServerError + } + e.Auth = newAuth + e.SetupCompleted = true + + return nil +} + +func createAuthenticator(e *env.Env, method auth.Method) (auth.Authenticator, error) { + config := auth.Config{ + SigningKey: e.Auth.GetSigningKey(), + CookieDomain: e.Auth.GetCookieDomain(), + TokenLifetime: e.Auth.GetTokenLifetime(), + } + switch method { + case auth.MethodGithub: + return auth.NewGithubAuthenticator(config) + case auth.MethodPassword: + return auth.NewPasswordAuthenticator(config, auth.DefaultBCryptCost) + } + return nil, fmt.Errorf("unknown method %s", method) +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..a293c89 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,43 @@ +package server + +import ( + "fmt" + "gitlab.com/texm/shokku/internal/server/auth" + "time" +) + +type initAuthConfig struct { + SigningKey []byte + TokenLifetime time.Duration + Method auth.Method + DebugMode bool + IsSetup bool +} + +func initAuthenticator(cfg initAuthConfig) (auth.Authenticator, error) { + authCfg := auth.Config{ + SigningKey: cfg.SigningKey, + TokenLifetime: cfg.TokenLifetime, + } + + if !cfg.IsSetup { + return auth.NewNoneAuthenticator(authCfg) + } + + bCryptCost := 14 + if cfg.DebugMode { + // make hashing faster in dev + bCryptCost = 3 + } + + switch cfg.Method { + case auth.MethodPassword: + return auth.NewPasswordAuthenticator(authCfg, bCryptCost) + case auth.MethodGithub: + return auth.NewGithubAuthenticator(authCfg) + case auth.MethodNone: + return auth.NewNoneAuthenticator(authCfg) + } + + return nil, fmt.Errorf("unsupported auth method '%s'", cfg.Method) +} diff --git a/internal/server/auth/auth.go b/internal/server/auth/auth.go new file mode 100644 index 0000000..90d2632 --- /dev/null +++ b/internal/server/auth/auth.go @@ -0,0 +1,151 @@ +package auth + +import ( + "errors" + "github.com/golang-jwt/jwt" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "strings" + "time" +) + +const ( + ContextUserKey = "user" + DataCookieName = "auth_data" + SignatureCookieName = "auth_sig" +) + +type Method string + +const ( + MethodNone = Method("none") + MethodPassword = Method("password") + MethodGithub = Method("github") +) + +var ( + ErrNoTokenInContext = errors.New("no token value in context") + ErrNoUserInContext = errors.New("failed to retrieve user from context") + ErrClaimsInvalid = errors.New("failed to cast jwt claims") +) + +type Authenticator interface { + NewToken(claims UserClaims) (string, error) + SetUserContext(c echo.Context, contextKey string) error + GetUserFromContext(c echo.Context) (*User, error) + SetTokenCookies(c echo.Context, jwt string) string + ClearTokenCookies(c echo.Context) + GetSigningKey() []byte + GetCookieDomain() string + GetTokenLifetime() time.Duration + GetMethod() Method +} + +type User struct { + UserClaims + jwt.StandardClaims +} + +type UserClaims struct { + Name string `json:"name"` +} + +type baseAuthenticator struct { + signingKey []byte + authMethod Method + cookieDomain string + tokenLifetime time.Duration +} + +type Config struct { + SigningKey []byte + CookieDomain string + TokenLifetime time.Duration +} + +func (a *baseAuthenticator) NewToken(claims UserClaims) (string, error) { + expiry := time.Now().Add(a.tokenLifetime) + stdClaims := jwt.StandardClaims{ + ExpiresAt: expiry.Unix(), + } + + user := &User{ + UserClaims: claims, + StandardClaims: stdClaims, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, user) + return token.SignedString(a.signingKey) +} + +func (a *baseAuthenticator) SetUserContext(c echo.Context, contextKey string) error { + token, ok := c.Get(contextKey).(*jwt.Token) + if !ok { + return ErrNoTokenInContext + } + + u, ok := token.Claims.(*User) + if !ok { + return ErrClaimsInvalid + } + + c.Set(ContextUserKey, u) + + return nil +} + +func (a *baseAuthenticator) GetUserFromContext(c echo.Context) (*User, error) { + user, ok := c.Get(ContextUserKey).(*User) + if !ok { + log.Error().Msg("failed to retrieve user from context") + return nil, ErrNoUserInContext + } + return user, nil +} + +func (a *baseAuthenticator) SetTokenCookies(c echo.Context, jwt string) string { + splitToken := strings.Split(jwt, ".") + dataCookieValue := strings.Join(splitToken[:2], ".") + signatureCookieValue := splitToken[2] + + // accessible to the js frontend + dataCookieValues := authCookieValues{ + name: DataCookieName, + value: dataCookieValue, + httpOnly: false, + lifetime: a.tokenLifetime, + } + c.SetCookie(makeAuthCookie(dataCookieValues)) + + // inaccessible to the js frontend + signatureCookieValues := authCookieValues{ + name: SignatureCookieName, + value: signatureCookieValue, + httpOnly: true, + lifetime: a.tokenLifetime, + } + c.SetCookie(makeAuthCookie(signatureCookieValues)) + + return dataCookieValue +} + +func (a *baseAuthenticator) ClearTokenCookies(c echo.Context) { + c.SetCookie(clearAuthCookie(DataCookieName, false, a.cookieDomain)) + c.SetCookie(clearAuthCookie(SignatureCookieName, true, a.cookieDomain)) +} + +func (a *baseAuthenticator) GetSigningKey() []byte { + return a.signingKey +} + +func (a *baseAuthenticator) GetMethod() Method { + return a.authMethod +} + +func (a *baseAuthenticator) GetCookieDomain() string { + return a.cookieDomain +} + +func (a *baseAuthenticator) GetTokenLifetime() time.Duration { + return a.tokenLifetime +} diff --git a/internal/server/auth/cookies.go b/internal/server/auth/cookies.go new file mode 100644 index 0000000..b5767a7 --- /dev/null +++ b/internal/server/auth/cookies.go @@ -0,0 +1,43 @@ +package auth + +import ( + "net/http" + "time" +) + +type authCookieValues struct { + name string + value string + // domain string + httpOnly bool + lifetime time.Duration +} + +func makeAuthCookie(values authCookieValues) *http.Cookie { + expiresAt := time.Now().Add(values.lifetime) + + cookie := &http.Cookie{ + Name: values.name, + Value: values.value, + Expires: expiresAt, + HttpOnly: values.httpOnly, + Path: "/", + // Domain: values.domain, + MaxAge: int(values.lifetime.Seconds()), + Secure: true, + SameSite: http.SameSiteLaxMode, + } + return cookie +} + +func clearAuthCookie(name string, httpOnly bool, domain string) *http.Cookie { + vals := authCookieValues{ + name: name, + value: "", + // domain: domain, + httpOnly: httpOnly, + lifetime: time.Second, + } + cookie := makeAuthCookie(vals) + return cookie +} diff --git a/internal/server/auth/github.go b/internal/server/auth/github.go new file mode 100644 index 0000000..8107416 --- /dev/null +++ b/internal/server/auth/github.go @@ -0,0 +1,16 @@ +package auth + +type GithubAuthenticator struct { + baseAuthenticator +} + +func NewGithubAuthenticator(cfg Config) (*GithubAuthenticator, error) { + ghAuth := &GithubAuthenticator{} + // TODO: check these + ghAuth.signingKey = cfg.SigningKey + ghAuth.tokenLifetime = cfg.TokenLifetime + ghAuth.cookieDomain = cfg.CookieDomain + ghAuth.authMethod = MethodGithub + + return ghAuth, nil +} diff --git a/internal/server/auth/none.go b/internal/server/auth/none.go new file mode 100644 index 0000000..71956d3 --- /dev/null +++ b/internal/server/auth/none.go @@ -0,0 +1,14 @@ +package auth + +type NoneAuthenticator struct { + baseAuthenticator +} + +func NewNoneAuthenticator(cfg Config) (*NoneAuthenticator, error) { + noneAuth := &NoneAuthenticator{} + noneAuth.signingKey = cfg.SigningKey + noneAuth.tokenLifetime = cfg.TokenLifetime + noneAuth.cookieDomain = cfg.CookieDomain + noneAuth.authMethod = MethodNone + return noneAuth, nil +} diff --git a/internal/server/auth/password.go b/internal/server/auth/password.go new file mode 100644 index 0000000..05531d2 --- /dev/null +++ b/internal/server/auth/password.go @@ -0,0 +1,31 @@ +package auth + +import ( + "golang.org/x/crypto/bcrypt" +) + +const DefaultBCryptCost = 14 + +type PasswordAuthenticator struct { + baseAuthenticator + bcryptCost int +} + +func NewPasswordAuthenticator(cfg Config, bCryptCost int) (*PasswordAuthenticator, error) { + pwAuth := &PasswordAuthenticator{} + pwAuth.bcryptCost = bCryptCost + pwAuth.signingKey = cfg.SigningKey + pwAuth.tokenLifetime = cfg.TokenLifetime + pwAuth.cookieDomain = cfg.CookieDomain + pwAuth.authMethod = MethodPassword + + return pwAuth, nil +} + +func (a *PasswordAuthenticator) HashPassword(password []byte) ([]byte, error) { + return bcrypt.GenerateFromPassword(password, a.bcryptCost) +} + +func (a *PasswordAuthenticator) VerifyHash(password []byte, hash []byte) bool { + return bcrypt.CompareHashAndPassword(hash, password) == nil +} diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go new file mode 100644 index 0000000..a040d13 --- /dev/null +++ b/internal/server/bootstrap.go @@ -0,0 +1,60 @@ +package server + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "encoding/gob" + "fmt" + "golang.org/x/crypto/ssh" + "gorm.io/gorm" + + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/db" +) + +func Bootstrap() error { + cfg, cfgErr := LoadConfig() + if cfgErr != nil { + return fmt.Errorf("failed to load server config: %w", cfgErr) + } + + var s models.ServerSecrets + s.SigningKey = []byte(generateRandomString(32)) + + svDb, dbErr := db.Init(cfg.DBPath) + if dbErr != nil { + return fmt.Errorf("failed to init db: %w", dbErr) + } + + deleteErr := svDb.Unscoped(). + Session(&gorm.Session{AllowGlobalUpdate: true}). + Delete(&models.ServerSecrets{}). + Error + if deleteErr != nil { + return fmt.Errorf("failed to delete existing keys: %w", deleteErr) + } + + key, genErr := rsa.GenerateKey(rand.Reader, 4096) + if genErr != nil { + return fmt.Errorf("failed to generate private key: %w", genErr) + } + + var buf bytes.Buffer + if encodeErr := gob.NewEncoder(&buf).Encode(key); encodeErr != nil { + return fmt.Errorf("failed to encode priv key: %w", encodeErr) + } + s.DokkuSSHKeyGob = buf.Bytes() + + publicRsaKey, err := ssh.NewPublicKey(&key.PublicKey) + if err != nil { + return err + } + + if saveErr := svDb.Save(&s).Error; saveErr != nil { + return fmt.Errorf("failed to save private key: %w", saveErr) + } + + fmt.Printf("%s", bytes.TrimSpace(ssh.MarshalAuthorizedKey(publicRsaKey))) + return nil +} diff --git a/internal/server/commands/commands.go b/internal/server/commands/commands.go new file mode 100644 index 0000000..50e60cf --- /dev/null +++ b/internal/server/commands/commands.go @@ -0,0 +1,159 @@ +package commands + +import ( + "bufio" + "bytes" + "errors" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/server/dto" + "io" + "regexp" + "strings" + "time" + "unicode" +) + +type AsyncDokkuCommand func() (*dokku.CommandOutputStream, error) + +const ansiRegexP = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" + +var ( + letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + executions = map[string]execution{} + statuses = map[string]*ExecutionStatus{} + ansiRe = regexp.MustCompile(ansiRegexP) + fiveMinutes = time.Minute * 5 + readTimeout = time.Second * 5 + + bufSize = 4096 +) + +var ( + ErrNotPolled = errors.New("not polled yet") + ErrNoExecution = errors.New("no such command id") +) + +func sanitiseOutput(b []byte) []byte { + b = ansiRe.ReplaceAll(b, []byte{}) + mapped := bytes.Map(func(r rune) rune { + if r > unicode.MaxASCII { + return -1 + } + return r + }, b) + return mapped +} + +func readOutput(s chan string, e chan error, r io.Reader) { + // defer close(s) + // defer close(e) + + output := bytes.Buffer{} + reader := bufio.NewReader(r) + for { + buf := make([]byte, bufSize) + n, err := reader.Read(buf) + if err != nil { + e <- err + break + } + if n > 0 { + output.Write(sanitiseOutput(buf[:n])) + } + if n < bufSize { + break + } + } + if output.Len() > 0 { + s <- strings.TrimSpace(output.String()) + } +} + +func ReadWithTimeout(reader io.Reader, timeout time.Duration) (string, error) { + s := make(chan string) + e := make(chan error) + + go readOutput(s, e, reader) + + select { + case str := <-s: + return str, nil + case err := <-e: + return "", err + case <-time.After(timeout): + return "", nil + } +} + +func toOutputLines(lines []string, outputType string, pollTime time.Time) []dto.OutputLine { + output := make([]dto.OutputLine, len(lines)) + for i, line := range lines { + output[i] = dto.OutputLine{Msg: line, Type: outputType, PolledAt: pollTime} + } + return output +} + +func getExecutionStatus(id string) *ExecutionStatus { + status, ok := statuses[id] + if !ok { + return &ExecutionStatus{} + } + return status +} + +func PollStatuses() { + for id, status := range statuses { + if !status.Finished { + continue + } + if time.Since(status.FinishedAt) > fiveMinutes { + delete(statuses, id) + } + } + + for id, exec := range executions { + if exec.output == nil { + continue + } + status := getExecutionStatus(id) + pollTime := time.Now() + + stdout, stdoutErr := ReadWithTimeout(exec.output.Stdout, readTimeout) + if stdoutErr != nil { + status.StdoutReadError = stdoutErr + } + if stdout != "" { + stdoutLines := strings.Split(stdout, "\n") + output := toOutputLines(stdoutLines, "stdout", pollTime) + + // status.Stdout = append(status.Stdout, stdoutLines...) + status.CombinedOutput = append(status.CombinedOutput, output...) + } + + stderr, stderrErr := ReadWithTimeout(exec.output.Stderr, readTimeout) + if stderrErr != nil { + status.StderrReadError = stderrErr + } + if stderr != "" { + stderrLines := strings.Split(stderr, "\n") + // status.Stderr = append(status.Stderr, stderrLines...) + output := toOutputLines(stderrLines, "stderr", pollTime) + status.CombinedOutput = append(status.CombinedOutput, output...) + } + + status.StreamError = exec.output.Error + + if errors.Is(stdoutErr, io.EOF) { + delete(executions, id) + status.Finished = true + status.FinishedAt = time.Now() + if exec.callback != nil { + status.CallbackError = exec.callback() + } + } + statuses[id] = status + } + + time.Sleep(time.Second) + PollStatuses() +} diff --git a/internal/server/commands/execution.go b/internal/server/commands/execution.go new file mode 100644 index 0000000..1a605df --- /dev/null +++ b/internal/server/commands/execution.go @@ -0,0 +1,72 @@ +package commands + +import ( + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/server/dto" + "math/rand" + "time" +) + +type CallbackFunc func() error + +type execution struct { + Id string + output *dokku.CommandOutputStream + callback CallbackFunc + error error +} + +type ExecutionStatus struct { + CombinedOutput []dto.OutputLine + + Finished bool + FinishedAt time.Time + + // TODO: this smells + CallbackError error + StreamError error + StdoutReadError error + StderrReadError error +} + +func generateCommandExecutionId() string { + b := make([]rune, 16) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} + +func RequestExecution(cmd AsyncDokkuCommand, callback CallbackFunc) string { + id := generateCommandExecutionId() + + exec := execution{ + Id: id, + callback: callback, + } + executions[id] = exec + + go func(exec execution, id string) { + stream, err := cmd() + exec.output = stream + if err != nil { + exec.error = err + } + executions[id] = exec + }(exec, id) + + return id +} + +func GetExecutionStatus(id string) (*ExecutionStatus, error) { + status, ok := statuses[id] + if !ok { + _, exists := executions[id] + if exists { + return nil, ErrNotPolled + } + return nil, ErrNoExecution + } + + return status, nil +} diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..b15f07d --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,25 @@ +package server + +import ( + "context" + "github.com/sethvargo/go-envconfig" +) + +type Config struct { + DebugMode bool `env:"DEBUG_MODE,default=false"` + Host string `env:"HOST,default=0.0.0.0"` + Port string `env:"PORT,default=5330"` + + DokkuSSHHost string `env:"DOKKU_SSH_HOST,default=127.0.0.1"` + DokkuSSHPort string `env:"DOKKU_SSH_PORT,default=22"` + + DBPath string `env:"DB_PATH,default=/data/shokku.db"` + + AuthTokenLifetimeMinutes int `env:"TOKEN_LIFETIME_MINS,default=15"` +} + +func LoadConfig() (Config, error) { + ctx := context.Background() + var cfg Config + return cfg, envconfig.Process(ctx, &cfg) +} diff --git a/internal/server/db/database.go b/internal/server/db/database.go new file mode 100644 index 0000000..e12c060 --- /dev/null +++ b/internal/server/db/database.go @@ -0,0 +1,38 @@ +package db + +import ( + "github.com/glebarez/sqlite" + "gitlab.com/texm/shokku/internal/models" + "gorm.io/gorm" +) + +func Init(dsn string) (*gorm.DB, error) { + dbCfg := &gorm.Config{ + Logger: Logger{}, + } + + /*if cfg.DebugMode == false { + dbCfg.Logger = dbCfg.Logger.LogMode(logger.Silent) + }*/ + + db, err := gorm.Open(sqlite.Open(dsn), dbCfg) + if err != nil { + return nil, err + } + + err = db.AutoMigrate( + &models.Server{}, + &models.ServerSecrets{}, + &models.App{}, + &models.Service{}, + &models.User{}, + &models.SSHKey{}, + &models.GithubApp{}, + &models.AppSetupConfig{}, + ) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/internal/server/db/logger.go b/internal/server/db/logger.go new file mode 100644 index 0000000..0f6f870 --- /dev/null +++ b/internal/server/db/logger.go @@ -0,0 +1,70 @@ +package db + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog" + "gorm.io/gorm/logger" +) + +var durationUnits = map[time.Duration]string{ + time.Nanosecond: "elapsed_ns", + time.Microsecond: "elapsed_us", + time.Millisecond: "elapsed_ms", + time.Second: "elapsed", + time.Minute: "elapsed_min", + time.Hour: "elapsed_hr", +} + +type Logger struct{} + +func (l Logger) LogMode(logger.LogLevel) logger.Interface { + return l +} + +func (l Logger) Error(ctx context.Context, msg string, opts ...interface{}) { + zerolog.Ctx(ctx).Error().Msg(fmt.Sprintf(msg, opts...)) +} + +func (l Logger) Warn(ctx context.Context, msg string, opts ...interface{}) { + zerolog.Ctx(ctx).Warn().Msg(fmt.Sprintf(msg, opts...)) +} + +func (l Logger) Info(ctx context.Context, msg string, opts ...interface{}) { + zerolog.Ctx(ctx).Info().Msg(fmt.Sprintf(msg, opts...)) +} + +func (l Logger) Trace(ctx context.Context, begin time.Time, f func() (string, int64), err error) { + zl := zerolog.Ctx(ctx) + var event *zerolog.Event + + if err != nil { + event = zl.Debug() + } else { + event = zl.Trace() + } + + durationKey, found := durationUnits[zerolog.DurationFieldUnit] + if !found { + zl.Error(). + Dur("zerolog.DurationFieldUnit", zerolog.DurationFieldUnit). + Msg("unknown value for zerolog.DurationFieldUnit") + durationKey = "elapsed_" + } + + event.Dur(durationKey, time.Since(begin)) + + sql, rows := f() + if sql != "" { + event.Str("sql", sql) + } + if rows > -1 { + event.Int64("rows", rows) + } + + event.Send() + + return +} diff --git a/internal/server/dokku/dokku.go b/internal/server/dokku/dokku.go new file mode 100644 index 0000000..d7ba827 --- /dev/null +++ b/internal/server/dokku/dokku.go @@ -0,0 +1,28 @@ +package dokku + +import ( + "crypto/rsa" + "github.com/texm/dokku-go" + "golang.org/x/crypto/ssh" +) + +type Config struct { + DebugMode bool + + PrivateKey *rsa.PrivateKey + + Host string + Port string +} + +func Init(cfg Config) (*dokku.SSHClient, error) { + dCfg := &dokku.SSHClientConfig{ + Host: cfg.Host, + Port: cfg.Port, + PrivateKey: cfg.PrivateKey, + // TODO: supply host key / actually check it + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + return dokku.NewSSHClient(dCfg) +} diff --git a/internal/server/dokku/sync.go b/internal/server/dokku/sync.go new file mode 100644 index 0000000..91f4d97 --- /dev/null +++ b/internal/server/dokku/sync.go @@ -0,0 +1,158 @@ +package dokku + +import ( + "errors" + "fmt" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "strings" +) + +// todo: deduplicate from api/services +var ( + dokkuErrPrefix = "! " + serviceTypes = []string{"redis", "postgres", "mysql", "mongo"} +) + +const FilteredApp = "shokku" + +func SyncState(e *env.Env) { + syncApps(e) + syncServices(e) +} + +func getServiceList(e *env.Env, serviceType string) ([]string, error) { + listCmd := fmt.Sprintf("%s:list --quiet", serviceType) + out, err := e.Dokku.Exec(listCmd) + if err != nil { + return nil, err + } + if strings.HasPrefix(out, dokkuErrPrefix) { + return []string{}, nil + } + return strings.Split(out, "\n"), nil +} + +func syncApps(e *env.Env) { + logger := log.With().Str("dokku_sync", "apps").Logger() + + apps, listErr := e.Dokku.ListApps() + if listErr != nil { + if !errors.Is(listErr, dokku.NoDeployedAppsError) { + logger.Error(). + Err(listErr). + Msg("Failed to get dokku apps") + return + } + } + + var filtered []string + for _, name := range apps { + if name != FilteredApp { + filtered = append(filtered, name) + } + } + + var dbApps []models.App + if dbErr := e.DB.Find(&dbApps).Error; dbErr != nil { + logger.Error(). + Err(dbErr). + Msg("Failed to query db apps") + return + } + + appMap := map[string]bool{} + for _, name := range filtered { + var dbApp models.App + res := e.DB.Limit(1). + Where(&models.App{Name: name}). + FirstOrCreate(&dbApp) + if res.Error != nil { + logger.Error(). + Err(res.Error). + Str("app_name", name). + Msg("failed to create db app") + } + + appMap[name] = true + } + + for _, dbApp := range dbApps { + status, found := appMap[dbApp.Name] + if !found || !status { + if err := e.DB.Delete(&dbApp).Error; err != nil { + logger.Error(). + Err(err). + Uint("id", dbApp.ID). + Str("name", dbApp.Name). + Msg("failed to clean old app") + } + continue + } + + if syncErr := syncApp(e, dbApp); syncErr != nil { + logger.Error(). + Err(syncErr). + Str("name", dbApp.Name). + Msg("failed to sync dokku app") + } + } +} + +func syncApp(e *env.Env, dbApp models.App) error { + // TODO: sync setup info + return nil +} + +func serviceKey(name, svcType string) string { + return fmt.Sprintf("%s_%s", svcType, name) +} + +func syncServices(e *env.Env) { + logger := log.With().Str("dokku_sync", "services").Logger() + + var dbServices []models.Service + if dbErr := e.DB.Find(&dbServices).Error; dbErr != nil { + logger.Error(). + Err(dbErr). + Msg("Failed to query db services") + return + } + + svcMap := map[string]bool{} + for _, serviceType := range serviceTypes { + services, err := getServiceList(e, serviceType) + if err != nil { + logger.Error(). + Err(err). + Msgf("failed getting %s services", serviceType) + continue + } + for _, name := range services { + svcMap[serviceKey(name, serviceType)] = true + var dbSvc models.Service + e.DB.Where(&models.Service{Name: name, Type: serviceType}). + Limit(1). + FirstOrCreate(&dbSvc) + + e.DB.Save(&dbSvc) + } + } + + for _, svc := range dbServices { + key := serviceKey(svc.Name, svc.Type) + status, found := svcMap[key] + if !found || !status { + if err := e.DB.Delete(&svc).Error; err != nil { + logger.Error(). + Err(err). + Uint("id", svc.ID). + Str("type", svc.Type). + Str("name", svc.Name). + Msg("failed to delete old service") + } + } + } +} diff --git a/internal/server/dto/apps.go b/internal/server/dto/apps.go new file mode 100644 index 0000000..f850d66 --- /dev/null +++ b/internal/server/dto/apps.go @@ -0,0 +1,325 @@ +package dto + +import ( + "github.com/texm/dokku-go" + "time" +) + +type GetAppOverviewRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppOverviewResponse struct { + Name string `json:"name,omitempty"` + IsSetup bool `json:"is_setup"` + SetupMethod string `json:"setup_method"` + GitDeployBranch string `json:"git_deploy_branch"` + GitLastUpdated string `json:"git_last_updated"` + IsDeployed bool `json:"is_deployed"` + IsRunning bool `json:"is_running"` + NumProcesses int `json:"num_processes"` + CanScale bool `json:"can_scale"` + Restore bool `json:"restore"` +} + +type GetAllAppsOverviewResponse struct { + Apps []GetAppOverviewResponse `json:"apps"` +} + +type GetAppsListItem struct { + Name string `json:"name"` + Type string `json:"type"` +} +type GetAppsListResponse struct { + Apps []GetAppsListItem `json:"apps"` +} + +type GetAppInfoRequest struct { + Name string `query:"name" validate:"appName"` +} + +type DestroyAppRequest struct { + Name string `json:"name"` +} + +type AppInfo struct { + Name string `json:"name"` + Directory string `json:"directory"` + DeploySource string `json:"deploy_source"` + DeploySourceMetadata string `json:"deploy_source_metadata"` + CreatedAt time.Time `json:"created_at"` + IsLocked bool `json:"is_locked"` +} + +type GetAppInfoResponse struct { + Info AppInfo `json:"info"` +} + +type ManageAppRequest struct { + Name string `json:"name" validate:"appName"` +} + +type GetAppSetupStatusRequest struct { + Name string `json:"name" validate:"appName"` +} +type GetAppSetupStatusResponse struct { + IsSetup bool `json:"is_setup"` + Method string `json:"method"` +} + +type GetAppSetupConfigRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppSetupConfigResponse struct { + IsSetup bool `json:"is_setup"` + Method string `json:"method"` + DeploymentBranch string `json:"deployment_branch,omitempty"` + RepoURL string `json:"repo_url,omitempty"` + RepoGitRef string `json:"repo_git_ref,omitempty"` + Image string `json:"image,omitempty"` +} + +type SetupAppNewRepoRequest struct { + Name string `json:"name" validate:"appName"` + DeploymentBranch string `json:"deployment_branch"` +} + +type SetupAppSyncRepoRequest struct { + Name string `json:"name" validate:"appName"` + RepositoryURL string `json:"repository_url"` + GitRef string `json:"git_ref"` +} + +type SetupAppPullImageRequest struct { + Name string `json:"name" validate:"appName"` + Image string `json:"image"` +} + +type SetupAppUploadArchiveRequest struct { + Name string `form:"name" validate:"appName"` +} + +type RenameAppRequest struct { + CurrentName string `json:"current_name" validate:"appName"` + NewName string `json:"new_name" validate:"appName"` +} + +/* + methods = ["Git Push", "Git Repository", "Archive File", "Dockerfile", "Docker Image"] + options = [["deploymentBranch", "envVar"], ["repositoryURL", "gitRef"], ["file"], + ["dockerfilePath", "usingBuildkit"], ["image"]] +*/ +type DeployAppRequest struct { + Name string `json:"name" validate:"appName"` + Method string `json:"method" validate:"alpha"` + Options map[string]string `json:"options" validate:"alpha"` +} + +type GetAppServicesRequest struct { + Name string `query:"name" validate:"appName"` +} + +type GetAppDeployChecksRequest struct { + Name string `query:"name" validate:"appName"` +} + +type GetAppDeployChecksResponse struct { + AllDisabled bool `json:"all_disabled"` + AllSkipped bool `json:"all_skipped"` + DisabledProcesses []string `json:"disabled_processes"` + SkippedProcesses []string `json:"skipped_processes"` +} + +type SetAppDeployChecksRequest struct { + Name string `json:"name" validate:"appName"` + // enabled, disabled, skipped + State string `json:"state" validate:"alpha"` +} + +type SetAppProcessDeployChecksRequest struct { + Name string `json:"name" validate:"appName"` + Process string `json:"process" validate:"processName"` + // enabled, disabled, skipped + State string `json:"state" validate:"alpha"` +} + +type GetAppProcessesRequest struct { + Name string `query:"name" validate:"appName"` +} + +type GetAppProcessesResponse struct { + Processes []string `json:"processes"` +} + +type GetAppProcessReportRequest struct { + Name string `query:"name" validate:"appName"` +} + +type AppProcessInfo struct { + Scale int `json:"scale"` + Resources dokku.ResourceSettings `json:"resources"` +} + +type GetAppProcessReportResponse struct { + ResourceDefaults dokku.ResourceSettings `json:"resource_defaults"` + Processes map[string]AppProcessInfo `json:"processes"` +} + +type AppResources struct { + CPU *int `json:"cpu"` + Memory *int `json:"memory"` + MemoryUnit *string `json:"memory_unit"` +} + +type SetAppProcessResourcesRequest struct { + Name string `json:"name" validate:"appName"` + Process string `json:"process" validate:"processName"` + ResourceLimits AppResources `json:"limits"` + ResourceReservations AppResources `json:"reservations"` +} + +type GetAppProcessScaleRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppProcessScaleResponse struct { + ProcessScale map[string]int `json:"process_scale"` +} + +type SetAppProcessScaleRequest struct { + Name string `json:"name" validate:"appName"` + Process string `json:"process" validate:"processName"` + Scale int `json:"scale" validate:"numeric"` + SkipDeploy bool `json:"skip_deploy"` +} + +type GetAppDomainsReportRequest struct { + Name string `query:"name" validate:"appName"` +} + +type GetAppDomainsReportResponse struct { + Domains []string `json:"domains"` + Enabled bool `json:"enabled"` +} + +type SetAppDomainsEnabledRequest struct { + Name string `json:"name" validate:"appName"` + Enabled bool `json:"enabled"` +} + +type GetAppLetsEncryptEnabledRequest struct { + Name string `query:"name" validate:"appName"` +} +type SetAppLetsEncryptEnabledRequest struct { + Name string `json:"name" validate:"appName"` + Enabled bool `json:"enabled"` +} + +type GetAppDomainsRequest struct { + Name string `query:"name" validate:"appName"` +} +type AlterAppDomainRequest struct { + Name string `json:"name" validate:"appName"` + Domain string `json:"domain" validate:"hostname_rfc1123"` +} + +type AlterNetworkRequest struct { + Network string `query:"network"` +} + +type ListNetworksResponse struct { + Networks []string `json:"networks"` +} + +type GetAppNetworksReportRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppNetworksReportResponse struct { + AttachInitial string `json:"attach_initial"` + AttachPostCreate string `json:"attach_post_create"` + AttachPostDeploy string `json:"attach_post_deploy"` + BindAllInterfaces bool `json:"bind_all_interfaces"` + TLD string `json:"tld"` + WebListeners string `json:"web_listeners"` +} + +type SetAppNetworksRequest struct { + Name string `query:"name" validate:"appName"` + + Initial *string `json:"attach_initial"` + PostCreate *string `json:"attach_post_create"` + PostDeploy *string `json:"attach_post_deploy"` + BindAllInterfaces *bool `json:"bind_all_interfaces"` + TLD *string `json:"tld"` +} + +type GetAppLogsRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppLogsResponse struct { + Logs []string `json:"logs"` +} + +type GetAppConfigRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppConfigResponse struct { + Config map[string]string `json:"config"` +} + +type SetAppConfigRequest struct { + Name string `json:"name" validate:"appName"` + // validate keys and values are alphanumeric + Config map[string]string `json:"config" validate:"dive,keys,alphanum,endkeys,alphanum"` + // Config map[string]string `json:"config"` +} + +type GetAppStorageRequest struct { + Name string `query:"name" validate:"appName"` +} +type StorageMount struct { + HostDir string `json:"hostDir"` + ContainerDir string `json:"mountDir"` + IsBuildMount bool `json:"isBuildMount"` + IsRunMount bool `json:"isRunMount"` + IsDeployMount bool `json:"isDeployMount"` +} +type GetAppStorageResponse struct { + Mounts []StorageMount `json:"mounts"` +} + +type AlterAppStorageRequest struct { + Name string `json:"name" validate:"appName"` + RestartApp bool `json:"restart"` + + // TODO: validation for these + StorageType string `json:"selectedType"` + HostDir string `json:"hostDir" validate:"gte=2,alphanum"` + ContainerDir string `json:"mountDir"` +} + +type GetAppBuilderRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppBuilderResponse struct { + Selected string `json:"selected"` +} + +type SetAppBuilderRequest struct { + Name string `json:"name" validate:"appName"` + Builder string `json:"builder" validate:"alphanum"` +} + +type GetAppBuildDirectoryRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetAppBuildDirectoryResponse struct { + Directory string `json:"directory"` +} + +type SetAppBuildDirectoryRequest struct { + Name string `json:"name" validate:"appName"` + Directory string `json:"directory" validate:"alphanum"` +} + +type ClearAppBuildDirectoryRequest struct { + Name string `query:"name" validate:"appName"` +} diff --git a/internal/server/dto/auth.go b/internal/server/dto/auth.go new file mode 100644 index 0000000..68e36e3 --- /dev/null +++ b/internal/server/dto/auth.go @@ -0,0 +1,65 @@ +package dto + +type PasswordLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + TotpCode string `json:"totp"` +} + +type PasswordLoginResponse struct { + Success bool `json:"success"` + NeedsTotp bool `json:"needs_totp"` +} + +type GithubAuthRequest struct { + Code string `json:"code"` + RedirectURL string `json:"redirect_url"` +} + +type GetGithubSetupStatus struct { + AppCreated bool `json:"created"` +} + +type CreateGithubAppRequest struct { + Code string `json:"code"` +} + +type CreateGithubAppResponse struct { + Slug string `json:"slug"` +} + +type InstallGithubAppResponse struct { + InstallURL string `json:"install_url"` +} + +type CompleteGithubSetupRequest struct { + Code string `json:"code"` + InstallationId int64 `json:"installation_id"` +} + +type GetGithubAuthInfoResponse struct { + ClientID string `json:"client_id"` +} + +type GenerateTotpResponse struct { + Secret string `json:"secret"` + Image string `json:"image"` + RecoveryCode string `json:"recovery_code"` +} + +type ConfirmTotpRequest struct { + Secret string `json:"secret"` + Code string `json:"code"` +} + +type ConfirmTotpResponse struct { + Valid bool `json:"valid"` +} + +type CompletePasswordSetupRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Enable2FA bool `json:"enable_2fa"` + TotpSecret string `json:"totp_secret"` + RecoveryCode string `json:"recovery_code"` +} diff --git a/internal/server/dto/command.go b/internal/server/dto/command.go new file mode 100644 index 0000000..1c20cad --- /dev/null +++ b/internal/server/dto/command.go @@ -0,0 +1,38 @@ +package dto + +import ( + "time" +) + +type GetCommandExecutionStatusRequest struct { + ExecutionID string `query:"execution_id" validate:"required"` +} + +type CommandExecutionResponse struct { + ExecutionID string `json:"execution_id"` +} + +type OutputLine struct { + Msg string `json:"message"` + Type string `json:"type"` + PolledAt time.Time `json:"polled_at"` +} + +type CommandExecutionStatusResponse struct { + Started bool `json:"started"` + Finished bool `json:"finished"` + Success bool `json:"success"` + + CombinedOutput []OutputLine `json:"output"` +} + +type AppExecInProcessRequest struct { + AppName string `json:"appName" validate:"appName"` + ProcessName string `json:"processName" validate:"required"` + Command string `json:"command"` +} + +type AppExecInProcessResponse struct { + Output string `json:"output"` + Error string `json:"error"` +} diff --git a/internal/server/dto/dto.go b/internal/server/dto/dto.go new file mode 100644 index 0000000..591fabb --- /dev/null +++ b/internal/server/dto/dto.go @@ -0,0 +1,117 @@ +package dto + +import ( + "errors" + "fmt" + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "github.com/texm/dokku-go" + "net/http" + "reflect" + "regexp" +) + +// allow alphanumeric, underscores, and hyphens +func appNameCharsValidator() func(level validator.FieldLevel) bool { + r, err := regexp.Compile("\\w[\\w\\-]*") + if err != nil { + log.Fatal().Err(err).Msg("failed to compile regexp") + } + return func(fl validator.FieldLevel) bool { + if fl.Field().Kind() != reflect.String { + return false + } + str := fl.Field().String() + return r.FindString(str) == str + } +} + +type requestValidator struct { + validator *validator.Validate +} + +func (rv *requestValidator) Validate(i interface{}) error { + return rv.validator.Struct(i) +} + +func NewRequestValidator() *requestValidator { + v := validator.New() + + err := v.RegisterValidation("appNameChars", appNameCharsValidator()) + if err != nil { + log.Fatal().Err(err).Msg("failed to register validator") + } + v.RegisterAlias("appName", "appNameChars,min=4,max=16") + v.RegisterAlias("processName", "appNameChars,min=2,max=16") + + return &requestValidator{validator: v} +} + +type RequestError struct { + err error + isBinding bool + isInvalidFormat bool + isInvalidData bool + validationErrors []validator.FieldError +} + +func (r *RequestError) String() string { + return fmt.Sprintf("%+v", r.validationErrors) +} + +func (r *RequestError) ToHTTP() *echo.HTTPError { + err := echo.NewHTTPError(http.StatusBadRequest).SetInternal(r.err) + + if r.isBinding { + err.Message = echo.Map{"type": "binding"} + } else if r.isInvalidFormat { + err.Message = echo.Map{"type": "format"} + } else if r.isInvalidData { + fields := map[string]string{} + for _, fe := range r.validationErrors { + fields[fe.Field()] = fe.ActualTag() + } + err.Message = echo.Map{ + "type": "validation", + "fields": fields, + } + } else { + err = echo.ErrInternalServerError + } + + return err +} + +func BindRequest(c echo.Context, r any) *RequestError { + if err := c.Bind(r); err != nil { + return &RequestError{ + err: err, + isBinding: true, + } + } + if err := c.Validate(r); err != nil { + if errors, ok := err.(validator.ValidationErrors); ok { + return &RequestError{ + err: err, + isInvalidData: true, + validationErrors: errors, + } + } + return &RequestError{ + err: err, + isInvalidFormat: true, + } + } + return nil +} + +func MaybeConvertDokkuError(err error) *echo.HTTPError { + if errors.Is(err, dokku.InvalidAppError) { + return echo.NewHTTPError(http.StatusBadRequest, "no such app") + } + if errors.Is(err, dokku.NameTakenError) { + return echo.NewHTTPError(http.StatusBadRequest, "duplicate app name") + } + return nil +} diff --git a/internal/server/dto/services.go b/internal/server/dto/services.go new file mode 100644 index 0000000..78130be --- /dev/null +++ b/internal/server/dto/services.go @@ -0,0 +1,114 @@ +package dto + +type ManageServiceRequest struct { + Name string `json:"name" validate:"appName"` + Type string `json:"type" validate:"alphanum"` +} + +type GenericServiceRequest struct { + Name string `query:"name" validate:"appName"` + Type string `query:"type" validate:"alphanum"` +} + +type GenericServiceCreationConfig struct { + ConfigOptions *string `json:"config-options"` + // validate inner pairs are len=2 + CustomEnv *[][]string `json:"custom-env"` + Image *string `json:"image"` + ImageVersion *string `json:"image-version"` + MemoryLimit *string `json:"memory"` + Password *string `json:"password"` + RootPassword *string `json:"root-password"` + SharedMemorySize *string `json:"shm-size"` +} + +type CreateGenericServiceRequest struct { + Name string `json:"name" validate:"appName"` + ServiceType string `json:"type"` + Config GenericServiceCreationConfig `json:"config"` +} + +type CloneServiceRequest struct { + Name string `json:"name" validate:"appName"` + NewName string `json:"newName" validate:"appName"` +} + +type ServiceInfo struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type ListServicesResponse struct { + Services []ServiceInfo `json:"services"` +} + +type GetServiceInfoResponse struct { + Info map[string]string `json:"info"` +} + +type GetServiceTypeRequest struct { + Name string `query:"name" validate:"appName"` +} +type GetServiceTypeResponse struct { + Type string `json:"type"` +} + +type LinkGenericServiceToAppRequest struct { + ServiceName string `json:"service_name" validate:"appName"` + AppName string `json:"app_name" validate:"appName"` + Alias string `json:"alias"` + QueryString string `json:"query_string"` +} + +type GetServiceLinkedAppsResponse struct { + Apps []string `json:"apps"` +} + +type GetServiceLogsResponse struct { + Logs []string `json:"logs"` +} + +type GetServiceBackupReportRequest struct { + Name string `query:"name" validate:"appName"` +} + +type ServiceBackupReport struct { + AuthSet bool `json:"auth_set"` + EncryptionSet bool `json:"encryption_set"` + Bucket string `json:"bucket"` + Schedule string `json:"schedule"` +} +type GetServiceBackupReportResponse struct { + Report ServiceBackupReport `json:"report"` +} + +type RunServiceBackupRequest struct { + Name string `query:"name" validate:"appName"` +} + +type BackupsAuthConfig struct { + AccessKeyId string `json:"access_key_id"` + SecretKey string `json:"secret_key"` + Region string `json:"region"` + SignatureVersion string `json:"signature_version"` + EndpointUrl string `json:"endpoint_url"` +} +type SetServiceBackupsAuthRequest struct { + Name string `json:"name" validate:"appName"` + Config BackupsAuthConfig `json:"config"` +} + +type SetServiceBackupsBucketRequest struct { + Name string `json:"name" validate:"appName"` + Bucket string `json:"bucket"` +} + +type SetServiceBackupsScheduleRequest struct { + Name string `json:"name" validate:"appName"` + Schedule string `json:"schedule"` +} + +type SetServiceBackupsEncryptionRequest struct { + Name string `json:"name" validate:"appName"` + Passphrase string `json:"passphrase"` +} diff --git a/internal/server/dto/settings.go b/internal/server/dto/settings.go new file mode 100644 index 0000000..9f6bea3 --- /dev/null +++ b/internal/server/dto/settings.go @@ -0,0 +1,78 @@ +package dto + +import "github.com/texm/dokku-go" + +type GetVersionsResponse struct { + Dokku string `json:"dokku"` + Shokku string `json:"shokku"` +} + +type GetLetsEncryptStatusResponse struct { + Installed bool `json:"installed"` + Email string `json:"email"` +} + +type User struct { + Name string `json:"name"` + Source string `json:"source"` + SSHKeys []string `json:"ssh_keys"` +} +type GetUsersResponse struct { + Users []User `json:"users"` +} + +type GetSSHKeysResponse struct { + Keys []dokku.SSHKey `json:"keys"` +} + +type GetGlobalDomainsResponse struct { + Domains []string `json:"domains"` + Enabled bool `json:"enabled"` +} +type AlterGlobalDomainRequest struct { + Domain string `json:"domain"` +} +type DeleteGlobalDomainRequest struct { + Domain string `query:"domain"` +} + +type AddGitAuthRequest struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` +} + +type RemoveGitAuthRequest struct { + Host string `json:"host"` +} + +type SetDockerRegistryRequest struct { + Server string `json:"server"` + Username string `json:"username"` + Password string `json:"password"` +} + +type GetDockerRegistryReportResponse struct { + Server string `json:"server"` + PushOnRelease bool `json:"push_on_release"` +} + +type SetEventLoggingEnabledRequest struct { + Enabled bool `json:"enabled"` +} +type GetEventLogsListResponse struct { + Events []string `json:"events"` +} +type GetEventLogsResponse struct { + Logs string `json:"logs"` +} + +type PluginInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Enabled bool `json:"enabled"` + Description string `json:"description"` +} +type ListPluginsResponse struct { + Plugins []PluginInfo `json:"plugins"` +} diff --git a/internal/server/dto/setup.go b/internal/server/dto/setup.go new file mode 100644 index 0000000..ece48b9 --- /dev/null +++ b/internal/server/dto/setup.go @@ -0,0 +1,6 @@ +package dto + +type GetSetupStatusResponse struct { + IsSetup bool `json:"is_setup"` + Method string `json:"method"` +} diff --git a/internal/server/github/app.go b/internal/server/github/app.go new file mode 100644 index 0000000..2d90b22 --- /dev/null +++ b/internal/server/github/app.go @@ -0,0 +1,86 @@ +package github + +import ( + "context" + "net/http" + + "github.com/bradleyfalzon/ghinstallation/v2" + gh "github.com/google/go-github/v48/github" + "github.com/rs/zerolog/log" + + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" +) + +type AppClient struct { + *gh.Client + appsTransport *ghinstallation.AppsTransport +} + +func GetAppClient(e *env.Env) (*AppClient, error) { + var dbApp models.GithubApp + if err := e.DB.Find(&dbApp).Error; err != nil { + return nil, err + } + transport, err := ghinstallation.NewAppsTransport( + http.DefaultTransport, dbApp.AppId, []byte(dbApp.PEM)) + if err != nil { + log.Debug().Err(err).Msg("failed to create transport") + return nil, err + } + appClient := &AppClient{ + Client: gh.NewClient(&http.Client{Transport: transport}), + appsTransport: transport, + } + return appClient, nil +} + +type AppInstallationClient struct { + *gh.Client +} + +func (c *AppClient) GetInstallationClient(id int64) *AppInstallationClient { + transport := ghinstallation.NewFromAppsTransport(c.appsTransport, id) + client := gh.NewClient(&http.Client{Transport: transport}) + return &AppInstallationClient{Client: client} +} + +func CompleteAppManifest(e *env.Env, code string) (*gh.AppConfig, error) { + ctx := context.Background() + client := gh.NewClient(nil) + cfg, _, ghErr := client.Apps.CompleteAppManifest(ctx, code) + if ghErr != nil { + return nil, ghErr + } + + appId := cfg.GetID() + dbApp := models.GithubApp{AppId: appId} + if dbErr := e.DB.FirstOrCreate(&dbApp).Error; dbErr != nil { + log.Error().Err(dbErr).Msg("failed db lookup") + return nil, dbErr + } + + dbApp.AppId = appId + dbApp.ClientId = cfg.GetClientID() + dbApp.NodeId = cfg.GetNodeID() + dbApp.Slug = cfg.GetSlug() + dbApp.PEM = cfg.GetPEM() + dbApp.ClientSecret = cfg.GetClientSecret() + dbApp.WebhookSecret = cfg.GetWebhookSecret() + + // saveRes := e.DB.Where(&models.GithubApp{AppId: appId}).Save(&dbApp) + if err := e.DB.Save(&dbApp).Error; err != nil { + log.Error().Err(err).Msg("failed to save db app") + return nil, err + } + + return cfg, nil +} + +func (c *AppClient) GetApp(ctx context.Context) (*gh.App, error) { + app, _, err := c.Apps.Get(ctx, "") + if err != nil { + return nil, err + } + return app, nil +} diff --git a/internal/server/github/sync.go b/internal/server/github/sync.go new file mode 100644 index 0000000..6552845 --- /dev/null +++ b/internal/server/github/sync.go @@ -0,0 +1,148 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + gh "github.com/google/go-github/v48/github" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gorm.io/gorm/clause" + "io" + "net/http" + "time" +) + +type githubUser struct { + User models.User + SSHKeys []models.SSHKey +} + +func Sync(e *env.Env) error { + if err := SyncUsersToDB(e); err != nil { + return fmt.Errorf("failed to sync github users: %w", err) + } + + //if err := SyncInstallationStatus(e); err != nil { + // return fmt.Errorf() + //} + return nil +} + +// SyncUsersToDB asynchronously get users in organization & add to db +func SyncUsersToDB(e *env.Env) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + client, clientErr := GetAppClient(e) + if clientErr != nil { + return fmt.Errorf("failed to get app client: %w", clientErr) + } + + installs, _, installsErr := client.Apps.ListInstallations(ctx, nil) + if installsErr != nil { + return installsErr + } + + var users []githubUser + for _, install := range installs { + var members []*gh.User + var err error + if install.GetAccount().GetType() == "Organization" { + insClient := client.GetInstallationClient(install.GetID()) + org := install.GetAccount().GetLogin() + members, _, err = insClient.Organizations.ListMembers(ctx, org, nil) + } else { + members = append(members, install.GetAccount()) + } + if err != nil { + log.Error().Err(err). + Int64("installation_id", install.GetID()). + Msg("failed to get members") + continue + } + for _, member := range members { + users = append(users, fetchUserInfo(member)) + } + } + + conflict := clause.OnConflict{ + DoUpdates: clause.AssignmentColumns([]string{"updated_at"}), + } + + for _, u := range users { + if err := e.DB.Clauses(conflict).Create(&u.User).Error; err != nil { + log.Error().Err(err). + Str("name", u.User.Name). + Msg("failed to create user") + continue + } + for _, key := range u.SSHKeys { + key.UserID = u.User.ID + if err := e.DB.Clauses(conflict).Create(&key).Error; err != nil { + log.Error().Err(err). + Str("name", u.User.Name). + Msg("failed to create user ssh key") + } + } + } + + oneMinuteAgo := time.Now().Add(-time.Minute) + var deletedUsers []models.User + rUsers := e.DB.Where("updated_at < ?", oneMinuteAgo).Delete(&deletedUsers) + if rUsers.Error != nil { + log.Error().Err(rUsers.Error).Msg("failed to delete old users") + } + + var deletedKeys []models.SSHKey + rKeys := e.DB.Where("updated_at < ?", oneMinuteAgo).Delete(&deletedKeys) + if rKeys.Error != nil { + log.Error().Err(rKeys.Error).Msg("failed to delete old ssh keys") + } + + log.Debug(). + Int("num_installations", len(installs)). + Int("synced_users", len(users)). + Int64("removed_users", rUsers.RowsAffected). + Int64("removed_keys", rKeys.RowsAffected). + Msgf("github user sync complete") + + return nil +} + +func fetchUserInfo(u *gh.User) githubUser { + username := u.GetLogin() + user := githubUser{ + User: models.User{Name: username, Source: "github"}, + } + userKeysApi := fmt.Sprintf("https://api.github.com/users/%s/keys", username) + res, reqErr := http.Get(userKeysApi) + if reqErr != nil { + log.Error().Err(reqErr). + Str("username", username). + Msg("failed to get users SSH keys") + return user + } + body, err := io.ReadAll(res.Body) + if err != nil { + log.Error().Err(err).Msg("failed to read response body") + return user + } + + var keys []gh.Key + if err := json.Unmarshal(body, &keys); err != nil { + log.Error().Err(err).Msg("failed to unmarshal keys") + return user + } + + user.SSHKeys = make([]models.SSHKey, len(keys)) + for i, key := range keys { + user.SSHKeys[i] = models.SSHKey{ + GithubID: key.GetID(), + Key: key.GetKey(), + } + } + + return user +} diff --git a/internal/server/github/user.go b/internal/server/github/user.go new file mode 100644 index 0000000..ccf4804 --- /dev/null +++ b/internal/server/github/user.go @@ -0,0 +1,56 @@ +package github + +import ( + "context" + gh "github.com/google/go-github/v48/github" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "golang.org/x/oauth2" + ghOAuth "golang.org/x/oauth2/github" +) + +type UserClient struct { + client *gh.Client +} + +type CodeExchangeParams struct { + Code string + Scopes []string + RedirectURL string +} + +func ExchangeCode(ctx context.Context, e *env.Env, p CodeExchangeParams) (*UserClient, error) { + var ghApp models.GithubApp + if err := e.DB.WithContext(ctx).First(&ghApp).Error; err != nil { + log.Error().Err(err).Msg("no github app in db") + return nil, err + } + + conf := &oauth2.Config{ + ClientID: ghApp.ClientId, + ClientSecret: ghApp.ClientSecret, + Scopes: p.Scopes, + RedirectURL: p.RedirectURL, + Endpoint: ghOAuth.Endpoint, + } + token, err := conf.Exchange(ctx, p.Code) + if err != nil { + log.Error().Err(err).Msg("failed to exchange code for token") + return nil, err + } + + tokenSource := oauth2.StaticTokenSource(token) + oauthClient := oauth2.NewClient(ctx, tokenSource) + client := gh.NewClient(oauthClient) + return &UserClient{client}, nil +} + +func (u *UserClient) GetUser(ctx context.Context) (*gh.User, error) { + user, _, err := u.client.Users.Get(ctx, "") + if err != nil { + log.Error().Err(err).Msg("failed to get user") + return nil, err + } + return user, nil +} diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go new file mode 100644 index 0000000..894a196 --- /dev/null +++ b/internal/server/middleware/auth.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "fmt" + "github.com/labstack/echo/v4" + echoMiddleware "github.com/labstack/echo/v4/middleware" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/auth" + "strings" +) + +const ( + tokenContextKey = "user-token" + usedCookieAuthContextKey = "cookie-auth" +) + +func skipDuringSetup(e *env.Env, c echo.Context) bool { + reqPath := c.Request().URL.Path + return !e.SetupCompleted && strings.HasPrefix(reqPath, "/api/setup") +} + +func ProvideUserContext(e *env.Env) echo.MiddlewareFunc { + logger := middlewareLogger("userContext") + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if skipDuringSetup(e, c) { + return next(c) + } + + if err := e.Auth.SetUserContext(c, tokenContextKey); err != nil { + logger.Error().Err(err).Msg("failed to set context") + return echo.ErrInternalServerError + } + return next(c) + } + } +} + +func tokenAuthSkipper(e *env.Env) echoMiddleware.Skipper { + return func(c echo.Context) bool { + return skipDuringSetup(e, c) + } +} + +func TokenAuth(e *env.Env) echo.MiddlewareFunc { + config := echoMiddleware.JWTConfig{ + Claims: &auth.User{}, + SigningKey: e.Auth.GetSigningKey(), + TokenLookupFuncs: []echoMiddleware.ValuesExtractor{SplitTokenLookup}, + TokenLookup: "header:Authorization", + ContextKey: tokenContextKey, + Skipper: tokenAuthSkipper(e), + } + return echoMiddleware.JWTWithConfig(config) +} + +func SplitTokenLookup(c echo.Context) ([]string, error) { + dataCookie, err := c.Request().Cookie(auth.DataCookieName) + if err != nil { + return nil, fmt.Errorf("no data cookie: %w", err) + } + signatureCookie, err := c.Request().Cookie(auth.SignatureCookieName) + if err != nil { + return nil, fmt.Errorf("no signature cookie: %w", err) + } + + c.Set(usedCookieAuthContextKey, true) + authToken := dataCookie.Value + "." + signatureCookie.Value + return []string{authToken}, nil +} + +func CheckCookieAuthUsed(c echo.Context) bool { + v, ok := c.Get(usedCookieAuthContextKey).(bool) + return ok && v +} diff --git a/internal/server/middleware/middleware.go b/internal/server/middleware/middleware.go new file mode 100644 index 0000000..78fa79d --- /dev/null +++ b/internal/server/middleware/middleware.go @@ -0,0 +1,10 @@ +package middleware + +import ( + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func middlewareLogger(ware string) zerolog.Logger { + return log.With().Str("middleware", ware).Logger() +} diff --git a/internal/server/middleware/recover.go b/internal/server/middleware/recover.go new file mode 100644 index 0000000..63960ef --- /dev/null +++ b/internal/server/middleware/recover.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + echoMiddleware "github.com/labstack/echo/v4/middleware" + "github.com/rs/zerolog" + "strings" +) + +func logErrorFunc(logger zerolog.Logger, debug bool) echoMiddleware.LogErrorFunc { + return func(c echo.Context, err error, stack []byte) error { + event := logger.Error().Err(err) + if debug { + stacklines := strings.Split(string(stack), "\n") + funcName := strings.TrimRight(strings.SplitAfter(stacklines[5], "(0x")[0], "(0x") + callSite := strings.Trim(strings.SplitAfter(stacklines[6], " ")[0], "\t ") + event.Str("func", funcName) + event.Str("callsite", callSite) + } + event.Msg("Recovered from panic") + return nil + } +} + +func Recover(debug bool) echo.MiddlewareFunc { + logger := middlewareLogger("recover") + cfg := echoMiddleware.RecoverConfig{ + LogErrorFunc: logErrorFunc(logger, debug), + } + return echoMiddleware.RecoverWithConfig(cfg) +} diff --git a/internal/server/middleware/request_logger.go b/internal/server/middleware/request_logger.go new file mode 100644 index 0000000..bcaea96 --- /dev/null +++ b/internal/server/middleware/request_logger.go @@ -0,0 +1,87 @@ +package middleware + +import ( + "fmt" + "gitlab.com/texm/shokku/internal/server/dto" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" +) + +func parseDokkuError(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := next(c) + if err == nil { + return nil + } + + if dokkuHttpErr := dto.MaybeConvertDokkuError(err); dokkuHttpErr != nil { + log.Debug().Err(err).Msgf("converted dokku error to %s", dokkuHttpErr.Error()) + return dokkuHttpErr + } + + log.Error().Err(err).Str("path", c.Path()).Msg("got error") + return echo.ErrInternalServerError + } +} + +func RequestLogger(debug bool) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + start := time.Now() + + chainErr := next(c) + if chainErr != nil { + c.Error(chainErr) + } + + req := c.Request() + res := c.Response() + + if isStaticFile, ok := c.Get("static").(bool); ok && isStaticFile { + contentType := res.Header().Get("Content-Type") + isHTML := strings.HasPrefix(contentType, echo.MIMETextHTML) + if !isHTML { + return nil + } + } + + n := res.Status + l := log.Info() + msg := "Success" + switch { + case n >= 500: + l = log.Error().Err(chainErr) + // logger.With(zap.Error(chainErr)).Error("Server error", fields...) + msg = "Server error" + case n >= 400: + msg = "Client error" + case n >= 300: + msg = "Redirect" + } + + if debug { + l.Str("request", fmt.Sprintf("%s %s", req.Method, req.RequestURI)) + l.Int("status", res.Status) + } else { + l.Str("remote_ip", c.RealIP()) + l.Str("latency", time.Since(start).String()) + l.Str("host", req.Host) + l.Str("request", fmt.Sprintf("%s %s", req.Method, req.RequestURI)) + l.Int("status", res.Status) + l.Int64("size", res.Size) + } + + id := req.Header.Get(echo.HeaderXRequestID) + if id != "" { + l.Str("request_id", id) + } + + l.Msg(msg) + + return nil + } + } +} diff --git a/internal/server/middleware/security.go b/internal/server/middleware/security.go new file mode 100644 index 0000000..906192d --- /dev/null +++ b/internal/server/middleware/security.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + echoMiddleware "github.com/labstack/echo/v4/middleware" + "net/http" +) + +func Secure() echo.MiddlewareFunc { + // logger := e.Logger.Desugar() + // debug := e.DebugMode + + // cfg := echomiddleware.SecureConfig{} + cfg := echoMiddleware.DefaultSecureConfig + return echoMiddleware.SecureWithConfig(cfg) +} + +func CSRF() echo.MiddlewareFunc { + // we skip requests where cookie authentication was not used, + // as these are api requests - not from the browser + cfg := echoMiddleware.CSRFConfig{ + CookieName: "_csrf", + CookiePath: "/", + CookieSameSite: http.SameSiteStrictMode, + Skipper: func(c echo.Context) bool { + return !CheckCookieAuthUsed(c) + }, + } + return echoMiddleware.CSRFWithConfig(cfg) +} diff --git a/internal/server/middleware/setup.go b/internal/server/middleware/setup.go new file mode 100644 index 0000000..2772bac --- /dev/null +++ b/internal/server/middleware/setup.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "net/http" + "strings" +) + +const ( + notSetupErr = "server not setup" + invalidKeyErr = "setup key invalid" + setupKeyHeader = "X-Setup-Key" +) + +func ServerSetupBlocker(e *env.Env, setupKey string) echo.MiddlewareFunc { + logger := middlewareLogger("setup") + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + reqPath := c.Request().URL.Path + + if !strings.HasPrefix(reqPath, "/api") { + return next(c) + } + + if reqPath == "/api/github/events" { + return next(c) + } + + if reqPath == "/api/setup/status" { + return next(c) + } + + isSetupRoute := strings.HasPrefix(reqPath, "/api/setup") + if e.SetupCompleted { + if isSetupRoute { + return echo.NewHTTPError(http.StatusBadRequest, "already set up") + } + return next(c) + } + + providedKey := c.Request().Header.Get(setupKeyHeader) + if providedKey != setupKey { + logger.Debug(). + Str("path", reqPath). + Str("provided", providedKey). + Msg("invalid setup key") + return echo.NewHTTPError(http.StatusForbidden, invalidKeyErr) + } + + if isSetupRoute { + return next(c) + } + + logger.Debug().Str("path", reqPath).Msg("not setup path") + return echo.NewHTTPError(http.StatusForbidden, notSetupErr) + } + } +} diff --git a/internal/server/middleware/static.go b/internal/server/middleware/static.go new file mode 100644 index 0000000..3c3ea3a --- /dev/null +++ b/internal/server/middleware/static.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "github.com/labstack/echo/v4" + echomiddleware "github.com/labstack/echo/v4/middleware" + "io/fs" + "net/http" + "strings" +) + +func staticFileSkipperFunc(c echo.Context) bool { + if strings.HasPrefix(c.Request().URL.Path, "/api") { + c.Set("static", false) + return true + } + c.Set("static", true) + return false +} + +func StaticFiles(staticFS fs.FS) echo.MiddlewareFunc { + cfg := echomiddleware.StaticConfig{ + Root: "dist", + Index: "app.html", + HTML5: true, + Filesystem: http.FS(staticFS), + Skipper: staticFileSkipperFunc, + } + return echomiddleware.StaticWithConfig(cfg) +} diff --git a/internal/server/rand.go b/internal/server/rand.go new file mode 100644 index 0000000..49896b9 --- /dev/null +++ b/internal/server/rand.go @@ -0,0 +1,13 @@ +package server + +import "math/rand" + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func generateRandomString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} diff --git a/internal/server/router.go b/internal/server/router.go new file mode 100644 index 0000000..feebaef --- /dev/null +++ b/internal/server/router.go @@ -0,0 +1,67 @@ +package server + +import ( + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/server/dto" + "gitlab.com/texm/shokku/internal/server/middleware" + "io/fs" + "net/http" +) + +func initRouter(debugMode bool, appFS fs.FS) *echo.Echo { + r := echo.New() + r.Debug = debugMode + r.HideBanner = true + r.Validator = dto.NewRequestValidator() + r.HTTPErrorHandler = errorHandler(debugMode) + + r.Use(middleware.Recover(debugMode)) + r.Use(middleware.Secure()) + r.Use(middleware.RequestLogger(debugMode)) + r.Use(middleware.StaticFiles(appFS)) + r.Use(middleware.CSRF()) + + return r +} + +func errorHandler(debug bool) func(error, echo.Context) { + return func(err error, c echo.Context) { + if c.Response().Committed { + return + } + + httpErr, ok := err.(*echo.HTTPError) + if ok && httpErr.Internal != nil { + if herr, ok := httpErr.Internal.(*echo.HTTPError); ok { + httpErr = herr + } + } else if !ok { + httpErr = &echo.HTTPError{ + Code: http.StatusInternalServerError, + Message: http.StatusText(http.StatusInternalServerError), + } + } + + // Issue #1426 + code := httpErr.Code + message := httpErr.Message + if m, strOk := httpErr.Message.(string); strOk { + if debug { + message = echo.Map{"message": m, "error": httpErr.Error()} + } else { + message = echo.Map{"message": m} + } + } + + // Send response + if c.Request().Method == http.MethodHead { // Issue #608 + err = c.NoContent(httpErr.Code) + } else { + err = c.JSON(code, message) + } + if err != nil { + log.Error().Err(err).Msg("error handler response") + } + } +} diff --git a/internal/server/secrets.go b/internal/server/secrets.go new file mode 100644 index 0000000..432e19d --- /dev/null +++ b/internal/server/secrets.go @@ -0,0 +1,47 @@ +package server + +import ( + "bytes" + "crypto/rsa" + "database/sql" + "encoding/gob" + "errors" + "fmt" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/models" + "gorm.io/gorm" +) + +type secrets struct { + signingKey []byte + privKey *rsa.PrivateKey +} + +func getServerSecrets(db *gorm.DB) (*secrets, error) { + var s models.ServerSecrets + if err := db.Find(&s).Error; err != nil && err != sql.ErrNoRows { + log.Error(). + Err(err). + Msg("failed to get server secrets") + return nil, err + } + + var key *rsa.PrivateKey + if len(s.DokkuSSHKeyGob) == 0 { + return nil, errors.New("no ssh key stored") + } + + r := bytes.NewReader(s.DokkuSSHKeyGob) + if decodeErr := gob.NewDecoder(r).Decode(&key); decodeErr != nil { + return nil, fmt.Errorf("failed to decode priv key: %w", decodeErr) + } + + if validErr := key.Validate(); validErr != nil { + return nil, fmt.Errorf("private key validation failed: %w", validErr) + } + + return &secrets{ + privKey: key, + signingKey: s.SigningKey, + }, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..61589d1 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,75 @@ +package server + +import ( + "fmt" + "github.com/labstack/echo/v4" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/server/api" + "gitlab.com/texm/shokku/internal/server/commands" + "gitlab.com/texm/shokku/internal/server/db" + "gitlab.com/texm/shokku/internal/server/dokku" + "gitlab.com/texm/shokku/internal/server/middleware" + "io/fs" + "time" +) + +func New(cfg Config, appFS fs.FS) (*env.Env, error) { + e := env.New(cfg.DebugMode) + e.Router = initRouter(cfg.DebugMode, appFS) + + dbSess, dbErr := db.Init(cfg.DBPath) + if dbErr != nil { + return nil, fmt.Errorf("failed to init db: %w", dbErr) + } + e.DB = dbSess + + serverSecrets, secretsErr := getServerSecrets(e.DB) + if secretsErr != nil { + return nil, fmt.Errorf("failed to get server secrets: %w", secretsErr) + } + + dokkuCfg := dokku.Config{ + DebugMode: cfg.DebugMode, + PrivateKey: serverSecrets.privKey, + Host: cfg.DokkuSSHHost, + Port: cfg.DokkuSSHPort, + } + dokkuClient, dokkuErr := dokku.Init(dokkuCfg) + if dokkuErr != nil { + return nil, fmt.Errorf("failed to init dokku client: %w", dokkuErr) + } + e.Dokku = dokkuClient + + serverState, stateErr := initServerState(e) + if stateErr != nil { + return nil, fmt.Errorf("failed to get setup state: %w", stateErr) + } + + authCfg := initAuthConfig{ + SigningKey: serverSecrets.signingKey, + TokenLifetime: time.Minute * time.Duration(cfg.AuthTokenLifetimeMinutes), + Method: serverState.AuthMethod, + DebugMode: cfg.DebugMode, + IsSetup: serverState.IsSetup, + } + authn, authErr := initAuthenticator(authCfg) + if authErr != nil { + return nil, fmt.Errorf("failed to init authenticator: %w", authErr) + } + e.Auth = authn + + if !e.SetupCompleted { + e.Router.Use(middleware.ServerSetupBlocker(e, serverState.SetupKey)) + } + + api.RegisterRoutes(e, []echo.MiddlewareFunc{ + middleware.TokenAuth(e), + middleware.ProvideUserContext(e), + }) + + dokku.SyncState(e) + + go commands.PollStatuses() + + return e, nil +} diff --git a/internal/server/setup.go b/internal/server/setup.go new file mode 100644 index 0000000..5604c5b --- /dev/null +++ b/internal/server/setup.go @@ -0,0 +1,72 @@ +package server + +import ( + "fmt" + "github.com/rs/zerolog/log" + "gitlab.com/texm/shokku/internal/env" + "gitlab.com/texm/shokku/internal/models" + "gitlab.com/texm/shokku/internal/server/auth" + "gitlab.com/texm/shokku/internal/server/github" + "time" +) + +func maybeSyncState(e *env.Env, state *models.Server) error { + if state.AuthMethod == auth.MethodGithub { + timeSinceLast := time.Since(state.LastSync) + needsSync := timeSinceLast > 6*time.Hour + if !needsSync { + return nil + } + if err := github.SyncUsersToDB(e); err != nil { + return fmt.Errorf("failed to sync github state: %w", err) + } + } + + state.LastSync = time.Now() + if err := e.DB.Save(state).Error; err != nil { + return fmt.Errorf("failed to update last sync time") + } + + return nil +} + +func initServerState(e *env.Env) (*models.Server, error) { + var state models.Server + if err := e.DB.FirstOrCreate(&state).Error; err != nil { + log.Error(). + Err(err). + Msg("failed to get state") + return nil, err + } + + log.Debug(). + Bool("is_setup", state.IsSetup). + Str("auth_method", string(state.AuthMethod)). + Time("last_sync", state.LastSync). + Msg("server setup state") + + e.SetupCompleted = state.IsSetup + + if state.IsSetup { + if syncErr := maybeSyncState(e, &state); syncErr != nil { + return nil, syncErr + } + return &state, nil + } + + if state.SetupKey == "" { + state.SetupKey = generateRandomString(16) + if err := e.DB.Save(&state).Error; err != nil { + log.Error(). + Err(err). + Msg("failed to update setup key") + return nil, err + } + } + + log.Info(). + Str("setup_key", state.SetupKey). + Msg("running in setup mode") + + return &state, nil +} diff --git a/internal/server/websocket.go b/internal/server/websocket.go new file mode 100644 index 0000000..6708e36 --- /dev/null +++ b/internal/server/websocket.go @@ -0,0 +1,35 @@ +package server + +import ( + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog/log" + "net/http" +) + +type wsErrorFunc func(w http.ResponseWriter, r *http.Request, status int, reason error) + +func createWSCheckOriginFunc(origin string) func(r *http.Request) bool { + return func(req *http.Request) bool { + log.Debug(). + Str("origin", req.Header.Get("Origin")). + Str("host", origin). + Msg("ws check origin") + return true + } +} + +func createWSErrorFunc(e *echo.Echo) wsErrorFunc { + return func(w http.ResponseWriter, r *http.Request, status int, reason error) { + err := echo.NewHTTPError(status, reason) + c := e.NewContext(r, w) + e.HTTPErrorHandler(err, c) + } +} + +func initWebsocketUpgrader(cfg Config, router *echo.Echo) websocket.Upgrader { + return websocket.Upgrader{ + CheckOrigin: createWSCheckOriginFunc(cfg.Host), + Error: createWSErrorFunc(router), + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..14549bc --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2626 @@ +{ + "name": "shokku-ui", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shokku-ui", + "version": "0.0.1", + "dependencies": { + "@sveltestack/svelte-query": "^1.6.0", + "daisyui": "^2.14.3", + "js-cookie": "^3.0.1", + "jwt-decode": "^3.1.2", + "svelte-file-dropzone": "^1.0.0", + "wretch": "^1.7.9" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "next", + "@sveltejs/adapter-static": "^1.0.0-next.29", + "@sveltejs/kit": "next", + "autoprefixer": "^10.4.13", + "cssnano": "^5.1.8", + "postcss": "^8.4.21", + "prettier": "^2.8.1", + "prettier-plugin-svelte": "^2.9.0", + "svelte": "^3.44.0", + "tabler-icons-svelte": "^1.8.0", + "tailwindcss": "^3.2.4" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.16.17.tgz", + "integrity": "sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz", + "integrity": "sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.16.17.tgz", + "integrity": "sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz", + "integrity": "sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz", + "integrity": "sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz", + "integrity": "sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz", + "integrity": "sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz", + "integrity": "sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz", + "integrity": "sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz", + "integrity": "sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz", + "integrity": "sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz", + "integrity": "sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz", + "integrity": "sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz", + "integrity": "sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz", + "integrity": "sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz", + "integrity": "sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz", + "integrity": "sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz", + "integrity": "sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz", + "integrity": "sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz", + "integrity": "sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz", + "integrity": "sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz", + "integrity": "sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "1.0.0-next.91", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-1.0.0-next.91.tgz", + "integrity": "sha512-U57tQdzTfFINim8tzZSARC9ztWPzwOoHwNOpGdb2o6XrD0mEQwU9DsII7dBblvzg+xCnmd0pw7PDtXz5c5t96w==", + "dev": true, + "dependencies": { + "import-meta-resolve": "^2.2.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^1.0.0-next.587" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-1.0.6.tgz", + "integrity": "sha512-gTus2jW6bEQAZoT1MdmPHWZZmcb6dfLWc0r6dFHnbzSDZ68kifqQ1E+dZDOMF7aXeRV91sgnPuAn2MtpinVdlA==", + "dev": true, + "peerDependencies": { + "@sveltejs/kit": "^1.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.3.10.tgz", + "integrity": "sha512-I3DgWCwTYbTz4ZPCJIRkSDrKkMu0bsdk6ghqsOBVNqesf1wBdTdfkXhag3ESWgIEjUV3VUIWPQF7fnt7328mhQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte": "^2.0.0", + "@types/cookie": "^0.5.1", + "cookie": "^0.5.0", + "devalue": "^4.2.3", + "esm-env": "^1.0.0", + "kleur": "^4.1.5", + "magic-string": "^0.27.0", + "mime": "^3.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.5.1", + "sirv": "^2.0.2", + "tiny-glob": "^0.2.9", + "undici": "5.16.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": "^16.14 || >=18" + }, + "peerDependencies": { + "svelte": "^3.54.0", + "vite": "^4.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.0.2.tgz", + "integrity": "sha512-xCEan0/NNpQuL0l5aS42FjwQ6wwskdxC3pW1OeFtEKNZwRg7Evro9lac9HesGP6TdFsTv2xMes5ASQVKbCacxg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "deepmerge": "^4.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.27.0", + "svelte-hmr": "^0.15.1", + "vitefu": "^0.2.3" + }, + "engines": { + "node": "^14.18.0 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.54.0", + "vite": "^4.0.0" + } + }, + "node_modules/@sveltestack/svelte-query": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sveltestack/svelte-query/-/svelte-query-1.6.0.tgz", + "integrity": "sha512-C0wWuh6av1zu3Pzwrg6EQmX3BhDZQ4gMAdYu6Tfv4bjbEZTB00uEDz52z92IZdONh+iUKuyo0xRZ2e16k2Xifg==", + "peerDependencies": { + "broadcast-channel": "^4.5.0" + }, + "peerDependenciesMeta": { + "broadcast-channel": { + "optional": true + } + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz", + "integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==", + "dev": true + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dependencies": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/autoprefixer": { + "version": "10.4.13", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz", + "integrity": "sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + } + ], + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-lite": "^1.0.30001426", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.21.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", + "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001449", + "electron-to-chromium": "^1.4.284", + "node-releases": "^2.0.8", + "update-browserslist-db": "^1.0.10" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001450", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001450.tgz", + "integrity": "sha512-qMBmvmQmFXaSxexkjjfMvD5rnDL0+m+dUMZKoDYsGG8iZN29RuYh9eRoMvKsT6uMAWlyUUGDEQGJJYjzCIO9ew==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz", + "integrity": "sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-tokenizer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", + "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", + "dependencies": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.14", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.14.tgz", + "integrity": "sha512-Oou7ihiTocbKqi0J1bB+TRJIQX5RMR3JghA8hcWSw9mjBLQ5Y3RWqEDoYG3sRNlAbCIXpqMoZGbq5KDR3vdzgw==", + "dev": true, + "dependencies": { + "cssnano-preset-default": "^5.2.13", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.13", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.13.tgz", + "integrity": "sha512-PX7sQ4Pb+UtOWuz8A1d+Rbi+WimBIxJTRyBdgGp1J75VU0r/HFQeLnMYgHiCAp6AR4rqrc7Y4R+1Rjk3KJz6DQ==", + "dev": true, + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.0", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.3", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.1", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/daisyui": { + "version": "2.50.0", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-2.50.0.tgz", + "integrity": "sha512-KiqRvqMXi9rgoH84M8D69gXPg6x+cbdiaHqm8pFHOsXXN1rTl/+OcCKkSnkEwTtIge9VJVDGU6l4B8/n+Juc5g==", + "dependencies": { + "color": "^4.2", + "css-selector-tokenizer": "^0.8.0", + "postcss-js": "^4.0.0", + "tailwindcss": "^3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/daisyui" + }, + "peerDependencies": { + "autoprefixer": "^10.0.2", + "postcss": "^8.1.6" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz", + "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detective": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", + "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", + "dependencies": { + "acorn-node": "^1.8.2", + "defined": "^1.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "detective": "bin/detective.js" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/devalue": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.2.3.tgz", + "integrity": "sha512-JG6Q248aN0pgFL57e3zqTVeFraBe+5W2ugvv1mLXsJP6YYIYJhRZhAl7QP8haJrqob6X10F9NEkuCvNILZTPeQ==", + "dev": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.286.tgz", + "integrity": "sha512-Vp3CVhmYpgf4iXNKAucoQUDcCrBQX3XLBtwgFqP9BUXuucgvAV9zWp1kYU7LL9j4++s9O+12cb3wMtN4SJy6UQ==" + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.16.17", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.17.tgz", + "integrity": "sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==", + "dev": true, + "hasInstallScript": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.16.17", + "@esbuild/android-arm64": "0.16.17", + "@esbuild/android-x64": "0.16.17", + "@esbuild/darwin-arm64": "0.16.17", + "@esbuild/darwin-x64": "0.16.17", + "@esbuild/freebsd-arm64": "0.16.17", + "@esbuild/freebsd-x64": "0.16.17", + "@esbuild/linux-arm": "0.16.17", + "@esbuild/linux-arm64": "0.16.17", + "@esbuild/linux-ia32": "0.16.17", + "@esbuild/linux-loong64": "0.16.17", + "@esbuild/linux-mips64el": "0.16.17", + "@esbuild/linux-ppc64": "0.16.17", + "@esbuild/linux-riscv64": "0.16.17", + "@esbuild/linux-s390x": "0.16.17", + "@esbuild/linux-x64": "0.16.17", + "@esbuild/netbsd-x64": "0.16.17", + "@esbuild/openbsd-x64": "0.16.17", + "@esbuild/sunos-x64": "0.16.17", + "@esbuild/win32-arm64": "0.16.17", + "@esbuild/win32-ia32": "0.16.17", + "@esbuild/win32-x64": "0.16.17" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", + "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==" + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-selector": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.2.4.tgz", + "integrity": "sha512-ZDsQNbrv6qRi1YTDOEWzf5J2KjZ9KMI1Q2SGeTkCJmNNW25Jg4TW4UMcmoqcg4WrAyKRcpBXdbWRxkfrOzVRbA==", + "dependencies": { + "tslib": "^2.0.3" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globalyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", + "dev": true + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-2.2.1.tgz", + "integrity": "sha512-C6lLL7EJPY44kBvA80gq4uMsVFw5x3oSKfuMl1cuZ2RkI5+UJqQXgn+6hlUew0y4ig7Ypt4CObAAIzU53Nfpuw==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-cookie": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz", + "integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", + "integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimist": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", + "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.9.tgz", + "integrity": "sha512-2xfmOrRkGogbTK9R6Leda0DGiXeY3p2NJpy4+gNCffdUvV6mdEJnaDEic1i3Ec2djAo8jWYoJMR5PB0MSMpxUA==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postcss": { + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", + "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", + "dev": true, + "dependencies": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-import": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", + "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", + "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.3.tgz", + "integrity": "sha512-LbLd7uFC00vpOuMvyZop8+vvhnfRGpp2S+IMQKeuOZZapPRY4SMq5ErjQeHbHsjCUgJkRNrlU+LmxsKIqPKQlA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.0.tgz", + "integrity": "sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==", + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.1.tgz", + "integrity": "sha512-//jeDqWcHPuXGZLoolFrUXBDyuEGbr9S2rMo19bkTIjBQ4PqkaO+oI8wua5BOUxpfi97i3PCoInsiFIEBfkm9w==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz", + "integrity": "sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/prettier": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", + "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-2.9.0.tgz", + "integrity": "sha512-3doBi5NO4IVgaNPtwewvrgPpqAcvNv0NwJNflr76PIGgi9nf1oguQV1Hpdm9TI2ALIQVn/9iIwLpBO5UcD2Jiw==", + "dev": true, + "peerDependencies": { + "prettier": "^1.16.4 || ^2.0.0", + "svelte": "^3.2.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", + "dependencies": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.13.0.tgz", + "integrity": "sha512-HJwQtrXAc0AmyDohTJ/2c+Bx/sWPScJLlAUJ1kuD7rAkCro8Cr2SnVB2gVYBiSLxpgD2kZ24jbyXtG++GumrYQ==", + "dev": true, + "peer": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz", + "integrity": "sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==", + "dev": true + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sirv": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz", + "integrity": "sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "3.55.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.55.1.tgz", + "integrity": "sha512-S+87/P0Ve67HxKkEV23iCdAh/SX1xiSfjF1HOglno/YTbSTW7RniICMCofWGdJJbdjw3S+0PfFb1JtGfTXE0oQ==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/svelte-file-dropzone": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svelte-file-dropzone/-/svelte-file-dropzone-1.0.0.tgz", + "integrity": "sha512-F2DN+wN2w7bKuUJFYQOFsdtTgaohQ/rNKau5m5n2l3LHJRRIccYS4wpq8f6dz/h5aSxYse3oRclmYdW6FWAfjw==", + "dependencies": { + "file-selector": "^0.2.4" + } + }, + "node_modules/svelte-hmr": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.1.tgz", + "integrity": "sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==", + "dev": true, + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": ">=3.19.0" + } + }, + "node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tabler-icons-svelte": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/tabler-icons-svelte/-/tabler-icons-svelte-1.10.0.tgz", + "integrity": "sha512-BERFRwLW+nLnEXrC1yc3TCwM9LxlMqixnQiMIF/u9XESbYXUCi6udjwvSGmwLepAD0DI9OdXBrZvfPBan7Dzcg==", + "deprecated": "Package no longer supported. Switch to @tabler/icons-svelte.", + "dev": true, + "peerDependencies": { + "svelte": ">=3.31.0 <=4" + } + }, + "node_modules/tailwindcss": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.4.tgz", + "integrity": "sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==", + "dependencies": { + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "color-name": "^1.1.4", + "detective": "^5.2.1", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "lilconfig": "^2.0.6", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.18", + "postcss-import": "^14.1.0", + "postcss-js": "^4.0.0", + "postcss-load-config": "^3.1.4", + "postcss-nested": "6.0.0", + "postcss-selector-parser": "^6.0.10", + "postcss-value-parser": "^4.2.0", + "quick-lru": "^5.1.1", + "resolve": "^1.22.1" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/tiny-glob": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", + "dev": true, + "dependencies": { + "globalyzer": "0.1.0", + "globrex": "^0.1.2" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz", + "integrity": "sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, + "node_modules/undici": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.16.0.tgz", + "integrity": "sha512-KWBOXNv6VX+oJQhchXieUznEmnJMqgXMbs0xxH2t8q/FUAWSJvOSr/rMaZKnX5RIVq7JDn0JbP4BOnKG2SGXLQ==", + "dev": true, + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=12.18" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", + "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "browserslist-lint": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vite": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.1.1.tgz", + "integrity": "sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==", + "dev": true, + "peer": true, + "dependencies": { + "esbuild": "^0.16.14", + "postcss": "^8.4.21", + "resolve": "^1.22.1", + "rollup": "^3.10.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.4.tgz", + "integrity": "sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/wretch": { + "version": "1.7.10", + "resolved": "https://registry.npmjs.org/wretch/-/wretch-1.7.10.tgz", + "integrity": "sha512-UgF2o63bZRsz3LoOxaxzAUdFdlIJzVYbCHHhQ+LNMSBD1FeFJn8ADaekopJclHUm6sN8Lhu0DQFGQloliS0Twg==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..004279d --- /dev/null +++ b/ui/package.json @@ -0,0 +1,32 @@ +{ + "name": "shokku-ui", + "version": "0.0.1", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prettier": "prettier --write --plugin-search-dir=. ./**/*.svelte" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "next", + "@sveltejs/adapter-static": "^1.0.0-next.29", + "@sveltejs/kit": "next", + "autoprefixer": "^10.4.13", + "cssnano": "^5.1.8", + "postcss": "^8.4.21", + "prettier": "^2.8.1", + "prettier-plugin-svelte": "^2.9.0", + "svelte": "^3.44.0", + "tabler-icons-svelte": "^1.8.0", + "tailwindcss": "^3.2.4" + }, + "type": "module", + "dependencies": { + "@sveltestack/svelte-query": "^1.6.0", + "daisyui": "^2.14.3", + "js-cookie": "^3.0.1", + "jwt-decode": "^3.1.2", + "svelte-file-dropzone": "^1.0.0", + "wretch": "^1.7.9" + } +} diff --git a/ui/postcss.config.cjs b/ui/postcss.config.cjs new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/ui/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..addd166 --- /dev/null +++ b/ui/src/app.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + /*height: 100vh;*/ +} \ No newline at end of file diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 0000000..ffe2e78 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte new file mode 100644 index 0000000..1876f85 --- /dev/null +++ b/ui/src/components/Header.svelte @@ -0,0 +1,49 @@ + + + diff --git a/ui/src/components/commands/CommandExecution.svelte b/ui/src/components/commands/CommandExecution.svelte new file mode 100644 index 0000000..0f642a4 --- /dev/null +++ b/ui/src/components/commands/CommandExecution.svelte @@ -0,0 +1,47 @@ + + +
+ {#each output as line, i} +
{line["message"]}
+ {/each} + + {#if !finished} +
+ +
+ {/if} +
+ + diff --git a/ui/src/components/commands/CommandExecutionWindow.svelte b/ui/src/components/commands/CommandExecutionWindow.svelte new file mode 100644 index 0000000..bd87a95 --- /dev/null +++ b/ui/src/components/commands/CommandExecutionWindow.svelte @@ -0,0 +1,88 @@ + + +
+
+ + +
+ + {#if selectedId && executions[selectedId]} +
+ +
+ {/if} +
+ + {#if selectedId} + + {/if} +
diff --git a/ui/src/components/commands/Terminal.svelte b/ui/src/components/commands/Terminal.svelte new file mode 100644 index 0000000..c9edff2 --- /dev/null +++ b/ui/src/components/commands/Terminal.svelte @@ -0,0 +1,94 @@ + + +
+
+ +
+ +
+
+ {#each output as line, i} + {#if line.input} + {line.input} + {/if} + {#if line.output} + {line.output} + {/if} + {#if line.error} + {line.error} + {/if} + {/each} +
+
+ +
+ > + +
+
+ + diff --git a/ui/src/components/common/Alert.svelte b/ui/src/components/common/Alert.svelte new file mode 100644 index 0000000..9f325ad --- /dev/null +++ b/ui/src/components/common/Alert.svelte @@ -0,0 +1,27 @@ + + +
+
+
+ + {message} +
+
+
diff --git a/ui/src/components/common/Card.svelte b/ui/src/components/common/Card.svelte new file mode 100644 index 0000000..a1b033b --- /dev/null +++ b/ui/src/components/common/Card.svelte @@ -0,0 +1,14 @@ + + +
+
+

{title}

+ +
+ +
+
+
diff --git a/ui/src/components/common/Cards.svelte b/ui/src/components/common/Cards.svelte new file mode 100644 index 0000000..96c50c1 --- /dev/null +++ b/ui/src/components/common/Cards.svelte @@ -0,0 +1,3 @@ +
+ +
diff --git a/ui/src/components/common/CenterCard.svelte b/ui/src/components/common/CenterCard.svelte new file mode 100644 index 0000000..dccb238 --- /dev/null +++ b/ui/src/components/common/CenterCard.svelte @@ -0,0 +1,7 @@ +
+
+
+ +
+
+
diff --git a/ui/src/components/common/Code.svelte b/ui/src/components/common/Code.svelte new file mode 100644 index 0000000..210019f --- /dev/null +++ b/ui/src/components/common/Code.svelte @@ -0,0 +1,10 @@ + + +
+ {#each lines as line} +
{line}
+ {/each} +
diff --git a/ui/src/components/common/ConfirmationModal.svelte b/ui/src/components/common/ConfirmationModal.svelte new file mode 100644 index 0000000..443d944 --- /dev/null +++ b/ui/src/components/common/ConfirmationModal.svelte @@ -0,0 +1,53 @@ + + + +
+ Are you sure you want to {action}? + + {#if extraOption} +
+ +
+ {/if} +
+ + + +
diff --git a/ui/src/components/common/ContentPage.svelte b/ui/src/components/common/ContentPage.svelte new file mode 100644 index 0000000..ea87d6d --- /dev/null +++ b/ui/src/components/common/ContentPage.svelte @@ -0,0 +1,27 @@ +
+ + + +
+ + + +
+
+ +
+
+
+
diff --git a/ui/src/components/common/Error.svelte b/ui/src/components/common/Error.svelte new file mode 100644 index 0000000..37f8d54 --- /dev/null +++ b/ui/src/components/common/Error.svelte @@ -0,0 +1,37 @@ + + +
+
+
+
+ Error {action} +
+ +
+ +
+
+

{displayMessage}

+
+
diff --git a/ui/src/components/common/Icon.svelte b/ui/src/components/common/Icon.svelte new file mode 100644 index 0000000..4edc884 --- /dev/null +++ b/ui/src/components/common/Icon.svelte @@ -0,0 +1,114 @@ + + +
+ +
diff --git a/ui/src/components/common/KVEditor.svelte b/ui/src/components/common/KVEditor.svelte new file mode 100644 index 0000000..3da0650 --- /dev/null +++ b/ui/src/components/common/KVEditor.svelte @@ -0,0 +1,145 @@ + + +
+ {#each varsList as pair, i} +
+ +
+ +
+
+ +
+ +
+ {/each} +
+ +
+ + {#if stateDirty && showSaveButton} + + {/if} +
+ + diff --git a/ui/src/components/common/Loader.svelte b/ui/src/components/common/Loader.svelte new file mode 100644 index 0000000..182244f --- /dev/null +++ b/ui/src/components/common/Loader.svelte @@ -0,0 +1,105 @@ +
+
+
+
+
+ + diff --git a/ui/src/components/common/Logs.svelte b/ui/src/components/common/Logs.svelte new file mode 100644 index 0000000..c8d5ebd --- /dev/null +++ b/ui/src/components/common/Logs.svelte @@ -0,0 +1,20 @@ + + +
+
+ {#each logs as line, i} +
{line.trim()}
+ {/each} +
+
+ + diff --git a/ui/src/components/common/Modal.svelte b/ui/src/components/common/Modal.svelte new file mode 100644 index 0000000..71f5ebb --- /dev/null +++ b/ui/src/components/common/Modal.svelte @@ -0,0 +1,45 @@ + + + diff --git a/ui/src/components/common/QueryDataWrapper.svelte b/ui/src/components/common/QueryDataWrapper.svelte new file mode 100644 index 0000000..fb37ffe --- /dev/null +++ b/ui/src/components/common/QueryDataWrapper.svelte @@ -0,0 +1,15 @@ + + +{#if $query.isLoading} + +{:else if $query.isError} + +{:else} + +{/if} diff --git a/ui/src/components/common/Sidebar.svelte b/ui/src/components/common/Sidebar.svelte new file mode 100644 index 0000000..dcb7b51 --- /dev/null +++ b/ui/src/components/common/Sidebar.svelte @@ -0,0 +1,57 @@ + + + + +
+
+ +
+ {pageLabel} +
+
+ +
+
+
diff --git a/ui/src/components/common/Steps.svelte b/ui/src/components/common/Steps.svelte new file mode 100644 index 0000000..431845d --- /dev/null +++ b/ui/src/components/common/Steps.svelte @@ -0,0 +1,92 @@ + + +
+
+
    + {#each steps as step, i} +
  • maybeSetStep(i)} + data-content={i === currentStep ? "●" : null} + > + {step.label} +
  • + {/each} +
+
+ +
+
+ updateStepStatus(currentStep, e.detail)} + bind:data + /> +
+ +
+ + +
+ + + +
+
+
+
diff --git a/ui/src/components/common/Todo.svelte b/ui/src/components/common/Todo.svelte new file mode 100644 index 0000000..ee3beaa --- /dev/null +++ b/ui/src/components/common/Todo.svelte @@ -0,0 +1,3 @@ +
+ TODO +
diff --git a/ui/src/components/common/devicons/Docker.svelte b/ui/src/components/common/devicons/Docker.svelte new file mode 100644 index 0000000..63ab248 --- /dev/null +++ b/ui/src/components/common/devicons/Docker.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/src/components/common/devicons/Github.svelte b/ui/src/components/common/devicons/Github.svelte new file mode 100644 index 0000000..afa8975 --- /dev/null +++ b/ui/src/components/common/devicons/Github.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/ui/src/components/common/devicons/Go.svelte b/ui/src/components/common/devicons/Go.svelte new file mode 100644 index 0000000..af50305 --- /dev/null +++ b/ui/src/components/common/devicons/Go.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/ui/src/components/common/devicons/Javascript.svelte b/ui/src/components/common/devicons/Javascript.svelte new file mode 100644 index 0000000..f0f44fe --- /dev/null +++ b/ui/src/components/common/devicons/Javascript.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/src/components/common/devicons/MongoDB.svelte b/ui/src/components/common/devicons/MongoDB.svelte new file mode 100644 index 0000000..a4df7f2 --- /dev/null +++ b/ui/src/components/common/devicons/MongoDB.svelte @@ -0,0 +1,92 @@ + + + + + diff --git a/ui/src/components/common/devicons/MySQL.svelte b/ui/src/components/common/devicons/MySQL.svelte new file mode 100644 index 0000000..2eae27d --- /dev/null +++ b/ui/src/components/common/devicons/MySQL.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/src/components/common/devicons/Postgres.svelte b/ui/src/components/common/devicons/Postgres.svelte new file mode 100644 index 0000000..dbf6d1f --- /dev/null +++ b/ui/src/components/common/devicons/Postgres.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/ui/src/components/common/devicons/Python.svelte b/ui/src/components/common/devicons/Python.svelte new file mode 100644 index 0000000..9b57642 --- /dev/null +++ b/ui/src/components/common/devicons/Python.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/ui/src/components/common/devicons/Redis.svelte b/ui/src/components/common/devicons/Redis.svelte new file mode 100644 index 0000000..a238db4 --- /dev/null +++ b/ui/src/components/common/devicons/Redis.svelte @@ -0,0 +1,36 @@ + + + + + diff --git a/ui/src/components/common/devicons/Ruby.svelte b/ui/src/components/common/devicons/Ruby.svelte new file mode 100644 index 0000000..7bf09fe --- /dev/null +++ b/ui/src/components/common/devicons/Ruby.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/ui/src/components/common/devicons/SQLite.svelte b/ui/src/components/common/devicons/SQLite.svelte new file mode 100644 index 0000000..c1b4182 --- /dev/null +++ b/ui/src/components/common/devicons/SQLite.svelte @@ -0,0 +1,35 @@ + + + + + diff --git a/ui/src/components/dashboard/DashboardCard.svelte b/ui/src/components/dashboard/DashboardCard.svelte new file mode 100644 index 0000000..1237bea --- /dev/null +++ b/ui/src/components/dashboard/DashboardCard.svelte @@ -0,0 +1,58 @@ + + +
+
+
+
+
+ {#if info.type} + + {/if} + {info.name} +
+
+ +
+ +
+ {#if contentType === "app" && state} +
+
+ + {state} + +
+ + +
+ {/if} +
+
+ + +
+
diff --git a/ui/src/components/dashboard/DashboardCardList.svelte b/ui/src/components/dashboard/DashboardCardList.svelte new file mode 100644 index 0000000..eda8adf --- /dev/null +++ b/ui/src/components/dashboard/DashboardCardList.svelte @@ -0,0 +1,59 @@ + + + +
+ {contentTypeTitle}s +
+
+ +
+
+ + {#if $query.data.length > 0} +
+ {#each $query.data as info} + + {/each} +
+ {:else} +
+
+
+
+ No {contentTypeTitle}s +
+
+ +
+ +
+
+
+ {/if} + diff --git a/ui/src/components/links/LinkCard.svelte b/ui/src/components/links/LinkCard.svelte new file mode 100644 index 0000000..43fec11 --- /dev/null +++ b/ui/src/components/links/LinkCard.svelte @@ -0,0 +1,91 @@ + + +
+ +
+ {#if cardType === "service"}{/if} +
+
+ {cardType === "service" ? serviceName : appName} +
+
+
+
+ +
+
+ +
+
+ +{#if isLinked} + +{:else} + +{/if} diff --git a/ui/src/components/links/LinkModal.svelte b/ui/src/components/links/LinkModal.svelte new file mode 100644 index 0000000..4582493 --- /dev/null +++ b/ui/src/components/links/LinkModal.svelte @@ -0,0 +1,51 @@ + + + + This will cause your app to restart! + +
+ +
Advanced Options
+
+ +
+
+ +
+ +
+
diff --git a/ui/src/components/links/link-configs/Generic.svelte b/ui/src/components/links/link-configs/Generic.svelte new file mode 100644 index 0000000..76bad6f --- /dev/null +++ b/ui/src/components/links/link-configs/Generic.svelte @@ -0,0 +1,32 @@ + + +
+ + +
+ +
+ + +
diff --git a/ui/src/components/processes/ProcessCard.svelte b/ui/src/components/processes/ProcessCard.svelte new file mode 100644 index 0000000..ed7379b --- /dev/null +++ b/ui/src/components/processes/ProcessCard.svelte @@ -0,0 +1,119 @@ + + +
+
+
+ {processName} +
+
+
+ +
+
+
+ {#if currentView !== resourceView} + + {:else} +
+ +
+
+ +
+ {/if} +
+ +
+ {#if report} + {#if currentView === resourceView} + + {:else if currentView === resourceEditView} + + {:else if currentView === deploymentEditView} + + {/if} + {/if} +
+
diff --git a/ui/src/components/processes/ProcessDeploymentEditor.svelte b/ui/src/components/processes/ProcessDeploymentEditor.svelte new file mode 100644 index 0000000..25daa88 --- /dev/null +++ b/ui/src/components/processes/ProcessDeploymentEditor.svelte @@ -0,0 +1,72 @@ + + +
+ +
+ +
+ +
diff --git a/ui/src/components/processes/ProcessResource.svelte b/ui/src/components/processes/ProcessResource.svelte new file mode 100644 index 0000000..d86e367 --- /dev/null +++ b/ui/src/components/processes/ProcessResource.svelte @@ -0,0 +1,55 @@ + + +
+ + {#if enabled} +
+ + +
+ {/if} +
diff --git a/ui/src/components/processes/ProcessResourceEditor.svelte b/ui/src/components/processes/ProcessResourceEditor.svelte new file mode 100644 index 0000000..a3e0f49 --- /dev/null +++ b/ui/src/components/processes/ProcessResourceEditor.svelte @@ -0,0 +1,97 @@ + + +
+
+ + + +
+ + {#if $setResourcesMutation.isError} + + {/if} + + +
diff --git a/ui/src/components/processes/ProcessResourceView.svelte b/ui/src/components/processes/ProcessResourceView.svelte new file mode 100644 index 0000000..357a20c --- /dev/null +++ b/ui/src/components/processes/ProcessResourceView.svelte @@ -0,0 +1,54 @@ + + +{#if showCPU || showMemLimit || showMemReserved} +
+ {#if showCPU} +
+
+
vCPU Limit
+
+ + {cpuLimit["amount"]} +
+
+
+ {/if} + + {#if showMemLimit} +
+
+
Memory Limit
+
+ + {memLimit["amount"]}{memLimit["unit"]} +
+
+
+ {/if} + + {#if showMemReserved} +
+
+
Memory Reserved
+
+ + {memReserved["amount"]}{memReserved["unit"]} +
+
+
+ {/if} +
+{/if} diff --git a/ui/src/components/processes/ProcessScaleSelector.svelte b/ui/src/components/processes/ProcessScaleSelector.svelte new file mode 100644 index 0000000..150d5c7 --- /dev/null +++ b/ui/src/components/processes/ProcessScaleSelector.svelte @@ -0,0 +1,72 @@ + + +
+
+ Scale: + {#if $setScaleMutation.isLoading} + + + + {/if} +
+
diff --git a/ui/src/components/processes/ProcessesOverview.svelte b/ui/src/components/processes/ProcessesOverview.svelte new file mode 100644 index 0000000..fe43908 --- /dev/null +++ b/ui/src/components/processes/ProcessesOverview.svelte @@ -0,0 +1,64 @@ + + +
+ {#if $processReport.isLoading || $appProcesses.isLoading || $checksReport.isLoading} + + {:else if $processReport.isError} + + {:else if $appProcesses.isError} + + {:else if $checksReport.isError} + + {:else} + {#each $appProcesses.data as processName, i} + + {/each} + {/if} +
diff --git a/ui/src/components/service-pages/redis/RedisIndex.svelte b/ui/src/components/service-pages/redis/RedisIndex.svelte new file mode 100644 index 0000000..9b01c53 --- /dev/null +++ b/ui/src/components/service-pages/redis/RedisIndex.svelte @@ -0,0 +1 @@ +redis index page diff --git a/ui/src/components/totp/TotpSetupButton.svelte b/ui/src/components/totp/TotpSetupButton.svelte new file mode 100644 index 0000000..5af4b70 --- /dev/null +++ b/ui/src/components/totp/TotpSetupButton.svelte @@ -0,0 +1,134 @@ + + +{#if $genQuery.isError} + +{/if} + + + +
+
+ Scan: + + + Or enter secret manually: +
+
+ + {totpSecret} +
+
+ +
+ Then enter code from authenticator: + +
+ +
+ {#if confirmError} +
+ {confirmError} +
+ {/if} + +
+
+ +
+ This is your recovery code, store it somewhere safe. +
+
+ + {recoveryCode} +
+ +
+
diff --git a/ui/src/lib/api.js b/ui/src/lib/api.js new file mode 100644 index 0000000..ccaab2d --- /dev/null +++ b/ui/src/lib/api.js @@ -0,0 +1,6 @@ +export * from "./apis/apps.js"; +export * from "./apis/auth.js"; +export * from "./apis/services.js"; +export * from "./apis/settings.js"; +export * from "./apis/exec.js"; +export * from "./apis/setup.js"; diff --git a/ui/src/lib/apis/apps.js b/ui/src/lib/apis/apps.js new file mode 100644 index 0000000..b7b9d37 --- /dev/null +++ b/ui/src/lib/apis/apps.js @@ -0,0 +1,287 @@ +import {createApiClient} from "./client.js"; + +const appsClient = createApiClient("/apps/"); + +export const getAppsList = async () => { + return await appsClient.url("list") + .get() + .json(res => res["apps"]); +} + +export const getAllAppsOverview = async () => { + return await appsClient.url("report") + .get() + .json(res => res["apps"]); +} + +export const getAppOverview = async (appName) => { + return await appsClient.url("overview") + .query({"name": appName}) + .get() + .json(); +} + +export const getAppIsSetup = async (appName) => { + return await appsClient.url("setup") + .query({"name": appName}) + .get() + .json(res => res["is_setup"]); +} + +export const createApp = async (appName) => { + return appsClient.url("create") + .post({"name": appName}) + .res(res => res.ok); +} + +export const destroyApp = async (appName) => { + return await appsClient.url("destroy") + .post({"name": appName}) + .res(res => res.ok); +} + +export const startApp = async (appName) => { + return appsClient.url("start") + .post({"name": appName}) + .json(res => res["execution_id"]); +} + +export const stopApp = async (appName) => { + return appsClient.url("stop") + .post({"name": appName}) + .json(res => res["execution_id"]); +} + +export const restartApp = async (appName) => { + return appsClient.url("restart") + .post({"name": appName}) + .json(res => res["execution_id"]); +} + +export const rebuildApp = async (appName) => { + return appsClient.url("rebuild") + .post({"name": appName}) + .json(res => res["execution_id"]); +} + +export const getAppInfo = async (appName) => { + return await appsClient.url("info") + .query({"name": appName}) + .get() + .json(res => res["info"]); +} + +export const renameApp = async (curName, newName) => { + return await appsClient.url("rename") + .post({"current_name": curName, "new_name": newName}) + .res(res => res.ok); +} + +export const setupAppAsync = async (appName, source, options) => { + const body = {"name": appName, ...options} + let req = appsClient.url("setup/" + source) + + if (source === "upload-archive") + return await req.formData(body).post().json(res => res["execution_id"]); + + return await req.post(body).json(res => res["execution_id"]); +} + +export const setupApp = async (appName, source, options) => { + return await appsClient.url("setup/" + source) + .post({"name": appName, ...options}) + .res(res => res.ok); +} + +export const getAppSetupConfig = async (appName) => { + return await appsClient.url("setup/config") + .query({"name": appName}) + .get() + .json(); +} + +export const listAppServices = async (appName) => { + return await appsClient.url("services") + .query({name: appName}) + .get() + .json(res => res["services"]); +} + +export const getAppDeployChecksReport = async (appName) => { + return await appsClient.url("deploy-checks") + .query({"name": appName}) + .get() + .json(res => res); +} + +export const setAppDeployChecksState = async (appName, state) => { + return await appsClient.url("deploy-checks") + .post({ + "name": appName, + "state": state, + }) + .res(res => res.ok); +} + +export const setAppProcessDeployChecksState = async (appName, process, state) => { + return await appsClient.url("process/deploy-checks") + .post({ + "name": appName, + "process": process, + "state": state, + }) + .res(res => res.ok); +} + +export const getAppProcesses = async (appName) => { + return await appsClient.url("process/list") + .query({"name": appName}) + .get() + .json(res => res["processes"]); +} + +export const getAppProcessReport = async (appName) => { + return await appsClient.url("process/report") + .query({"name": appName}) + .get() + .json(); +} + +export const setAppProcessResources = async (appName, process, resources) => { + return await appsClient.url("process/resources") + .post({"name": appName, "process": process, ...resources}) + .res(res => res.ok); +} + +export const getAppProcessScale = async (appName) => { + return await appsClient.url("process/scale") + .query({"name": appName}) + .get() + .json(res => res["scale"]); +} + +export const setAppProcessScale = async (appName, processName, scale) => { + return await appsClient.url("process/scale") + .post({"name": appName, "process": processName, "scale": scale}) + .json(res => res["execution_id"]); +} + +export const getAppNetworksReport = async (appName) => { + return await appsClient.url("networks") + .query({"name": appName}) + .get() + .json(res => res); +} + +export const updateAppNetworks = async (appName, config) => { + return await appsClient.url("networks") + .post({"name": appName, ...config}) + .res(res => res.ok); +} + +export const getAppDomainsReport = async (appName) => { + return await appsClient.url("domains") + .query({"name": appName}) + .get() + .json(res => res); +} + +export const setAppDomainsEnabled = async (appName, enabled) => { + return await appsClient.url("domains/state") + .post({"name": appName, "enabled": enabled}) + .res(res => res.ok); +} + +export const getAppLetsEncryptEnabled = async (appName) => { + return await appsClient.url("letsencrypt") + .query({"name": appName}) + .get() + .json(res => res["enabled"]); +} + +export const addAppDomain = async (appName, domain) => { + return await appsClient.url("domain") + .post({"name": appName, "domain": domain}) + .res(res => res.ok); +} + +export const removeAppDomain = async (appName, domain) => { + return await appsClient.url("domain") + .json({"name": appName, "domain": domain}) + .delete() + .res(res => res.ok); +} + +export const getAppLogs = async (appName) => { + return await appsClient.url("logs") + .query({"name": appName}) + .get() + .json(res => res["logs"]); +} + +export const getAppConfig = async (appName) => { + return await appsClient.url("config") + .query({"name": appName}) + .get() + .json(res => res["config"]); +} + +export const setAppConfig = async ({appName, newConfig}) => { + return await appsClient.url("config") + .post({"name": appName, "config": newConfig}) + .res(res => res.ok); +} + +export const getAppStorages = async (appName) => { + return await appsClient.url("storage") + .query({"name": appName}) + .get() + .json(res => res); +} + +export const mountAppStorage = async (appName, options) => { + return await appsClient.url("storage/mount") + .post({"name": appName, ...options}) + .res(res => res.ok); +} + +export const unmountAppStorage = async (appName, options) => { + return await appsClient.url("storage/unmount") + .post({"name": appName, ...options}) + .res(res => res.ok); +} + +export const getSelectedBuilder = async (appName) => { + return await appsClient.url("builder") + .query({"name": appName}) + .get() + .json(res => { + return res["selected"]; + }); +} + +export const setSelectedBuilder = async (appName, builder) => { + return await appsClient.url("builder") + .post({"name": appName, "builder": builder}) + .res(res => res.ok); +} + +export const getAppBuildDirectory = async (appName) => { + return await appsClient.url("build-directory") + .query({"name": appName}) + .get() + .json(res => res["directory"]); +} + +export const setAppBuildDirectory = async (appName, newDir) => { + return await appsClient.url("build-directory") + .post({"name": appName, "directory": newDir}) + .res(res => res.ok); +} + +export const clearAppBuildDirectory = async (appName) => { + return await appsClient.url("build-directory") + .query({"name": appName}) + .delete() + .res(res => res.ok); +} diff --git a/ui/src/lib/apis/auth.js b/ui/src/lib/apis/auth.js new file mode 100644 index 0000000..4300da5 --- /dev/null +++ b/ui/src/lib/apis/auth.js @@ -0,0 +1,40 @@ +import {createApiClient} from "./client.js"; + +export const authClient = createApiClient("/auth/"); + +export const doLogin = async (authDetails) => { + return await authClient.url("login") + .post(authDetails) + .json() + .catch(_ => false) +} + +export const refreshAuthToken = async () => { + return authClient.url("refresh").post().res(); +} + +export const doLogout = async () => { + return await authClient.url("logout") + .post() + .res(r => r.ok); +} + +export const fetchAuthDetails = async () => { + return await authClient.url("details") + .get() + .forbidden(err => {}) + .json(r => r) + .catch(err => {}) +} + +export const getGithubAuthInfo = async () => { + return await authClient.url("github") + .get() + .json(); +} + +export const completeGithubAuth = async (code, redirectUrl) => { + return await authClient.url("github/auth") + .post({code, "redirect_url": redirectUrl}) + .res(r => r.ok) +} diff --git a/ui/src/lib/apis/client.js b/ui/src/lib/apis/client.js new file mode 100644 index 0000000..98c9b53 --- /dev/null +++ b/ui/src/lib/apis/client.js @@ -0,0 +1,14 @@ +import wretch from "wretch"; +import Cookies from "js-cookie"; + +const dev = false; + +const csrfMiddleware = next => (url, opts) => { + const token = Cookies.get("_csrf"); + if (token) opts.headers = {...opts.headers, "X-CSRF-Token": token}; + return next(url, opts); +}; + +export const createApiClient = (base) => { + return wretch("/api" + base).middlewares([csrfMiddleware]) +} \ No newline at end of file diff --git a/ui/src/lib/apis/exec.js b/ui/src/lib/apis/exec.js new file mode 100644 index 0000000..a7156ab --- /dev/null +++ b/ui/src/lib/apis/exec.js @@ -0,0 +1,29 @@ +import {createApiClient} from "./client.js"; + +const execClient = createApiClient("/exec/"); + +export const getExecutionStatus = async (id) => { + return await execClient.url("status") + .query({"execution_id": id}) + .get() + .json(res => res); +} + +export const executeCommandInProcess = async (appName, processName, command) => { + return await execClient.url("command") + .post({appName, processName, command}) + .json(res => res); +} +/* +const getSocketURI = (appName, processName) => { + const loc = window.location; + const proto = (loc.protocol === 'https:') ? "wss:" : "ws:"; + const queryParams = `app_name=${appName}&process_name=${processName}` + return `${proto}//${loc.host}/api/exec/socket?${queryParams}` +} + +export const getAppProcessExecutionSocket = (appName, processName) => { + const uri = getSocketURI(appName, processName); + return new WebSocket(uri) +} +*/ \ No newline at end of file diff --git a/ui/src/lib/apis/services.js b/ui/src/lib/apis/services.js new file mode 100644 index 0000000..3ad680d --- /dev/null +++ b/ui/src/lib/apis/services.js @@ -0,0 +1,137 @@ +import {createApiClient} from "./client.js"; + +const servicesClient = createApiClient("/services/"); + +export const createService = async (name, type, config) => { + return await servicesClient.url(`create`) + .post({name, config, type}) + .res(res => res.ok); +} + +export const cloneService = async (name, newName) => { + return await servicesClient.url(`clone`) + .post({name, newName}) + .res(res => res.ok); +} + +export const listServices = async () => { + return await servicesClient.url("list") + .get() + .json(res => res["services"]); +} + +export const getServiceType = async (serviceName) => { + return await servicesClient.url("type") + .query({"name": serviceName}) + .get() + .json(res => res["type"]); +} + +export const getServiceInfo = async (serviceName, serviceType) => { + return await servicesClient.url("info") + .query({"name": serviceName, "type": serviceType}) + .get() + .json(res => res["info"]); +} + +export const linkServiceToApp = async (serviceName, appName, options) => { + return await servicesClient.url("link") + .post({ + "service_name": serviceName, + "app_name": appName, + ...options, + }) + .json(res => res["execution_id"]); +} + +export const unlinkServiceFromApp = async (serviceName, appName) => { + return await servicesClient.url("unlink") + .post({ + "service_name": serviceName, + "app_name": appName, + }) + .json(res => res["execution_id"]); +} + +export const getServiceLinkedApps = async (serviceName, serviceType) => { + return await servicesClient.url("linked-apps") + .query({"name": serviceName, "type": serviceType}) + .get() + .json(res => res["apps"]); +} + +export const getServiceLogs = async (serviceName, serviceType) => { + return await servicesClient.url("logs") + .query({"name": serviceName, "type": serviceType}) + .get() + .json(res => res["logs"]); +} + +export const startService = async (serviceType, serviceName) => { + return await servicesClient.url("start") + .post({"name": serviceName, "type": serviceType}) + .res(res => res.ok); +} + +export const stopService = async (serviceType, serviceName) => { + return await servicesClient.url("stop") + .post({"name": serviceName, "type": serviceType}) + .res(res => res.ok); +} + +export const restartService = async (serviceType, serviceName) => { + return await servicesClient.url("restart") + .post({"name": serviceName, "type": serviceType}) + .res(res => res.ok); +} + +export const destroyService = async (serviceType, serviceName) => { + return await servicesClient.url("destroy") + .post({"name": serviceName, "type": serviceType}) + .res(res => res.ok); +} + +export const getServiceBackupReport = async (serviceName) => { + return await servicesClient.url("backups/report") + .query({"name": serviceName}) + .get() + .json(res => res["report"]); +} + +export const doServiceBackup = async (serviceName) => { + return await servicesClient.url("backups/run") + .post({"name": serviceName}) + .res(res => res.ok); +} + +export const updateServiceBackupAuth = async (serviceName, config) => { + return await servicesClient.url("backups/auth") + .post({"name": serviceName, "config": config}) + .res(res => res.ok); +} + +export const setServiceBackupsSchedule = async (serviceName, schedule) => { + return await servicesClient.url("backups/schedule") + .post({"name": serviceName, "schedule": schedule}) + .res(res => res.ok); +} + +export const unscheduleServiceBackups = async (serviceName) => { + return await servicesClient.url("backups/schedule") + .body({"name": serviceName}) + .delete() + .res(res => res.ok); +} + +export const setServiceBackupEncryption = async (serviceName, passphrase) => { + return await servicesClient.url("backups/encryption") + .post({"name": serviceName, "passphrase": passphrase}) + .res(res => res.ok); +} + +export const unsetServiceBackupEncryption = async (serviceName) => { + return await servicesClient.url("backups/encryption") + .body({"name": serviceName}) + .delete() + .res(res => res.ok); +} \ No newline at end of file diff --git a/ui/src/lib/apis/settings.js b/ui/src/lib/apis/settings.js new file mode 100644 index 0000000..7b2c61b --- /dev/null +++ b/ui/src/lib/apis/settings.js @@ -0,0 +1,88 @@ +import {createApiClient} from "./client.js"; + +const settingsClient = createApiClient("/settings/"); + +export const getSettings = async () => { + return await settingsClient.url("basic") + .get() + .json(res => res["settings"]); +} + +export const getLetsEncryptStatus = async () => { + return await settingsClient.url("letsencrypt") + .get() + .json(res => res); +} + +export const getUsers = async () => { + return await settingsClient.url("users") + .get() + .json(res => res["users"]); +} + +export const getSSHKeys = async () => { + return await settingsClient.url("ssh-keys") + .get() + .json(res => res["keys"]); +} + +export const getGlobalDomainsList = async () => { + return await settingsClient.url("domains") + .get() + .json(res => res["domains"]); +} + +export const addGlobalDomain = async (domain) => { + return await settingsClient.url("domains") + .post({domain}) + .res(res => res.ok); +} + +export const removeGlobalDomain = async (domain) => { + return await settingsClient.url("domains") + .query({domain}) + .delete() + .res(res => res.ok); +} + +export const getVersions = async () => { + return await settingsClient.url("versions") + .get() + .json(res => res); +} + +export const getNetworksList = async (appName) => { + return await settingsClient.url("networks") + .get() + .json(res => res["networks"]); +} + +export const getPlugins = async (appName) => { + return await settingsClient.url("plugins") + .get() + .json(res => res["plugins"]); +} + +export const getDockerRegistryReport = async () => { + return await settingsClient.url("registry") + .get() + .json(res => res); +} + +export const setDockerRegistry = async ({server, username, password}) => { + return await settingsClient.url("registry") + .post({server, username, password}) + .res(res => res.ok); +} + +export const addGitAuth = async ({host, username, password}) => { + return await settingsClient.url("git-auth") + .post({host, username, password}) + .res(res => res.ok); +} + +export const removeGitAuth = async (host) => { + return await settingsClient.url("git-auth") + .post({host}) + .res(res => res.ok); +} \ No newline at end of file diff --git a/ui/src/lib/apis/setup.js b/ui/src/lib/apis/setup.js new file mode 100644 index 0000000..656532d --- /dev/null +++ b/ui/src/lib/apis/setup.js @@ -0,0 +1,60 @@ +import {createApiClient} from "./client.js"; + +const setupKeyMiddleware = next => (url, opts) => { + const key = localStorage.getItem("setup_key"); + if (key) opts.headers = {...opts.headers, "X-Setup-Key": key}; + return next(url, opts); +}; + +const setupClient = createApiClient("/setup/") + .middlewares([setupKeyMiddleware]); + +export const verifySetupKeyValid = async () => { + return setupClient.url("verify-key") + .get() + .res(r => r.ok) + .catch(r => false); +} + +export const completeCreateAppHandshake = async (code) => { + return setupClient.url("github/create-app") + .post({code}) + .json(); +} + +export const getGithubAppSetupStatus = async () => { + return setupClient.url("github/status") + .get() + .json(); +} + +export const getGithubAppInstallInfo = async () => { + return setupClient.url("github/install-info") + .get() + .json(); +} + +export const completeGithubSetup = async (params) => { + return setupClient.url("github/completed") + .post(params) + .res(); +} + +export const requestGenerateTotp = async () => { + return setupClient.url("totp/new") + .post() + .json() +} + +export const confirmTotpCode = async (secret, code) => { + return setupClient.url("totp/confirm") + .post({secret, code}) + .json(res => res["valid"]) + .catch(_ => false) +} + +export const confirmPasswordSetup = async (opts) => { + return setupClient.url("password") + .post(opts) + .res(res => res.ok) +} \ No newline at end of file diff --git a/ui/src/lib/auth.js b/ui/src/lib/auth.js new file mode 100644 index 0000000..1534d6f --- /dev/null +++ b/ui/src/lib/auth.js @@ -0,0 +1,95 @@ +import Cookies from "js-cookie"; +import jwt_decode from "jwt-decode"; +import {refreshAuthToken} from "./api"; + +const AuthDataCookieKey = "auth_data"; + +const timeTillExpiry = (authCookie) => { + const expiryTimestampMillis = authCookie["exp"] * 1000; + const expiryTime = new Date(expiryTimestampMillis); + return expiryTime.getTime() - Date.now(); +} + +export const readAuthCookie = () => { + const val = Cookies.get(AuthDataCookieKey); + if (!val) return null; + + const payload = jwt_decode(val); + const timeLeft = timeTillExpiry(payload); + if (timeLeft < 0) return null; + return payload; +} + +// check every 30 seconds, refresh token at 2 minutes left +const refreshTokenCutoffMs = 2 * 60_000; +const refreshIntervalDelayMs = 30_000; + +const tryRefreshAuth = async () => { + const auth = readAuthCookie(); + if (!auth) return; + if (timeTillExpiry(auth) - refreshTokenCutoffMs > 0) return; + + try { + await refreshAuthToken(); + } catch (e) { + console.error("error refreshing auth", e); + } +} + +let refreshIntervalId; +export const startAuthRefresh = async () => { + if (refreshIntervalId) stopAuthRefresh(); + refreshIntervalId = setInterval(tryRefreshAuth, refreshIntervalDelayMs) +} + +export const stopAuthRefresh = () => { + if (refreshIntervalId) clearInterval(refreshIntervalId); + refreshIntervalId = null; +} + +const generateRandomString = (n) => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let vals = new Uint8Array(n); + window.crypto.getRandomValues(vals); + return String.fromCharCode.apply(null, vals.map( + x => chars.charCodeAt(x % chars.length))); +} + +export const createStoredState = (storageKey) => { + const state = generateRandomString(16); + localStorage.setItem(storageKey, state); + return state; +} + +export const checkStoredState = (storageKey, supplied) => { + const storedState = localStorage.getItem(storageKey); + if (supplied === storedState) { + localStorage.removeItem("github_state"); + return true; + } + return false; +} + +export const getSetupStatus = async (refresh) => { + if (!refresh) { + const storedStatus = localStorage.getItem("setup_status"); + if (!!storedStatus) return JSON.parse(storedStatus); + } + + const status = await fetch("/api/setup/status").then(r => r.json()); + setSetupStatus(status) + return status; +} + +export const setSetupStatus = (status) => { + localStorage.setItem("setup_status", JSON.stringify(status)); +} + +export const checkSetupKeySet = () => { + const storedKey = localStorage.getItem("setup_key"); + return !!storedKey; +} + +export const setSetupKey = (key) => { + localStorage.setItem("setup_key", key); +} \ No newline at end of file diff --git a/ui/src/lib/stores.js b/ui/src/lib/stores.js new file mode 100644 index 0000000..e81f3f5 --- /dev/null +++ b/ui/src/lib/stores.js @@ -0,0 +1,87 @@ +import {readable, writable} from "svelte/store"; +import {getExecutionStatus} from "./api.js"; + +const createAppThemeStore = () => { + const themeKey = "app_theme"; + const defaultTheme = "business"; + const storedValue = localStorage.getItem(themeKey); + const {subscribe, set} = writable(storedValue || defaultTheme); + return { + subscribe, + set: (val) => { + localStorage.setItem(themeKey, val); + set(val); + } + } +} + +let completionCallbacks = {} +const createCommandExecutionIdsStore = () => { + const {subscribe, update} = writable([]); + const remove = (id) => update(val => val.filter(el => el !== id)); + return { + subscribe, + addID: (id) => { + update(ids => [...ids, id]); + return new Promise(res => completionCallbacks[id] = res); + }, + signalFinished: (id, success) => { + remove(id); + if (completionCallbacks[id]) { + completionCallbacks[id](success); + delete completionCallbacks[id]; + } + }, + remove, + } +} + +const pollCommandExecutionsFn = (idsStore) => { + const pollIntervalMs = 5000; + + let ids = []; + idsStore.subscribe(val => ids = val); + + return (set) => { + let isPolling = {} + const interval = setInterval(async () => { + let didPoll = false; + + let statuses = {} + for (const i in ids) { + const id = ids[i]; + + if (isPolling[id]) return; + if (!completionCallbacks[id]) return; + didPoll = true; + + isPolling[id] = true; + try { + let status = await getExecutionStatus(id); + statuses[id] = status; + isPolling[id] = false + + if (status["finished"]) { + idsStore.signalFinished(id, status["success"]); + } + } catch (e) { + if (e.status === 404) { + delete isPolling[id]; + idsStore.remove(id); + return; + } + } + isPolling[id] = false; + } + + if (didPoll) set(statuses); + }, pollIntervalMs); + + return () => clearInterval(interval); + } +} + +export const appTheme = createAppThemeStore(); +export const commandExecutionIds = createCommandExecutionIdsStore(); +export const commandExecutions = readable({}, pollCommandExecutionsFn(commandExecutionIds)); +export const executionIdDescriptions = writable({}); \ No newline at end of file diff --git a/ui/src/routes/+layout.js b/ui/src/routes/+layout.js new file mode 100644 index 0000000..6a8cc91 --- /dev/null +++ b/ui/src/routes/+layout.js @@ -0,0 +1,38 @@ +import {readAuthCookie, getSetupStatus} from "$lib/auth"; +import {redirect} from "@sveltejs/kit"; + +export const ssr = false; +export const prerender = false; + +export async function load({url, depends}) { + depends("app:load"); + + const path = url.pathname; + const onSetupPath = path.startsWith("/setup"); + const invalidateStatus = url.searchParams.get("invalidate_setup") === "1"; + + let setupStatus; + try { + setupStatus = await getSetupStatus(onSetupPath || invalidateStatus); + } catch (e) { + console.error("failed to get setup status", e); + } + + let isSetup = (setupStatus && setupStatus["is_setup"]); + if (onSetupPath) { + if (isSetup) throw redirect(302, "/"); + return {}; + } + + if (!setupStatus || !setupStatus["is_setup"]) + throw redirect(302, "/setup"); + + const authDetails = await readAuthCookie(); + if (authDetails) return {authDetails}; + + const loginMethod = setupStatus["method"]; + const loginBasePath = `/login/${loginMethod}` + if (path.startsWith(loginBasePath)) return {}; + + throw redirect(307, loginBasePath); +} \ No newline at end of file diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte new file mode 100644 index 0000000..c4188eb --- /dev/null +++ b/ui/src/routes/+layout.svelte @@ -0,0 +1,82 @@ + + + + shokku + + +
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+ +
+
+
diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte new file mode 100644 index 0000000..ce1b98f --- /dev/null +++ b/ui/src/routes/+page.svelte @@ -0,0 +1,37 @@ + + +
+