From 87ba4560ad137a6ecc595a8d61a6b69171df38ca Mon Sep 17 00:00:00 2001 From: Andras Bacsai Date: Wed, 6 Jul 2022 11:02:36 +0200 Subject: [PATCH] v3.0.0 (#476) * New Version: 3.0.0 --- .dockerignore | 21 +- .env.template | 8 - .gitignore | 21 +- .husky/_/.gitignore | 1 - .husky/pre-commit | 4 - .lintstagedrc.json | 5 - CONTRIBUTING.md | 2 + Dockerfile | 35 +- README.md | 6 +- apps/api/.eslintignore | 9 + apps/api/.eslintrc | 11 + apps/api/.gitignore | 11 + db/.gitkeep => apps/api/.prettierignore | 0 .prettierrc => apps/api/.prettierrc | 0 apps/api/db/.gitkeep | 0 apps/api/nodemon.json | 7 + apps/api/package.json | 67 + .../20220131142425_init/migration.sql | 0 .../20220210104005_redis_aol/migration.sql | 0 .../migration.sql | 0 .../20220217211304_dualcerts/migration.sql | 0 .../20220219231255_prmr_secrets/migration.sql | 0 .../migration.sql | 0 .../20220301101928_proxyhash/migration.sql | 0 .../migration.sql | 0 .../20220311213422_autodeploy/migration.sql | 0 .../20220320141424_phpmodules/migration.sql | 0 .../migration.sql | 0 .../20220327180323_ghost/migration.sql | 0 .../20220402135305_python/migration.sql | 0 .../20220402210645_meilisearch/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20220425071132_umami/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20220427133656_hasura/migration.sql | 0 .../20220429202516_fider/migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../migration.sql | 0 .../20220517081328_traefik/migration.sql | 0 .../migration.sql | 0 .../prisma}/migrations/migration_lock.toml | 0 {prisma => apps/api/prisma}/schema.prisma | 0 apps/api/prisma/seed.js | 75 + apps/api/src/index.ts | 132 + apps/api/src/jobs/autoUpdater.ts | 43 + apps/api/src/jobs/checkProxies.ts | 88 + apps/api/src/jobs/cleanupStorage.ts | 90 + apps/api/src/jobs/deployApplication.ts | 352 + .../api/src}/lib/buildPacks/common.ts | 647 +- {src => apps/api/src}/lib/buildPacks/deno.ts | 2 +- .../api/src}/lib/buildPacks/docker.ts | 2 +- .../api/src}/lib/buildPacks/gatsby.ts | 2 +- {src => apps/api/src}/lib/buildPacks/index.ts | 0 .../api/src}/lib/buildPacks/laravel.ts | 2 +- .../api/src}/lib/buildPacks/nestjs.ts | 2 +- .../api/src}/lib/buildPacks/nextjs.ts | 3 +- {src => apps/api/src}/lib/buildPacks/node.ts | 5 +- .../api/src}/lib/buildPacks/nuxtjs.ts | 3 +- {src => apps/api/src}/lib/buildPacks/php.ts | 19 +- .../api/src}/lib/buildPacks/python.ts | 2 +- {src => apps/api/src}/lib/buildPacks/react.ts | 2 +- {src => apps/api/src}/lib/buildPacks/rust.ts | 4 +- .../api/src}/lib/buildPacks/static.ts | 2 +- .../api/src}/lib/buildPacks/svelte.ts | 2 +- {src => apps/api/src}/lib/buildPacks/vuejs.ts | 2 +- apps/api/src/lib/common.ts | 1480 ++++ {src => apps/api/src}/lib/dayjs.ts | 2 +- apps/api/src/lib/docker.ts | 78 + {src => apps/api/src}/lib/importers/github.ts | 13 +- {src => apps/api/src}/lib/importers/gitlab.ts | 3 +- {src => apps/api/src}/lib/importers/index.ts | 0 apps/api/src/lib/scheduler.ts | 44 + apps/api/src/lib/serviceFields.ts | 407 ++ apps/api/src/plugins/jwt.ts | 34 + .../routes/api/v1/applications/handlers.ts | 871 +++ .../src/routes/api/v1/applications/index.ts | 89 + .../src/routes/api/v1/databases/handlers.ts | 471 ++ apps/api/src/routes/api/v1/databases/index.ts | 32 + .../routes/api/v1/destinations/handlers.ts | 200 + .../src/routes/api/v1/destinations/index.ts | 24 + apps/api/src/routes/api/v1/handlers.ts | 280 + apps/api/src/routes/api/v1/iam/handlers.ts | 453 ++ apps/api/src/routes/api/v1/iam/index.ts | 29 + apps/api/src/routes/api/v1/index.ts | 51 + .../src/routes/api/v1/services/handlers.ts | 2417 +++++++ apps/api/src/routes/api/v1/services/index.ts | 70 + .../src/routes/api/v1/settings/handlers.ts | 85 + apps/api/src/routes/api/v1/settings/index.ts | 17 + .../api/src/routes/api/v1/sources/handlers.ts | 182 + apps/api/src/routes/api/v1/sources/index.ts | 21 + .../src/routes/webhooks/github/handlers.ts | 222 + apps/api/src/routes/webhooks/github/index.ts | 10 + .../src/routes/webhooks/gitlab/handlers.ts | 178 + apps/api/src/routes/webhooks/gitlab/index.ts | 9 + .../src/routes/webhooks/traefik/handlers.ts | 489 ++ apps/api/src/routes/webhooks/traefik/index.ts | 9 + apps/api/tsconfig.json | 7 + apps/ui/.eslintignore | 13 + .eslintrc.cjs => apps/ui/.eslintrc.cjs | 0 apps/ui/.npmrc | 1 + apps/ui/.prettierignore | 13 + apps/ui/.prettierrc | 6 + apps/ui/README.md | 38 + apps/ui/package.json | 50 + apps/ui/playwright.config.ts | 10 + .../ui/postcss.config.cjs | 0 apps/ui/src/app.d.ts | 21 + {src => apps/ui/src}/app.html | 5 +- apps/ui/src/hooks.ts | 4 + {src => apps/ui/src}/lib/api.ts | 49 +- apps/ui/src/lib/common.ts | 420 ++ .../lib/components/CopyPasswordField.svelte | 10 +- .../ui/src}/lib/components/DeleteIcon.svelte | 0 .../ui/src}/lib/components/Explainer.svelte | 4 +- .../ui/src}/lib/components/Loading.svelte | 0 .../ui/src/lib/components/LoadingLogs.svelte | 0 .../ui/src}/lib/components/PageLoader.svelte | 4 +- .../ui/src}/lib/components/Setting.svelte | 8 +- .../ui/src/lib/components/Trend.svelte | 2 +- .../src/lib/components/UpdateAvailable.svelte | 183 + apps/ui/src/lib/components/Usage.svelte | 147 + .../components/svg/applications/Astro.svelte | 0 .../components/svg/applications/Deno.svelte | 0 .../components/svg/applications/Docker.svelte | 0 .../svg/applications/Eleventy.svelte | 0 .../components/svg/applications/Gatsby.svelte | 0 .../svg/applications/Laravel.svelte | 0 .../components/svg/applications/Nestjs.svelte | 0 .../components/svg/applications/Nextjs.svelte | 0 .../components/svg/applications/Nodejs.svelte | 0 .../components/svg/applications/Nuxtjs.svelte | 0 .../components/svg/applications/PHP.svelte | 0 .../components/svg/applications/Python.svelte | 0 .../components/svg/applications/React.svelte | 0 .../components/svg/applications/Rust.svelte | 0 .../components/svg/applications/Static.svelte | 0 .../components/svg/applications/Svelte.svelte | 0 .../components/svg/applications/Vuejs.svelte | 0 .../svg/databases/Clickhouse.svelte | 0 .../components/svg/databases/CouchDB.svelte | 0 .../components/svg/databases/MariaDB.svelte | 0 .../components/svg/databases/MongoDB.svelte | 0 .../lib/components/svg/databases/MySQL.svelte | 0 .../svg/databases/PostgreSQL.svelte | 0 .../lib/components/svg/databases/Redis.svelte | 0 .../lib/components/svg/services/Fider.svelte | 0 .../lib/components/svg/services/Ghost.svelte | 0 .../lib/components/svg/services/Hasura.svelte | 0 .../svg/services/LanguageTool.svelte | 0 .../svg/services/MeiliSearch.svelte | 0 .../lib/components/svg/services/MinIO.svelte | 0 .../lib/components/svg/services/N8n.svelte | 0 .../lib/components/svg/services/NocoDB.svelte | 0 .../svg/services/PlausibleAnalytics.svelte | 0 .../lib/components/svg/services/Umami.svelte | 0 .../components/svg/services/UptimeKuma.svelte | 0 .../svg/services/VSCodeServer.svelte | 0 .../svg/services/VaultWarden.svelte | 0 .../components/svg/services/Wordpress.svelte | 0 {src => apps/ui/src}/lib/lang.json | 0 {src => apps/ui/src}/lib/locales/en.json | 6 +- {src => apps/ui/src}/lib/locales/fr.json | 0 apps/ui/src/lib/store.ts | 60 + .../ui/src/lib}/templates.ts | 4 +- {src => apps/ui/src}/lib/translations.ts | 0 {src => apps/ui/src}/routes/__error.svelte | 11 +- {src => apps/ui/src}/routes/__layout.svelte | 307 +- .../routes/applications/[id]}/_Secret.svelte | 50 +- .../routes/applications/[id]/_Setting.svelte | 73 + .../routes/applications/[id]}/_Storage.svelte | 8 +- .../routes/applications/[id]/__layout.svelte | 163 +- .../[id]/configuration/_BuildPack.svelte | 20 +- .../configuration/_GithubRepositories.svelte | 76 +- .../configuration/_GitlabRepositories.svelte | 222 +- .../[id]/configuration/buildpack.svelte | 165 +- .../[id]/configuration/destination.svelte | 57 +- .../[id]/configuration/repository.svelte | 38 +- .../[id]/configuration/source.svelte | 70 +- .../routes/applications/[id]/index.svelte | 129 +- .../applications/[id]/logs}/_BuildLog.svelte | 61 +- .../applications/[id]/logs/build.svelte | 46 +- .../applications/[id]/logs/index.svelte | 39 +- .../routes/applications/[id]/previews.svelte | 43 +- .../routes/applications/[id]/secrets.svelte | 33 +- .../routes/applications/[id]/storages.svelte | 27 +- .../ui/src/routes/applications/[id]}/utils.ts | 8 +- .../ui/src}/routes/applications/index.svelte | 77 +- .../databases/[id]/_DatabaseLinks.svelte | 29 + .../databases/[id]/_Databases/_CouchDb.svelte | 4 +- .../[id]/_Databases/_Databases.svelte | 75 +- .../databases/[id]/_Databases/_MariaDB.svelte | 14 +- .../databases/[id]/_Databases/_MongoDB.svelte | 10 +- .../databases/[id]/_Databases/_MySQL.svelte | 14 +- .../[id]/_Databases/_PostgreSQL.svelte | 14 +- .../databases/[id]/_Databases/_Redis.svelte | 10 +- .../src/routes/databases/[id]/__layout.svelte | 297 + .../[id]/configuration/destination.svelte | 47 +- .../databases/[id]/configuration/type.svelte | 45 +- .../[id]/configuration/version.svelte | 45 +- .../src}/routes/databases/[id]/index.svelte | 58 +- .../databases/[id]/logs/_Loading.svelte | 0 .../routes/databases/[id]/logs/index.svelte | 37 +- .../ui/src}/routes/databases/index.svelte | 42 +- .../destinations/[id]/_Destination.svelte | 25 + .../destinations/[id]/_LocalDocker.svelte | 41 +- .../src/routes/destinations/[id]/_New.svelte | 15 +- .../destinations/[id]/_NewLocalDocker.svelte | 21 +- .../destinations/[id]/_NewRemoteDocker.svelte | 11 +- .../destinations/[id]/_RemoteDocker.svelte | 22 +- .../routes/destinations/[id]/__layout.svelte | 71 + .../src/routes/destinations/[id]/index.svelte | 24 + .../ui/src}/routes/destinations/index.svelte | 40 +- {src => apps/ui/src}/routes/iam/index.svelte | 128 +- .../src/routes/iam/team/[id]/__layout.svelte | 63 + .../ui/src}/routes/iam/team/[id]/index.svelte | 54 +- apps/ui/src/routes/index.svelte | 110 + apps/ui/src/routes/login.svelte | 109 + .../ui/src/routes/register.svelte | 52 +- .../src/routes/services/[id]}/_Secret.svelte | 10 +- .../routes/services/[id]/_ServiceLinks.svelte | 36 +- .../services/[id]/_Services/_Fider.svelte | 7 +- .../services/[id]/_Services/_Ghost.svelte | 5 +- .../services/[id]/_Services/_Hasura.svelte | 2 +- .../[id]/_Services/_MeiliSearch.svelte | 2 +- .../services/[id]/_Services/_MinIO.svelte | 2 +- .../[id]/_Services/_PlausibleAnalytics.svelte | 12 +- .../services/[id]/_Services/_Services.svelte | 88 +- .../services/[id]/_Services/_Umami.svelte | 2 +- .../[id]/_Services/_VSCodeServer.svelte | 2 +- .../services/[id]/_Services/_Wordpress.svelte | 61 +- .../src/routes/services/[id]}/_Storage.svelte | 29 +- .../src}/routes/services/[id]/__layout.svelte | 135 +- .../[id]/configuration/destination.svelte | 46 +- .../services/[id]/configuration/type.svelte | 51 +- .../[id]/configuration/version.svelte | 47 +- .../ui/src}/routes/services/[id]/index.svelte | 53 +- .../routes/services/[id]/logs/_Loading.svelte | 0 .../routes/services/[id]/logs/index.svelte | 37 +- .../src/routes/services/[id]/secrets.svelte | 28 +- .../src/routes/services/[id]/storages.svelte | 29 +- .../ui/src}/routes/services/index.svelte | 47 +- .../ui/src}/routes/settings/index.svelte | 107 +- .../src}/routes/sources/[id]/_Github.svelte | 106 +- .../src}/routes/sources/[id]/_Gitlab.svelte | 65 +- apps/ui/src/routes/sources/[id]/_New.svelte | 62 + .../ui/src/routes/sources/[id]/_Source.svelte | 90 +- .../src/routes/sources/[id]/__layout.svelte | 70 + apps/ui/src/routes/sources/[id]/index.svelte | 24 + .../ui/src}/routes/sources/index.svelte | 64 +- apps/ui/src/routes/webhooks/success.svelte | 6 + {src => apps/ui/src}/tailwind.css | 23 +- {static => apps/ui/static}/favicon.png | Bin {static => apps/ui/static}/ghost.png | Bin {static => apps/ui/static}/minio.png | Bin {static => apps/ui/static}/nocodb.png | Bin {static => apps/ui/static}/plausible.png | Bin ...ns-v19-latin-ext_latin_devanagari-500.woff | Bin ...s-v19-latin-ext_latin_devanagari-500.woff2 | Bin ...19-latin-ext_latin_devanagari-regular.woff | Bin ...9-latin-ext_latin_devanagari-regular.woff2 | Bin apps/ui/svelte.config.js | 20 + .../ui/tailwind.config.cjs | 0 apps/ui/tests/test.ts | 6 + apps/ui/tsconfig.json | 18 + apps/ui/vite.config.js | 11 + data/haproxy-http.Dockerfile | 6 - data/haproxy-tcp.Dockerfile | 6 - data/haproxy.Dockerfile | 6 - data/haproxy/dataplaneapi.hcl | 29 - data/haproxy/haproxy.cfg-http.template | 19 - data/haproxy/haproxy.cfg-tcp.template | 15 - data/haproxy/haproxy.cfg.template | 38 - data/haproxy/ssl/default.pem | 81 - data/prisma/build-prisma-engine.sh | 1 - docker-compose-haproxy.yaml | 23 - docker-compose.yaml | 7 - {data => others}/docker/daemon.json | 0 {data => others}/fluentd/Dockerfile-dev | 0 {data => others}/fluentd/fluentbit-dev.conf | 0 {data => others}/fluentd/fluentd-dev.conf | 0 others/prisma/build-prisma-engine.sh | 1 + .../prisma/prisma-engine.Dockerfile | 4 +- .../traefik/docker-compose-tcp.yaml | 0 package.json | 129 +- pnpm-lock.yaml | 6363 +++++++++-------- pnpm-workspace.yaml | 3 + prisma/seed.cjs | 159 - src/app.d.ts | 74 - src/hooks.ts | 118 - src/lib/common.ts | 282 - src/lib/components/DatabaseLinks.svelte | 28 - src/lib/components/common.ts | 243 - src/lib/crypto.ts | 36 - src/lib/database/applications.ts | 368 - src/lib/database/checks.ts | 97 - src/lib/database/common.ts | 316 - src/lib/database/databases.ts | 211 - src/lib/database/destinations.ts | 194 - src/lib/database/gitSources.ts | 128 - src/lib/database/github.ts | 64 - src/lib/database/gitlab.ts | 54 - src/lib/database/index.ts | 14 - src/lib/database/logs.ts | 20 - src/lib/database/secrets.ts | 133 - src/lib/database/services.ts | 521 -- src/lib/database/settings.ts | 9 - src/lib/database/teams.ts | 25 - src/lib/database/users.ts | 163 - src/lib/docker.ts | 202 - src/lib/form.ts | 82 - src/lib/haproxy/configuration.ts | 299 - src/lib/haproxy/index.ts | 520 -- src/lib/letsencrypt/index.ts | 319 - src/lib/queues/autoUpdater.ts | 42 - src/lib/queues/builder.ts | 336 - src/lib/queues/cleanup.ts | 75 - src/lib/queues/index.ts | 183 - src/lib/queues/logger.ts | 9 - src/lib/queues/proxy.ts | 16 - src/lib/queues/proxyTcpHttp.ts | 103 - src/lib/queues/ssl.ts | 14 - src/lib/queues/sslrenewal.ts | 14 - src/lib/realtime.ts | 3 - src/lib/settings.ts | 8 - src/lib/store.ts | 25 - src/lib/types/builderJob.ts | 57 - src/lib/types/composeFile.ts | 62 - src/lib/types/destinations.ts | 8 - src/routes/applications/[id]/cancel.json.ts | 87 - src/routes/applications/[id]/check.json.ts | 74 - .../[id]/configuration/buildpack.json.ts | 65 - .../[id]/configuration/deploykey.json.ts | 17 - .../[id]/configuration/destination.json.ts | 19 - .../[id]/configuration/githubToken.json.ts | 45 - .../[id]/configuration/repository.json.ts | 52 - .../[id]/configuration/source.json.ts | 18 - .../[id]/configuration/sshkey.json.ts | 20 - src/routes/applications/[id]/delete.json.ts | 20 - src/routes/applications/[id]/deploy.json.ts | 76 - src/routes/applications/[id]/index.json.ts | 105 - .../[id]/logs/build/build.json.ts | 28 - .../[id]/logs/build/index.json.ts | 40 - .../applications/[id]/logs/index.json.ts | 66 - .../applications/[id]/previews/index.json.ts | 49 - .../[id]/secrets/_BatchSecrets.svelte | 49 - .../applications/[id]/secrets/index.json.ts | 70 - src/routes/applications/[id]/settings.json.ts | 26 - src/routes/applications/[id]/status.json.ts | 36 - src/routes/applications/[id]/stop.json.ts | 31 - .../applications/[id]/storage/index.json.ts | 63 - src/routes/applications/[id]/usage.json.ts | 30 - src/routes/applications/index.ts | 21 - src/routes/applications/new.ts | 16 - src/routes/common/getUniqueName.json.ts | 10 - src/routes/dashboard.json.ts | 90 - src/routes/databases/[id]/__layout.svelte | 250 - .../[id]/configuration/destination.json.ts | 19 - .../databases/[id]/configuration/type.json.ts | 33 - .../[id]/configuration/version.json.ts | 37 - src/routes/databases/[id]/delete.json.ts | 22 - src/routes/databases/[id]/index.json.ts | 90 - src/routes/databases/[id]/logs/index.json.ts | 66 - src/routes/databases/[id]/settings.json.ts | 44 - src/routes/databases/[id]/start.json.ts | 91 - src/routes/databases/[id]/stop.json.ts | 27 - src/routes/databases/[id]/usage.json.ts | 30 - src/routes/databases/index.ts | 21 - src/routes/databases/new.ts | 16 - src/routes/destinations/[id]/_FoundApp.svelte | 78 - src/routes/destinations/[id]/__layout.svelte | 69 - src/routes/destinations/[id]/index.json.ts | 71 - src/routes/destinations/[id]/index.svelte | 57 - src/routes/destinations/[id]/restart.json.ts | 35 - src/routes/destinations/[id]/scan.json.ts | 64 - src/routes/destinations/[id]/settings.json.ts | 18 - src/routes/destinations/[id]/start.json.ts | 37 - src/routes/destinations/[id]/stop.json.ts | 26 - src/routes/destinations/index.json.ts | 19 - src/routes/iam/index.json.ts | 135 - src/routes/iam/password.json.ts | 22 - src/routes/iam/team/[id]/__layout.svelte | 28 - src/routes/iam/team/[id]/index.json.ts | 55 - .../iam/team/[id]/invitation/accept.json.ts | 36 - .../iam/team/[id]/invitation/invite.json.ts | 69 - .../iam/team/[id]/invitation/revoke.json.ts | 20 - .../iam/team/[id]/permission/change.json.ts | 23 - src/routes/iam/team/[id]/remove/user.json.ts | 24 - src/routes/index.svelte | 291 - src/routes/login/index.json.ts | 33 - src/routes/login/index.svelte | 107 - src/routes/logout/index.json.ts | 15 - src/routes/new/destination/check.json.ts | 25 - src/routes/new/destination/docker.json.ts | 24 - src/routes/new/team/index.json.ts | 18 - src/routes/new/team/index.svelte | 56 - src/routes/register/index.ts | 11 - .../services/[id]/appwrite-wip/index.json.ts | 21 - .../services/[id]/appwrite-wip/start.json.ts | 519 -- .../services/[id]/appwrite-wip/stop.json.ts | 35 - src/routes/services/[id]/check.json.ts | 63 - .../[id]/configuration/destination.json.ts | 19 - .../services/[id]/configuration/type.json.ts | 33 - .../[id]/configuration/version.json.ts | 42 - src/routes/services/[id]/delete.json.ts | 18 - src/routes/services/[id]/fider/index.json.ts | 57 - src/routes/services/[id]/fider/start.json.ts | 158 - src/routes/services/[id]/fider/stop.json.ts | 42 - src/routes/services/[id]/ghost/index.json.ts | 24 - src/routes/services/[id]/ghost/start.json.ts | 154 - src/routes/services/[id]/ghost/stop.json.ts | 39 - src/routes/services/[id]/hasura/index.json.ts | 21 - src/routes/services/[id]/hasura/start.json.ts | 126 - src/routes/services/[id]/hasura/stop.json.ts | 42 - src/routes/services/[id]/index.json.ts | 56 - .../services/[id]/languagetool/index.json.ts | 23 - .../services/[id]/languagetool/start.json.ts | 87 - .../services/[id]/languagetool/stop.json.ts | 35 - src/routes/services/[id]/logs/index.json.ts | 66 - .../services/[id]/meilisearch/index.json.ts | 22 - .../services/[id]/meilisearch/start.json.ts | 92 - .../services/[id]/meilisearch/stop.json.ts | 35 - src/routes/services/[id]/minio/index.json.ts | 27 - src/routes/services/[id]/minio/start.json.ts | 106 - src/routes/services/[id]/minio/stop.json.ts | 36 - src/routes/services/[id]/n8n/index.json.ts | 21 - src/routes/services/[id]/n8n/start.json.ts | 88 - src/routes/services/[id]/n8n/stop.json.ts | 35 - src/routes/services/[id]/nocodb/index.json.ts | 21 - src/routes/services/[id]/nocodb/start.json.ts | 86 - src/routes/services/[id]/nocodb/stop.json.ts | 35 - .../[id]/plausibleanalytics/activate.json.ts | 33 - .../[id]/plausibleanalytics/index.json.ts | 41 - .../[id]/plausibleanalytics/start.json.ts | 209 - .../[id]/plausibleanalytics/stop.json.ts | 44 - .../services/[id]/secrets/index.json.ts | 70 - src/routes/services/[id]/settings.json.ts | 19 - .../services/[id]/storage/index.json.ts | 65 - src/routes/services/[id]/umami/index.json.ts | 22 - src/routes/services/[id]/umami/start.json.ts | 218 - src/routes/services/[id]/umami/stop.json.ts | 42 - .../services/[id]/uptimekuma/index.json.ts | 21 - .../services/[id]/uptimekuma/start.json.ts | 86 - .../services/[id]/uptimekuma/stop.json.ts | 35 - src/routes/services/[id]/usage.json.ts | 30 - .../services/[id]/vaultwarden/index.json.ts | 21 - .../services/[id]/vaultwarden/start.json.ts | 86 - .../services/[id]/vaultwarden/stop.json.ts | 34 - .../services/[id]/vscodeserver/index.json.ts | 22 - .../services/[id]/vscodeserver/start.json.ts | 120 - .../services/[id]/vscodeserver/stop.json.ts | 34 - .../services/[id]/wordpress/ftp.json.ts | 187 - .../services/[id]/wordpress/index.json.ts | 39 - .../services/[id]/wordpress/settings.json.ts | 32 - .../services/[id]/wordpress/start.json.ts | 146 - .../services/[id]/wordpress/stop.json.ts | 61 - src/routes/services/index.ts | 20 - src/routes/services/new.ts | 16 - src/routes/settings/_Language.svelte | 22 - src/routes/settings/check.json.ts | 56 - src/routes/settings/index.json.ts | 95 - src/routes/settings/renew.json.ts | 26 - src/routes/sources/[id]/__layout.svelte | 69 - src/routes/sources/[id]/check.json.ts | 22 - src/routes/sources/[id]/github.json.ts | 18 - src/routes/sources/[id]/gitlab.json.ts | 33 - src/routes/sources/[id]/index.json.ts | 54 - src/routes/sources/[id]/newGithubApp.svelte | 89 - src/routes/sources/index.json.ts | 16 - src/routes/sources/new.ts | 17 - src/routes/undead.json.ts | 8 - src/routes/update.json.ts | 106 - src/routes/webhooks/github/events.ts | 223 - src/routes/webhooks/github/index.ts | 41 - src/routes/webhooks/github/install.ts | 29 - src/routes/webhooks/gitlab/events.ts | 220 - src/routes/webhooks/gitlab/index.ts | 59 - src/routes/webhooks/success/index.svelte | 3 - src/routes/webhooks/traefik/main.json.ts | 364 - src/routes/webhooks/traefik/other.json.ts | 137 - svelte.config.js | 30 - tsconfig.json | 32 - 491 files changed, 16824 insertions(+), 20459 deletions(-) delete mode 100644 .env.template delete mode 100644 .husky/_/.gitignore delete mode 100755 .husky/pre-commit delete mode 100644 .lintstagedrc.json create mode 100644 apps/api/.eslintignore create mode 100644 apps/api/.eslintrc create mode 100644 apps/api/.gitignore rename db/.gitkeep => apps/api/.prettierignore (100%) rename .prettierrc => apps/api/.prettierrc (100%) create mode 100644 apps/api/db/.gitkeep create mode 100644 apps/api/nodemon.json create mode 100644 apps/api/package.json rename {prisma => apps/api/prisma}/migrations/20220131142425_init/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220210104005_redis_aol/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220212142309_unique_secret_by_application/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220217211304_dualcerts/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220219231255_prmr_secrets/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220220141136_public_portrange/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220301101928_proxyhash/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220304141408_service_secrets/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220311213422_autodeploy/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220320141424_phpmodules/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220322135800_persistent_storage/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220327180323_ghost/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220402135305_python/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220402210645_meilisearch/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220405151428_wordpress_sftp/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220407220809_unique_storage_fix/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220408070805_added_expose_port/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220418214843_persistent_storage_services/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220419203408_multiply_dockerfile_locations/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220420202031_deno_configurations/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220420210057_branch_for_builds/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220425071132_umami/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220425075326_auto_update_coolify/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220426125053_select_base_image/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220427133656_hasura/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220429202516_fider/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220429214112_fider_correction/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220430111953_ssl_dns_check_settings/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220430124553_expose_port_for_services/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220509130501_custom_plausible_script/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220510081125_custom_wordpress_db/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220517081328_traefik/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/20220519095648_minio_apifqdn/migration.sql (100%) rename {prisma => apps/api/prisma}/migrations/migration_lock.toml (100%) rename {prisma => apps/api/prisma}/schema.prisma (100%) create mode 100644 apps/api/prisma/seed.js create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/jobs/autoUpdater.ts create mode 100644 apps/api/src/jobs/checkProxies.ts create mode 100644 apps/api/src/jobs/cleanupStorage.ts create mode 100644 apps/api/src/jobs/deployApplication.ts rename {src => apps/api/src}/lib/buildPacks/common.ts (62%) rename {src => apps/api/src}/lib/buildPacks/deno.ts (97%) rename {src => apps/api/src}/lib/buildPacks/docker.ts (96%) rename {src => apps/api/src}/lib/buildPacks/gatsby.ts (93%) rename {src => apps/api/src}/lib/buildPacks/index.ts (100%) rename {src => apps/api/src}/lib/buildPacks/laravel.ts (95%) rename {src => apps/api/src}/lib/buildPacks/nestjs.ts (93%) rename {src => apps/api/src}/lib/buildPacks/nextjs.ts (94%) rename {src => apps/api/src}/lib/buildPacks/node.ts (91%) rename {src => apps/api/src}/lib/buildPacks/nuxtjs.ts (94%) rename {src => apps/api/src}/lib/buildPacks/php.ts (71%) rename {src => apps/api/src}/lib/buildPacks/python.ts (98%) rename {src => apps/api/src}/lib/buildPacks/react.ts (93%) rename {src => apps/api/src}/lib/buildPacks/rust.ts (93%) rename {src => apps/api/src}/lib/buildPacks/static.ts (95%) rename {src => apps/api/src}/lib/buildPacks/svelte.ts (93%) rename {src => apps/api/src}/lib/buildPacks/vuejs.ts (93%) create mode 100644 apps/api/src/lib/common.ts rename {src => apps/api/src}/lib/dayjs.ts (87%) create mode 100644 apps/api/src/lib/docker.ts rename {src => apps/api/src}/lib/importers/github.ts (80%) rename {src => apps/api/src}/lib/importers/gitlab.ts (91%) rename {src => apps/api/src}/lib/importers/index.ts (100%) create mode 100644 apps/api/src/lib/scheduler.ts create mode 100644 apps/api/src/lib/serviceFields.ts create mode 100644 apps/api/src/plugins/jwt.ts create mode 100644 apps/api/src/routes/api/v1/applications/handlers.ts create mode 100644 apps/api/src/routes/api/v1/applications/index.ts create mode 100644 apps/api/src/routes/api/v1/databases/handlers.ts create mode 100644 apps/api/src/routes/api/v1/databases/index.ts create mode 100644 apps/api/src/routes/api/v1/destinations/handlers.ts create mode 100644 apps/api/src/routes/api/v1/destinations/index.ts create mode 100644 apps/api/src/routes/api/v1/handlers.ts create mode 100644 apps/api/src/routes/api/v1/iam/handlers.ts create mode 100644 apps/api/src/routes/api/v1/iam/index.ts create mode 100644 apps/api/src/routes/api/v1/index.ts create mode 100644 apps/api/src/routes/api/v1/services/handlers.ts create mode 100644 apps/api/src/routes/api/v1/services/index.ts create mode 100644 apps/api/src/routes/api/v1/settings/handlers.ts create mode 100644 apps/api/src/routes/api/v1/settings/index.ts create mode 100644 apps/api/src/routes/api/v1/sources/handlers.ts create mode 100644 apps/api/src/routes/api/v1/sources/index.ts create mode 100644 apps/api/src/routes/webhooks/github/handlers.ts create mode 100644 apps/api/src/routes/webhooks/github/index.ts create mode 100644 apps/api/src/routes/webhooks/gitlab/handlers.ts create mode 100644 apps/api/src/routes/webhooks/gitlab/index.ts create mode 100644 apps/api/src/routes/webhooks/traefik/handlers.ts create mode 100644 apps/api/src/routes/webhooks/traefik/index.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/ui/.eslintignore rename .eslintrc.cjs => apps/ui/.eslintrc.cjs (100%) create mode 100644 apps/ui/.npmrc create mode 100644 apps/ui/.prettierignore create mode 100644 apps/ui/.prettierrc create mode 100644 apps/ui/README.md create mode 100644 apps/ui/package.json create mode 100644 apps/ui/playwright.config.ts rename postcss.config.cjs => apps/ui/postcss.config.cjs (100%) create mode 100644 apps/ui/src/app.d.ts rename {src => apps/ui/src}/app.html (59%) create mode 100644 apps/ui/src/hooks.ts rename {src => apps/ui/src}/lib/api.ts (59%) create mode 100644 apps/ui/src/lib/common.ts rename {src => apps/ui/src}/lib/components/CopyPasswordField.svelte (95%) rename {src => apps/ui/src}/lib/components/DeleteIcon.svelte (100%) rename {src => apps/ui/src}/lib/components/Explainer.svelte (73%) rename {src => apps/ui/src}/lib/components/Loading.svelte (100%) rename src/routes/applications/[id]/logs/_Loading.svelte => apps/ui/src/lib/components/LoadingLogs.svelte (100%) rename {src => apps/ui/src}/lib/components/PageLoader.svelte (91%) rename {src => apps/ui/src}/lib/components/Setting.svelte (95%) rename src/routes/_Trend.svelte => apps/ui/src/lib/components/Trend.svelte (97%) create mode 100644 apps/ui/src/lib/components/UpdateAvailable.svelte create mode 100644 apps/ui/src/lib/components/Usage.svelte rename {src => apps/ui/src}/lib/components/svg/applications/Astro.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Deno.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Docker.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Eleventy.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Gatsby.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Laravel.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Nestjs.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Nextjs.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Nodejs.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Nuxtjs.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/PHP.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Python.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/React.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Rust.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Static.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Svelte.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/applications/Vuejs.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/databases/Clickhouse.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/databases/CouchDB.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/databases/MariaDB.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/databases/MongoDB.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/databases/MySQL.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/databases/PostgreSQL.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/databases/Redis.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/Fider.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/Ghost.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/Hasura.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/LanguageTool.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/MeiliSearch.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/MinIO.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/N8n.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/NocoDB.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/PlausibleAnalytics.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/Umami.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/UptimeKuma.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/VSCodeServer.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/VaultWarden.svelte (100%) rename {src => apps/ui/src}/lib/components/svg/services/Wordpress.svelte (100%) rename {src => apps/ui/src}/lib/lang.json (100%) rename {src => apps/ui/src}/lib/locales/en.json (98%) rename {src => apps/ui/src}/lib/locales/fr.json (100%) create mode 100644 apps/ui/src/lib/store.ts rename {src/lib/components => apps/ui/src/lib}/templates.ts (97%) rename {src => apps/ui/src}/lib/translations.ts (100%) rename {src => apps/ui/src}/routes/__error.svelte (79%) rename {src => apps/ui/src}/routes/__layout.svelte (50%) rename {src/routes/applications/[id]/secrets => apps/ui/src/routes/applications/[id]}/_Secret.svelte (86%) create mode 100644 apps/ui/src/routes/applications/[id]/_Setting.svelte rename {src/routes/applications/[id]/storage => apps/ui/src/routes/applications/[id]}/_Storage.svelte (91%) rename {src => apps/ui/src}/routes/applications/[id]/__layout.svelte (78%) rename {src => apps/ui/src}/routes/applications/[id]/configuration/_BuildPack.svelte (73%) rename {src => apps/ui/src}/routes/applications/[id]/configuration/_GithubRepositories.svelte (71%) rename {src => apps/ui/src}/routes/applications/[id]/configuration/_GitlabRepositories.svelte (67%) rename {src => apps/ui/src}/routes/applications/[id]/configuration/buildpack.svelte (58%) rename {src => apps/ui/src}/routes/applications/[id]/configuration/destination.svelte (71%) rename {src => apps/ui/src}/routes/applications/[id]/configuration/repository.svelte (58%) rename {src => apps/ui/src}/routes/applications/[id]/configuration/source.svelte (73%) rename {src => apps/ui/src}/routes/applications/[id]/index.svelte (90%) rename {src/routes/applications/[id]/logs/build => apps/ui/src/routes/applications/[id]/logs}/_BuildLog.svelte (71%) rename src/routes/applications/[id]/logs/build/index.svelte => apps/ui/src/routes/applications/[id]/logs/build.svelte (89%) rename {src => apps/ui/src}/routes/applications/[id]/logs/index.svelte (92%) rename src/routes/applications/[id]/previews/index.svelte => apps/ui/src/routes/applications/[id]/previews.svelte (89%) rename src/routes/applications/[id]/secrets/index.svelte => apps/ui/src/routes/applications/[id]/secrets.svelte (91%) rename src/routes/applications/[id]/storage/index.svelte => apps/ui/src/routes/applications/[id]/storages.svelte (91%) rename {src/routes/applications/[id]/secrets => apps/ui/src/routes/applications/[id]}/utils.ts (82%) rename {src => apps/ui/src}/routes/applications/index.svelte (81%) create mode 100644 apps/ui/src/routes/databases/[id]/_DatabaseLinks.svelte rename {src => apps/ui/src}/routes/databases/[id]/_Databases/_CouchDb.svelte (97%) rename {src => apps/ui/src}/routes/databases/[id]/_Databases/_Databases.svelte (79%) rename {src => apps/ui/src}/routes/databases/[id]/_Databases/_MariaDB.svelte (89%) rename {src => apps/ui/src}/routes/databases/[id]/_Databases/_MongoDB.svelte (86%) rename {src => apps/ui/src}/routes/databases/[id]/_Databases/_MySQL.svelte (89%) rename {src => apps/ui/src}/routes/databases/[id]/_Databases/_PostgreSQL.svelte (88%) rename {src => apps/ui/src}/routes/databases/[id]/_Databases/_Redis.svelte (81%) create mode 100644 apps/ui/src/routes/databases/[id]/__layout.svelte rename {src => apps/ui/src}/routes/databases/[id]/configuration/destination.svelte (73%) rename {src => apps/ui/src}/routes/databases/[id]/configuration/type.svelte (76%) rename {src => apps/ui/src}/routes/databases/[id]/configuration/version.svelte (68%) rename {src => apps/ui/src}/routes/databases/[id]/index.svelte (66%) rename {src => apps/ui/src}/routes/databases/[id]/logs/_Loading.svelte (100%) rename {src => apps/ui/src}/routes/databases/[id]/logs/index.svelte (88%) rename {src => apps/ui/src}/routes/databases/index.svelte (83%) create mode 100644 apps/ui/src/routes/destinations/[id]/_Destination.svelte rename {src => apps/ui/src}/routes/destinations/[id]/_LocalDocker.svelte (87%) rename src/routes/new/destination/index.svelte => apps/ui/src/routes/destinations/[id]/_New.svelte (81%) rename src/routes/new/destination/_LocalDocker.svelte => apps/ui/src/routes/destinations/[id]/_NewLocalDocker.svelte (84%) rename src/routes/new/destination/_RemoteDocker.svelte => apps/ui/src/routes/destinations/[id]/_NewRemoteDocker.svelte (93%) rename {src => apps/ui/src}/routes/destinations/[id]/_RemoteDocker.svelte (94%) create mode 100644 apps/ui/src/routes/destinations/[id]/__layout.svelte create mode 100644 apps/ui/src/routes/destinations/[id]/index.svelte rename {src => apps/ui/src}/routes/destinations/index.svelte (75%) rename {src => apps/ui/src}/routes/iam/index.svelte (66%) create mode 100644 apps/ui/src/routes/iam/team/[id]/__layout.svelte rename {src => apps/ui/src}/routes/iam/team/[id]/index.svelte (82%) create mode 100644 apps/ui/src/routes/index.svelte create mode 100644 apps/ui/src/routes/login.svelte rename src/routes/register/index.svelte => apps/ui/src/routes/register.svelte (70%) rename {src/routes/services/[id]/secrets => apps/ui/src/routes/services/[id]}/_Secret.svelte (91%) rename src/lib/components/ServiceLinks.svelte => apps/ui/src/routes/services/[id]/_ServiceLinks.svelte (60%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_Fider.svelte (96%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_Ghost.svelte (97%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_Hasura.svelte (98%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_MeiliSearch.svelte (94%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_MinIO.svelte (97%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_PlausibleAnalytics.svelte (91%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_Services.svelte (79%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_Umami.svelte (97%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_VSCodeServer.svelte (94%) rename {src => apps/ui/src}/routes/services/[id]/_Services/_Wordpress.svelte (78%) rename {src/routes/services/[id]/storage => apps/ui/src/routes/services/[id]}/_Storage.svelte (77%) rename {src => apps/ui/src}/routes/services/[id]/__layout.svelte (71%) rename {src => apps/ui/src}/routes/services/[id]/configuration/destination.svelte (72%) rename {src => apps/ui/src}/routes/services/[id]/configuration/type.svelte (83%) rename {src => apps/ui/src}/routes/services/[id]/configuration/version.svelte (69%) rename {src => apps/ui/src}/routes/services/[id]/index.svelte (73%) rename {src => apps/ui/src}/routes/services/[id]/logs/_Loading.svelte (100%) rename {src => apps/ui/src}/routes/services/[id]/logs/index.svelte (88%) rename src/routes/services/[id]/secrets/index.svelte => apps/ui/src/routes/services/[id]/secrets.svelte (79%) rename src/routes/services/[id]/storage/index.svelte => apps/ui/src/routes/services/[id]/storages.svelte (82%) rename {src => apps/ui/src}/routes/services/index.svelte (87%) rename {src => apps/ui/src}/routes/settings/index.svelte (75%) rename {src => apps/ui/src}/routes/sources/[id]/_Github.svelte (63%) rename {src => apps/ui/src}/routes/sources/[id]/_Gitlab.svelte (85%) create mode 100644 apps/ui/src/routes/sources/[id]/_New.svelte rename src/routes/sources/[id]/index.svelte => apps/ui/src/routes/sources/[id]/_Source.svelte (62%) create mode 100644 apps/ui/src/routes/sources/[id]/__layout.svelte create mode 100644 apps/ui/src/routes/sources/[id]/index.svelte rename {src => apps/ui/src}/routes/sources/index.svelte (86%) create mode 100644 apps/ui/src/routes/webhooks/success.svelte rename {src => apps/ui/src}/tailwind.css (93%) rename {static => apps/ui/static}/favicon.png (100%) rename {static => apps/ui/static}/ghost.png (100%) rename {static => apps/ui/static}/minio.png (100%) rename {static => apps/ui/static}/nocodb.png (100%) rename {static => apps/ui/static}/plausible.png (100%) rename {static => apps/ui/static}/poppins-v19-latin-ext_latin_devanagari-500.woff (100%) rename {static => apps/ui/static}/poppins-v19-latin-ext_latin_devanagari-500.woff2 (100%) rename {static => apps/ui/static}/poppins-v19-latin-ext_latin_devanagari-regular.woff (100%) rename {static => apps/ui/static}/poppins-v19-latin-ext_latin_devanagari-regular.woff2 (100%) create mode 100644 apps/ui/svelte.config.js rename tailwind.config.cjs => apps/ui/tailwind.config.cjs (100%) create mode 100644 apps/ui/tests/test.ts create mode 100644 apps/ui/tsconfig.json create mode 100644 apps/ui/vite.config.js delete mode 100644 data/haproxy-http.Dockerfile delete mode 100644 data/haproxy-tcp.Dockerfile delete mode 100644 data/haproxy.Dockerfile delete mode 100644 data/haproxy/dataplaneapi.hcl delete mode 100644 data/haproxy/haproxy.cfg-http.template delete mode 100644 data/haproxy/haproxy.cfg-tcp.template delete mode 100644 data/haproxy/haproxy.cfg.template delete mode 100644 data/haproxy/ssl/default.pem delete mode 100644 data/prisma/build-prisma-engine.sh delete mode 100644 docker-compose-haproxy.yaml rename {data => others}/docker/daemon.json (100%) rename {data => others}/fluentd/Dockerfile-dev (100%) rename {data => others}/fluentd/fluentbit-dev.conf (100%) rename {data => others}/fluentd/fluentd-dev.conf (100%) create mode 100644 others/prisma/build-prisma-engine.sh rename {data => others}/prisma/prisma-engine.Dockerfile (80%) rename {data => others}/traefik/docker-compose-tcp.yaml (100%) create mode 100644 pnpm-workspace.yaml delete mode 100644 prisma/seed.cjs delete mode 100644 src/app.d.ts delete mode 100644 src/hooks.ts delete mode 100644 src/lib/common.ts delete mode 100644 src/lib/components/DatabaseLinks.svelte delete mode 100644 src/lib/components/common.ts delete mode 100644 src/lib/crypto.ts delete mode 100644 src/lib/database/applications.ts delete mode 100644 src/lib/database/checks.ts delete mode 100644 src/lib/database/common.ts delete mode 100644 src/lib/database/databases.ts delete mode 100644 src/lib/database/destinations.ts delete mode 100644 src/lib/database/gitSources.ts delete mode 100644 src/lib/database/github.ts delete mode 100644 src/lib/database/gitlab.ts delete mode 100644 src/lib/database/index.ts delete mode 100644 src/lib/database/logs.ts delete mode 100644 src/lib/database/secrets.ts delete mode 100644 src/lib/database/services.ts delete mode 100644 src/lib/database/settings.ts delete mode 100644 src/lib/database/teams.ts delete mode 100644 src/lib/database/users.ts delete mode 100644 src/lib/docker.ts delete mode 100644 src/lib/form.ts delete mode 100644 src/lib/haproxy/configuration.ts delete mode 100644 src/lib/haproxy/index.ts delete mode 100644 src/lib/letsencrypt/index.ts delete mode 100644 src/lib/queues/autoUpdater.ts delete mode 100644 src/lib/queues/builder.ts delete mode 100644 src/lib/queues/cleanup.ts delete mode 100644 src/lib/queues/index.ts delete mode 100644 src/lib/queues/logger.ts delete mode 100644 src/lib/queues/proxy.ts delete mode 100644 src/lib/queues/proxyTcpHttp.ts delete mode 100644 src/lib/queues/ssl.ts delete mode 100644 src/lib/queues/sslrenewal.ts delete mode 100644 src/lib/realtime.ts delete mode 100644 src/lib/settings.ts delete mode 100644 src/lib/store.ts delete mode 100644 src/lib/types/builderJob.ts delete mode 100644 src/lib/types/composeFile.ts delete mode 100644 src/lib/types/destinations.ts delete mode 100644 src/routes/applications/[id]/cancel.json.ts delete mode 100644 src/routes/applications/[id]/check.json.ts delete mode 100644 src/routes/applications/[id]/configuration/buildpack.json.ts delete mode 100644 src/routes/applications/[id]/configuration/deploykey.json.ts delete mode 100644 src/routes/applications/[id]/configuration/destination.json.ts delete mode 100644 src/routes/applications/[id]/configuration/githubToken.json.ts delete mode 100644 src/routes/applications/[id]/configuration/repository.json.ts delete mode 100644 src/routes/applications/[id]/configuration/source.json.ts delete mode 100644 src/routes/applications/[id]/configuration/sshkey.json.ts delete mode 100644 src/routes/applications/[id]/delete.json.ts delete mode 100644 src/routes/applications/[id]/deploy.json.ts delete mode 100644 src/routes/applications/[id]/index.json.ts delete mode 100644 src/routes/applications/[id]/logs/build/build.json.ts delete mode 100644 src/routes/applications/[id]/logs/build/index.json.ts delete mode 100644 src/routes/applications/[id]/logs/index.json.ts delete mode 100644 src/routes/applications/[id]/previews/index.json.ts delete mode 100644 src/routes/applications/[id]/secrets/_BatchSecrets.svelte delete mode 100644 src/routes/applications/[id]/secrets/index.json.ts delete mode 100644 src/routes/applications/[id]/settings.json.ts delete mode 100644 src/routes/applications/[id]/status.json.ts delete mode 100644 src/routes/applications/[id]/stop.json.ts delete mode 100644 src/routes/applications/[id]/storage/index.json.ts delete mode 100644 src/routes/applications/[id]/usage.json.ts delete mode 100644 src/routes/applications/index.ts delete mode 100644 src/routes/applications/new.ts delete mode 100644 src/routes/common/getUniqueName.json.ts delete mode 100644 src/routes/dashboard.json.ts delete mode 100644 src/routes/databases/[id]/__layout.svelte delete mode 100644 src/routes/databases/[id]/configuration/destination.json.ts delete mode 100644 src/routes/databases/[id]/configuration/type.json.ts delete mode 100644 src/routes/databases/[id]/configuration/version.json.ts delete mode 100644 src/routes/databases/[id]/delete.json.ts delete mode 100644 src/routes/databases/[id]/index.json.ts delete mode 100644 src/routes/databases/[id]/logs/index.json.ts delete mode 100644 src/routes/databases/[id]/settings.json.ts delete mode 100644 src/routes/databases/[id]/start.json.ts delete mode 100644 src/routes/databases/[id]/stop.json.ts delete mode 100644 src/routes/databases/[id]/usage.json.ts delete mode 100644 src/routes/databases/index.ts delete mode 100644 src/routes/databases/new.ts delete mode 100644 src/routes/destinations/[id]/_FoundApp.svelte delete mode 100644 src/routes/destinations/[id]/__layout.svelte delete mode 100644 src/routes/destinations/[id]/index.json.ts delete mode 100644 src/routes/destinations/[id]/index.svelte delete mode 100644 src/routes/destinations/[id]/restart.json.ts delete mode 100644 src/routes/destinations/[id]/scan.json.ts delete mode 100644 src/routes/destinations/[id]/settings.json.ts delete mode 100644 src/routes/destinations/[id]/start.json.ts delete mode 100644 src/routes/destinations/[id]/stop.json.ts delete mode 100644 src/routes/destinations/index.json.ts delete mode 100644 src/routes/iam/index.json.ts delete mode 100644 src/routes/iam/password.json.ts delete mode 100644 src/routes/iam/team/[id]/__layout.svelte delete mode 100644 src/routes/iam/team/[id]/index.json.ts delete mode 100644 src/routes/iam/team/[id]/invitation/accept.json.ts delete mode 100644 src/routes/iam/team/[id]/invitation/invite.json.ts delete mode 100644 src/routes/iam/team/[id]/invitation/revoke.json.ts delete mode 100644 src/routes/iam/team/[id]/permission/change.json.ts delete mode 100644 src/routes/iam/team/[id]/remove/user.json.ts delete mode 100644 src/routes/index.svelte delete mode 100644 src/routes/login/index.json.ts delete mode 100644 src/routes/login/index.svelte delete mode 100644 src/routes/logout/index.json.ts delete mode 100644 src/routes/new/destination/check.json.ts delete mode 100644 src/routes/new/destination/docker.json.ts delete mode 100644 src/routes/new/team/index.json.ts delete mode 100644 src/routes/new/team/index.svelte delete mode 100644 src/routes/register/index.ts delete mode 100644 src/routes/services/[id]/appwrite-wip/index.json.ts delete mode 100644 src/routes/services/[id]/appwrite-wip/start.json.ts delete mode 100644 src/routes/services/[id]/appwrite-wip/stop.json.ts delete mode 100644 src/routes/services/[id]/check.json.ts delete mode 100644 src/routes/services/[id]/configuration/destination.json.ts delete mode 100644 src/routes/services/[id]/configuration/type.json.ts delete mode 100644 src/routes/services/[id]/configuration/version.json.ts delete mode 100644 src/routes/services/[id]/delete.json.ts delete mode 100644 src/routes/services/[id]/fider/index.json.ts delete mode 100644 src/routes/services/[id]/fider/start.json.ts delete mode 100644 src/routes/services/[id]/fider/stop.json.ts delete mode 100644 src/routes/services/[id]/ghost/index.json.ts delete mode 100644 src/routes/services/[id]/ghost/start.json.ts delete mode 100644 src/routes/services/[id]/ghost/stop.json.ts delete mode 100644 src/routes/services/[id]/hasura/index.json.ts delete mode 100644 src/routes/services/[id]/hasura/start.json.ts delete mode 100644 src/routes/services/[id]/hasura/stop.json.ts delete mode 100644 src/routes/services/[id]/index.json.ts delete mode 100644 src/routes/services/[id]/languagetool/index.json.ts delete mode 100644 src/routes/services/[id]/languagetool/start.json.ts delete mode 100644 src/routes/services/[id]/languagetool/stop.json.ts delete mode 100644 src/routes/services/[id]/logs/index.json.ts delete mode 100644 src/routes/services/[id]/meilisearch/index.json.ts delete mode 100644 src/routes/services/[id]/meilisearch/start.json.ts delete mode 100644 src/routes/services/[id]/meilisearch/stop.json.ts delete mode 100644 src/routes/services/[id]/minio/index.json.ts delete mode 100644 src/routes/services/[id]/minio/start.json.ts delete mode 100644 src/routes/services/[id]/minio/stop.json.ts delete mode 100644 src/routes/services/[id]/n8n/index.json.ts delete mode 100644 src/routes/services/[id]/n8n/start.json.ts delete mode 100644 src/routes/services/[id]/n8n/stop.json.ts delete mode 100644 src/routes/services/[id]/nocodb/index.json.ts delete mode 100644 src/routes/services/[id]/nocodb/start.json.ts delete mode 100644 src/routes/services/[id]/nocodb/stop.json.ts delete mode 100644 src/routes/services/[id]/plausibleanalytics/activate.json.ts delete mode 100644 src/routes/services/[id]/plausibleanalytics/index.json.ts delete mode 100644 src/routes/services/[id]/plausibleanalytics/start.json.ts delete mode 100644 src/routes/services/[id]/plausibleanalytics/stop.json.ts delete mode 100644 src/routes/services/[id]/secrets/index.json.ts delete mode 100644 src/routes/services/[id]/settings.json.ts delete mode 100644 src/routes/services/[id]/storage/index.json.ts delete mode 100644 src/routes/services/[id]/umami/index.json.ts delete mode 100644 src/routes/services/[id]/umami/start.json.ts delete mode 100644 src/routes/services/[id]/umami/stop.json.ts delete mode 100644 src/routes/services/[id]/uptimekuma/index.json.ts delete mode 100644 src/routes/services/[id]/uptimekuma/start.json.ts delete mode 100644 src/routes/services/[id]/uptimekuma/stop.json.ts delete mode 100644 src/routes/services/[id]/usage.json.ts delete mode 100644 src/routes/services/[id]/vaultwarden/index.json.ts delete mode 100644 src/routes/services/[id]/vaultwarden/start.json.ts delete mode 100644 src/routes/services/[id]/vaultwarden/stop.json.ts delete mode 100644 src/routes/services/[id]/vscodeserver/index.json.ts delete mode 100644 src/routes/services/[id]/vscodeserver/start.json.ts delete mode 100644 src/routes/services/[id]/vscodeserver/stop.json.ts delete mode 100644 src/routes/services/[id]/wordpress/ftp.json.ts delete mode 100644 src/routes/services/[id]/wordpress/index.json.ts delete mode 100644 src/routes/services/[id]/wordpress/settings.json.ts delete mode 100644 src/routes/services/[id]/wordpress/start.json.ts delete mode 100644 src/routes/services/[id]/wordpress/stop.json.ts delete mode 100644 src/routes/services/index.ts delete mode 100644 src/routes/services/new.ts delete mode 100644 src/routes/settings/_Language.svelte delete mode 100644 src/routes/settings/check.json.ts delete mode 100644 src/routes/settings/index.json.ts delete mode 100644 src/routes/settings/renew.json.ts delete mode 100644 src/routes/sources/[id]/__layout.svelte delete mode 100644 src/routes/sources/[id]/check.json.ts delete mode 100644 src/routes/sources/[id]/github.json.ts delete mode 100644 src/routes/sources/[id]/gitlab.json.ts delete mode 100644 src/routes/sources/[id]/index.json.ts delete mode 100644 src/routes/sources/[id]/newGithubApp.svelte delete mode 100644 src/routes/sources/index.json.ts delete mode 100644 src/routes/sources/new.ts delete mode 100644 src/routes/undead.json.ts delete mode 100644 src/routes/update.json.ts delete mode 100644 src/routes/webhooks/github/events.ts delete mode 100644 src/routes/webhooks/github/index.ts delete mode 100644 src/routes/webhooks/github/install.ts delete mode 100644 src/routes/webhooks/gitlab/events.ts delete mode 100644 src/routes/webhooks/gitlab/index.ts delete mode 100644 src/routes/webhooks/success/index.svelte delete mode 100644 src/routes/webhooks/traefik/main.json.ts delete mode 100644 src/routes/webhooks/traefik/other.json.ts delete mode 100644 svelte.config.js delete mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore index a6e626b0d..62248479b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,16 +1,11 @@ .DS_Store node_modules -/build -/.svelte-kit -/package -/yarn.lock -/.pnpm-store -/ssl - +build +.svelte-kit +package .env -.env.prod -.env.stag -/db/*.db -/db/*.db-journal -/data/haproxy/haproxy.cfg -/data/haproxy/haproxy.cfg.lkg \ No newline at end of file +.env.* +!.env.example +dist +client +apps/api/db/*.db \ No newline at end of file diff --git a/.env.template b/.env.template deleted file mode 100644 index 3237d9b2c..000000000 --- a/.env.template +++ /dev/null @@ -1,8 +0,0 @@ -COOLIFY_APP_ID= -COOLIFY_SECRET_KEY=12341234123412341234123412341234 -COOLIFY_DATABASE_URL=file:../db/dev.db -COOLIFY_SENTRY_DSN= -COOLIFY_IS_ON=docker -COOLIFY_WHITE_LABELED=false -COOLIFY_WHITE_LABELED_ICON= -COOLIFY_AUTO_UPDATE=false \ No newline at end of file diff --git a/.gitignore b/.gitignore index e8abfd833..62248479b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,11 @@ .DS_Store node_modules -/build -/.svelte-kit -/package -/yarn.lock -/.pnpm-store -/ssl - +build +.svelte-kit +package .env -.env.prod -.env.stag -/db/*.db -/db/*.db-journal -/data/haproxy/haproxy.cfg -/data/haproxy/haproxy.cfg.lkg +.env.* +!.env.example +dist +client +apps/api/db/*.db \ No newline at end of file diff --git a/.husky/_/.gitignore b/.husky/_/.gitignore deleted file mode 100644 index f59ec20aa..000000000 --- a/.husky/_/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index fab6428a1..000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -pnpm lint-staged diff --git a/.lintstagedrc.json b/.lintstagedrc.json deleted file mode 100644 index f56aabfec..000000000 --- a/.lintstagedrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "**/*.{js,jsx,ts,tsx,cjs,svelte,json,css,scss,md,yaml}": [ - "prettier --ignore-path .gitignore --write --plugin-search-dir=." - ] -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62f919153..99c5433bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,8 @@ First of all, thank you for considering contributing to my project! It means a lot 💜. +Contribution guide is for v2, not applicable for v3 + ## 🙋 Want to help? If you begin in GitHub contribution, you can find the [first contribution](https://github.com/firstcontributions/first-contributions) and follow this guide. diff --git a/Dockerfile b/Dockerfile index 87c4d54c9..1a2ced973 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,18 @@ -FROM node:16.14.2-alpine as install +FROM node:18-alpine as build WORKDIR /app RUN apk add --no-cache curl -RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@6 -RUN pnpm add -g pnpm +RUN curl -sL https://unpkg.com/@pnpm/self-installer | node -COPY package*.json . +COPY . . RUN pnpm install +RUN pnpm build -FROM node:16.14.2-alpine -ARG TARGETPLATFORM - +# Production build +FROM node:18-alpine WORKDIR /app +ENV NODE_ENV production +ARG TARGETPLATFORM ENV PRISMA_QUERY_ENGINE_BINARY=/app/prisma-engines/query-engine \ PRISMA_MIGRATION_ENGINE_BINARY=/app/prisma-engines/migration-engine \ @@ -19,24 +20,24 @@ ENV PRISMA_QUERY_ENGINE_BINARY=/app/prisma-engines/query-engine \ PRISMA_FMT_BINARY=/app/prisma-engines/prisma-fmt \ PRISMA_CLI_QUERY_ENGINE_TYPE=binary \ PRISMA_CLIENT_ENGINE_TYPE=binary - -COPY --from=coollabsio/prisma-engine:latest /prisma-engines/query-engine /prisma-engines/migration-engine /prisma-engines/introspection-engine /prisma-engines/prisma-fmt /app/prisma-engines/ -COPY --from=install /app/node_modules ./node_modules -COPY . . +COPY --from=coollabsio/prisma-engine:3.15 /prisma-engines/query-engine /prisma-engines/migration-engine /prisma-engines/introspection-engine /prisma-engines/prisma-fmt /app/prisma-engines/ RUN apk add --no-cache git git-lfs openssh-client curl jq cmake sqlite openssl -RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@6 -RUN pnpm add -g pnpm +RUN curl -sL https://unpkg.com/@pnpm/self-installer | node + RUN mkdir -p ~/.docker/cli-plugins/ RUN curl -SL https://cdn.coollabs.io/bin/$TARGETPLATFORM/docker-20.10.9 -o /usr/bin/docker RUN curl -SL https://cdn.coollabs.io/bin/$TARGETPLATFORM/docker-compose-linux-2.3.4 -o ~/.docker/cli-plugins/docker-compose RUN chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker -RUN pnpm prisma generate -RUN pnpm build - +COPY --from=build /app/apps/api/build/ . +COPY --from=build /app/apps/ui/build/ ./public +COPY --from=build /app/apps/api/prisma/ ./prisma +COPY --from=build /app/apps/api/package.json . +COPY --from=build /app/docker-compose.yaml . +RUN pnpm install -p EXPOSE 3000 -CMD ["pnpm", "start"] \ No newline at end of file +CMD pnpm start \ No newline at end of file diff --git a/README.md b/README.md index da200223a..b08d7dfa6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ An open-source & self-hostable Heroku / Netlify alternative. https://demo.coolify.io/ -(If it is unresponsive, that means someone overloaded the server. 🙃) +(If it is unresponsive, that means someone overloaded the server. 😄) ## Feedback @@ -30,6 +30,8 @@ For more details goto the [docs](https://docs.coollabs.io/coolify/installation). ## Features +ARM support is in beta! + ### Git Sources You can use the following Git Sources to be auto-deployed to your Coolifyt instance! (Self-hosted versions are also supported.) @@ -97,7 +99,7 @@ You can host cool open-source services as well: ## Migration from v1 -A fresh installation is necessary. v2 is not compatible with v1. +A fresh installation is necessary. v2 and v3 are not compatible with v1. ## Support diff --git a/apps/api/.eslintignore b/apps/api/.eslintignore new file mode 100644 index 000000000..70dc23b71 --- /dev/null +++ b/apps/api/.eslintignore @@ -0,0 +1,9 @@ +seed.js +.DS_Store +node_modules +build +.env +.env.* +!.env.example +dist +dev.db \ No newline at end of file diff --git a/apps/api/.eslintrc b/apps/api/.eslintrc new file mode 100644 index 000000000..92583263b --- /dev/null +++ b/apps/api/.eslintrc @@ -0,0 +1,11 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "prettier"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ] +} diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 000000000..9d4e0d4e8 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +node_modules +build +.svelte-kit +package +.env +.env.* +!.env.example +dist +dev.db +client \ No newline at end of file diff --git a/db/.gitkeep b/apps/api/.prettierignore similarity index 100% rename from db/.gitkeep rename to apps/api/.prettierignore diff --git a/.prettierrc b/apps/api/.prettierrc similarity index 100% rename from .prettierrc rename to apps/api/.prettierrc diff --git a/apps/api/db/.gitkeep b/apps/api/db/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/nodemon.json b/apps/api/nodemon.json new file mode 100644 index 000000000..b19da37f0 --- /dev/null +++ b/apps/api/nodemon.json @@ -0,0 +1,7 @@ +{ + "watch": ["src"], + "ignore": ["src/**/*.test.ts"], + "ext": "ts,mjs,json,graphql", + "exec": "rimraf build && esbuild `find src \\( -name '*.ts' \\) | grep -v client/` --platform=node --outdir=build --format=cjs && node build", + "legacyWatch": true + } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 000000000..aac907a28 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,67 @@ +{ + "name": "coolify-api", + "description": "Coolify's Fastify API", + "license": "AGPL-3.0", + "scripts": { + "db:push": "prisma db push && prisma generate", + "db:seed": "prisma db seed", + "db:studio": "prisma studio", + "dev": "nodemon", + "build": "rimraf build && esbuild `find src \\( -name '*.ts' \\)| grep -v client/` --platform=node --outdir=build --format=cjs", + "format": "prettier --write 'src/**/*.{js,ts,json,md}'", + "lint": "prettier --check 'src/**/*.{js,ts,json,md}' && eslint --ignore-path .eslintignore .", + "start": "NODE_ENV=production npx -y prisma migrate deploy && npx prisma generate && npx prisma db seed && node index.js" + }, + "dependencies": { + "@breejs/ts-worker": "2.0.0", + "@fastify/autoload": "5.0.0", + "@fastify/cookie": "7.0.0", + "@fastify/cors": "8.0.0", + "@fastify/env": "4.0.0", + "@fastify/jwt": "6.2.0", + "@fastify/static": "6.4.0", + "@iarna/toml": "2.2.5", + "@prisma/client": "3.15.2", + "axios": "0.27.2", + "bcryptjs": "2.4.3", + "bree": "9.1.1", + "cabin": "9.1.2", + "compare-versions": "4.1.3", + "cuid": "2.1.8", + "dayjs": "1.11.3", + "dockerode": "3.3.2", + "dotenv-extended": "2.9.0", + "fastify": "4.2.0", + "fastify-plugin": "3.0.1", + "generate-password": "1.7.0", + "get-port": "6.1.2", + "got": "12.1.0", + "is-ip": "4.0.0", + "js-yaml": "4.1.0", + "jsonwebtoken": "8.5.1", + "node-forge": "1.3.1", + "node-os-utils": "1.3.7", + "p-queue": "7.2.0", + "strip-ansi": "7.0.1", + "unique-names-generator": "4.7.1" + }, + "devDependencies": { + "@types/node": "18.0.3", + "@types/node-os-utils": "1.3.0", + "@typescript-eslint/eslint-plugin": "5.30.5", + "@typescript-eslint/parser": "5.30.5", + "esbuild": "0.14.48", + "eslint": "8.19.0", + "eslint-config-prettier": "8.5.0", + "eslint-plugin-prettier": "4.2.1", + "nodemon": "2.0.19", + "prettier": "2.7.1", + "prisma": "3.15.2", + "rimraf": "3.0.2", + "tsconfig-paths": "4.0.0", + "typescript": "4.7.4" + }, + "prisma": { + "seed": "node prisma/seed.js" + } +} \ No newline at end of file diff --git a/prisma/migrations/20220131142425_init/migration.sql b/apps/api/prisma/migrations/20220131142425_init/migration.sql similarity index 100% rename from prisma/migrations/20220131142425_init/migration.sql rename to apps/api/prisma/migrations/20220131142425_init/migration.sql diff --git a/prisma/migrations/20220210104005_redis_aol/migration.sql b/apps/api/prisma/migrations/20220210104005_redis_aol/migration.sql similarity index 100% rename from prisma/migrations/20220210104005_redis_aol/migration.sql rename to apps/api/prisma/migrations/20220210104005_redis_aol/migration.sql diff --git a/prisma/migrations/20220212142309_unique_secret_by_application/migration.sql b/apps/api/prisma/migrations/20220212142309_unique_secret_by_application/migration.sql similarity index 100% rename from prisma/migrations/20220212142309_unique_secret_by_application/migration.sql rename to apps/api/prisma/migrations/20220212142309_unique_secret_by_application/migration.sql diff --git a/prisma/migrations/20220217211304_dualcerts/migration.sql b/apps/api/prisma/migrations/20220217211304_dualcerts/migration.sql similarity index 100% rename from prisma/migrations/20220217211304_dualcerts/migration.sql rename to apps/api/prisma/migrations/20220217211304_dualcerts/migration.sql diff --git a/prisma/migrations/20220219231255_prmr_secrets/migration.sql b/apps/api/prisma/migrations/20220219231255_prmr_secrets/migration.sql similarity index 100% rename from prisma/migrations/20220219231255_prmr_secrets/migration.sql rename to apps/api/prisma/migrations/20220219231255_prmr_secrets/migration.sql diff --git a/prisma/migrations/20220220141136_public_portrange/migration.sql b/apps/api/prisma/migrations/20220220141136_public_portrange/migration.sql similarity index 100% rename from prisma/migrations/20220220141136_public_portrange/migration.sql rename to apps/api/prisma/migrations/20220220141136_public_portrange/migration.sql diff --git a/prisma/migrations/20220301101928_proxyhash/migration.sql b/apps/api/prisma/migrations/20220301101928_proxyhash/migration.sql similarity index 100% rename from prisma/migrations/20220301101928_proxyhash/migration.sql rename to apps/api/prisma/migrations/20220301101928_proxyhash/migration.sql diff --git a/prisma/migrations/20220304141408_service_secrets/migration.sql b/apps/api/prisma/migrations/20220304141408_service_secrets/migration.sql similarity index 100% rename from prisma/migrations/20220304141408_service_secrets/migration.sql rename to apps/api/prisma/migrations/20220304141408_service_secrets/migration.sql diff --git a/prisma/migrations/20220311213422_autodeploy/migration.sql b/apps/api/prisma/migrations/20220311213422_autodeploy/migration.sql similarity index 100% rename from prisma/migrations/20220311213422_autodeploy/migration.sql rename to apps/api/prisma/migrations/20220311213422_autodeploy/migration.sql diff --git a/prisma/migrations/20220320141424_phpmodules/migration.sql b/apps/api/prisma/migrations/20220320141424_phpmodules/migration.sql similarity index 100% rename from prisma/migrations/20220320141424_phpmodules/migration.sql rename to apps/api/prisma/migrations/20220320141424_phpmodules/migration.sql diff --git a/prisma/migrations/20220322135800_persistent_storage/migration.sql b/apps/api/prisma/migrations/20220322135800_persistent_storage/migration.sql similarity index 100% rename from prisma/migrations/20220322135800_persistent_storage/migration.sql rename to apps/api/prisma/migrations/20220322135800_persistent_storage/migration.sql diff --git a/prisma/migrations/20220327180323_ghost/migration.sql b/apps/api/prisma/migrations/20220327180323_ghost/migration.sql similarity index 100% rename from prisma/migrations/20220327180323_ghost/migration.sql rename to apps/api/prisma/migrations/20220327180323_ghost/migration.sql diff --git a/prisma/migrations/20220402135305_python/migration.sql b/apps/api/prisma/migrations/20220402135305_python/migration.sql similarity index 100% rename from prisma/migrations/20220402135305_python/migration.sql rename to apps/api/prisma/migrations/20220402135305_python/migration.sql diff --git a/prisma/migrations/20220402210645_meilisearch/migration.sql b/apps/api/prisma/migrations/20220402210645_meilisearch/migration.sql similarity index 100% rename from prisma/migrations/20220402210645_meilisearch/migration.sql rename to apps/api/prisma/migrations/20220402210645_meilisearch/migration.sql diff --git a/prisma/migrations/20220405151428_wordpress_sftp/migration.sql b/apps/api/prisma/migrations/20220405151428_wordpress_sftp/migration.sql similarity index 100% rename from prisma/migrations/20220405151428_wordpress_sftp/migration.sql rename to apps/api/prisma/migrations/20220405151428_wordpress_sftp/migration.sql diff --git a/prisma/migrations/20220407220809_unique_storage_fix/migration.sql b/apps/api/prisma/migrations/20220407220809_unique_storage_fix/migration.sql similarity index 100% rename from prisma/migrations/20220407220809_unique_storage_fix/migration.sql rename to apps/api/prisma/migrations/20220407220809_unique_storage_fix/migration.sql diff --git a/prisma/migrations/20220408070805_added_expose_port/migration.sql b/apps/api/prisma/migrations/20220408070805_added_expose_port/migration.sql similarity index 100% rename from prisma/migrations/20220408070805_added_expose_port/migration.sql rename to apps/api/prisma/migrations/20220408070805_added_expose_port/migration.sql diff --git a/prisma/migrations/20220418214843_persistent_storage_services/migration.sql b/apps/api/prisma/migrations/20220418214843_persistent_storage_services/migration.sql similarity index 100% rename from prisma/migrations/20220418214843_persistent_storage_services/migration.sql rename to apps/api/prisma/migrations/20220418214843_persistent_storage_services/migration.sql diff --git a/prisma/migrations/20220419203408_multiply_dockerfile_locations/migration.sql b/apps/api/prisma/migrations/20220419203408_multiply_dockerfile_locations/migration.sql similarity index 100% rename from prisma/migrations/20220419203408_multiply_dockerfile_locations/migration.sql rename to apps/api/prisma/migrations/20220419203408_multiply_dockerfile_locations/migration.sql diff --git a/prisma/migrations/20220420202031_deno_configurations/migration.sql b/apps/api/prisma/migrations/20220420202031_deno_configurations/migration.sql similarity index 100% rename from prisma/migrations/20220420202031_deno_configurations/migration.sql rename to apps/api/prisma/migrations/20220420202031_deno_configurations/migration.sql diff --git a/prisma/migrations/20220420210057_branch_for_builds/migration.sql b/apps/api/prisma/migrations/20220420210057_branch_for_builds/migration.sql similarity index 100% rename from prisma/migrations/20220420210057_branch_for_builds/migration.sql rename to apps/api/prisma/migrations/20220420210057_branch_for_builds/migration.sql diff --git a/prisma/migrations/20220425071132_umami/migration.sql b/apps/api/prisma/migrations/20220425071132_umami/migration.sql similarity index 100% rename from prisma/migrations/20220425071132_umami/migration.sql rename to apps/api/prisma/migrations/20220425071132_umami/migration.sql diff --git a/prisma/migrations/20220425075326_auto_update_coolify/migration.sql b/apps/api/prisma/migrations/20220425075326_auto_update_coolify/migration.sql similarity index 100% rename from prisma/migrations/20220425075326_auto_update_coolify/migration.sql rename to apps/api/prisma/migrations/20220425075326_auto_update_coolify/migration.sql diff --git a/prisma/migrations/20220426125053_select_base_image/migration.sql b/apps/api/prisma/migrations/20220426125053_select_base_image/migration.sql similarity index 100% rename from prisma/migrations/20220426125053_select_base_image/migration.sql rename to apps/api/prisma/migrations/20220426125053_select_base_image/migration.sql diff --git a/prisma/migrations/20220427133656_hasura/migration.sql b/apps/api/prisma/migrations/20220427133656_hasura/migration.sql similarity index 100% rename from prisma/migrations/20220427133656_hasura/migration.sql rename to apps/api/prisma/migrations/20220427133656_hasura/migration.sql diff --git a/prisma/migrations/20220429202516_fider/migration.sql b/apps/api/prisma/migrations/20220429202516_fider/migration.sql similarity index 100% rename from prisma/migrations/20220429202516_fider/migration.sql rename to apps/api/prisma/migrations/20220429202516_fider/migration.sql diff --git a/prisma/migrations/20220429214112_fider_correction/migration.sql b/apps/api/prisma/migrations/20220429214112_fider_correction/migration.sql similarity index 100% rename from prisma/migrations/20220429214112_fider_correction/migration.sql rename to apps/api/prisma/migrations/20220429214112_fider_correction/migration.sql diff --git a/prisma/migrations/20220430111953_ssl_dns_check_settings/migration.sql b/apps/api/prisma/migrations/20220430111953_ssl_dns_check_settings/migration.sql similarity index 100% rename from prisma/migrations/20220430111953_ssl_dns_check_settings/migration.sql rename to apps/api/prisma/migrations/20220430111953_ssl_dns_check_settings/migration.sql diff --git a/prisma/migrations/20220430124553_expose_port_for_services/migration.sql b/apps/api/prisma/migrations/20220430124553_expose_port_for_services/migration.sql similarity index 100% rename from prisma/migrations/20220430124553_expose_port_for_services/migration.sql rename to apps/api/prisma/migrations/20220430124553_expose_port_for_services/migration.sql diff --git a/prisma/migrations/20220509130501_custom_plausible_script/migration.sql b/apps/api/prisma/migrations/20220509130501_custom_plausible_script/migration.sql similarity index 100% rename from prisma/migrations/20220509130501_custom_plausible_script/migration.sql rename to apps/api/prisma/migrations/20220509130501_custom_plausible_script/migration.sql diff --git a/prisma/migrations/20220510081125_custom_wordpress_db/migration.sql b/apps/api/prisma/migrations/20220510081125_custom_wordpress_db/migration.sql similarity index 100% rename from prisma/migrations/20220510081125_custom_wordpress_db/migration.sql rename to apps/api/prisma/migrations/20220510081125_custom_wordpress_db/migration.sql diff --git a/prisma/migrations/20220517081328_traefik/migration.sql b/apps/api/prisma/migrations/20220517081328_traefik/migration.sql similarity index 100% rename from prisma/migrations/20220517081328_traefik/migration.sql rename to apps/api/prisma/migrations/20220517081328_traefik/migration.sql diff --git a/prisma/migrations/20220519095648_minio_apifqdn/migration.sql b/apps/api/prisma/migrations/20220519095648_minio_apifqdn/migration.sql similarity index 100% rename from prisma/migrations/20220519095648_minio_apifqdn/migration.sql rename to apps/api/prisma/migrations/20220519095648_minio_apifqdn/migration.sql diff --git a/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml similarity index 100% rename from prisma/migrations/migration_lock.toml rename to apps/api/prisma/migrations/migration_lock.toml diff --git a/prisma/schema.prisma b/apps/api/prisma/schema.prisma similarity index 100% rename from prisma/schema.prisma rename to apps/api/prisma/schema.prisma diff --git a/apps/api/prisma/seed.js b/apps/api/prisma/seed.js new file mode 100644 index 000000000..011359682 --- /dev/null +++ b/apps/api/prisma/seed.js @@ -0,0 +1,75 @@ +const dotEnvExtended = require('dotenv-extended'); +dotEnvExtended.load(); +const crypto = require('crypto'); +const generator = require('generate-password'); +const cuid = require('cuid'); +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +function generatePassword(length = 24) { + return generator.generate({ + length, + numbers: true, + strict: true + }); +} +const algorithm = 'aes-256-ctr'; + +async function main() { + // Enable registration for the first user + // Set initial HAProxy password + const settingsFound = await prisma.setting.findFirst({}); + if (!settingsFound) { + await prisma.setting.create({ + data: { + isRegistrationEnabled: true, + isTraefikUsed: true, + } + }); + } else { + await prisma.setting.update({ + where: { + id: settingsFound.id + }, + data: { + isTraefikUsed: true, + proxyHash: null + } + }); + } + const localDocker = await prisma.destinationDocker.findFirst({ + where: { engine: '/var/run/docker.sock' } + }); + if (!localDocker) { + await prisma.destinationDocker.create({ + data: { + engine: '/var/run/docker.sock', + name: 'Local Docker', + isCoolifyProxyUsed: true, + network: 'coolify' + } + }); + } + + // Set auto-update based on env variable + const isAutoUpdateEnabled = process.env['COOLIFY_AUTO_UPDATE'] === 'true'; + const settings = await prisma.setting.findFirst({}); + if (settings) { + await prisma.setting.update({ + where: { + id: settings.id + }, + data: { + isAutoUpdateEnabled + } + }); + } +} +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 000000000..362049263 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,132 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import serve from '@fastify/static'; +import env from '@fastify/env'; +import cookie from '@fastify/cookie'; +import path, { join } from 'path'; +import autoLoad from '@fastify/autoload'; +import { asyncExecShell, isDev, prisma } from './lib/common'; +import { scheduler } from './lib/scheduler'; + +declare module 'fastify' { + interface FastifyInstance { + config: { + COOLIFY_APP_ID: string, + COOLIFY_SECRET_KEY: string, + COOLIFY_DATABASE_URL: string, + COOLIFY_SENTRY_DSN: string, + COOLIFY_IS_ON: string, + COOLIFY_WHITE_LABELED: boolean, + COOLIFY_WHITE_LABELED_ICON: string | null, + COOLIFY_AUTO_UPDATE: boolean, + }; + } +} + +const port = isDev ? 3001 : 3000; +const host = '0.0.0.0'; +const fastify = Fastify({ + logger: false +}); +const schema = { + type: 'object', + required: ['COOLIFY_SECRET_KEY', 'COOLIFY_DATABASE_URL', 'COOLIFY_IS_ON'], + properties: { + COOLIFY_APP_ID: { + type: 'string', + }, + COOLIFY_SECRET_KEY: { + type: 'string', + }, + COOLIFY_DATABASE_URL: { + type: 'string', + default: 'file:../db/dev.db' + }, + COOLIFY_SENTRY_DSN: { + type: 'string', + default: null + }, + COOLIFY_IS_ON: { + type: 'string', + default: 'docker' + }, + COOLIFY_WHITE_LABELED: { + type: 'boolean', + default: false + }, + COOLIFY_WHITE_LABELED_ICON: { + type: 'string', + default: null + }, + COOLIFY_AUTO_UPDATE: { + type: 'boolean', + default: false + }, + + } +}; + +const options = { + schema +}; +fastify.register(env, options); +if (!isDev) { + fastify.register(serve, { + root: path.join(__dirname, './public'), + preCompressed: true + }); + fastify.setNotFoundHandler(function (request, reply) { + if (request.raw.url && request.raw.url.startsWith('/api')) { + return reply.status(404).send({ + success: false + }); + } + return reply.status(200).sendFile('index.html'); + }); +} +fastify.register(autoLoad, { + dir: join(__dirname, 'plugins') +}); +fastify.register(autoLoad, { + dir: join(__dirname, 'routes') +}); + +fastify.register(cookie) +fastify.register(cors); +fastify.listen({ port, host }, async (err: any, address: any) => { + if (err) { + console.error(err); + process.exit(1); + } + console.log(`Coolify's API is listening on ${host}:${port}`); + await initServer() + await scheduler.start('deployApplication'); + await scheduler.start('cleanupStorage'); + await scheduler.start('checkProxies') + + // Check if no build is running, try to autoupdate. + setInterval(async () => { + const { isAutoUpdateEnabled } = await prisma.setting.findFirst(); + if (isAutoUpdateEnabled) { + if (scheduler.workers.has('deployApplication')) { + scheduler.workers.get('deployApplication').postMessage("status"); + } + } + }, 30000 * 10) + + scheduler.on('worker deleted', async (name) => { + if (name === 'autoUpdater') { + await scheduler.start('deployApplication'); + } + + }); + +}); + +async function initServer() { + try { + await asyncExecShell(`docker network create --attachable coolify`); + } catch (error) { } +} + + diff --git a/apps/api/src/jobs/autoUpdater.ts b/apps/api/src/jobs/autoUpdater.ts new file mode 100644 index 000000000..566ffea29 --- /dev/null +++ b/apps/api/src/jobs/autoUpdater.ts @@ -0,0 +1,43 @@ +import axios from 'axios'; +import compareVersions from 'compare-versions'; +import { parentPort } from 'node:worker_threads'; +import { asyncExecShell, asyncSleep, isDev, prisma, version } from '../lib/common'; + +(async () => { + if (parentPort) { + try { + const currentVersion = version; + const { data: versions } = await axios + .get( + `https://get.coollabs.io/versions.json` + , { + params: { + appId: process.env['COOLIFY_APP_ID'] || undefined, + version: currentVersion + } + }) + const latestVersion = versions['coolify'].main.version; + const isUpdateAvailable = compareVersions(latestVersion, currentVersion); + if (isUpdateAvailable === 1) { + const activeCount = 0 + if (activeCount === 0) { + if (!isDev) { + console.log(`Updating Coolify to ${latestVersion}.`); + await asyncExecShell(`docker pull coollabsio/coolify:${latestVersion}`); + await asyncExecShell(`env | grep COOLIFY > .env`); + await asyncExecShell( + `docker run --rm -tid --env-file .env -v /var/run/docker.sock:/var/run/docker.sock -v coolify-db coollabsio/coolify:${latestVersion} /bin/sh -c "env | grep COOLIFY > .env && echo 'TAG=${latestVersion}' >> .env && docker stop -t 0 coolify && docker rm coolify && docker compose up -d --force-recreate"` + ); + } else { + console.log('Updating (not really in dev mode).'); + } + } + } + } catch (error) { + console.log(error); + } finally { + await prisma.$disconnect(); + } + + } else process.exit(0); +})(); diff --git a/apps/api/src/jobs/checkProxies.ts b/apps/api/src/jobs/checkProxies.ts new file mode 100644 index 000000000..6168baf5a --- /dev/null +++ b/apps/api/src/jobs/checkProxies.ts @@ -0,0 +1,88 @@ +import { parentPort } from 'node:worker_threads'; +import { prisma, startTraefikTCPProxy, generateDatabaseConfiguration, startTraefikProxy, asyncExecShell } from '../lib/common'; +import { checkContainer, getEngine } from '../lib/docker'; + +(async () => { + if (parentPort) { + // Coolify Proxy + const engine = '/var/run/docker.sock'; + const localDocker = await prisma.destinationDocker.findFirst({ + where: { engine, network: 'coolify' } + }); + if (localDocker && localDocker.isCoolifyProxyUsed) { + // Remove HAProxy + const found = await checkContainer(engine, 'coolify-haproxy'); + const host = getEngine(engine); + if (found) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker stop -t 0 coolify-haproxy && docker rm coolify-haproxy` + ); + } + await startTraefikProxy(engine); + + } + + // TCP Proxies + const databasesWithPublicPort = await prisma.database.findMany({ + where: { publicPort: { not: null } }, + include: { settings: true, destinationDocker: true } + }); + for (const database of databasesWithPublicPort) { + const { destinationDockerId, destinationDocker, publicPort, id } = database; + if (destinationDockerId && destinationDocker.isCoolifyProxyUsed) { + const { privatePort } = generateDatabaseConfiguration(database); + // Remove HAProxy + const found = await checkContainer(engine, `haproxy-for-${publicPort}`); + const host = getEngine(engine); + if (found) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker stop -t 0 haproxy-for-${publicPort} && docker rm haproxy-for-${publicPort}` + ); + } + await startTraefikTCPProxy(destinationDocker, id, publicPort, privatePort); + + } + } + const wordpressWithFtp = await prisma.wordpress.findMany({ + where: { ftpPublicPort: { not: null } }, + include: { service: { include: { destinationDocker: true } } } + }); + for (const ftp of wordpressWithFtp) { + const { service, ftpPublicPort } = ftp; + const { destinationDockerId, destinationDocker, id } = service; + if (destinationDockerId && destinationDocker.isCoolifyProxyUsed) { + // Remove HAProxy + const found = await checkContainer(engine, `haproxy-for-${ftpPublicPort}`); + const host = getEngine(engine); + if (found) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker stop -t 0 haproxy-for-${ftpPublicPort} && docker rm haproxy-for-${ftpPublicPort} ` + ); + } + await startTraefikTCPProxy(destinationDocker, id, ftpPublicPort, 22, 'wordpressftp'); + } + } + + // HTTP Proxies + const minioInstances = await prisma.minio.findMany({ + where: { publicPort: { not: null } }, + include: { service: { include: { destinationDocker: true } } } + }); + for (const minio of minioInstances) { + const { service, publicPort } = minio; + const { destinationDockerId, destinationDocker, id } = service; + if (destinationDockerId && destinationDocker.isCoolifyProxyUsed) { + // Remove HAProxy + const found = await checkContainer(engine, `${id}-${publicPort}`); + const host = getEngine(engine); + if (found) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker stop -t 0 ${id}-${publicPort} && docker rm ${id}-${publicPort}` + ); + } + await startTraefikTCPProxy(destinationDocker, id, publicPort, 9000); + } + } + await prisma.$disconnect(); + } else process.exit(0); +})(); diff --git a/apps/api/src/jobs/cleanupStorage.ts b/apps/api/src/jobs/cleanupStorage.ts new file mode 100644 index 000000000..5cb04bdab --- /dev/null +++ b/apps/api/src/jobs/cleanupStorage.ts @@ -0,0 +1,90 @@ +import { parentPort } from 'node:worker_threads'; +import { asyncExecShell, isDev, prisma, version } from '../lib/common'; +import { getEngine } from '../lib/docker'; + +(async () => { + if (parentPort) { + const destinationDockers = await prisma.destinationDocker.findMany(); + const engines = [...new Set(destinationDockers.map(({ engine }) => engine))]; + for (const engine of engines) { + let lowDiskSpace = false; + const host = getEngine(engine); + // try { + // let stdout = null + // if (!isDev) { + // const output = await asyncExecShell( + // `DOCKER_HOST=${host} docker exec coolify sh -c 'df -kPT /'` + // ); + // stdout = output.stdout; + // } else { + // const output = await asyncExecShell( + // `df -kPT /` + // ); + // stdout = output.stdout; + // } + // let lines = stdout.trim().split('\n'); + // let header = lines[0]; + // let regex = + // /^Filesystem\s+|Type\s+|1024-blocks|\s+Used|\s+Available|\s+Capacity|\s+Mounted on\s*$/g; + // const boundaries = []; + // let match; + + // while ((match = regex.exec(header))) { + // boundaries.push(match[0].length); + // } + + // boundaries[boundaries.length - 1] = -1; + // const data = lines.slice(1).map((line) => { + // const cl = boundaries.map((boundary) => { + // const column = boundary > 0 ? line.slice(0, boundary) : line; + // line = line.slice(boundary); + // return column.trim(); + // }); + // return { + // capacity: Number.parseInt(cl[5], 10) / 100 + // }; + // }); + // if (data.length > 0) { + // const { capacity } = data[0]; + // if (capacity > 0.6) { + // lowDiskSpace = true; + // } + // } + // } catch (error) { + // console.log(error); + // } + if (!isDev) { + // Cleanup old coolify images + try { + let { stdout: images } = await asyncExecShell( + `DOCKER_HOST=${host} docker images coollabsio/coolify --filter before="coollabsio/coolify:${version}" -q | xargs ` + ); + images = images.trim(); + if (images) { + await asyncExecShell(`DOCKER_HOST=${host} docker rmi -f ${images}`); + } + } catch (error) { + //console.log(error); + } + try { + await asyncExecShell(`DOCKER_HOST=${host} docker container prune -f`); + } catch (error) { + //console.log(error); + } + try { + await asyncExecShell(`DOCKER_HOST=${host} docker image prune -f`); + } catch (error) { + //console.log(error); + } + try { + await asyncExecShell(`DOCKER_HOST=${host} docker image prune -a -f`); + } catch (error) { + //console.log(error); + } + } else { + console.log(`[DEV MODE] Low disk space: ${lowDiskSpace}`); + } + } + await prisma.$disconnect(); + } else process.exit(0); +})(); diff --git a/apps/api/src/jobs/deployApplication.ts b/apps/api/src/jobs/deployApplication.ts new file mode 100644 index 000000000..10703a105 --- /dev/null +++ b/apps/api/src/jobs/deployApplication.ts @@ -0,0 +1,352 @@ +import { parentPort } from 'node:worker_threads'; +import crypto from 'crypto'; +import fs from 'fs/promises'; +import yaml from 'js-yaml'; + +import { copyBaseConfigurationFiles, makeLabelForStandaloneApplication, saveBuildLog, setDefaultConfiguration } from '../lib/buildPacks/common'; +import { asyncExecShell, createDirectories, decrypt, getDomain, prisma } from '../lib/common'; +import { dockerInstance, getEngine } from '../lib/docker'; +import * as importers from '../lib/importers'; +import * as buildpacks from '../lib/buildPacks'; + +(async () => { + if (parentPort) { + const concurrency = 1 + const PQueue = await import('p-queue'); + const queue = new PQueue.default({ concurrency }); + parentPort.on('message', async (message) => { + if (parentPort) { + if (message === 'error') throw new Error('oops'); + if (message === 'cancel') { + parentPort.postMessage('cancelled'); + return; + } + if (message === 'status') { + parentPort.postMessage({ size: queue.size, pending: queue.pending }); + return; + } + + await queue.add(async () => { + const { + id: applicationId, + repository, + name, + destinationDocker, + destinationDockerId, + gitSource, + build_id: buildId, + configHash, + fqdn, + projectId, + secrets, + phpModules, + type, + pullmergeRequestId = null, + sourceBranch = null, + settings, + persistentStorage, + pythonWSGI, + pythonModule, + pythonVariable, + denoOptions, + exposePort, + baseImage, + baseBuildImage + } = message + let { + branch, + buildPack, + port, + installCommand, + buildCommand, + startCommand, + baseDirectory, + publishDirectory, + dockerFileLocation, + denoMainFile + } = message + try { + const { debug } = settings; + if (concurrency === 1) { + await prisma.build.updateMany({ + where: { + status: { in: ['queued', 'running'] }, + id: { not: buildId }, + applicationId, + createdAt: { lt: new Date(new Date().getTime() - 10 * 1000) } + }, + data: { status: 'failed' } + }); + } + let imageId = applicationId; + let domain = getDomain(fqdn); + const volumes = + persistentStorage?.map((storage) => { + return `${applicationId}${storage.path.replace(/\//gi, '-')}:${buildPack !== 'docker' ? '/app' : '' + }${storage.path}`; + }) || []; + // Previews, we need to get the source branch and set subdomain + if (pullmergeRequestId) { + branch = sourceBranch; + domain = `${pullmergeRequestId}.${domain}`; + imageId = `${applicationId}-${pullmergeRequestId}`; + } + + let deployNeeded = true; + let destinationType; + + if (destinationDockerId) { + destinationType = 'docker'; + } + if (destinationType === 'docker') { + const docker = dockerInstance({ destinationDocker }); + const host = getEngine(destinationDocker.engine); + + await prisma.build.update({ where: { id: buildId }, data: { status: 'running' } }); + const { workdir, repodir } = await createDirectories({ repository, buildId }); + const configuration = await setDefaultConfiguration(message); + + buildPack = configuration.buildPack; + port = configuration.port; + installCommand = configuration.installCommand; + startCommand = configuration.startCommand; + buildCommand = configuration.buildCommand; + publishDirectory = configuration.publishDirectory; + baseDirectory = configuration.baseDirectory; + dockerFileLocation = configuration.dockerFileLocation; + denoMainFile = configuration.denoMainFile; + const commit = await importers[gitSource.type]({ + applicationId, + debug, + workdir, + repodir, + githubAppId: gitSource.githubApp?.id, + gitlabAppId: gitSource.gitlabApp?.id, + repository, + branch, + buildId, + apiUrl: gitSource.apiUrl, + htmlUrl: gitSource.htmlUrl, + projectId, + deployKeyId: gitSource.gitlabApp?.deployKeyId || null, + privateSshKey: decrypt(gitSource.gitlabApp?.privateSshKey) || null + }); + if (!commit) { + throw new Error('No commit found?'); + } + let tag = commit.slice(0, 7); + if (pullmergeRequestId) { + tag = `${commit.slice(0, 7)}-${pullmergeRequestId}`; + } + + try { + await prisma.build.update({ where: { id: buildId }, data: { commit } }); + } catch (err) { + console.log(err); + } + if (!pullmergeRequestId) { + const currentHash = crypto + //@ts-ignore + .createHash('sha256') + .update( + JSON.stringify({ + buildPack, + port, + exposePort, + installCommand, + buildCommand, + startCommand, + secrets, + branch, + repository, + fqdn + }) + ) + .digest('hex'); + + if (configHash !== currentHash) { + await prisma.application.update({ + where: { id: applicationId }, + data: { configHash: currentHash } + }); + deployNeeded = true; + if (configHash) { + await saveBuildLog({ line: 'Configuration changed.', buildId, applicationId }); + } + } else { + deployNeeded = false; + } + } else { + deployNeeded = true; + } + const image = await docker.engine.getImage(`${applicationId}:${tag}`); + let imageFound = false; + try { + await image.inspect(); + imageFound = false; + } catch (error) { + // + } + if (!imageFound || deployNeeded) { + await copyBaseConfigurationFiles(buildPack, workdir, buildId, applicationId, baseImage); + if (buildpacks[buildPack]) + await buildpacks[buildPack]({ + buildId, + applicationId, + domain, + name, + type, + pullmergeRequestId, + buildPack, + repository, + branch, + projectId, + publishDirectory, + debug, + commit, + tag, + workdir, + docker, + port: exposePort ? `${exposePort}:${port}` : port, + installCommand, + buildCommand, + startCommand, + baseDirectory, + secrets, + phpModules, + pythonWSGI, + pythonModule, + pythonVariable, + dockerFileLocation, + denoMainFile, + denoOptions, + baseImage, + baseBuildImage + }); + else { + await saveBuildLog({ line: `Build pack ${buildPack} not found`, buildId, applicationId }); + throw new Error(`Build pack ${buildPack} not found.`); + } + } else { + await saveBuildLog({ line: 'Nothing changed.', buildId, applicationId }); + } + try { + await asyncExecShell(`DOCKER_HOST=${host} docker stop -t 0 ${imageId}`); + await asyncExecShell(`DOCKER_HOST=${host} docker rm ${imageId}`); + } catch (error) { + // + } + const envs = []; + if (secrets.length > 0) { + secrets.forEach((secret) => { + if (pullmergeRequestId) { + if (secret.isPRMRSecret) { + envs.push(`${secret.name}=${secret.value}`); + } + } else { + if (!secret.isPRMRSecret) { + envs.push(`${secret.name}=${secret.value}`); + } + } + }); + } + await fs.writeFile(`${workdir}/.env`, envs.join('\n')); + const labels = makeLabelForStandaloneApplication({ + applicationId, + fqdn, + name, + type, + pullmergeRequestId, + buildPack, + repository, + branch, + projectId, + port: exposePort ? `${exposePort}:${port}` : port, + commit, + installCommand, + buildCommand, + startCommand, + baseDirectory, + publishDirectory + }); + let envFound = false; + try { + envFound = !!(await fs.stat(`${workdir}/.env`)); + } catch (error) { + // + } + try { + await saveBuildLog({ line: 'Deployment started.', buildId, applicationId }); + const composeVolumes = volumes.map((volume) => { + return { + [`${volume.split(':')[0]}`]: { + name: volume.split(':')[0] + } + }; + }); + const composeFile = { + version: '3.8', + services: { + [imageId]: { + image: `${applicationId}:${tag}`, + container_name: imageId, + volumes, + env_file: envFound ? [`${workdir}/.env`] : [], + networks: [docker.network], + labels, + depends_on: [], + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + // logging: { + // driver: 'fluentd', + // }, + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [docker.network]: { + external: true + } + }, + volumes: Object.assign({}, ...composeVolumes) + }; + await fs.writeFile(`${workdir}/docker-compose.yml`, yaml.dump(composeFile)); + await asyncExecShell( + `DOCKER_HOST=${host} docker compose --project-directory ${workdir} up -d` + ); + await saveBuildLog({ line: 'Deployment successful!', buildId, applicationId }); + } catch (error) { + await saveBuildLog({ line: error, buildId, applicationId }); + await prisma.build.update({ + where: { id: message.build_id }, + data: { status: 'failed' } + }); + throw new Error(error); + } + await saveBuildLog({ line: 'Proxy will be updated shortly.', buildId, applicationId }); + await prisma.build.update({ where: { id: message.build_id }, data: { status: 'success' } }); + } + + } + catch (error) { + await prisma.build.update({ + where: { id: message.build_id }, + data: { status: 'failed' } + }); + await saveBuildLog({ line: error, buildId, applicationId }); + } finally { + await prisma.$disconnect(); + } + }); + await prisma.$disconnect(); + } + }); + } else process.exit(0); +})(); diff --git a/src/lib/buildPacks/common.ts b/apps/api/src/lib/buildPacks/common.ts similarity index 62% rename from src/lib/buildPacks/common.ts rename to apps/api/src/lib/buildPacks/common.ts index c6f1f75f8..a2330cf52 100644 --- a/src/lib/buildPacks/common.ts +++ b/apps/api/src/lib/buildPacks/common.ts @@ -1,10 +1,7 @@ -import { base64Encode } from '$lib/crypto'; -import { getDomain, saveBuildLog, version } from '$lib/common'; -import * as db from '$lib/database'; -import { scanningTemplates } from '$lib/components/templates'; +import { asyncExecShell, base64Encode, generateTimestamp, getDomain, isDev, prisma, version } from "../common"; +import { scheduler } from "../scheduler"; import { promises as fs } from 'fs'; -import { staticDeployments } from '$lib/components/common'; - +import { day } from "../dayjs"; const staticApps = ['static', 'react', 'vuejs', 'svelte', 'gatsby', 'astro', 'eleventy']; const nodeBased = [ 'react', @@ -20,223 +17,7 @@ const nodeBased = [ 'nextjs' ]; -export function makeLabelForStandaloneApplication({ - applicationId, - fqdn, - name, - type, - pullmergeRequestId = null, - buildPack, - repository, - branch, - projectId, - port, - commit, - installCommand, - buildCommand, - startCommand, - baseDirectory, - publishDirectory -}) { - if (pullmergeRequestId) { - const protocol = fqdn.startsWith('https://') ? 'https' : 'http'; - const domain = getDomain(fqdn); - fqdn = `${protocol}://${pullmergeRequestId}.${domain}`; - } - return [ - 'coolify.managed=true', - `coolify.version=${version}`, - `coolify.type=standalone-application`, - `coolify.configuration=${base64Encode( - JSON.stringify({ - applicationId, - fqdn, - name, - type, - pullmergeRequestId, - buildPack, - repository, - branch, - projectId, - port, - commit, - installCommand, - buildCommand, - startCommand, - baseDirectory, - publishDirectory - }) - )}` - ]; -} -export async function makeLabelForStandaloneDatabase({ id, image, volume }) { - const database = await db.prisma.database.findFirst({ where: { id } }); - delete database.destinationDockerId; - delete database.createdAt; - delete database.updatedAt; - return [ - 'coolify.managed=true', - `coolify.version=${version}`, - `coolify.type=standalone-database`, - `coolify.configuration=${base64Encode( - JSON.stringify({ - version, - image, - volume, - ...database - }) - )}` - ]; -} - -export function makeLabelForServices(type) { - return [ - 'coolify.managed=true', - `coolify.version=${version}`, - `coolify.type=service`, - `coolify.service.type=${type}` - ]; -} - -export const setDefaultConfiguration = async (data) => { - let { - buildPack, - port, - installCommand, - startCommand, - buildCommand, - publishDirectory, - baseDirectory, - dockerFileLocation, - denoMainFile - } = data; - const template = scanningTemplates[buildPack]; - if (!port) { - port = template?.port || 3000; - - if (buildPack === 'static') port = 80; - else if (buildPack === 'node') port = 3000; - else if (buildPack === 'php') port = 80; - else if (buildPack === 'python') port = 8000; - } - if (!installCommand && buildPack !== 'static' && buildPack !== 'laravel') - installCommand = template?.installCommand || 'yarn install'; - if (!startCommand && buildPack !== 'static' && buildPack !== 'laravel') - startCommand = template?.startCommand || 'yarn start'; - if (!buildCommand && buildPack !== 'static' && buildPack !== 'laravel') - buildCommand = template?.buildCommand || null; - if (!publishDirectory) publishDirectory = template?.publishDirectory || null; - if (baseDirectory) { - if (!baseDirectory.startsWith('/')) baseDirectory = `/${baseDirectory}`; - if (!baseDirectory.endsWith('/')) baseDirectory = `${baseDirectory}/`; - } - if (dockerFileLocation) { - if (!dockerFileLocation.startsWith('/')) dockerFileLocation = `/${dockerFileLocation}`; - if (dockerFileLocation.endsWith('/')) dockerFileLocation = dockerFileLocation.slice(0, -1); - } else { - dockerFileLocation = '/Dockerfile'; - } - if (!denoMainFile) { - denoMainFile = 'main.ts'; - } - - return { - buildPack, - port, - installCommand, - startCommand, - buildCommand, - publishDirectory, - baseDirectory, - dockerFileLocation, - denoMainFile - }; -}; - -export async function copyBaseConfigurationFiles( - buildPack, - workdir, - buildId, - applicationId, - baseImage -) { - try { - if (buildPack === 'php') { - await fs.writeFile(`${workdir}/entrypoint.sh`, `chown -R 1000 /app`); - await saveBuildLog({ - line: 'Copied default configuration file for PHP.', - buildId, - applicationId - }); - } else if (staticDeployments.includes(buildPack) && baseImage.includes('nginx')) { - await fs.writeFile( - `${workdir}/nginx.conf`, - `user nginx; - worker_processes auto; - - error_log /docker.stdout; - pid /run/nginx.pid; - - events { - worker_connections 1024; - } - - http { - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /docker.stdout main; - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - - include /etc/nginx/mime.types; - default_type application/octet-stream; - - server { - listen 80; - server_name localhost; - - location / { - root /app; - index index.html; - try_files $uri $uri/index.html $uri/ /index.html =404; - } - - error_page 404 /50x.html; - - # redirect server error pages to the static page /50x.html - # - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /app; - } - - } - - } - ` - ); - } - } catch (error) { - console.log(error); - throw new Error(error); - } -} - -export function checkPnpm(installCommand = null, buildCommand = null, startCommand = null) { - return ( - installCommand?.includes('pnpm') || - buildCommand?.includes('pnpm') || - startCommand?.includes('pnpm') - ); -} - -export function setDefaultBaseImage(buildPack) { +export function setDefaultBaseImage(buildPack: string | null) { const nodeVersions = [ { value: 'node:lts', @@ -471,7 +252,7 @@ export function setDefaultBaseImage(buildPack) { label: 'python:3.7-slim-bullseye' } ]; - let payload = { + let payload: any = { baseImage: null, baseBuildImage: null, baseImages: [], @@ -513,3 +294,421 @@ export function setDefaultBaseImage(buildPack) { } return payload; } + +export const setDefaultConfiguration = async (data: any) => { + let { + buildPack, + port, + installCommand, + startCommand, + buildCommand, + publishDirectory, + baseDirectory, + dockerFileLocation, + denoMainFile + } = data; + //@ts-ignore + const template = scanningTemplates[buildPack]; + if (!port) { + port = template?.port || 3000; + + if (buildPack === 'static') port = 80; + else if (buildPack === 'node') port = 3000; + else if (buildPack === 'php') port = 80; + else if (buildPack === 'python') port = 8000; + } + if (!installCommand && buildPack !== 'static' && buildPack !== 'laravel') + installCommand = template?.installCommand || 'yarn install'; + if (!startCommand && buildPack !== 'static' && buildPack !== 'laravel') + startCommand = template?.startCommand || 'yarn start'; + if (!buildCommand && buildPack !== 'static' && buildPack !== 'laravel') + buildCommand = template?.buildCommand || null; + if (!publishDirectory) publishDirectory = template?.publishDirectory || null; + if (baseDirectory) { + if (!baseDirectory.startsWith('/')) baseDirectory = `/${baseDirectory}`; + if (!baseDirectory.endsWith('/')) baseDirectory = `${baseDirectory}/`; + } + if (dockerFileLocation) { + if (!dockerFileLocation.startsWith('/')) dockerFileLocation = `/${dockerFileLocation}`; + if (dockerFileLocation.endsWith('/')) dockerFileLocation = dockerFileLocation.slice(0, -1); + } else { + dockerFileLocation = '/Dockerfile'; + } + if (!denoMainFile) { + denoMainFile = 'main.ts'; + } + + return { + buildPack, + port, + installCommand, + startCommand, + buildCommand, + publishDirectory, + baseDirectory, + dockerFileLocation, + denoMainFile + }; +}; + +export const scanningTemplates = { + '@sveltejs/kit': { + buildPack: 'nodejs' + }, + astro: { + buildPack: 'astro' + }, + '@11ty/eleventy': { + buildPack: 'eleventy' + }, + svelte: { + buildPack: 'svelte' + }, + '@nestjs/core': { + buildPack: 'nestjs' + }, + next: { + buildPack: 'nextjs' + }, + nuxt: { + buildPack: 'nuxtjs' + }, + 'react-scripts': { + buildPack: 'react' + }, + 'parcel-bundler': { + buildPack: 'static' + }, + '@vue/cli-service': { + buildPack: 'vuejs' + }, + vuejs: { + buildPack: 'vuejs' + }, + gatsby: { + buildPack: 'gatsby' + }, + 'preact-cli': { + buildPack: 'react' + } +}; + + +export const saveBuildLog = async ({ + line, + buildId, + applicationId +}: { + line: string; + buildId: string; + applicationId: string; +}): Promise => { + if (line && typeof line === 'string' && line.includes('ghs_')) { + const regex = /ghs_.*@/g; + line = line.replace(regex, '@'); + } + const addTimestamp = `[${generateTimestamp()}] ${line}`; + if (isDev) console.debug(`[${applicationId}] ${addTimestamp}`); + return await prisma.buildLog.create({ + data: { + line: addTimestamp, buildId, time: Number(day().valueOf()), applicationId + } + }); +}; + +export async function copyBaseConfigurationFiles( + buildPack, + workdir, + buildId, + applicationId, + baseImage +) { + try { + if (buildPack === 'php') { + await fs.writeFile(`${workdir}/entrypoint.sh`, `chown -R 1000 /app`); + await saveBuildLog({ + line: 'Copied default configuration file for PHP.', + buildId, + applicationId + }); + } else if (staticApps.includes(buildPack) && baseImage.includes('nginx')) { + await fs.writeFile( + `${workdir}/nginx.conf`, + `user nginx; + worker_processes auto; + + error_log /docker.stdout; + pid /run/nginx.pid; + + events { + worker_connections 1024; + } + + http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /docker.stdout main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + listen 80; + server_name localhost; + + location / { + root /app; + index index.html; + try_files $uri $uri/index.html $uri/ /index.html =404; + } + + error_page 404 /50x.html; + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /app; + } + + } + + } + ` + ); + } + } catch (error) { + console.log(error); + throw new Error(error); + } +} + +export function checkPnpm(installCommand = null, buildCommand = null, startCommand = null) { + return ( + installCommand?.includes('pnpm') || + buildCommand?.includes('pnpm') || + startCommand?.includes('pnpm') + ); +} + + +export async function buildImage({ + applicationId, + tag, + workdir, + docker, + buildId, + isCache = false, + debug = false, + dockerFileLocation = '/Dockerfile' +}) { + if (isCache) { + await saveBuildLog({ line: `Building cache image started.`, buildId, applicationId }); + } else { + await saveBuildLog({ line: `Building image started.`, buildId, applicationId }); + } + if (!debug && isCache) { + await saveBuildLog({ + line: `Debug turned off. To see more details, allow it in the configuration.`, + buildId, + applicationId + }); + } + + const stream = await docker.engine.buildImage( + { src: ['.'], context: workdir }, + { + dockerfile: isCache ? `${dockerFileLocation}-cache` : dockerFileLocation, + t: `${applicationId}:${tag}${isCache ? '-cache' : ''}` + } + ); + await streamEvents({ stream, docker, buildId, applicationId, debug }); + await saveBuildLog({ line: `Building image successful!`, buildId, applicationId }); +} + +export async function streamEvents({ stream, docker, buildId, applicationId, debug }) { + await new Promise((resolve, reject) => { + docker.engine.modem.followProgress(stream, onFinished, onProgress); + function onFinished(err, res) { + if (err) reject(err); + resolve(res); + } + async function onProgress(event) { + if (event.error) { + reject(event.error); + } else if (event.stream) { + if (event.stream !== '\n') { + if (debug) + await saveBuildLog({ + line: `${event.stream.replace('\n', '')}`, + buildId, + applicationId + }); + } + } + } + }); +} + +export function makeLabelForStandaloneApplication({ + applicationId, + fqdn, + name, + type, + pullmergeRequestId = null, + buildPack, + repository, + branch, + projectId, + port, + commit, + installCommand, + buildCommand, + startCommand, + baseDirectory, + publishDirectory +}) { + if (pullmergeRequestId) { + const protocol = fqdn.startsWith('https://') ? 'https' : 'http'; + const domain = getDomain(fqdn); + fqdn = `${protocol}://${pullmergeRequestId}.${domain}`; + } + return [ + 'coolify.managed=true', + `coolify.version=${version}`, + `coolify.type=standalone-application`, + `coolify.configuration=${base64Encode( + JSON.stringify({ + applicationId, + fqdn, + name, + type, + pullmergeRequestId, + buildPack, + repository, + branch, + projectId, + port, + commit, + installCommand, + buildCommand, + startCommand, + baseDirectory, + publishDirectory + }) + )}` + ]; +} + +export async function buildCacheImageWithNode(data, imageForBuild) { + const { + applicationId, + tag, + workdir, + docker, + buildId, + baseDirectory, + installCommand, + buildCommand, + debug, + secrets, + pullmergeRequestId + } = data; + const isPnpm = checkPnpm(installCommand, buildCommand); + const Dockerfile: Array = []; + Dockerfile.push(`FROM ${imageForBuild}`); + Dockerfile.push('WORKDIR /app'); + Dockerfile.push(`LABEL coolify.buildId=${buildId}`); + if (secrets.length > 0) { + secrets.forEach((secret) => { + if (secret.isBuildSecret) { + if (pullmergeRequestId) { + if (secret.isPRMRSecret) { + Dockerfile.push(`ARG ${secret.name}=${secret.value}`); + } + } else { + if (!secret.isPRMRSecret) { + Dockerfile.push(`ARG ${secret.name}=${secret.value}`); + } + } + } + }); + } + if (isPnpm) { + Dockerfile.push('RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7'); + } + if (installCommand) { + Dockerfile.push(`COPY .${baseDirectory || ''}/package.json ./`); + Dockerfile.push(`RUN ${installCommand}`); + } + Dockerfile.push(`COPY .${baseDirectory || ''} ./`); + Dockerfile.push(`RUN ${buildCommand}`); + await fs.writeFile(`${workdir}/Dockerfile-cache`, Dockerfile.join('\n')); + await buildImage({ applicationId, tag, workdir, docker, buildId, isCache: true, debug }); +} + +export async function buildCacheImageForLaravel(data, imageForBuild) { + const { applicationId, tag, workdir, docker, buildId, debug, secrets, pullmergeRequestId } = data; + const Dockerfile: Array = []; + Dockerfile.push(`FROM ${imageForBuild}`); + Dockerfile.push('WORKDIR /app'); + Dockerfile.push(`LABEL coolify.buildId=${buildId}`); + if (secrets.length > 0) { + secrets.forEach((secret) => { + if (secret.isBuildSecret) { + if (pullmergeRequestId) { + if (secret.isPRMRSecret) { + Dockerfile.push(`ARG ${secret.name}=${secret.value}`); + } + } else { + if (!secret.isPRMRSecret) { + Dockerfile.push(`ARG ${secret.name}=${secret.value}`); + } + } + } + }); + } + Dockerfile.push(`COPY *.json *.mix.js /app/`); + Dockerfile.push(`COPY resources /app/resources`); + Dockerfile.push(`RUN yarn install && yarn production`); + await fs.writeFile(`${workdir}/Dockerfile-cache`, Dockerfile.join('\n')); + await buildImage({ applicationId, tag, workdir, docker, buildId, isCache: true, debug }); +} + +export async function buildCacheImageWithCargo(data, imageForBuild) { + const { + applicationId, + tag, + workdir, + docker, + buildId, + baseDirectory, + installCommand, + buildCommand, + debug, + secrets + } = data; + const Dockerfile: Array = []; + Dockerfile.push(`FROM ${imageForBuild} as planner-${applicationId}`); + Dockerfile.push(`LABEL coolify.buildId=${buildId}`); + Dockerfile.push('WORKDIR /app'); + Dockerfile.push('RUN cargo install cargo-chef'); + Dockerfile.push('COPY . .'); + Dockerfile.push('RUN cargo chef prepare --recipe-path recipe.json'); + Dockerfile.push(`FROM ${imageForBuild}`); + Dockerfile.push(`LABEL coolify.buildId=${buildId}`); + Dockerfile.push('WORKDIR /app'); + Dockerfile.push('RUN cargo install cargo-chef'); + Dockerfile.push(`COPY --from=planner-${applicationId} /app/recipe.json recipe.json`); + Dockerfile.push('RUN cargo chef cook --release --recipe-path recipe.json'); + await fs.writeFile(`${workdir}/Dockerfile-cache`, Dockerfile.join('\n')); + await buildImage({ applicationId, tag, workdir, docker, buildId, isCache: true, debug }); +} \ No newline at end of file diff --git a/src/lib/buildPacks/deno.ts b/apps/api/src/lib/buildPacks/deno.ts similarity index 97% rename from src/lib/buildPacks/deno.ts rename to apps/api/src/lib/buildPacks/deno.ts index b593596f7..3b35641bc 100644 --- a/src/lib/buildPacks/deno.ts +++ b/apps/api/src/lib/buildPacks/deno.ts @@ -1,5 +1,5 @@ -import { buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { diff --git a/src/lib/buildPacks/docker.ts b/apps/api/src/lib/buildPacks/docker.ts similarity index 96% rename from src/lib/buildPacks/docker.ts rename to apps/api/src/lib/buildPacks/docker.ts index 3b032fc4b..88d900c38 100644 --- a/src/lib/buildPacks/docker.ts +++ b/apps/api/src/lib/buildPacks/docker.ts @@ -1,5 +1,5 @@ -import { buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildImage } from './common'; export default async function ({ applicationId, diff --git a/src/lib/buildPacks/gatsby.ts b/apps/api/src/lib/buildPacks/gatsby.ts similarity index 93% rename from src/lib/buildPacks/gatsby.ts rename to apps/api/src/lib/buildPacks/gatsby.ts index dc1c7d0e8..c0b76b2a5 100644 --- a/src/lib/buildPacks/gatsby.ts +++ b/apps/api/src/lib/buildPacks/gatsby.ts @@ -1,5 +1,5 @@ -import { buildCacheImageWithNode, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildCacheImageWithNode, buildImage } from './common'; const createDockerfile = async (data, imageforBuild): Promise => { const { applicationId, tag, workdir, publishDirectory, baseImage, buildId, port } = data; diff --git a/src/lib/buildPacks/index.ts b/apps/api/src/lib/buildPacks/index.ts similarity index 100% rename from src/lib/buildPacks/index.ts rename to apps/api/src/lib/buildPacks/index.ts diff --git a/src/lib/buildPacks/laravel.ts b/apps/api/src/lib/buildPacks/laravel.ts similarity index 95% rename from src/lib/buildPacks/laravel.ts rename to apps/api/src/lib/buildPacks/laravel.ts index 4912a772f..9a00eff08 100644 --- a/src/lib/buildPacks/laravel.ts +++ b/apps/api/src/lib/buildPacks/laravel.ts @@ -1,5 +1,5 @@ -import { buildCacheImageForLaravel, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildCacheImageForLaravel, buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { workdir, applicationId, tag, buildId, port } = data; diff --git a/src/lib/buildPacks/nestjs.ts b/apps/api/src/lib/buildPacks/nestjs.ts similarity index 93% rename from src/lib/buildPacks/nestjs.ts rename to apps/api/src/lib/buildPacks/nestjs.ts index 9486c9e8e..90c99301b 100644 --- a/src/lib/buildPacks/nestjs.ts +++ b/apps/api/src/lib/buildPacks/nestjs.ts @@ -1,5 +1,5 @@ -import { buildCacheImageWithNode, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildCacheImageWithNode, buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { buildId, applicationId, tag, port, startCommand, workdir, baseDirectory } = data; diff --git a/src/lib/buildPacks/nextjs.ts b/apps/api/src/lib/buildPacks/nextjs.ts similarity index 94% rename from src/lib/buildPacks/nextjs.ts rename to apps/api/src/lib/buildPacks/nextjs.ts index 5ebb30410..e7cc69290 100644 --- a/src/lib/buildPacks/nextjs.ts +++ b/apps/api/src/lib/buildPacks/nextjs.ts @@ -1,6 +1,5 @@ -import { buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; -import { checkPnpm } from './common'; +import { buildImage, checkPnpm } from './common'; const createDockerfile = async (data, image): Promise => { const { diff --git a/src/lib/buildPacks/node.ts b/apps/api/src/lib/buildPacks/node.ts similarity index 91% rename from src/lib/buildPacks/node.ts rename to apps/api/src/lib/buildPacks/node.ts index 46043593b..ad234aed1 100644 --- a/src/lib/buildPacks/node.ts +++ b/apps/api/src/lib/buildPacks/node.ts @@ -1,6 +1,5 @@ -import { buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; -import { checkPnpm } from './common'; +import { buildImage, checkPnpm } from './common'; const createDockerfile = async (data, image): Promise => { const { @@ -50,7 +49,7 @@ const createDockerfile = async (data, image): Promise => { export default async function (data) { try { - const { baseImage, baseBuildImage } = data; + const { baseImage } = data; await createDockerfile(data, baseImage); await buildImage(data); } catch (error) { diff --git a/src/lib/buildPacks/nuxtjs.ts b/apps/api/src/lib/buildPacks/nuxtjs.ts similarity index 94% rename from src/lib/buildPacks/nuxtjs.ts rename to apps/api/src/lib/buildPacks/nuxtjs.ts index e1fded7f2..e7b4ee0b7 100644 --- a/src/lib/buildPacks/nuxtjs.ts +++ b/apps/api/src/lib/buildPacks/nuxtjs.ts @@ -1,6 +1,5 @@ -import { buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; -import { checkPnpm } from './common'; +import { buildImage, checkPnpm } from './common'; const createDockerfile = async (data, image): Promise => { const { diff --git a/src/lib/buildPacks/php.ts b/apps/api/src/lib/buildPacks/php.ts similarity index 71% rename from src/lib/buildPacks/php.ts rename to apps/api/src/lib/buildPacks/php.ts index d04e86712..e6dc1699f 100644 --- a/src/lib/buildPacks/php.ts +++ b/apps/api/src/lib/buildPacks/php.ts @@ -1,8 +1,8 @@ -import { buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildImage } from './common'; const createDockerfile = async (data, image, htaccessFound): Promise => { - const { workdir, baseDirectory, buildId, port } = data; + const { workdir, baseDirectory, buildId, port, secrets, pullmergeRequestId } = data; const Dockerfile: Array = []; let composerFound = false; try { @@ -12,6 +12,21 @@ const createDockerfile = async (data, image, htaccessFound): Promise => { Dockerfile.push(`FROM ${image}`); Dockerfile.push(`LABEL coolify.buildId=${buildId}`); + if (secrets.length > 0) { + secrets.forEach((secret) => { + if (secret.isBuildSecret) { + if (pullmergeRequestId) { + if (secret.isPRMRSecret) { + Dockerfile.push(`ARG ${secret.name}=${secret.value}`); + } + } else { + if (!secret.isPRMRSecret) { + Dockerfile.push(`ARG ${secret.name}=${secret.value}`); + } + } + } + }); + } Dockerfile.push('WORKDIR /app'); Dockerfile.push(`COPY .${baseDirectory || ''} /app`); if (htaccessFound) { diff --git a/src/lib/buildPacks/python.ts b/apps/api/src/lib/buildPacks/python.ts similarity index 98% rename from src/lib/buildPacks/python.ts rename to apps/api/src/lib/buildPacks/python.ts index cd42d6fd6..110471179 100644 --- a/src/lib/buildPacks/python.ts +++ b/apps/api/src/lib/buildPacks/python.ts @@ -1,5 +1,5 @@ -import { buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { diff --git a/src/lib/buildPacks/react.ts b/apps/api/src/lib/buildPacks/react.ts similarity index 93% rename from src/lib/buildPacks/react.ts rename to apps/api/src/lib/buildPacks/react.ts index 5217e8f96..169af33cc 100644 --- a/src/lib/buildPacks/react.ts +++ b/apps/api/src/lib/buildPacks/react.ts @@ -1,5 +1,5 @@ -import { buildCacheImageWithNode, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildCacheImageWithNode, buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { applicationId, tag, workdir, publishDirectory, baseImage, buildId, port } = data; diff --git a/src/lib/buildPacks/rust.ts b/apps/api/src/lib/buildPacks/rust.ts similarity index 93% rename from src/lib/buildPacks/rust.ts rename to apps/api/src/lib/buildPacks/rust.ts index 72f0c6273..1af215869 100644 --- a/src/lib/buildPacks/rust.ts +++ b/apps/api/src/lib/buildPacks/rust.ts @@ -1,7 +1,7 @@ -import { asyncExecShell } from '$lib/common'; -import { buildCacheImageWithCargo, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; import TOML from '@iarna/toml'; +import { asyncExecShell } from '../common'; +import { buildCacheImageWithCargo, buildImage } from './common'; const createDockerfile = async (data, image, name): Promise => { const { workdir, port, applicationId, tag, buildId } = data; diff --git a/src/lib/buildPacks/static.ts b/apps/api/src/lib/buildPacks/static.ts similarity index 95% rename from src/lib/buildPacks/static.ts rename to apps/api/src/lib/buildPacks/static.ts index 79647ae93..67f85b4f4 100644 --- a/src/lib/buildPacks/static.ts +++ b/apps/api/src/lib/buildPacks/static.ts @@ -1,5 +1,5 @@ -import { buildCacheImageWithNode, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildCacheImageWithNode, buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { diff --git a/src/lib/buildPacks/svelte.ts b/apps/api/src/lib/buildPacks/svelte.ts similarity index 93% rename from src/lib/buildPacks/svelte.ts rename to apps/api/src/lib/buildPacks/svelte.ts index bede0e806..4933d10ff 100644 --- a/src/lib/buildPacks/svelte.ts +++ b/apps/api/src/lib/buildPacks/svelte.ts @@ -1,5 +1,5 @@ -import { buildCacheImageWithNode, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildCacheImageWithNode, buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { applicationId, tag, workdir, publishDirectory, baseImage, buildId, port } = data; diff --git a/src/lib/buildPacks/vuejs.ts b/apps/api/src/lib/buildPacks/vuejs.ts similarity index 93% rename from src/lib/buildPacks/vuejs.ts rename to apps/api/src/lib/buildPacks/vuejs.ts index bede0e806..4933d10ff 100644 --- a/src/lib/buildPacks/vuejs.ts +++ b/apps/api/src/lib/buildPacks/vuejs.ts @@ -1,5 +1,5 @@ -import { buildCacheImageWithNode, buildImage } from '$lib/docker'; import { promises as fs } from 'fs'; +import { buildCacheImageWithNode, buildImage } from './common'; const createDockerfile = async (data, image): Promise => { const { applicationId, tag, workdir, publishDirectory, baseImage, buildId, port } = data; diff --git a/apps/api/src/lib/common.ts b/apps/api/src/lib/common.ts new file mode 100644 index 000000000..9690025ea --- /dev/null +++ b/apps/api/src/lib/common.ts @@ -0,0 +1,1480 @@ +import child from 'child_process'; +import util from 'util'; +import fs from 'fs/promises'; +import yaml from 'js-yaml'; +import forge from 'node-forge'; +import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator'; +import type { Config } from 'unique-names-generator'; +import generator from 'generate-password'; +import crypto from 'crypto'; +import { promises as dns } from 'dns'; +import { PrismaClient } from '@prisma/client'; +import cuid from 'cuid'; + +import { checkContainer, getEngine, removeContainer } from './docker'; +import { day } from './dayjs'; +import * as serviceFields from './serviceFields' + +const algorithm = 'aes-256-ctr'; +const customConfig: Config = { + dictionaries: [adjectives, colors, animals], + style: 'capital', + separator: ' ', + length: 3 +}; +export const isDev = process.env.NODE_ENV === 'development'; +export const version = '3.0.0'; + +export const defaultProxyImage = `coolify-haproxy-alpine:latest`; +export const defaultProxyImageTcp = `coolify-haproxy-tcp-alpine:latest`; +export const defaultProxyImageHttp = `coolify-haproxy-http-alpine:latest`; +export const defaultTraefikImage = `traefik:v2.6`; + +const mainTraefikEndpoint = isDev + ? 'http://host.docker.internal:3001/webhooks/traefik/main.json' + : 'http://coolify:3000/webhooks/traefik/main.json'; + +const otherTraefikEndpoint = isDev + ? 'http://host.docker.internal:3001/webhooks/traefik/other.json' + : 'http://coolify:3000/webhooks/traefik/other.json'; + + +export const include: any = { + destinationDocker: true, + persistentStorage: true, + serviceSecret: true, + minio: true, + plausibleAnalytics: true, + vscodeserver: true, + wordpress: true, + ghost: true, + meiliSearch: true, + umami: true, + hasura: true, + fider: true +}; + +export const uniqueName = (): string => uniqueNamesGenerator(customConfig); +export const asyncExecShell = util.promisify(child.exec); +export const asyncSleep = (delay: number): Promise => + new Promise((resolve) => setTimeout(resolve, delay)); +export const prisma = new PrismaClient({ + errorFormat: 'minimal' +}); + +export const base64Encode = (text: string): string => { + return Buffer.from(text).toString('base64'); +}; +export const base64Decode = (text: string): string => { + return Buffer.from(text, 'base64').toString('ascii'); +}; +export const decrypt = (hashString: string) => { + if (hashString) { + const hash = JSON.parse(hashString); + const decipher = crypto.createDecipheriv( + algorithm, + process.env['COOLIFY_SECRET_KEY'], + Buffer.from(hash.iv, 'hex') + ); + const decrpyted = Buffer.concat([ + decipher.update(Buffer.from(hash.content, 'hex')), + decipher.final() + ]); + return decrpyted.toString(); + } +}; +export const encrypt = (text: string) => { + if (text) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(algorithm, process.env['COOLIFY_SECRET_KEY'], iv); + const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); + return JSON.stringify({ + iv: iv.toString('hex'), + content: encrypted.toString('hex') + }); + } +}; +export async function checkDoubleBranch(branch: string, projectId: number): Promise { + const applications = await prisma.application.findMany({ where: { branch, projectId } }); + return applications.length > 1; +} +export async function isDNSValid(hostname: any, domain: string): Promise { + const { isIP } = await import('is-ip'); + let resolves = []; + try { + if (isIP(hostname)) { + resolves = [hostname]; + } else { + resolves = await dns.resolve4(hostname); + } + } catch (error) { + throw 'Invalid DNS.' + } + + try { + let ipDomainFound = false; + dns.setServers(['1.1.1.1', '8.8.8.8']); + const dnsResolve = await dns.resolve4(domain); + if (dnsResolve.length > 0) { + for (const ip of dnsResolve) { + if (resolves.includes(ip)) { + ipDomainFound = true; + } + } + } + if (!ipDomainFound) throw false; + } catch (error) { + throw 'DNS not set' + } +} + + +export function getDomain(domain: string): string { + return domain?.replace('https://', '').replace('http://', ''); +} + +export async function isDomainConfigured({ + id, + fqdn, + checkOwn = false +}: { + id: string; + fqdn: string; + checkOwn?: boolean; +}): Promise { + const domain = getDomain(fqdn); + const nakedDomain = domain.replace('www.', ''); + const foundApp = await prisma.application.findFirst({ + where: { + OR: [ + { fqdn: { endsWith: `//${nakedDomain}` } }, + { fqdn: { endsWith: `//www.${nakedDomain}` } } + ], + id: { not: id } + }, + select: { fqdn: true } + }); + const foundService = await prisma.service.findFirst({ + where: { + OR: [ + { fqdn: { endsWith: `//${nakedDomain}` } }, + { fqdn: { endsWith: `//www.${nakedDomain}` } }, + { minio: { apiFqdn: { endsWith: `//${nakedDomain}` } } }, + { minio: { apiFqdn: { endsWith: `//www.${nakedDomain}` } } } + ], + id: { not: checkOwn ? undefined : id } + }, + select: { fqdn: true } + }); + + const coolifyFqdn = await prisma.setting.findFirst({ + where: { + OR: [ + { fqdn: { endsWith: `//${nakedDomain}` } }, + { fqdn: { endsWith: `//www.${nakedDomain}` } } + ], + id: { not: id } + }, + select: { fqdn: true } + }); + return !!(foundApp || foundService || coolifyFqdn); +} + +export async function getContainerUsage(engine: string, container: string): Promise { + const host = getEngine(engine); + try { + const { stdout } = await asyncExecShell( + `DOCKER_HOST="${host}" docker container stats ${container} --no-stream --no-trunc --format "{{json .}}"` + ); + return JSON.parse(stdout); + } catch (err) { + return { + MemUsage: 0, + CPUPerc: 0, + NetIO: 0 + }; + } +} + +export async function checkDomainsIsValidInDNS({ hostname, fqdn, dualCerts }): Promise { + const { isIP } = await import('is-ip'); + const domain = getDomain(fqdn); + const domainDualCert = domain.includes('www.') ? domain.replace('www.', '') : `www.${domain}`; + dns.setServers(['1.1.1.1', '8.8.8.8']); + let resolves = []; + try { + if (isIP(hostname)) { + resolves = [hostname]; + } else { + resolves = await dns.resolve4(hostname); + } + } catch (error) { + throw `DNS not set correctly or propogated.
Please check your DNS settings.` + } + + if (dualCerts) { + try { + const ipDomain = await dns.resolve4(domain); + const ipDomainDualCert = await dns.resolve4(domainDualCert); + + let ipDomainFound = false; + let ipDomainDualCertFound = false; + + for (const ip of ipDomain) { + if (resolves.includes(ip)) { + ipDomainFound = true; + } + } + for (const ip of ipDomainDualCert) { + if (resolves.includes(ip)) { + ipDomainDualCertFound = true; + } + } + if (ipDomainFound && ipDomainDualCertFound) return { status: 200 }; + throw false; + } catch (error) { + throw `DNS not set correctly or propogated.
Please check your DNS settings.` + } + } else { + try { + const ipDomain = await dns.resolve4(domain); + let ipDomainFound = false; + for (const ip of ipDomain) { + if (resolves.includes(ip)) { + ipDomainFound = true; + } + } + if (ipDomainFound) return { status: 200 }; + throw false; + } catch (error) { + throw `DNS not set correctly or propogated.
Please check your DNS settings.` + } + } +} +export function generateTimestamp(): string { + return `${day().format('HH:mm:ss.SSS')}`; +} + +export async function listServicesWithIncludes(): Promise { + return await prisma.service.findMany({ + include, + orderBy: { createdAt: 'desc' } + }); +} + +export const supportedDatabaseTypesAndVersions = [ + { + name: 'mongodb', + fancyName: 'MongoDB', + baseImage: 'bitnami/mongodb', + versions: ['5.0', '4.4', '4.2'] + }, + { name: 'mysql', fancyName: 'MySQL', baseImage: 'bitnami/mysql', versions: ['8.0', '5.7'] }, + { + name: 'mariadb', + fancyName: 'MariaDB', + baseImage: 'bitnami/mariadb', + versions: ['10.7', '10.6', '10.5', '10.4', '10.3', '10.2'] + }, + { + name: 'postgresql', + fancyName: 'PostgreSQL', + baseImage: 'bitnami/postgresql', + versions: ['14.4.0', '13.6.0', '12.10.0', '11.15.0', '10.20.0'] + }, + { + name: 'redis', + fancyName: 'Redis', + baseImage: 'bitnami/redis', + versions: ['6.2', '6.0', '5.0'] + }, + { name: 'couchdb', fancyName: 'CouchDB', baseImage: 'bitnami/couchdb', versions: ['3.2.1'] } +]; +export const supportedServiceTypesAndVersions = [ + { + name: 'plausibleanalytics', + fancyName: 'Plausible Analytics', + baseImage: 'plausible/analytics', + images: ['bitnami/postgresql:13.2.0', 'yandex/clickhouse-server:21.3.2.5'], + versions: ['latest', 'stable'], + recommendedVersion: 'stable', + ports: { + main: 8000 + } + }, + { + name: 'nocodb', + fancyName: 'NocoDB', + baseImage: 'nocodb/nocodb', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 8080 + } + }, + { + name: 'minio', + fancyName: 'MinIO', + baseImage: 'minio/minio', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 9001 + } + }, + { + name: 'vscodeserver', + fancyName: 'VSCode Server', + baseImage: 'codercom/code-server', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 8080 + } + }, + { + name: 'wordpress', + fancyName: 'Wordpress', + baseImage: 'wordpress', + images: ['bitnami/mysql:5.7'], + versions: ['latest', 'php8.1', 'php8.0', 'php7.4', 'php7.3'], + recommendedVersion: 'latest', + ports: { + main: 80 + } + }, + { + name: 'vaultwarden', + fancyName: 'Vaultwarden', + baseImage: 'vaultwarden/server', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 80 + } + }, + { + name: 'languagetool', + fancyName: 'LanguageTool', + baseImage: 'silviof/docker-languagetool', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 8010 + } + }, + { + name: 'n8n', + fancyName: 'n8n', + baseImage: 'n8nio/n8n', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 5678 + } + }, + { + name: 'uptimekuma', + fancyName: 'Uptime Kuma', + baseImage: 'louislam/uptime-kuma', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 3001 + } + }, + { + name: 'ghost', + fancyName: 'Ghost', + baseImage: 'bitnami/ghost', + images: ['bitnami/mariadb'], + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 2368 + } + }, + { + name: 'meilisearch', + fancyName: 'Meilisearch', + baseImage: 'getmeili/meilisearch', + images: [], + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 7700 + } + }, + { + name: 'umami', + fancyName: 'Umami', + baseImage: 'ghcr.io/mikecao/umami', + images: ['postgres:12-alpine'], + versions: ['postgresql-latest'], + recommendedVersion: 'postgresql-latest', + ports: { + main: 3000 + } + }, + { + name: 'hasura', + fancyName: 'Hasura', + baseImage: 'hasura/graphql-engine', + images: ['postgres:12-alpine'], + versions: ['latest', 'v2.8.3'], + recommendedVersion: 'v2.8.3', + ports: { + main: 8080 + } + }, + { + name: 'fider', + fancyName: 'Fider', + baseImage: 'getfider/fider', + images: ['postgres:12-alpine'], + versions: ['stable'], + recommendedVersion: 'stable', + ports: { + main: 3000 + } + // }, + // { + // name: 'appwrite', + // fancyName: 'AppWrite', + // baseImage: 'appwrite/appwrite', + // images: ['appwrite/influxdb', 'appwrite/telegraf', 'mariadb:10.7', 'redis:6.0-alpine3.12'], + // versions: ['latest', '0.13.0'], + // recommendedVersion: '0.13.0', + // ports: { + // main: 3000 + // } + // } + } +]; + +export async function startTraefikProxy(engine: string): Promise { + const host = getEngine(engine); + const found = await checkContainer(engine, 'coolify-proxy', true); + const { proxyPassword, proxyUser, id } = await listSettings(); + if (!found) { + const { stdout: Config } = await asyncExecShell( + `DOCKER_HOST="${host}" docker network inspect bridge --format '{{json .IPAM.Config }}'` + ); + const ip = JSON.parse(Config)[0].Gateway; + await asyncExecShell( + `DOCKER_HOST="${host}" docker run --restart always \ + --add-host 'host.docker.internal:host-gateway' \ + --add-host 'host.docker.internal:${ip}' \ + -v coolify-traefik-letsencrypt:/etc/traefik/acme \ + -v /var/run/docker.sock:/var/run/docker.sock \ + --network coolify-infra \ + -p "80:80" \ + -p "443:443" \ + --name coolify-proxy \ + -d ${defaultTraefikImage} \ + --entrypoints.web.address=:80 \ + --entrypoints.web.forwardedHeaders.insecure=true \ + --entrypoints.websecure.address=:443 \ + --entrypoints.websecure.forwardedHeaders.insecure=true \ + --providers.docker=true \ + --providers.docker.exposedbydefault=false \ + --providers.http.endpoint=${mainTraefikEndpoint} \ + --providers.http.pollTimeout=5s \ + --certificatesresolvers.letsencrypt.acme.httpchallenge=true \ + --certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme/acme.json \ + --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web \ + --log.level=error` + ); + await prisma.setting.update({ where: { id }, data: { proxyHash: null } }); + await prisma.destinationDocker.updateMany({ + where: { engine }, + data: { isCoolifyProxyUsed: true } + }); + } + await configureNetworkTraefikProxy(engine); +} + +export async function configureNetworkTraefikProxy(engine: string): Promise { + const host = getEngine(engine); + const destinations = await prisma.destinationDocker.findMany({ where: { engine } }); + const { stdout: networks } = await asyncExecShell( + `DOCKER_HOST="${host}" docker ps -a --filter name=coolify-proxy --format '{{json .Networks}}'` + ); + const configuredNetworks = networks.replace(/"/g, '').replace('\n', '').split(','); + for (const destination of destinations) { + if (!configuredNetworks.includes(destination.network)) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker network connect ${destination.network} coolify-proxy` + ); + } + } +} + +export async function stopTraefikProxy( + engine: string +): Promise<{ stdout: string; stderr: string } | Error> { + const host = getEngine(engine); + const found = await checkContainer(engine, 'coolify-proxy'); + await prisma.destinationDocker.updateMany({ + where: { engine }, + data: { isCoolifyProxyUsed: false } + }); + const { id } = await prisma.setting.findFirst({}); + await prisma.setting.update({ where: { id }, data: { proxyHash: null } }); + try { + if (found) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker stop -t 0 coolify-proxy && docker rm coolify-proxy` + ); + } + } catch (error) { + return error; + } +} + +export async function startCoolifyProxy(engine: string): Promise { + const host = getEngine(engine); + const found = await checkContainer(engine, 'coolify-haproxy', true); + const { proxyPassword, proxyUser, id } = await listSettings(); + if (!found) { + const { stdout: Config } = await asyncExecShell( + `DOCKER_HOST="${host}" docker network inspect bridge --format '{{json .IPAM.Config }}'` + ); + const ip = JSON.parse(Config)[0].Gateway; + await asyncExecShell( + `DOCKER_HOST="${host}" docker run -e HAPROXY_USERNAME=${proxyUser} -e HAPROXY_PASSWORD=${proxyPassword} --restart always --add-host 'host.docker.internal:host-gateway' --add-host 'host.docker.internal:${ip}' -v coolify-ssl-certs:/usr/local/etc/haproxy/ssl --network coolify-infra -p "80:80" -p "443:443" -p "8404:8404" -p "5555:5555" -p "5000:5000" --name coolify-haproxy -d coollabsio/${defaultProxyImage}` + ); + await prisma.setting.update({ where: { id }, data: { proxyHash: null } }); + await prisma.destinationDocker.updateMany({ + where: { engine }, + data: { isCoolifyProxyUsed: true } + }); + } + await configureNetworkCoolifyProxy(engine); +} + +export async function configureNetworkCoolifyProxy(engine: string): Promise { + const host = getEngine(engine); + const destinations = await prisma.destinationDocker.findMany({ where: { engine } }); + const { stdout: networks } = await asyncExecShell( + `DOCKER_HOST="${host}" docker ps -a --filter name=coolify-haproxy --format '{{json .Networks}}'` + ); + const configuredNetworks = networks.replace(/"/g, '').replace('\n', '').split(','); + for (const destination of destinations) { + if (!configuredNetworks.includes(destination.network)) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker network connect ${destination.network} coolify-haproxy` + ); + } + } +} +export async function listSettings(): Promise { + const settings = await prisma.setting.findFirst({}); + if (settings.proxyPassword) settings.proxyPassword = decrypt(settings.proxyPassword); + return settings; +} + + + +// export async function stopCoolifyProxy( +// engine: string +// ): Promise<{ stdout: string; stderr: string } | Error> { +// const host = getEngine(engine); +// const found = await checkContainer(engine, 'coolify-haproxy'); +// await prisma.destinationDocker.updateMany({ +// where: { engine }, +// data: { isCoolifyProxyUsed: false } +// }); +// const { id } = await prisma.setting.findFirst({}); +// await prisma.setting.update({ where: { id }, data: { proxyHash: null } }); +// try { +// if (found) { +// await asyncExecShell( +// `DOCKER_HOST="${host}" docker stop -t 0 coolify-haproxy && docker rm coolify-haproxy` +// ); +// } +// } catch (error) { +// return error; +// } +// } + +export function generatePassword(length = 24, symbols = false): string { + return generator.generate({ + length, + numbers: true, + strict: true, + symbols + }); +} + +export function generateDatabaseConfiguration(database: any): + | { + volume: string; + image: string; + ulimits: Record; + privatePort: number; + environmentVariables: { + MYSQL_DATABASE: string; + MYSQL_PASSWORD: string; + MYSQL_ROOT_USER: string; + MYSQL_USER: string; + MYSQL_ROOT_PASSWORD: string; + }; + } + | { + volume: string; + image: string; + ulimits: Record; + privatePort: number; + environmentVariables: { + MONGODB_ROOT_USER: string; + MONGODB_ROOT_PASSWORD: string; + }; + } + | { + volume: string; + image: string; + ulimits: Record; + privatePort: number; + environmentVariables: { + MARIADB_ROOT_USER: string; + MARIADB_ROOT_PASSWORD: string; + MARIADB_USER: string; + MARIADB_PASSWORD: string; + MARIADB_DATABASE: string; + }; + } + | { + volume: string; + image: string; + ulimits: Record; + privatePort: number; + environmentVariables: { + POSTGRESQL_POSTGRES_PASSWORD: string; + POSTGRESQL_USERNAME: string; + POSTGRESQL_PASSWORD: string; + POSTGRESQL_DATABASE: string; + }; + } + | { + volume: string; + image: string; + ulimits: Record; + privatePort: number; + environmentVariables: { + REDIS_AOF_ENABLED: string; + REDIS_PASSWORD: string; + }; + } + | { + volume: string; + image: string; + ulimits: Record; + privatePort: number; + environmentVariables: { + COUCHDB_PASSWORD: string; + COUCHDB_USER: string; + }; + } { + const { + id, + dbUser, + dbUserPassword, + rootUser, + rootUserPassword, + defaultDatabase, + version, + type, + settings: { appendOnly } + } = database; + const baseImage = getDatabaseImage(type); + if (type === 'mysql') { + return { + privatePort: 3306, + environmentVariables: { + MYSQL_USER: dbUser, + MYSQL_PASSWORD: dbUserPassword, + MYSQL_ROOT_PASSWORD: rootUserPassword, + MYSQL_ROOT_USER: rootUser, + MYSQL_DATABASE: defaultDatabase + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/mysql/data`, + ulimits: {} + }; + } else if (type === 'mariadb') { + return { + privatePort: 3306, + environmentVariables: { + MARIADB_ROOT_USER: rootUser, + MARIADB_ROOT_PASSWORD: rootUserPassword, + MARIADB_USER: dbUser, + MARIADB_PASSWORD: dbUserPassword, + MARIADB_DATABASE: defaultDatabase + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/mariadb`, + ulimits: {} + }; + } else if (type === 'mongodb') { + return { + privatePort: 27017, + environmentVariables: { + MONGODB_ROOT_USER: rootUser, + MONGODB_ROOT_PASSWORD: rootUserPassword + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/mongodb`, + ulimits: {} + }; + } else if (type === 'postgresql') { + return { + privatePort: 5432, + environmentVariables: { + POSTGRESQL_POSTGRES_PASSWORD: rootUserPassword, + POSTGRESQL_PASSWORD: dbUserPassword, + POSTGRESQL_USERNAME: dbUser, + POSTGRESQL_DATABASE: defaultDatabase + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/postgresql`, + ulimits: {} + }; + } else if (type === 'redis') { + return { + privatePort: 6379, + environmentVariables: { + REDIS_PASSWORD: dbUserPassword, + REDIS_AOF_ENABLED: appendOnly ? 'yes' : 'no' + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/redis/data`, + ulimits: {} + }; + } else if (type === 'couchdb') { + return { + privatePort: 5984, + environmentVariables: { + COUCHDB_PASSWORD: dbUserPassword, + COUCHDB_USER: dbUser + }, + image: `${baseImage}:${version}`, + volume: `${id}-${type}-data:/bitnami/couchdb`, + ulimits: {} + }; + } +} + +export function getDatabaseImage(type: string): string { + const found = supportedDatabaseTypesAndVersions.find((t) => t.name === type); + if (found) { + return found.baseImage; + } + return ''; +} + +export function getDatabaseVersions(type: string): string[] { + const found = supportedDatabaseTypesAndVersions.find((t) => t.name === type); + if (found) { + return found.versions; + } + return []; +} + + +export type ComposeFile = { + version: ComposerFileVersion; + services: Record; + networks: Record; + volumes?: Record; +}; + +export type ComposeFileService = { + container_name: string; + image?: string; + networks: string[]; + environment?: Record; + volumes?: string[]; + ulimits?: unknown; + labels?: string[]; + env_file?: string[]; + extra_hosts?: string[]; + restart: ComposeFileRestartOption; + depends_on?: string[]; + command?: string; + ports?: string[]; + build?: { + context: string; + dockerfile: string; + args?: Record; + }; + deploy?: { + restart_policy?: { + condition?: string; + delay?: string; + max_attempts?: number; + window?: string; + }; + }; +}; + +export type ComposerFileVersion = + | '3.8' + | '3.7' + | '3.6' + | '3.5' + | '3.4' + | '3.3' + | '3.2' + | '3.1' + | '3.0' + | '2.4' + | '2.3' + | '2.2' + | '2.1' + | '2.0'; + +export type ComposeFileRestartOption = 'no' | 'always' | 'on-failure' | 'unless-stopped'; + +export type ComposeFileNetwork = { + external: boolean; +}; + +export type ComposeFileVolume = { + external?: boolean; + name?: string; +}; + +export async function makeLabelForStandaloneDatabase({ id, image, volume }) { + const database = await prisma.database.findFirst({ where: { id } }); + delete database.destinationDockerId; + delete database.createdAt; + delete database.updatedAt; + return [ + 'coolify.managed=true', + `coolify.version=${version}`, + `coolify.type=standalone-database`, + `coolify.configuration=${base64Encode( + JSON.stringify({ + version, + image, + volume, + ...database + }) + )}` + ]; +} +export const createDirectories = async ({ + repository, + buildId +}: { + repository: string; + buildId: string; +}): Promise<{ workdir: string; repodir: string }> => { + const repodir = `/tmp/build-sources/${repository}/`; + const workdir = `/tmp/build-sources/${repository}/${buildId}`; + + await asyncExecShell(`mkdir -p ${workdir}`); + + return { + workdir, + repodir + }; +}; + +export async function startTcpProxy( + destinationDocker: any, + id: string, + publicPort: number, + privatePort: number +): Promise<{ stdout: string; stderr: string } | Error> { + const { network, engine } = destinationDocker; + const host = getEngine(engine); + + const containerName = `haproxy-for-${publicPort}`; + const found = await checkContainer(engine, containerName, true); + const foundDependentContainer = await checkContainer(engine, id, true); + try { + if (foundDependentContainer && !found) { + const { stdout: Config } = await asyncExecShell( + `DOCKER_HOST="${host}" docker network inspect bridge --format '{{json .IPAM.Config }}'` + ); + const ip = JSON.parse(Config)[0].Gateway; + return await asyncExecShell( + `DOCKER_HOST=${host} docker run --restart always -e PORT=${publicPort} -e APP=${id} -e PRIVATE_PORT=${privatePort} --add-host 'host.docker.internal:host-gateway' --add-host 'host.docker.internal:${ip}' --network ${network} -p ${publicPort}:${publicPort} --name ${containerName} -d coollabsio/${defaultProxyImageTcp}` + ); + } + if (!foundDependentContainer && found) { + return await asyncExecShell( + `DOCKER_HOST=${host} docker stop -t 0 ${containerName} && docker rm ${containerName}` + ); + } + } catch (error) { + return error; + } +} + + +export async function stopDatabaseContainer( + database: any +): Promise { + let everStarted = false; + const { + id, + destinationDockerId, + destinationDocker: { engine } + } = database; + if (destinationDockerId) { + try { + const host = getEngine(engine); + const { stdout } = await asyncExecShell( + `DOCKER_HOST=${host} docker inspect --format '{{json .State}}' ${id}` + ); + if (stdout) { + everStarted = true; + await removeContainer({ id, engine }); + } + } catch (error) { + // + } + } + return everStarted; +} + + +export async function stopTcpHttpProxy( + id: string, + destinationDocker: any, + publicPort: number, + forceName: string = null +): Promise<{ stdout: string; stderr: string } | Error> { + const { engine } = destinationDocker; + const host = getEngine(engine); + const settings = await listSettings(); + let containerName = `${id}-${publicPort}`; + if (!settings.isTraefikUsed) { + containerName = `haproxy-for-${publicPort}`; + } + if (forceName) containerName = forceName; + const found = await checkContainer(engine, containerName); + + try { + if (found) { + return await asyncExecShell( + `DOCKER_HOST=${host} docker stop -t 0 ${containerName} && docker rm ${containerName}` + ); + } + } catch (error) { + return error; + } +} + + +export async function updatePasswordInDb(database, user, newPassword, isRoot) { + const { + id, + type, + rootUser, + rootUserPassword, + dbUser, + dbUserPassword, + defaultDatabase, + destinationDockerId, + destinationDocker: { engine } + } = database; + if (destinationDockerId) { + const host = getEngine(engine); + if (type === 'mysql') { + await asyncExecShell( + `DOCKER_HOST=${host} docker exec ${id} mysql -u ${rootUser} -p${rootUserPassword} -e \"ALTER USER '${user}'@'%' IDENTIFIED WITH caching_sha2_password BY '${newPassword}';\"` + ); + } else if (type === 'mariadb') { + await asyncExecShell( + `DOCKER_HOST=${host} docker exec ${id} mysql -u ${rootUser} -p${rootUserPassword} -e \"SET PASSWORD FOR '${user}'@'%' = PASSWORD('${newPassword}');\"` + ); + } else if (type === 'postgresql') { + if (isRoot) { + await asyncExecShell( + `DOCKER_HOST=${host} docker exec ${id} psql postgresql://postgres:${rootUserPassword}@${id}:5432/${defaultDatabase} -c "ALTER role postgres WITH PASSWORD '${newPassword}'"` + ); + } else { + await asyncExecShell( + `DOCKER_HOST=${host} docker exec ${id} psql postgresql://${dbUser}:${dbUserPassword}@${id}:5432/${defaultDatabase} -c "ALTER role ${user} WITH PASSWORD '${newPassword}'"` + ); + } + } else if (type === 'mongodb') { + await asyncExecShell( + `DOCKER_HOST=${host} docker exec ${id} mongo 'mongodb://${rootUser}:${rootUserPassword}@${id}:27017/admin?readPreference=primary&ssl=false' --eval "db.changeUserPassword('${user}','${newPassword}')"` + ); + } else if (type === 'redis') { + await asyncExecShell( + `DOCKER_HOST=${host} docker exec ${id} redis-cli -u redis://${dbUserPassword}@${id}:6379 --raw CONFIG SET requirepass ${newPassword}` + ); + } + } +} + +export async function getFreePort() { + const { default: getPort, portNumbers } = await import('get-port'); + const data = await prisma.setting.findFirst(); + const { minPort, maxPort } = data; + + const dbUsed = await ( + await prisma.database.findMany({ + where: { publicPort: { not: null } }, + select: { publicPort: true } + }) + ).map((a) => a.publicPort); + const wpFtpUsed = await ( + await prisma.wordpress.findMany({ + where: { ftpPublicPort: { not: null } }, + select: { ftpPublicPort: true } + }) + ).map((a) => a.ftpPublicPort); + const wpUsed = await ( + await prisma.wordpress.findMany({ + where: { mysqlPublicPort: { not: null } }, + select: { mysqlPublicPort: true } + }) + ).map((a) => a.mysqlPublicPort); + const minioUsed = await ( + await prisma.minio.findMany({ + where: { publicPort: { not: null } }, + select: { publicPort: true } + }) + ).map((a) => a.publicPort); + const usedPorts = [...dbUsed, ...wpFtpUsed, ...wpUsed, ...minioUsed]; + return await getPort({ port: portNumbers(minPort, maxPort), exclude: usedPorts }); +} + + +export async function startTraefikTCPProxy( + destinationDocker: any, + id: string, + publicPort: number, + privatePort: number, + type?: string +): Promise<{ stdout: string; stderr: string } | Error> { + const { network, engine } = destinationDocker; + const host = getEngine(engine); + const containerName = `${id}-${publicPort}`; + const found = await checkContainer(engine, containerName, true); + let dependentId = id; + if (type === 'wordpressftp') dependentId = `${id}-ftp`; + const foundDependentContainer = await checkContainer(engine, dependentId, true); + try { + if (foundDependentContainer && !found) { + const { stdout: Config } = await asyncExecShell( + `DOCKER_HOST="${host}" docker network inspect bridge --format '{{json .IPAM.Config }}'` + ); + const ip = JSON.parse(Config)[0].Gateway; + const tcpProxy = { + version: '3.5', + services: { + [`${id}-${publicPort}`]: { + container_name: containerName, + image: 'traefik:v2.6', + command: [ + `--entrypoints.tcp.address=:${publicPort}`, + `--entryPoints.tcp.forwardedHeaders.insecure=true`, + `--providers.http.endpoint=${otherTraefikEndpoint}?id=${id}&privatePort=${privatePort}&publicPort=${publicPort}&type=tcp&address=${dependentId}`, + '--providers.http.pollTimeout=2s', + '--log.level=error' + ], + ports: [`${publicPort}:${publicPort}`], + extra_hosts: ['host.docker.internal:host-gateway', `host.docker.internal:${ip}`], + volumes: ['/var/run/docker.sock:/var/run/docker.sock'], + networks: ['coolify-infra', network] + } + }, + networks: { + [network]: { + external: false, + name: network + }, + 'coolify-infra': { + external: false, + name: 'coolify-infra' + } + } + }; + await fs.writeFile(`/tmp/docker-compose-${id}.yaml`, yaml.dump(tcpProxy)); + await asyncExecShell( + `DOCKER_HOST=${host} docker compose -f /tmp/docker-compose-${id}.yaml up -d` + ); + await fs.rm(`/tmp/docker-compose-${id}.yaml`); + } + if (!foundDependentContainer && found) { + return await asyncExecShell( + `DOCKER_HOST=${host} docker stop -t 0 ${containerName} && docker rm ${containerName}` + ); + } + } catch (error) { + console.log(error); + return error; + } +} + +export async function getServiceFromDB({ id, teamId }: { id: string; teamId: string }): Promise { + const settings = await prisma.setting.findFirst(); + const body = await prisma.service.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include + }); + let { type } = body + type = fixType(type) + + if (body?.serviceSecret.length > 0) { + body.serviceSecret = body.serviceSecret.map((s) => { + s.value = decrypt(s.value); + return s; + }); + } + body[type] = { ...body[type], ...getUpdateableFields(type, body[type]) } + return { ...body, settings }; +} + +export function getServiceImage(type: string): string { + const found = supportedServiceTypesAndVersions.find((t) => t.name === type); + if (found) { + return found.baseImage; + } + return ''; +} + +export function getServiceImages(type: string): string[] { + const found = supportedServiceTypesAndVersions.find((t) => t.name === type); + if (found) { + return found.images; + } + return []; +} + +export async function configureServiceType({ + id, + type +}: { + id: string; + type: string; +}): Promise { + if (type === 'plausibleanalytics') { + const password = encrypt(generatePassword()); + const postgresqlUser = cuid(); + const postgresqlPassword = encrypt(generatePassword()); + const postgresqlDatabase = 'plausibleanalytics'; + const secretKeyBase = encrypt(generatePassword(64)); + + await prisma.service.update({ + where: { id }, + data: { + type, + plausibleAnalytics: { + create: { + postgresqlDatabase, + postgresqlUser, + postgresqlPassword, + password, + secretKeyBase + } + } + } + }); + } else if (type === 'nocodb') { + await prisma.service.update({ + where: { id }, + data: { type } + }); + } else if (type === 'minio') { + const rootUser = cuid(); + const rootUserPassword = encrypt(generatePassword()); + await prisma.service.update({ + where: { id }, + data: { type, minio: { create: { rootUser, rootUserPassword } } } + }); + } else if (type === 'vscodeserver') { + const password = encrypt(generatePassword()); + await prisma.service.update({ + where: { id }, + data: { type, vscodeserver: { create: { password } } } + }); + } else if (type === 'wordpress') { + const mysqlUser = cuid(); + const mysqlPassword = encrypt(generatePassword()); + const mysqlRootUser = cuid(); + const mysqlRootUserPassword = encrypt(generatePassword()); + await prisma.service.update({ + where: { id }, + data: { + type, + wordpress: { create: { mysqlPassword, mysqlRootUserPassword, mysqlRootUser, mysqlUser } } + } + }); + } else if (type === 'vaultwarden') { + await prisma.service.update({ + where: { id }, + data: { + type + } + }); + } else if (type === 'languagetool') { + await prisma.service.update({ + where: { id }, + data: { + type + } + }); + } else if (type === 'n8n') { + await prisma.service.update({ + where: { id }, + data: { + type + } + }); + } else if (type === 'uptimekuma') { + await prisma.service.update({ + where: { id }, + data: { + type + } + }); + } else if (type === 'ghost') { + const defaultEmail = `${cuid()}@example.com`; + const defaultPassword = encrypt(generatePassword()); + const mariadbUser = cuid(); + const mariadbPassword = encrypt(generatePassword()); + const mariadbRootUser = cuid(); + const mariadbRootUserPassword = encrypt(generatePassword()); + + await prisma.service.update({ + where: { id }, + data: { + type, + ghost: { + create: { + defaultEmail, + defaultPassword, + mariadbUser, + mariadbPassword, + mariadbRootUser, + mariadbRootUserPassword + } + } + } + }); + } else if (type === 'meilisearch') { + const masterKey = encrypt(generatePassword(32)); + await prisma.service.update({ + where: { id }, + data: { + type, + meiliSearch: { create: { masterKey } } + } + }); + } else if (type === 'umami') { + const umamiAdminPassword = encrypt(generatePassword()); + const postgresqlUser = cuid(); + const postgresqlPassword = encrypt(generatePassword()); + const postgresqlDatabase = 'umami'; + const hashSalt = encrypt(generatePassword(64)); + await prisma.service.update({ + where: { id }, + data: { + type, + umami: { + create: { + umamiAdminPassword, + postgresqlDatabase, + postgresqlPassword, + postgresqlUser, + hashSalt + } + } + } + }); + } else if (type === 'hasura') { + const postgresqlUser = cuid(); + const postgresqlPassword = encrypt(generatePassword()); + const postgresqlDatabase = 'hasura'; + const graphQLAdminPassword = encrypt(generatePassword()); + await prisma.service.update({ + where: { id }, + data: { + type, + hasura: { + create: { + postgresqlDatabase, + postgresqlPassword, + postgresqlUser, + graphQLAdminPassword + } + } + } + }); + } else if (type === 'fider') { + const postgresqlUser = cuid(); + const postgresqlPassword = encrypt(generatePassword()); + const postgresqlDatabase = 'fider'; + const jwtSecret = encrypt(generatePassword(64, true)); + await prisma.service.update({ + where: { id }, + data: { + type, + fider: { + create: { + postgresqlDatabase, + postgresqlPassword, + postgresqlUser, + jwtSecret + } + } + } + }); + } +} + +export async function removeService({ id }: { id: string }): Promise { + await prisma.servicePersistentStorage.deleteMany({ where: { serviceId: id } }); + await prisma.meiliSearch.deleteMany({ where: { serviceId: id } }); + await prisma.fider.deleteMany({ where: { serviceId: id } }); + await prisma.ghost.deleteMany({ where: { serviceId: id } }); + await prisma.umami.deleteMany({ where: { serviceId: id } }); + await prisma.hasura.deleteMany({ where: { serviceId: id } }); + await prisma.plausibleAnalytics.deleteMany({ where: { serviceId: id } }); + await prisma.minio.deleteMany({ where: { serviceId: id } }); + await prisma.vscodeserver.deleteMany({ where: { serviceId: id } }); + await prisma.wordpress.deleteMany({ where: { serviceId: id } }); + await prisma.serviceSecret.deleteMany({ where: { serviceId: id } }); + + await prisma.service.delete({ where: { id } }); +} + +export function saveUpdateableFields(type: string, data: any) { + let update = {}; + if (type && serviceFields[type]) { + serviceFields[type].map((k) => { + let temp = data[k.name] + if (temp) { + if (k.isEncrypted) { + temp = encrypt(temp) + } + if (k.isLowerCase) { + temp = temp.toLowerCase() + } + if (k.isNumber) { + temp = Number(temp) + } + if (k.isBoolean) { + temp = Boolean(temp) + } + } + update[k.name] = temp + }); + } + return update +} + +export function getUpdateableFields(type: string, data: any) { + let update = {}; + if (type && serviceFields[type]) { + serviceFields[type].map((k) => { + let temp = data[k.name] + if (temp) { + if (k.isEncrypted) { + temp = decrypt(temp) + } + update[k.name] = temp + } + update[k.name] = temp + }); + } + return update +} + +export function fixType(type) { + // Hack to fix the type case sensitivity... + if (type === 'plausibleanalytics') type = 'plausibleAnalytics'; + if (type === 'meilisearch') type = 'meiliSearch'; + return type +} + +export const getServiceMainPort = (service: string) => { + const serviceType = supportedServiceTypesAndVersions.find((s) => s.name === service); + if (serviceType) { + return serviceType.ports.main; + } + return null; +}; + + +export function makeLabelForServices(type) { + return [ + 'coolify.managed=true', + `coolify.version=${version}`, + `coolify.type=service`, + `coolify.service.type=${type}` + ]; +} +export function errorHandler({ status = 500, message = 'Unknown error.' }: { status: number, message: string | any }) { + if (message.message) message = message.message + throw { status, message }; +} +export async function generateSshKeyPair(): Promise<{ publicKey: string; privateKey: string }> { + return await new Promise((resolve, reject) => { + forge.pki.rsa.generateKeyPair({ bits: 4096, workers: -1 }, function (err, keys) { + if (keys) { + resolve({ + publicKey: forge.ssh.publicKeyToOpenSSH(keys.publicKey), + privateKey: forge.ssh.privateKeyToOpenSSH(keys.privateKey) + }); + } else { + reject(keys); + } + }); + }); +} + +export async function stopBuild(buildId, applicationId) { + let count = 0; + await new Promise(async (resolve, reject) => { + const { destinationDockerId, status } = await prisma.build.findFirst({ where: { id: buildId } }); + const { engine } = await prisma.destinationDocker.findFirst({ where: { id: destinationDockerId } }); + const host = getEngine(engine); + let interval = setInterval(async () => { + try { + if (status === 'failed') { + clearInterval(interval); + return resolve(); + } + if (count > 100) { + clearInterval(interval); + return reject(new Error('Build canceled')); + } + + const { stdout: buildContainers } = await asyncExecShell( + `DOCKER_HOST=${host} docker container ls --filter "label=coolify.buildId=${buildId}" --format '{{json .}}'` + ); + if (buildContainers) { + const containersArray = buildContainers.trim().split('\n'); + for (const container of containersArray) { + const containerObj = JSON.parse(container); + const id = containerObj.ID; + if (!containerObj.Names.startsWith(`${applicationId}`)) { + await removeContainer({ id, engine }); + await cleanupDB(buildId); + clearInterval(interval); + return resolve(); + } + } + } + count++; + } catch (error) { } + }, 100); + }); +} + +async function cleanupDB(buildId: string) { + const data = await prisma.build.findUnique({ where: { id: buildId } }); + if (data?.status === 'queued' || data?.status === 'running') { + await prisma.build.update({ where: { id: buildId }, data: { status: 'failed' } }); + } +} \ No newline at end of file diff --git a/src/lib/dayjs.ts b/apps/api/src/lib/dayjs.ts similarity index 87% rename from src/lib/dayjs.ts rename to apps/api/src/lib/dayjs.ts index 49859a8c6..9ff5b0a1a 100644 --- a/src/lib/dayjs.ts +++ b/apps/api/src/lib/dayjs.ts @@ -4,4 +4,4 @@ import relativeTime from 'dayjs/plugin/relativeTime.js'; dayjs.extend(utc); dayjs.extend(relativeTime); -export { dayjs }; +export { dayjs as day }; diff --git a/apps/api/src/lib/docker.ts b/apps/api/src/lib/docker.ts new file mode 100644 index 000000000..730666590 --- /dev/null +++ b/apps/api/src/lib/docker.ts @@ -0,0 +1,78 @@ +import { asyncExecShell } from './common'; +import Dockerode from 'dockerode'; +export function getEngine(engine: string): string { + return engine === '/var/run/docker.sock' ? 'unix:///var/run/docker.sock' : engine; +} +export function dockerInstance({ destinationDocker }): { engine: Dockerode; network: string } { + return { + engine: new Dockerode({ + socketPath: destinationDocker.engine + }), + network: destinationDocker.network + }; +} + +export async function checkContainer(engine: string, container: string, remove = false): Promise { + const host = getEngine(engine); + let containerFound = false; + + try { + const { stdout } = await asyncExecShell( + `DOCKER_HOST="${host}" docker inspect --format '{{json .State}}' ${container}` + ); + const parsedStdout = JSON.parse(stdout); + const status = parsedStdout.Status; + const isRunning = status === 'running'; + if (status === 'created') { + await asyncExecShell(`DOCKER_HOST="${host}" docker rm ${container}`); + } + if (remove && status === 'exited') { + await asyncExecShell(`DOCKER_HOST="${host}" docker rm ${container}`); + } + if (isRunning) { + containerFound = true; + } + } catch (err) { + // Container not found + } + return containerFound; +} + +export async function isContainerExited(engine: string, containerName: string): Promise { + let isExited = false; + const host = getEngine(engine); + try { + const { stdout } = await asyncExecShell( + `DOCKER_HOST="${host}" docker inspect -f '{{.State.Status}}' ${containerName}` + ); + if (stdout.trim() === 'exited') { + isExited = true; + } + } catch (error) { + // + } + + return isExited; +} + +export async function removeContainer({ + id, + engine +}: { + id: string; + engine: string; +}): Promise { + const host = getEngine(engine); + try { + const { stdout } = await asyncExecShell( + `DOCKER_HOST=${host} docker inspect --format '{{json .State}}' ${id}` + ); + if (JSON.parse(stdout).Running) { + await asyncExecShell(`DOCKER_HOST=${host} docker stop -t 0 ${id}`); + await asyncExecShell(`DOCKER_HOST=${host} docker rm ${id}`); + } + } catch (error) { + console.log(error); + throw error; + } +} diff --git a/src/lib/importers/github.ts b/apps/api/src/lib/importers/github.ts similarity index 80% rename from src/lib/importers/github.ts rename to apps/api/src/lib/importers/github.ts index a08fb87b4..76aee1398 100644 --- a/src/lib/importers/github.ts +++ b/apps/api/src/lib/importers/github.ts @@ -1,7 +1,7 @@ -import { asyncExecShell, saveBuildLog } from '$lib/common'; -import got from 'got'; + import jsonwebtoken from 'jsonwebtoken'; -import * as db from '$lib/database'; +import { saveBuildLog } from '../buildPacks/common'; +import { asyncExecShell, decrypt, prisma } from '../common'; export default async function ({ applicationId, @@ -22,9 +22,14 @@ export default async function ({ branch: string; buildId: string; }): Promise { + const { default: got } = await import('got') const url = htmlUrl.replace('https://', '').replace('http://', ''); await saveBuildLog({ line: 'GitHub importer started.', buildId, applicationId }); - const { privateKey, appId, installationId } = await db.getUniqueGithubApp({ githubAppId }); + + const body = await prisma.githubApp.findUnique({ where: { id: githubAppId } }); + if (body.privateKey) body.privateKey = decrypt(body.privateKey); + const { privateKey, appId, installationId } = body + const githubPrivateKey = privateKey.replace(/\\n/g, '\n').replace(/"/g, ''); const payload = { diff --git a/src/lib/importers/gitlab.ts b/apps/api/src/lib/importers/gitlab.ts similarity index 91% rename from src/lib/importers/gitlab.ts rename to apps/api/src/lib/importers/gitlab.ts index 966b22c96..d5366303e 100644 --- a/src/lib/importers/gitlab.ts +++ b/apps/api/src/lib/importers/gitlab.ts @@ -1,4 +1,5 @@ -import { asyncExecShell, saveBuildLog } from '$lib/common'; +import { saveBuildLog } from "../buildPacks/common"; +import { asyncExecShell } from "../common"; export default async function ({ applicationId, diff --git a/src/lib/importers/index.ts b/apps/api/src/lib/importers/index.ts similarity index 100% rename from src/lib/importers/index.ts rename to apps/api/src/lib/importers/index.ts diff --git a/apps/api/src/lib/scheduler.ts b/apps/api/src/lib/scheduler.ts new file mode 100644 index 000000000..929016570 --- /dev/null +++ b/apps/api/src/lib/scheduler.ts @@ -0,0 +1,44 @@ +import Bree from 'bree'; +import path from 'path'; +import Cabin from 'cabin'; +import TSBree from '@breejs/ts-worker'; +import { isDev } from './common'; + +Bree.extend(TSBree); + +const options: any = { + defaultExtension: 'js', + logger: false, + workerMessageHandler: async ({ name, message }) => { + if (name === 'deployApplication') { + if (message.pending === 0) { + if (!scheduler.workers.has('autoUpdater')) { + await scheduler.stop('deployApplication'); + await scheduler.run('autoUpdater') + } + } + } + }, + jobs: [ + { + name: 'deployApplication' + }, + { + name: 'cleanupStorage', + interval: '10m' + }, + { + name: 'checkProxies', + interval: '10s' + }, + { + name: 'autoUpdater', + } + ], +}; +if (isDev) options.root = path.join(__dirname, '../jobs'); + + +export const scheduler = new Bree(options); + + diff --git a/apps/api/src/lib/serviceFields.ts b/apps/api/src/lib/serviceFields.ts new file mode 100644 index 000000000..01f628be2 --- /dev/null +++ b/apps/api/src/lib/serviceFields.ts @@ -0,0 +1,407 @@ +// Example: +// export const nocodb = [{ +// name: 'postgreslUser', +// isEditable: false, +// isLowerCase: false, +// isNumber: false, +// isBoolean: false, +// isEncrypted: false +// }] + +export const plausibleAnalytics = [{ + name: 'email', + isEditable: true, + isLowerCase: true, + isNumber: false, + isBoolean: false, + isEncrypted: false +},{ + name: 'username', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'password', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'postgresqlUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'postgresqlDatabase', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlPublicPort', + isEditable: false, + isLowerCase: false, + isNumber: true, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'secretKeyBase', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'scriptName', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}] +export const minio = [{ + name: 'apiFqdn', + isEditable: true, + isLowerCase: true, + isNumber: false, + isBoolean: false, + isEncrypted: false +},{ + name: 'rootUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'rootUserPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}] +export const vscodeserver = [{ + name: 'password', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}] +export const wordpress = [{ + name: 'extraConfig', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'mysqlHost', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'mysqlPort', + isEditable: true, + isLowerCase: false, + isNumber: true, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'mysqlUser', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'mysqlPassword', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'mysqlRootUser', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'mysqlRootUserPassword', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'mysqlDatabase', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}] +export const ghost = [{ + name: 'defaultEmail', + isEditable: false, + isLowerCase: true, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'defaultPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'mariadbUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'mariadbPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'mariadbRootUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'mariadbRootUserPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'mariadbDatabase', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}] +export const meiliSearch = [{ + name: 'masterKey', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}] +export const umami = [{ + name: 'postgresqlUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'postgresqlDatabase', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'umamiAdminPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'hashSalt', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}] +export const hasura = [{ + name: 'postgresqlUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'postgresqlDatabase', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'graphQLAdminPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}] +export const fider = [{ + name: 'jwtSecret', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +},{ + name: 'postgreslUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'emailNoreply', + isEditable: true, + isLowerCase: true, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'emailSmtpHost', + isEditable: true, + isLowerCase: true, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'emailSmtpPassword', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'emailSmtpPort', + isEditable: true, + isLowerCase: false, + isNumber: true, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'emailSmtpUser', + isEditable: true, + isLowerCase: true, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'emailSmtpEnableStartTls', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: true, + isEncrypted: false +}, +{ + name: 'emailMailgunApiKey', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'emailMailgunDomain', + isEditable: true, + isLowerCase: true, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'emailMailgunRegion', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}] \ No newline at end of file diff --git a/apps/api/src/plugins/jwt.ts b/apps/api/src/plugins/jwt.ts new file mode 100644 index 000000000..54dd1b72d --- /dev/null +++ b/apps/api/src/plugins/jwt.ts @@ -0,0 +1,34 @@ +import fp from 'fastify-plugin' +import fastifyJwt, { FastifyJWTOptions } from '@fastify/jwt' + +declare module "@fastify/jwt" { + interface FastifyJWT { + user: { + userId: string, + teamId: string, + permission: string, + isAdmin: boolean + } + } +} + +export default fp(async (fastify, opts) => { + fastify.register(fastifyJwt, { + secret: fastify.config.COOLIFY_SECRET_KEY + }) + + fastify.decorate("authenticate", async function (request, reply) { + try { + await request.jwtVerify() + } catch (err) { + console.log(err) + reply.send(err) + } + }) +}) + +declare module 'fastify' { + export interface FastifyInstance { + authenticate(): Promise + } +} diff --git a/apps/api/src/routes/api/v1/applications/handlers.ts b/apps/api/src/routes/api/v1/applications/handlers.ts new file mode 100644 index 000000000..3b7ec835a --- /dev/null +++ b/apps/api/src/routes/api/v1/applications/handlers.ts @@ -0,0 +1,871 @@ +import cuid from 'cuid'; +import crypto from 'node:crypto' +import jsonwebtoken from 'jsonwebtoken'; +import axios from 'axios'; +import { day } from '../../../../lib/dayjs'; + + +import type { FastifyRequest } from 'fastify'; +import { FastifyReply } from 'fastify'; + +import { CheckDNS, DeleteApplication, DeployApplication, GetApplication, SaveApplication, SaveApplicationSettings } from '.'; +import { setDefaultBaseImage, setDefaultConfiguration } from '../../../../lib/buildPacks/common'; +import { asyncExecShell, checkDomainsIsValidInDNS, checkDoubleBranch, decrypt, encrypt, errorHandler, generateSshKeyPair, getContainerUsage, getDomain, isDev, isDomainConfigured, prisma, stopBuild, uniqueName } from '../../../../lib/common'; +import { checkContainer, dockerInstance, getEngine, isContainerExited, removeContainer } from '../../../../lib/docker'; +import { scheduler } from '../../../../lib/scheduler'; + + +export async function listApplications(request: FastifyRequest) { + try { + const { teamId } = request.user + const applications = await prisma.application.findMany({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { teams: true } + }); + const settings = await prisma.setting.findFirst() + return { + applications, + settings + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getApplication(request: FastifyRequest) { + try { + const { id } = request.params + const { teamId } = request.user + const appId = process.env['COOLIFY_APP_ID']; + let isRunning = false; + let isExited = false; + const application = await getApplicationFromDB(id, teamId); + if (application?.destinationDockerId && application.destinationDocker?.engine) { + isRunning = await checkContainer(application.destinationDocker.engine, id); + isExited = await isContainerExited(application.destinationDocker.engine, id); + } + return { + isQueueActive: scheduler.workers.has('deployApplication'), + isRunning, + isExited, + application, + appId + }; + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function newApplication(request: FastifyRequest, reply: FastifyReply) { + try { + const name = uniqueName(); + const { teamId } = request.user + const { id } = await prisma.application.create({ + data: { + name, + teams: { connect: { id: teamId } }, + settings: { create: { debug: false, previews: false } } + } + }); + return reply.code(201).send({ id }); + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +function decryptApplication(application: any) { + if (application) { + if (application?.gitSource?.githubApp?.clientSecret) { + application.gitSource.githubApp.clientSecret = decrypt(application.gitSource.githubApp.clientSecret) || null; + } + if (application?.gitSource?.githubApp?.webhookSecret) { + application.gitSource.githubApp.webhookSecret = decrypt(application.gitSource.githubApp.webhookSecret) || null; + } + if (application?.gitSource?.githubApp?.privateKey) { + application.gitSource.githubApp.privateKey = decrypt(application.gitSource.githubApp.privateKey) || null; + } + if (application?.gitSource?.gitlabApp?.appSecret) { + application.gitSource.gitlabApp.appSecret = decrypt(application.gitSource.gitlabApp.appSecret) || null; + } + if (application?.secrets.length > 0) { + application.secrets = application.secrets.map((s: any) => { + s.value = decrypt(s.value) || null + return s; + }); + } + + return application; + } +} +export async function getApplicationFromDB(id: string, teamId: string) { + try { + let application = await prisma.application.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { + destinationDocker: true, + settings: true, + gitSource: { include: { githubApp: true, gitlabApp: true } }, + secrets: true, + persistentStorage: true + } + }); + if (!application) { + throw { status: 404, message: 'Application not found.' }; + } + application = decryptApplication(application); + const buildPack = application?.buildPack || null; + const { baseImage, baseBuildImage, baseBuildImages, baseImages } = setDefaultBaseImage( + buildPack + ); + + // Set default build images + if (!application.baseImage) { + application.baseImage = baseImage; + } + if (!application.baseBuildImage) { + application.baseBuildImage = baseBuildImage; + } + return { ...application, baseBuildImages, baseImages }; + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getApplicationFromDBWebhook(projectId: number, branch: string) { + try { + let application = await prisma.application.findFirst({ + where: { projectId, branch, settings: { autodeploy: true } }, + include: { + destinationDocker: true, + settings: true, + gitSource: { include: { githubApp: true, gitlabApp: true } }, + secrets: true, + persistentStorage: true + } + }); + if (!application) { + throw { status: 500, message: 'Application not configured.' } + } + application = decryptApplication(application); + const { baseImage, baseBuildImage, baseBuildImages, baseImages } = setDefaultBaseImage( + application.buildPack + ); + + // Set default build images + if (!application.baseImage) { + application.baseImage = baseImage; + } + if (!application.baseBuildImage) { + application.baseBuildImage = baseBuildImage; + } + return { ...application, baseBuildImages, baseImages }; + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveApplication(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + let { + name, + buildPack, + fqdn, + port, + exposePort, + installCommand, + buildCommand, + startCommand, + baseDirectory, + publishDirectory, + pythonWSGI, + pythonModule, + pythonVariable, + dockerFileLocation, + denoMainFile, + denoOptions, + baseImage, + baseBuildImage + } = request.body + + if (port) port = Number(port); + if (exposePort) { + exposePort = Number(exposePort); + } + if (denoOptions) denoOptions = denoOptions.trim(); + const defaultConfiguration = await setDefaultConfiguration({ + buildPack, + port, + installCommand, + startCommand, + buildCommand, + publishDirectory, + baseDirectory, + dockerFileLocation, + denoMainFile + }); + await prisma.application.update({ + where: { id }, + data: { + name, + fqdn, + exposePort, + pythonWSGI, + pythonModule, + pythonVariable, + denoOptions, + baseImage, + baseBuildImage, + ...defaultConfiguration + } + }); + return reply.code(201).send(); + } catch ({ status, message }) { + return errorHandler({ status, message }) + } + +} + +export async function saveApplicationSettings(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { debug, previews, dualCerts, autodeploy, branch, projectId } = request.body + const isDouble = await checkDoubleBranch(branch, projectId); + if (isDouble && autodeploy) { + throw { status: 500, message: 'Application not configured.' } + } + await prisma.application.update({ + where: { id }, + data: { settings: { update: { debug, previews, dualCerts, autodeploy } } }, + include: { destinationDocker: true } + }); + return reply.code(201).send(); + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function stopApplication(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { teamId } = request.user + const application = await getApplicationFromDB(id, teamId); + if (application?.destinationDockerId && application.destinationDocker?.engine) { + const { engine } = application.destinationDocker; + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } + return reply.code(201).send(); + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteApplication(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { teamId } = request.user + const application = await prisma.application.findUnique({ + where: { id }, + include: { destinationDocker: true } + }); + if (application?.destinationDockerId && application.destinationDocker?.engine && application.destinationDocker?.network) { + const host = getEngine(application.destinationDocker.engine); + const { stdout: containers } = await asyncExecShell( + `DOCKER_HOST=${host} docker ps -a --filter network=${application.destinationDocker.network} --filter name=${id} --format '{{json .}}'` + ); + if (containers) { + const containersArray = containers.trim().split('\n'); + for (const container of containersArray) { + const containerObj = JSON.parse(container); + const id = containerObj.ID; + await removeContainer({ id, engine: application.destinationDocker.engine }); + } + } + } + await prisma.applicationSettings.deleteMany({ where: { application: { id } } }); + await prisma.buildLog.deleteMany({ where: { applicationId: id } }); + await prisma.build.deleteMany({ where: { applicationId: id } }); + await prisma.secret.deleteMany({ where: { applicationId: id } }); + await prisma.applicationPersistentStorage.deleteMany({ where: { applicationId: id } }); + if (teamId === '0') { + await prisma.application.deleteMany({ where: { id } }); + } else { + await prisma.application.deleteMany({ where: { id, teams: { some: { id: teamId } } } }); + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function checkDNS(request: FastifyRequest) { + try { + const { id } = request.params + + let { exposePort, fqdn, forceSave, dualCerts } = request.body + fqdn = fqdn.toLowerCase(); + + const { isDNSCheckEnabled } = await prisma.setting.findFirst({}); + const found = await isDomainConfigured({ id, fqdn }); + if (found) { + throw { status: 500, message: `Domain ${getDomain(fqdn).replace('www.', '')} is already in use!` } + } + if (exposePort) { + exposePort = Number(exposePort); + + if (exposePort < 1024 || exposePort > 65535) { + throw { status: 500, message: `Exposed Port needs to be between 1024 and 65535.` } + } + const { default: getPort } = await import('get-port'); + const publicPort = await getPort({ port: exposePort }); + if (publicPort !== exposePort) { + throw { status: 500, message: `Port ${exposePort} is already in use.` } + } + } + if (isDNSCheckEnabled && !isDev && !forceSave) { + return await checkDomainsIsValidInDNS({ hostname: request.hostname, fqdn, dualCerts }); + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getUsage(request) { + try { + const { id } = request.params + const teamId = request.user?.teamId; + const application = await getApplicationFromDB(id, teamId); + let usage = {}; + if (application.destinationDockerId) { + [usage] = await Promise.all([getContainerUsage(application.destinationDocker.engine, id)]); + } + return { + usage + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deployApplication(request: FastifyRequest) { + try { + const { id } = request.params + const teamId = request.user?.teamId; + + const { pullmergeRequestId = null, branch } = request.body + const buildId = cuid(); + const application = await getApplicationFromDB(id, teamId); + if (application) { + if (!application?.configHash) { + const configHash = crypto.createHash('sha256') + .update( + JSON.stringify({ + buildPack: application.buildPack, + port: application.port, + exposePort: application.exposePort, + installCommand: application.installCommand, + buildCommand: application.buildCommand, + startCommand: application.startCommand + }) + ) + .digest('hex'); + await prisma.application.update({ where: { id }, data: { configHash } }); + } + await prisma.application.update({ where: { id }, data: { updatedAt: new Date() } }); + await prisma.build.create({ + data: { + id: buildId, + applicationId: id, + branch: application.branch, + destinationDockerId: application.destinationDocker?.id, + gitSourceId: application.gitSource?.id, + githubAppId: application.gitSource?.githubApp?.id, + gitlabAppId: application.gitSource?.gitlabApp?.id, + status: 'queued', + type: 'manual' + } + }); + if (pullmergeRequestId) { + scheduler.workers.get('deployApplication').postMessage({ + build_id: buildId, + type: 'manual', + ...application, + sourceBranch: branch, + pullmergeRequestId + }); + } else { + scheduler.workers.get('deployApplication').postMessage({ + build_id: buildId, + type: 'manual', + ...application + }); + + } + return { + buildId + }; + } + throw { status: 500, message: 'Application not found!' } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + + +export async function saveApplicationSource(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { gitSourceId } = request.body + await prisma.application.update({ + where: { id }, + data: { gitSource: { connect: { id: gitSourceId } } } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getGitHubToken(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { teamId } = request.user + const application = await getApplicationFromDB(id, teamId); + const payload = { + iat: Math.round(new Date().getTime() / 1000), + exp: Math.round(new Date().getTime() / 1000 + 60), + iss: application.gitSource.githubApp.appId + }; + const githubToken = jsonwebtoken.sign(payload, application.gitSource.githubApp.privateKey, { + algorithm: 'RS256' + }); + const { data } = await axios.post(`${application.gitSource.apiUrl}/app/installations/${application.gitSource.githubApp.installationId}/access_tokens`, {}, { + headers: { + Authorization: `Bearer ${githubToken}` + } + }) + return reply.code(201).send({ + token: data.token + }) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function checkRepository(request: FastifyRequest) { + try { + const { id } = request.params + const { repository, branch } = request.query + const application = await prisma.application.findUnique({ + where: { id }, + include: { gitSource: true } + }); + const found = await prisma.application.findFirst({ + where: { branch, repository, gitSource: { type: application.gitSource.type }, id: { not: id } } + }); + return { + used: found ? true : false + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveRepository(request, reply) { + try { + const { id } = request.params + let { repository, branch, projectId, autodeploy, webhookToken } = request.body + + repository = repository.toLowerCase(); + branch = branch.toLowerCase(); + projectId = Number(projectId); + if (webhookToken) { + await prisma.application.update({ + where: { id }, + data: { repository, branch, projectId, gitSource: { update: { gitlabApp: { update: { webhookToken: webhookToken ? webhookToken : undefined } } } }, settings: { update: { autodeploy } } } + }); + } else { + await prisma.application.update({ + where: { id }, + data: { repository, branch, projectId, settings: { update: { autodeploy } } } + }); + } + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveDestination(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { destinationId } = request.body + await prisma.application.update({ + where: { id }, + data: { destinationDocker: { connect: { id: destinationId } } } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getBuildPack(request) { + try { + const { id } = request.params + const teamId = request.user?.teamId; + const application = await getApplicationFromDB(id, teamId); + return { + type: application.gitSource.type, + projectId: application.projectId, + repository: application.repository, + branch: application.branch, + apiUrl: application.gitSource.apiUrl + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveBuildPack(request, reply) { + try { + const { id } = request.params + const { buildPack } = request.body + await prisma.application.update({ where: { id }, data: { buildPack } }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getSecrets(request: FastifyRequest) { + try { + const { id } = request.params + let secrets = await prisma.secret.findMany({ + where: { applicationId: id }, + orderBy: { createdAt: 'desc' } + }); + secrets = secrets.map((secret) => { + secret.value = decrypt(secret.value); + return secret; + }); + secrets = secrets.filter((secret) => !secret.isPRMRSecret).sort((a, b) => { + return ('' + a.name).localeCompare(b.name); + }) + return { + secrets + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveSecret(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + let { name, value, isBuildSecret, isPRMRSecret, isNew } = request.body + + if (isNew) { + const found = await prisma.secret.findFirst({ where: { name, applicationId: id, isPRMRSecret } }); + if (found) { + throw { status: 500, message: `Secret ${name} already exists.` } + } else { + value = encrypt(value); + await prisma.secret.create({ + data: { name, value, isBuildSecret, isPRMRSecret, application: { connect: { id } } } + }); + } + } else { + value = encrypt(value); + const found = await prisma.secret.findFirst({ where: { applicationId: id, name, isPRMRSecret } }); + + if (found) { + await prisma.secret.updateMany({ + where: { applicationId: id, name, isPRMRSecret }, + data: { value, isBuildSecret, isPRMRSecret } + }); + } else { + await prisma.secret.create({ + data: { name, value, isBuildSecret, isPRMRSecret, application: { connect: { id } } } + }); + } + } + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteSecret(request: FastifyRequest) { + try { + const { id } = request.params + const { name } = request.body + await prisma.secret.deleteMany({ where: { applicationId: id, name } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getStorages(request: FastifyRequest) { + try { + const { id } = request.params + const persistentStorages = await prisma.applicationPersistentStorage.findMany({ where: { applicationId: id } }); + return { + persistentStorages + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveStorage(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { path, newStorage, storageId } = request.body + + if (newStorage) { + await prisma.applicationPersistentStorage.create({ + data: { path, application: { connect: { id } } } + }); + } else { + await prisma.applicationPersistentStorage.update({ + where: { id: storageId }, + data: { path } + }); + } + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function deleteStorage(request: FastifyRequest) { + try { + const { id } = request.params + const { path } = request.body + await prisma.applicationPersistentStorage.deleteMany({ where: { applicationId: id, path } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getPreviews(request: FastifyRequest) { + try { + const { id } = request.params + const { teamId } = request.user + let secrets = await prisma.secret.findMany({ + where: { applicationId: id }, + orderBy: { createdAt: 'desc' } + }); + secrets = secrets.map((secret) => { + secret.value = decrypt(secret.value); + return secret; + }); + const applicationSecrets = secrets.filter((secret) => !secret.isPRMRSecret); + const PRMRSecrets = secrets.filter((secret) => secret.isPRMRSecret); + const destinationDocker = await prisma.destinationDocker.findFirst({ + where: { application: { some: { id } }, teams: { some: { id: teamId } } } + }); + const docker = dockerInstance({ destinationDocker }); + const listContainers = await docker.engine.listContainers({ + filters: { network: [destinationDocker.network], name: [id] } + }); + const containers = listContainers.filter((container) => { + return ( + container.Labels['coolify.configuration'] && + container.Labels['coolify.type'] === 'standalone-application' + ); + }); + const jsonContainers = containers + .map((container) => + JSON.parse(Buffer.from(container.Labels['coolify.configuration'], 'base64').toString()) + ) + .filter((container) => { + return container.pullmergeRequestId && container.applicationId === id; + }); + return { + containers: jsonContainers, + applicationSecrets: applicationSecrets.sort((a, b) => { + return ('' + a.name).localeCompare(b.name); + }), + PRMRSecrets: PRMRSecrets.sort((a, b) => { + return ('' + a.name).localeCompare(b.name); + }) + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getApplicationLogs(request: FastifyRequest) { + try { + const { id } = request.params + let { since = 0 } = request.query + if (since !== 0) { + since = day(since).unix(); + } + const { destinationDockerId, destinationDocker } = await prisma.application.findUnique({ + where: { id }, + include: { destinationDocker: true } + }); + if (destinationDockerId) { + const docker = dockerInstance({ destinationDocker }); + try { + const container = await docker.engine.getContainer(id); + if (container) { + const { default: ansi } = await import('strip-ansi') + const logs = ( + await container.logs({ + stdout: true, + stderr: true, + timestamps: true, + since, + tail: 5000 + }) + ) + .toString() + .split('\n') + .map((l) => ansi(l.slice(8))) + .filter((a) => a); + return { + logs + }; + } + } catch (error) { + return { + logs: [] + }; + } + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getBuildLogs(request: FastifyRequest) { + try { + const { id } = request.params + let { buildId, skip = 0 } = request.query + if (typeof skip !== 'number') { + skip = Number(skip) + } + + let builds = []; + + const buildCount = await prisma.build.count({ where: { applicationId: id } }); + if (buildId) { + builds = await prisma.build.findMany({ where: { applicationId: id, id: buildId } }); + } else { + builds = await prisma.build.findMany({ + where: { applicationId: id }, + orderBy: { createdAt: 'desc' }, + take: 5, + skip + }); + } + + builds = builds.map((build) => { + const updatedAt = day(build.updatedAt).utc(); + build.took = updatedAt.diff(day(build.createdAt)) / 1000; + build.since = updatedAt.fromNow(); + return build; + }); + return { + builds, + buildCount + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getBuildIdLogs(request: FastifyRequest) { + try { + const { id, buildId } = request.params + let { sequence = 0 } = request.query + if (typeof sequence !== 'number') { + sequence = Number(sequence) + } + let logs = await prisma.buildLog.findMany({ + where: { buildId, time: { gt: sequence } }, + orderBy: { time: 'asc' } + }); + const data = await prisma.build.findFirst({ where: { id: buildId } }); + return { + logs, + status: data?.status || 'queued' + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getGitLabSSHKey(request: FastifyRequest) { + try { + const { id } = request.params + const application = await prisma.application.findUnique({ + where: { id }, + include: { gitSource: { include: { gitlabApp: true } } } + }); + return { publicKey: application.gitSource.gitlabApp.publicSshKey }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveGitLabSSHKey(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const application = await prisma.application.findUnique({ + where: { id }, + include: { gitSource: { include: { gitlabApp: true } } } + }); + if (!application.gitSource?.gitlabApp?.privateSshKey) { + const keys = await generateSshKeyPair(); + const encryptedPrivateKey = encrypt(keys.privateKey); + await prisma.gitlabApp.update({ + where: { id: application.gitSource.gitlabApp.id }, + data: { privateSshKey: encryptedPrivateKey, publicSshKey: keys.publicKey } + }); + return reply.code(201).send({ publicKey: keys.publicKey }) + } + return { message: 'SSH key already exists' } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveDeployKey(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + let { deployKeyId } = request.body; + + deployKeyId = Number(deployKeyId); + const application = await prisma.application.findUnique({ + where: { id }, + include: { gitSource: { include: { gitlabApp: true } } } + }); + await prisma.gitlabApp.update({ + where: { id: application.gitSource.gitlabApp.id }, + data: { deployKeyId } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function cancelDeployment(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { buildId, applicationId } = request.body; + if (!buildId) { + throw { status: 500, message: 'buildId is required' } + + } + await stopBuild(buildId, applicationId); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/applications/index.ts b/apps/api/src/routes/api/v1/applications/index.ts new file mode 100644 index 000000000..74a481358 --- /dev/null +++ b/apps/api/src/routes/api/v1/applications/index.ts @@ -0,0 +1,89 @@ +import { FastifyPluginAsync } from 'fastify'; +import { cancelDeployment, checkDNS, checkRepository, deleteApplication, deleteSecret, deleteStorage, deployApplication, getApplication, getApplicationLogs, getBuildIdLogs, getBuildLogs, getBuildPack, getGitHubToken, getGitLabSSHKey, getPreviews, getSecrets, getStorages, getUsage, listApplications, newApplication, saveApplication, saveApplicationSettings, saveApplicationSource, saveBuildPack, saveDeployKey, saveDestination, saveGitLabSSHKey, saveRepository, saveSecret, saveStorage, stopApplication } from './handlers'; + +export interface GetApplication { + Params: { id: string; } +} + +export interface SaveApplication { + Params: { id: string; }, + Body: any +} + +export interface SaveApplicationSettings { + Params: { id: string; }; + Querystring: { domain: string; }; + Body: { debug: boolean; previews: boolean; dualCerts: boolean; autodeploy: boolean; branch: string; projectId: number; }; +} + +export interface DeleteApplication { + Params: { id: string; }; + Querystring: { domain: string; }; +} + +export interface CheckDNS { + Params: { id: string; }; + Querystring: { domain: string; }; +} + +export interface DeployApplication { + Params: { id: string }, + Querystring: { domain: string } + Body: { pullmergeRequestId: string | null, branch: string } +} + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.addHook('onRequest', async (request, reply) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listApplications(request)); + + fastify.post('/new', async (request, reply) => await newApplication(request, reply)); + + fastify.get('/:id', async (request) => await getApplication(request)); + fastify.post('/:id', async (request, reply) => await saveApplication(request, reply)); + fastify.delete('/:id', async (request, reply) => await deleteApplication(request, reply)); + + fastify.post('/:id/stop', async (request, reply) => await stopApplication(request, reply)); + + fastify.post('/:id/settings', async (request, reply) => await saveApplicationSettings(request, reply)); + fastify.post('/:id/check', async (request) => await checkDNS(request)); + + fastify.get('/:id/secrets', async (request) => await getSecrets(request)); + fastify.post('/:id/secrets', async (request, reply) => await saveSecret(request, reply)); + fastify.delete('/:id/secrets', async (request) => await deleteSecret(request)); + + fastify.get('/:id/storages', async (request) => await getStorages(request)); + fastify.post('/:id/storages', async (request, reply) => await saveStorage(request, reply)); + fastify.delete('/:id/storages', async (request) => await deleteStorage(request)); + + fastify.get('/:id/previews', async (request) => await getPreviews(request)); + + fastify.get('/:id/logs', async (request) => await getApplicationLogs(request)); + fastify.get('/:id/logs/build', async (request) => await getBuildLogs(request)); + fastify.get('/:id/logs/build/:buildId', async (request) => await getBuildIdLogs(request)); + + fastify.get('/:id/usage', async (request) => await getUsage(request)) + + fastify.post('/:id/deploy', async (request) => await deployApplication(request)) + fastify.post('/:id/cancel', async (request, reply) => await cancelDeployment(request, reply)); + + fastify.post('/:id/configuration/source', async (request, reply) => await saveApplicationSource(request, reply)); + + fastify.get('/:id/configuration/repository', async (request) => await checkRepository(request)); + fastify.post('/:id/configuration/repository', async (request, reply) => await saveRepository(request, reply)); + fastify.post('/:id/configuration/destination', async (request, reply) => await saveDestination(request, reply)); + fastify.get('/:id/configuration/buildpack', async (request) => await getBuildPack(request)); + fastify.post('/:id/configuration/buildpack', async (request, reply) => await saveBuildPack(request, reply)); + + fastify.get('/:id/configuration/sshkey', async (request) => await getGitLabSSHKey(request)); + fastify.post('/:id/configuration/sshkey', async (request, reply) => await saveGitLabSSHKey(request, reply)); + + fastify.post('/:id/configuration/deploykey', async (request, reply) => await saveDeployKey(request, reply)); + + + + fastify.get('/:id/configuration/githubToken', async (request, reply) => await getGitHubToken(request, reply)); +}; + +export default root; diff --git a/apps/api/src/routes/api/v1/databases/handlers.ts b/apps/api/src/routes/api/v1/databases/handlers.ts new file mode 100644 index 000000000..52110b8b7 --- /dev/null +++ b/apps/api/src/routes/api/v1/databases/handlers.ts @@ -0,0 +1,471 @@ +import cuid from 'cuid'; +import type { FastifyRequest } from 'fastify'; +import { FastifyReply } from 'fastify'; +import yaml from 'js-yaml'; +import fs from 'fs/promises'; +import { asyncExecShell, ComposeFile, createDirectories, decrypt, encrypt, errorHandler, generateDatabaseConfiguration, generatePassword, getContainerUsage, getDatabaseImage, getDatabaseVersions, getFreePort, listSettings, makeLabelForStandaloneDatabase, prisma, startTcpProxy, startTraefikTCPProxy, stopDatabaseContainer, stopTcpHttpProxy, supportedDatabaseTypesAndVersions, uniqueName, updatePasswordInDb } from '../../../../lib/common'; +import { dockerInstance, getEngine } from '../../../../lib/docker'; +import { day } from '../../../../lib/dayjs'; + +export async function listDatabases(request: FastifyRequest) { + try { + const userId = request.user.userId; + const teamId = request.user.teamId; + let databases = [] + if (teamId === '0') { + databases = await prisma.database.findMany({ include: { teams: true } }); + } else { + databases = await prisma.database.findMany({ + where: { teams: { some: { id: teamId } } }, + include: { teams: true } + }); + } + return { + databases + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function newDatabase(request: FastifyRequest, reply: FastifyReply) { + try { + const teamId = request.user.teamId; + + const name = uniqueName(); + const dbUser = cuid(); + const dbUserPassword = encrypt(generatePassword()); + const rootUser = cuid(); + const rootUserPassword = encrypt(generatePassword()); + const defaultDatabase = cuid(); + + const { id } = await prisma.database.create({ + data: { + name, + defaultDatabase, + dbUser, + dbUserPassword, + rootUser, + rootUserPassword, + teams: { connect: { id: teamId } }, + settings: { create: { isPublic: false } } + } + }); + return reply.code(201).send({ id }) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getDatabase(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (!database) { + throw { status: 404, message: 'Database not found.' } + } + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + const { destinationDockerId, destinationDocker } = database; + let isRunning = false; + if (destinationDockerId) { + const host = getEngine(destinationDocker.engine); + + try { + const { stdout } = await asyncExecShell( + `DOCKER_HOST=${host} docker inspect --format '{{json .State}}' ${id}` + ); + + if (JSON.parse(stdout).Running) { + isRunning = true; + } + } catch (error) { + // + } + } + const configuration = generateDatabaseConfiguration(database); + const settings = await listSettings(); + return { + privatePort: configuration?.privatePort, + database, + isRunning, + versions: await getDatabaseVersions(database.type), + settings + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getDatabaseTypes(request: FastifyRequest) { + try { + return { + types: supportedDatabaseTypesAndVersions + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveDatabaseType(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params; + const { type } = request.body; + await prisma.database.update({ + where: { id }, + data: { type } + }); + return reply.code(201).send({}) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getVersions(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const { type } = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + return { + versions: supportedDatabaseTypesAndVersions.find((name) => name.name === type).versions + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveVersion(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params; + const { version } = request.body; + + await prisma.database.update({ + where: { id }, + data: { + version, + + } + }); + return reply.code(201).send({}) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveDatabaseDestination(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params; + const { destinationId } = request.body; + + await prisma.database.update({ + where: { id }, + data: { destinationDocker: { connect: { id: destinationId } } } + }); + + const { + destinationDockerId, + destinationDocker: { engine }, + version, + type + } = await prisma.database.findUnique({ where: { id }, include: { destinationDocker: true } }); + + if (destinationDockerId) { + const host = getEngine(engine); + if (type && version) { + const baseImage = getDatabaseImage(type); + asyncExecShell(`DOCKER_HOST=${host} docker pull ${baseImage}:${version}`); + } + } + return reply.code(201).send({}) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getDatabaseUsage(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + let usage = {}; + + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + if (database.destinationDockerId) { + [usage] = await Promise.all([getContainerUsage(database.destinationDocker.engine, id)]); + } + return { + usage + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function startDatabase(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + const { + type, + destinationDockerId, + destinationDocker, + publicPort, + settings: { isPublic } + } = database; + const { privatePort, environmentVariables, image, volume, ulimits } = + generateDatabaseConfiguration(database); + + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const engine = destinationDocker.engine; + const volumeName = volume.split(':')[0]; + const labels = await makeLabelForStandaloneDatabase({ id, image, volume }); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image, + networks: [network], + environment: environmentVariables, + volumes: [volume], + ulimits, + labels, + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [volumeName]: { + external: true + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + try { + await asyncExecShell(`DOCKER_HOST=${host} docker volume create ${volumeName}`); + } catch (error) { + console.log(error); + } + try { + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + if (isPublic) await startTcpProxy(destinationDocker, id, publicPort, privatePort); + return {}; + } catch (error) { + throw { + error + }; + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function stopDatabase(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + const everStarted = await stopDatabaseContainer(database); + if (everStarted) await stopTcpHttpProxy(id, database.destinationDocker, database.publicPort); + await prisma.database.update({ + where: { id }, + data: { + settings: { upsert: { update: { isPublic: false }, create: { isPublic: false } } } + } + }); + await prisma.database.update({ where: { id }, data: { publicPort: null } }); + return {}; + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getDatabaseLogs(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + let { since = 0 } = request.query + if (since !== 0) { + since = day(since).unix(); + } + const { destinationDockerId, destinationDocker } = await prisma.database.findUnique({ + where: { id }, + include: { destinationDocker: true } + }); + if (destinationDockerId) { + const docker = dockerInstance({ destinationDocker }); + try { + const container = await docker.engine.getContainer(id); + if (container) { + const { default: ansi } = await import('strip-ansi') + const logs = ( + await container.logs({ + stdout: true, + stderr: true, + timestamps: true, + since, + tail: 5000 + }) + ) + .toString() + .split('\n') + .map((l) => ansi(l.slice(8))) + .filter((a) => a); + return { + logs + }; + } + } catch (error) { + const { statusCode } = error; + if (statusCode === 404) { + return { + logs: [] + }; + } + } + } + return { + message: 'No logs found.' + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteDatabase(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + if (database.destinationDockerId) { + const everStarted = await stopDatabaseContainer(database); + if (everStarted) await stopTcpHttpProxy(id, database.destinationDocker, database.publicPort); + } + await prisma.databaseSettings.deleteMany({ where: { databaseId: id } }); + await prisma.database.delete({ where: { id } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveDatabase(request: FastifyRequest, reply: FastifyReply) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const { + name, + defaultDatabase, + dbUser, + dbUserPassword, + rootUser, + rootUserPassword, + version, + isRunning + } = request.body; + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + if (isRunning) { + if (database.dbUserPassword !== dbUserPassword) { + await updatePasswordInDb(database, dbUser, dbUserPassword, false); + } else if (database.rootUserPassword !== rootUserPassword) { + await updatePasswordInDb(database, rootUser, rootUserPassword, true); + } + } + const encryptedDbUserPassword = dbUserPassword && encrypt(dbUserPassword); + const encryptedRootUserPassword = rootUserPassword && encrypt(rootUserPassword); + await prisma.database.update({ + where: { id }, + data: { + name, + defaultDatabase, + dbUser, + dbUserPassword: encryptedDbUserPassword, + rootUser, + rootUserPassword: encryptedRootUserPassword, + version + } + }); + return reply.code(201).send({}) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveDatabaseSettings(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const { isPublic, appendOnly = true } = request.body; + const publicPort = await getFreePort(); + const settings = await listSettings(); + await prisma.database.update({ + where: { id }, + data: { + settings: { upsert: { update: { isPublic, appendOnly }, create: { isPublic, appendOnly } } } + } + }); + const database = await prisma.database.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { destinationDocker: true, settings: true } + }); + if (database.dbUserPassword) database.dbUserPassword = decrypt(database.dbUserPassword); + if (database.rootUserPassword) database.rootUserPassword = decrypt(database.rootUserPassword); + + const { destinationDockerId, destinationDocker, publicPort: oldPublicPort } = database; + const { privatePort } = generateDatabaseConfiguration(database); + + if (destinationDockerId) { + if (isPublic) { + await prisma.database.update({ where: { id }, data: { publicPort } }); + if (settings.isTraefikUsed) { + await startTraefikTCPProxy(destinationDocker, id, publicPort, privatePort); + } else { + await startTcpProxy(destinationDocker, id, publicPort, privatePort); + } + } else { + await prisma.database.update({ where: { id }, data: { publicPort: null } }); + await stopTcpHttpProxy(id, destinationDocker, oldPublicPort); + } + } + return { publicPort } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/databases/index.ts b/apps/api/src/routes/api/v1/databases/index.ts new file mode 100644 index 000000000..cd4a8c159 --- /dev/null +++ b/apps/api/src/routes/api/v1/databases/index.ts @@ -0,0 +1,32 @@ +import { FastifyPluginAsync } from 'fastify'; +import { deleteDatabase, getDatabase, getDatabaseLogs, getDatabaseTypes, getDatabaseUsage, getVersions, listDatabases, newDatabase, saveDatabase, saveDatabaseDestination, saveDatabaseSettings, saveDatabaseType, saveVersion, startDatabase, stopDatabase } from './handlers'; + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.addHook('onRequest', async (request, reply) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listDatabases(request)); + fastify.post('/new', async (request, reply) => await newDatabase(request, reply)); + + fastify.get('/:id', async (request) => await getDatabase(request)); + fastify.post('/:id', async (request, reply) => await saveDatabase(request, reply)); + fastify.delete('/:id', async (request) => await deleteDatabase(request)); + + fastify.post('/:id/settings', async (request) => await saveDatabaseSettings(request)); + + fastify.get('/:id/configuration/type', async (request) => await getDatabaseTypes(request)); + fastify.post('/:id/configuration/type', async (request, reply) => await saveDatabaseType(request, reply)); + + fastify.get('/:id/configuration/version', async (request) => await getVersions(request)); + fastify.post('/:id/configuration/version', async (request, reply) => await saveVersion(request, reply)); + + fastify.post('/:id/configuration/destination', async (request, reply) => await saveDatabaseDestination(request, reply)); + + fastify.get('/:id/usage', async (request) => await getDatabaseUsage(request)); + fastify.get('/:id/logs', async (request) => await getDatabaseLogs(request)); + + fastify.post('/:id/start', async (request) => await startDatabase(request)); + fastify.post('/:id/stop', async (request) => await stopDatabase(request)); +}; + +export default root; diff --git a/apps/api/src/routes/api/v1/destinations/handlers.ts b/apps/api/src/routes/api/v1/destinations/handlers.ts new file mode 100644 index 000000000..36e7f7aa4 --- /dev/null +++ b/apps/api/src/routes/api/v1/destinations/handlers.ts @@ -0,0 +1,200 @@ +import type { FastifyRequest } from 'fastify'; +import { FastifyReply } from 'fastify'; +import { asyncExecShell, errorHandler, listSettings, prisma, startCoolifyProxy, startTraefikProxy, stopTraefikProxy } from '../../../../lib/common'; +import { checkContainer, dockerInstance, getEngine } from '../../../../lib/docker'; + +export async function listDestinations(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + let destinations = [] + if (teamId === '0') { + destinations = await prisma.destinationDocker.findMany({ include: { teams: true } }); + } else { + destinations = await prisma.destinationDocker.findMany({ + where: { teams: { some: { id: teamId } } }, + include: { teams: true } + }); + } + return { + destinations + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function checkDestination(request: FastifyRequest) { + try { + const { network } = request.body; + const found = await prisma.destinationDocker.findFirst({ where: { network } }); + if (found) { + throw { + message: `Network already exists: ${network}` + }; + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getDestination(request: FastifyRequest) { + try { + const { id } = request.params + const teamId = request.user?.teamId; + const destination = await prisma.destinationDocker.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } } + }); + if (!destination) { + throw { status: 404, message: `Destination not found.` }; + } + const settings = await listSettings(); + let payload = { + destination, + settings, + state: false + }; + + if (destination?.remoteEngine) { + // const { stdout } = await asyncExecShell( + // `ssh -p ${destination.port} ${destination.user}@${destination.ipAddress} "docker ps -a"` + // ); + // console.log(stdout) + // const engine = await generateRemoteEngine(destination); + // // await saveSshKey(destination); + // payload.state = await checkContainer(engine, 'coolify-haproxy'); + } else { + let containerName = 'coolify-proxy'; + if (!settings.isTraefikUsed) { + containerName = 'coolify-haproxy'; + } + payload.state = + destination?.engine && (await checkContainer(destination.engine, containerName)); + } + return { + ...payload + }; + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function newDestination(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + let { name, network, engine, isCoolifyProxyUsed } = request.body + const teamId = request.user.teamId; + if (id === 'new') { + const host = getEngine(engine); + const docker = dockerInstance({ destinationDocker: { engine, network } }); + const found = await docker.engine.listNetworks({ filters: { name: [`^${network}$`] } }); + if (found.length === 0) { + await asyncExecShell(`DOCKER_HOST=${host} docker network create --attachable ${network}`); + } + await prisma.destinationDocker.create({ + data: { name, teams: { connect: { id: teamId } }, engine, network, isCoolifyProxyUsed } + }); + const destinations = await prisma.destinationDocker.findMany({ where: { engine } }); + const destination = destinations.find((destination) => destination.network === network); + + if (destinations.length > 0) { + const proxyConfigured = destinations.find( + (destination) => destination.network !== network && destination.isCoolifyProxyUsed === true + ); + if (proxyConfigured) { + isCoolifyProxyUsed = !!proxyConfigured.isCoolifyProxyUsed; + } + await prisma.destinationDocker.updateMany({ where: { engine }, data: { isCoolifyProxyUsed } }); + } + if (isCoolifyProxyUsed) { + const settings = await prisma.setting.findFirst(); + if (settings?.isTraefikUsed) { + await startTraefikProxy(engine); + } else { + await startCoolifyProxy(engine); + } + } + return reply.code(201).send({ id: destination.id }); + } else { + await prisma.destinationDocker.update({ where: { id }, data: { name, engine, network } }); + return reply.code(201).send(); + } + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteDestination(request: FastifyRequest) { + try { + const { id } = request.params + const destination = await prisma.destinationDocker.delete({ where: { id } }); + if (destination.isCoolifyProxyUsed) { + const host = getEngine(destination.engine); + const { network } = destination; + const settings = await prisma.setting.findFirst(); + const containerName = settings.isTraefikUsed ? 'coolify-proxy' : 'coolify-haproxy'; + const { stdout: found } = await asyncExecShell( + `DOCKER_HOST=${host} docker ps -a --filter network=${network} --filter name=${containerName} --format '{{.}}'` + ); + if (found) { + await asyncExecShell( + `DOCKER_HOST="${host}" docker network disconnect ${network} ${containerName}` + ); + await asyncExecShell(`DOCKER_HOST="${host}" docker network rm ${network}`); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveDestinationSettings(request: FastifyRequest, reply: FastifyReply) { + try { + const { engine, isCoolifyProxyUsed } = request.body; + await prisma.destinationDocker.updateMany({ + where: { engine }, + data: { isCoolifyProxyUsed } + }); + + return { + status: 202 + } + // return reply.code(201).send(); + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function startProxy(request: FastifyRequest, reply: FastifyReply) { + const { engine } = request.body; + try { + await startTraefikProxy(engine); + return {} + } catch ({ status, message }) { + await stopTraefikProxy(engine); + return errorHandler({ status, message }) + } +} +export async function stopProxy(request: FastifyRequest, reply: FastifyReply) { + const settings = await prisma.setting.findFirst({}); + const { engine } = request.body; + try { + await stopTraefikProxy(engine); + + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function restartProxy(request: FastifyRequest, reply: FastifyReply) { + const settings = await prisma.setting.findFirst({}); + const { engine } = request.body; + try { + await stopTraefikProxy(engine); + await startTraefikProxy(engine); + await prisma.destinationDocker.updateMany({ + where: { engine }, + data: { isCoolifyProxyUsed: true } + }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} diff --git a/apps/api/src/routes/api/v1/destinations/index.ts b/apps/api/src/routes/api/v1/destinations/index.ts new file mode 100644 index 000000000..0e395a787 --- /dev/null +++ b/apps/api/src/routes/api/v1/destinations/index.ts @@ -0,0 +1,24 @@ +import { FastifyPluginAsync } from 'fastify'; +import { checkDestination, deleteDestination, getDestination, listDestinations, newDestination, restartProxy, saveDestinationSettings, startProxy, stopProxy } from './handlers'; + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.addHook('onRequest', async (request, reply) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listDestinations(request)); + fastify.post('/check', async (request) => await checkDestination(request)); + + fastify.get('/:id', async (request) => await getDestination(request)); + fastify.post('/:id', async (request, reply) => await newDestination(request, reply)); + fastify.delete('/:id', async (request) => await deleteDestination(request)); + + fastify.post('/:id/settings', async (request, reply) => await saveDestinationSettings(request, reply)); + fastify.post('/:id/start', async (request, reply) => await startProxy(request, reply)); + fastify.post('/:id/stop', async (request, reply) => await stopProxy(request, reply)); + fastify.post('/:id/restart', async (request, reply) => await restartProxy(request, reply)); + + + +}; + +export default root; diff --git a/apps/api/src/routes/api/v1/handlers.ts b/apps/api/src/routes/api/v1/handlers.ts new file mode 100644 index 000000000..b998fd4b5 --- /dev/null +++ b/apps/api/src/routes/api/v1/handlers.ts @@ -0,0 +1,280 @@ +import os from 'node:os'; +import osu from 'node-os-utils'; +import axios from 'axios'; +import compare from 'compare-versions'; +import cuid from 'cuid'; +import bcrypt from 'bcryptjs'; +import { asyncExecShell, asyncSleep, errorHandler, isDev, prisma, uniqueName, version } from '../../../lib/common'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { Login, Update } from '.'; + + +export async function hashPassword(password: string): Promise { + const saltRounds = 15; + return bcrypt.hash(password, saltRounds); +} + + +export async function checkUpdate(request: FastifyRequest) { + try { + const currentVersion = version; + const { data: versions } = await axios.get( + `https://get.coollabs.io/versions.json?appId=${process.env['COOLIFY_APP_ID']}&version=${currentVersion}` + ); + const latestVersion = + request.hostname === 'staging.coolify.io' + ? versions['coolify'].next.version + : versions['coolify'].main.version; + const isUpdateAvailable = compare(latestVersion, currentVersion); + return { + isUpdateAvailable: isUpdateAvailable === 1, + latestVersion + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function update(request: FastifyRequest) { + const { latestVersion } = request.body; + try { + if (!isDev) { + const { isAutoUpdateEnabled } = (await prisma.setting.findFirst()) || { + isAutoUpdateEnabled: false + }; + await asyncExecShell(`docker pull coollabsio/coolify:${latestVersion}`); + await asyncExecShell(`env | grep COOLIFY > .env`); + await asyncExecShell( + `sed -i '/COOLIFY_AUTO_UPDATE=/cCOOLIFY_AUTO_UPDATE=${isAutoUpdateEnabled}' .env` + ); + await asyncExecShell( + `docker run --rm -tid --env-file .env -v /var/run/docker.sock:/var/run/docker.sock -v coolify-db coollabsio/coolify:${latestVersion} /bin/sh -c "env | grep COOLIFY > .env && echo 'TAG=${latestVersion}' >> .env && docker stop -t 0 coolify && docker rm coolify && docker compose up -d --force-recreate"` + ); + return {}; + } else { + console.log(latestVersion); + await asyncSleep(2000); + return {}; + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function showUsage() { + try { + return { + usage: { + uptime: os.uptime(), + memory: await osu.mem.info(), + cpu: { + load: os.loadavg(), + usage: await osu.cpu.usage(), + count: os.cpus().length + }, + disk: await osu.drive.info('/') + } + + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function showDashboard(request: FastifyRequest) { + try { + const userId = request.user.userId; + const teamId = request.user.teamId; + const applicationsCount = await prisma.application.count({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } + }); + const sourcesCount = await prisma.gitSource.count({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } + }); + const destinationsCount = await prisma.destinationDocker.count({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } + }); + const teamsCount = await prisma.permission.count({ where: { userId } }); + const databasesCount = await prisma.database.count({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } + }); + const servicesCount = await prisma.service.count({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } } + }); + const teams = await prisma.permission.findMany({ + where: { userId }, + include: { team: { include: { _count: { select: { users: true } } } } } + }); + return { + teams, + applicationsCount, + sourcesCount, + destinationsCount, + teamsCount, + databasesCount, + servicesCount, + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function login(request: FastifyRequest, reply: FastifyReply) { + if (request.user) { + return reply.redirect('/dashboard'); + } else { + const { email, password, isLogin } = request.body || {}; + if (!email || !password) { + throw { status: 500, message: 'Email and password are required.' }; + } + const users = await prisma.user.count(); + const userFound = await prisma.user.findUnique({ + where: { email }, + include: { teams: true, permission: true }, + rejectOnNotFound: false + }); + if (!userFound && isLogin) { + throw { status: 500, message: 'User not found.' }; + } + const { isRegistrationEnabled, id } = await prisma.setting.findFirst() + let uid = cuid(); + let permission = 'read'; + let isAdmin = false; + + if (users === 0) { + await prisma.setting.update({ where: { id }, data: { isRegistrationEnabled: false } }); + uid = '0'; + } + if (userFound) { + if (userFound.type === 'email') { + // TODO: Review this one + if (userFound.password === 'RESETME') { + const hashedPassword = await hashPassword(password); + if (userFound.updatedAt < new Date(Date.now() - 1000 * 60 * 10)) { + await prisma.user.update({ + where: { email: userFound.email }, + data: { password: 'RESETTIMEOUT' } + }); + throw { + status: 500, + message: 'Password reset link has expired. Please request a new one.' + }; + } else { + await prisma.user.update({ + where: { email: userFound.email }, + data: { password: hashedPassword } + }); + return { + userId: userFound.id, + teamId: userFound.id, + permission: userFound.permission, + isAdmin: true + }; + } + } + + const passwordMatch = await bcrypt.compare(password, userFound.password); + if (!passwordMatch) { + throw { + status: 500, + message: 'Wrong password or email address.' + }; + } + uid = userFound.id; + isAdmin = true; + } + } else { + permission = 'owner'; + isAdmin = true; + if (!isRegistrationEnabled) { + throw { + status: 404, + message: 'Registration disabled by administrator.' + }; + } + const hashedPassword = await hashPassword(password); + if (users === 0) { + await prisma.user.create({ + data: { + id: uid, + email, + password: hashedPassword, + type: 'email', + teams: { + create: { + id: uid, + name: uniqueName(), + destinationDocker: { connect: { network: 'coolify' } } + } + }, + permission: { create: { teamId: uid, permission: 'owner' } } + }, + include: { teams: true } + }); + } else { + await prisma.user.create({ + data: { + id: uid, + email, + password: hashedPassword, + type: 'email', + teams: { + create: { + id: uid, + name: uniqueName() + } + }, + permission: { create: { teamId: uid, permission: 'owner' } } + }, + include: { teams: true } + }); + } + } + return { + userId: uid, + teamId: uid, + permission, + isAdmin + }; + } +} + +export async function getCurrentUser(request: FastifyRequest, fastify) { + let token = null + + try { + const user = await prisma.user.findUnique({ + where: { id: request.user.userId } + }) + if (!user) { + throw "User not found"; + } + } catch (error) { + throw { status: 401, message: error }; + } + if (request.query.teamId) { + try { + const user = await prisma.user.findFirst({ + where: { id: request.user.userId, teams: { some: { id: request.query.teamId } } }, + include: { teams: true, permission: true } + }) + if (user) { + const payload = { + ...request.user, + teamId: request.query.teamId, + permission: user.permission.find(p => p.teamId === request.query.teamId).permission || null, + isAdmin: user.permission.find(p => p.teamId === request.query.teamId).permission === 'owner' + + } + token = fastify.jwt.sign(payload) + } + + } catch (error) { + // No new token -> not switching teams + } + } + return { + settings: await prisma.setting.findFirst(), + token, + ...request.user + } +} diff --git a/apps/api/src/routes/api/v1/iam/handlers.ts b/apps/api/src/routes/api/v1/iam/handlers.ts new file mode 100644 index 000000000..12095c26d --- /dev/null +++ b/apps/api/src/routes/api/v1/iam/handlers.ts @@ -0,0 +1,453 @@ +import cuid from 'cuid'; +import type { FastifyRequest } from 'fastify'; +import { FastifyReply } from 'fastify'; +import { decrypt, errorHandler, prisma, uniqueName } from '../../../../lib/common'; +import { day } from '../../../../lib/dayjs'; +export async function listTeams(request: FastifyRequest) { + try { + const userId = request.user.userId; + const teamId = request.user.teamId; + const account = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, teams: true } + }); + let accounts = []; + let allTeams = []; + if (teamId === '0') { + accounts = await prisma.user.findMany({ select: { id: true, email: true, teams: true } }); + allTeams = await prisma.team.findMany({ + where: { users: { none: { id: userId } } }, + include: { permissions: true } + }); + } + const ownTeams = await prisma.team.findMany({ + where: { users: { some: { id: userId } } }, + include: { permissions: true } + }); + const invitations = await prisma.teamInvitation.findMany({ where: { uid: userId } }); + return { + ownTeams, + allTeams, + invitations, + account, + accounts + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteTeam(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = request.user.userId; + const { id } = request.params; + + const aloneInTeams = await prisma.team.findMany({ where: { users: { every: { id: userId } }, id } }); + if (aloneInTeams.length > 0) { + for (const team of aloneInTeams) { + const applications = await prisma.application.findMany({ + where: { teams: { every: { id: team.id } } } + }); + if (applications.length > 0) { + for (const application of applications) { + await prisma.application.update({ + where: { id: application.id }, + data: { teams: { connect: { id: '0' } } } + }); + } + } + const services = await prisma.service.findMany({ + where: { teams: { every: { id: team.id } } } + }); + if (services.length > 0) { + for (const service of services) { + await prisma.service.update({ + where: { id: service.id }, + data: { teams: { connect: { id: '0' } } } + }); + } + } + const databases = await prisma.database.findMany({ + where: { teams: { every: { id: team.id } } } + }); + if (databases.length > 0) { + for (const database of databases) { + await prisma.database.update({ + where: { id: database.id }, + data: { teams: { connect: { id: '0' } } } + }); + } + } + const sources = await prisma.gitSource.findMany({ + where: { teams: { every: { id: team.id } } } + }); + if (sources.length > 0) { + for (const source of sources) { + await prisma.gitSource.update({ + where: { id: source.id }, + data: { teams: { connect: { id: '0' } } } + }); + } + } + const destinations = await prisma.destinationDocker.findMany({ + where: { teams: { every: { id: team.id } } } + }); + if (destinations.length > 0) { + for (const destination of destinations) { + await prisma.destinationDocker.update({ + where: { id: destination.id }, + data: { teams: { connect: { id: '0' } } } + }); + } + } + await prisma.teamInvitation.deleteMany({ where: { teamId: team.id } }); + await prisma.permission.deleteMany({ where: { teamId: team.id } }); + // await prisma.user.delete({ where: { id } }); + await prisma.team.delete({ where: { id: team.id } }); + } + } + + const notAloneInTeams = await prisma.team.findMany({ where: { users: { some: { id: userId } } } }); + if (notAloneInTeams.length > 0) { + for (const team of notAloneInTeams) { + await prisma.team.update({ + where: { id: team.id }, + data: { users: { disconnect: { id } } } + }); + } + } + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function newTeam(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = request.user?.userId; + const name = uniqueName(); + const { id } = await prisma.team.create({ + data: { + name, + permissions: { create: { user: { connect: { id: userId } }, permission: 'owner' } }, + users: { connect: { id: userId } } + } + }); + return reply.code(201).send({ id }) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getTeam(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = request.user.userId; + const teamId = request.user.teamId; + const { id } = request.params; + + const user = await prisma.user.findFirst({ + where: { id: userId, teams: teamId === '0' ? undefined : { some: { id } } }, + include: { permission: true } + }); + if (!user) return reply.code(401).send() + + const permissions = await prisma.permission.findMany({ + where: { teamId: id }, + include: { user: { select: { id: true, email: true } } } + }); + const team = await prisma.team.findUnique({ where: { id }, include: { permissions: true } }); + const invitations = await prisma.teamInvitation.findMany({ where: { teamId: team.id } }); + return { + team, + permissions, + invitations + }; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveTeam(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params; + const { name } = request.body; + + await prisma.team.update({ where: { id }, data: { name: { set: name } } }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +// export async function deleteUser(request: FastifyRequest, reply: FastifyReply) { +// try { +// const userId = request.user.userId; +// const { id } = request.params; + +// const aloneInTeams = await prisma.team.findMany({ where: { users: { every: { id: userId } }, id } }); +// if (aloneInTeams.length > 0) { +// for (const team of aloneInTeams) { +// const applications = await prisma.application.findMany({ +// where: { teams: { every: { id: team.id } } } +// }); +// if (applications.length > 0) { +// for (const application of applications) { +// await prisma.application.update({ +// where: { id: application.id }, +// data: { teams: { connect: { id: '0' } } } +// }); +// } +// } +// const services = await prisma.service.findMany({ +// where: { teams: { every: { id: team.id } } } +// }); +// if (services.length > 0) { +// for (const service of services) { +// await prisma.service.update({ +// where: { id: service.id }, +// data: { teams: { connect: { id: '0' } } } +// }); +// } +// } +// const databases = await prisma.database.findMany({ +// where: { teams: { every: { id: team.id } } } +// }); +// if (databases.length > 0) { +// for (const database of databases) { +// await prisma.database.update({ +// where: { id: database.id }, +// data: { teams: { connect: { id: '0' } } } +// }); +// } +// } +// const sources = await prisma.gitSource.findMany({ +// where: { teams: { every: { id: team.id } } } +// }); +// if (sources.length > 0) { +// for (const source of sources) { +// await prisma.gitSource.update({ +// where: { id: source.id }, +// data: { teams: { connect: { id: '0' } } } +// }); +// } +// } +// const destinations = await prisma.destinationDocker.findMany({ +// where: { teams: { every: { id: team.id } } } +// }); +// if (destinations.length > 0) { +// for (const destination of destinations) { +// await prisma.destinationDocker.update({ +// where: { id: destination.id }, +// data: { teams: { connect: { id: '0' } } } +// }); +// } +// } +// await prisma.teamInvitation.deleteMany({ where: { teamId: team.id } }); +// await prisma.permission.deleteMany({ where: { teamId: team.id } }); +// await prisma.user.delete({ where: { id: userId } }); +// await prisma.team.delete({ where: { id: team.id } }); + +// } +// } + +// const notAloneInTeams = await prisma.team.findMany({ where: { users: { some: { id: userId } } } }); +// if (notAloneInTeams.length > 0) { +// for (const team of notAloneInTeams) { +// await prisma.team.update({ +// where: { id: team.id }, +// data: { users: { disconnect: { id } } } +// }); +// } +// } + +// return reply.code(201).send() +// } catch (error) { +// console.log(error) +// throw { status: 500, message: error } +// } +// } + +export async function inviteToTeam(request: FastifyRequest, reply: FastifyReply) { + try { + const userId = request.user.userId; + const { email, permission, teamId, teamName } = request.body; + const userFound = await prisma.user.findUnique({ where: { email } }); + if (!userFound) { + throw `No user found with '${email}' email address.` + } + const uid = userFound.id; + if (uid === userId) { + throw `Invitation to yourself? Whaaaaat?` + } + const alreadyInTeam = await prisma.team.findFirst({ + where: { id: teamId, users: { some: { id: uid } } } + }); + if (alreadyInTeam) { + throw { + message: `Already in the team.` + }; + } + const invitationFound = await prisma.teamInvitation.findFirst({ where: { uid, teamId } }); + if (invitationFound) { + if (day().toDate() < day(invitationFound.createdAt).add(1, 'day').toDate()) { + throw 'Invitiation already pending on user confirmation.' + } else { + await prisma.teamInvitation.delete({ where: { id: invitationFound.id } }); + await prisma.teamInvitation.create({ + data: { email, uid, teamId, teamName, permission } + }); + return reply.code(201).send({ message: 'Invitiation sent.' }) + } + } else { + await prisma.teamInvitation.create({ + data: { email, uid, teamId, teamName, permission } + }); + return reply.code(201).send({ message: 'Invitiation sent.' }) + + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function acceptInvitation(request: FastifyRequest) { + try { + const userId = request.user.userId; + const { id } = request.body; + const invitation = await prisma.teamInvitation.findFirst({ + where: { uid: userId }, + rejectOnNotFound: true + }); + await prisma.team.update({ + where: { id: invitation.teamId }, + data: { users: { connect: { id: userId } } } + }); + await prisma.permission.create({ + data: { + user: { connect: { id: userId } }, + permission: invitation.permission, + team: { connect: { id: invitation.teamId } } + } + }); + await prisma.teamInvitation.delete({ where: { id } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function revokeInvitation(request: FastifyRequest) { + try { + const { id } = request.body + await prisma.teamInvitation.delete({ where: { id } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function removeUser(request: FastifyRequest, reply: FastifyReply) { + try { + const { uid } = request.body; + const user = await prisma.user.findUnique({ where: { id: uid }, include: { teams: true, permission: true } }); + if (user) { + const permissions = user.permission; + if (permissions.length > 0) { + for (const permission of permissions) { + await prisma.permission.deleteMany({ where: { id: permission.id, userId: uid } }); + } + } + const teams = user.teams; + if (teams.length > 0) { + for (const team of teams) { + const newTeam = await prisma.team.update({ + where: { id: team.id }, + data: { users: { disconnect: { id: uid } } }, + include: { applications: true, database: true, gitHubApps: true, gitLabApps: true, gitSources: true, destinationDocker: true, service: true, users: true } + }); + if (newTeam.users.length === 0) { + if (newTeam.applications.length > 0) { + for (const application of newTeam.applications) { + await prisma.application.update({ + where: { id: application.id }, + data: { teams: { disconnect: { id: team.id }, connect: { id: '0' } } } + }); + } + } + if (newTeam.database.length > 0) { + for (const database of newTeam.database) { + await prisma.database.update({ + where: { id: database.id }, + data: { teams: { disconnect: { id: team.id }, connect: { id: '0' } } } + }); + } + } + if (newTeam.service.length > 0) { + for (const service of newTeam.service) { + await prisma.service.update({ + where: { id: service.id }, + data: { teams: { disconnect: { id: team.id }, connect: { id: '0' } } } + }); + } + } + if (newTeam.gitHubApps.length > 0) { + for (const gitHubApp of newTeam.gitHubApps) { + await prisma.githubApp.update({ + where: { id: gitHubApp.id }, + data: { teams: { disconnect: { id: team.id }, connect: { id: '0' } } } + }); + } + } + if (newTeam.gitLabApps.length > 0) { + for (const gitLabApp of newTeam.gitLabApps) { + await prisma.gitlabApp.update({ + where: { id: gitLabApp.id }, + data: { teams: { disconnect: { id: team.id }, connect: { id: '0' } } } + }); + } + } + if (newTeam.gitSources.length > 0) { + for (const gitSource of newTeam.gitSources) { + await prisma.gitSource.update({ + where: { id: gitSource.id }, + data: { teams: { disconnect: { id: team.id }, connect: { id: '0' } } } + }); + } + } + if (newTeam.destinationDocker.length > 0) { + for (const destinationDocker of newTeam.destinationDocker) { + await prisma.destinationDocker.update({ + where: { id: destinationDocker.id }, + data: { teams: { disconnect: { id: team.id }, connect: { id: '0' } } } + }); + } + } + await prisma.team.delete({ where: { id: team.id } }); + } + } + } + } + await prisma.user.delete({ where: { id: uid } }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function setPermission(request: FastifyRequest, reply: FastifyReply) { + try { + const { userId, newPermission, permissionId } = request.body; + await prisma.permission.updateMany({ + where: { id: permissionId, userId }, + data: { permission: { set: newPermission } } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function changePassword(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.body; + await prisma.user.update({ where: { id: undefined }, data: { password: 'RESETME' } }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/iam/index.ts b/apps/api/src/routes/api/v1/iam/index.ts new file mode 100644 index 000000000..d82c39878 --- /dev/null +++ b/apps/api/src/routes/api/v1/iam/index.ts @@ -0,0 +1,29 @@ +import { FastifyPluginAsync } from 'fastify'; +import { acceptInvitation, changePassword, deleteTeam, getTeam, inviteToTeam, listTeams, newTeam, removeUser, revokeInvitation, saveTeam, setPermission } from './handlers'; + + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.addHook('onRequest', async (request, reply) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listTeams(request)); + fastify.post('/new', async (request, reply) => await newTeam(request, reply)); + + fastify.get('/team/:id', async (request, reply) => await getTeam(request, reply)); + fastify.post('/team/:id', async (request, reply) => await saveTeam(request, reply)); + fastify.delete('/team/:id', async (request, reply) => await deleteTeam(request, reply)); + + fastify.post('/team/:id/invitation/invite', async (request, reply) => await inviteToTeam(request, reply)) + fastify.post('/team/:id/invitation/accept', async (request) => await acceptInvitation(request)); + fastify.post('/team/:id/invitation/revoke', async (request) => await revokeInvitation(request)); + + + fastify.post('/team/:id/permission', async (request, reply) => await setPermission(request, reply)); + + fastify.delete('/user/remove', async (request, reply) => await removeUser(request, reply)); + fastify.post('/user/password', async (request, reply) => await changePassword(request, reply)); + // fastify.delete('/user', async (request, reply) => await deleteUser(request, reply)); + +}; + +export default root; diff --git a/apps/api/src/routes/api/v1/index.ts b/apps/api/src/routes/api/v1/index.ts new file mode 100644 index 000000000..c8438cffe --- /dev/null +++ b/apps/api/src/routes/api/v1/index.ts @@ -0,0 +1,51 @@ +import { FastifyPluginAsync } from 'fastify'; +import { scheduler } from '../../../lib/scheduler'; +import { checkUpdate, login, showDashboard, update, showUsage, getCurrentUser } from './handlers'; + +export interface Update { + Body: { latestVersion: string } +} +export interface Login { + Body: { email: string, password: string, isLogin: boolean } +} + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.get('/', async function (_request, reply) { + return reply.redirect(302, '/'); + }); + fastify.post('/login', async (request, reply) => { + const payload = await login(request, reply) + const token = fastify.jwt.sign(payload) + return { token, payload } + }); + + fastify.get('/user', { + onRequest: [fastify.authenticate] + }, async (request) => await getCurrentUser(request, fastify)); + + fastify.get('/undead', { + onRequest: [fastify.authenticate] + }, async function () { + return { message: 'nope' }; + }); + + fastify.get('/update', { + onRequest: [fastify.authenticate] + }, async (request) => await checkUpdate(request)); + + fastify.post( + '/update', { + onRequest: [fastify.authenticate] + }, + async (request) => await update(request) + ); + fastify.get('/resources', { + onRequest: [fastify.authenticate] + }, async (request) => await showDashboard(request)); + + fastify.get('/usage', { + onRequest: [fastify.authenticate] + }, async () => await showUsage()); +}; + +export default root; diff --git a/apps/api/src/routes/api/v1/services/handlers.ts b/apps/api/src/routes/api/v1/services/handlers.ts new file mode 100644 index 000000000..832e2b630 --- /dev/null +++ b/apps/api/src/routes/api/v1/services/handlers.ts @@ -0,0 +1,2417 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import fs from 'fs/promises'; +import yaml from 'js-yaml'; +import bcrypt from 'bcryptjs'; +import { prisma, uniqueName, asyncExecShell, getServiceImage, getServiceImages, configureServiceType, getServiceFromDB, getContainerUsage, removeService, isDomainConfigured, saveUpdateableFields, fixType, decrypt, encrypt, getServiceMainPort, createDirectories, ComposeFile, makeLabelForServices, getFreePort, getDomain, errorHandler, supportedServiceTypesAndVersions } from '../../../../lib/common'; +import { day } from '../../../../lib/dayjs'; +import { checkContainer, dockerInstance, getEngine, removeContainer } from '../../../../lib/docker'; + +export async function listServices(request: FastifyRequest) { + try { + const userId = request.user.userId; + const teamId = request.user.teamId; + let services = [] + if (teamId === '0') { + services = await prisma.service.findMany({ include: { teams: true } }); + } else { + services = await prisma.service.findMany({ + where: { teams: { some: { id: teamId } } }, + include: { teams: true } + }); + } + return { + services + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function newService(request: FastifyRequest, reply: FastifyReply) { + try { + const teamId = request.user.teamId; + const name = uniqueName(); + + const { id } = await prisma.service.create({ data: { name, teams: { connect: { id: teamId } } } }); + return reply.status(201).send({ id }); + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getService(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const service = await getServiceFromDB({ id, teamId }); + + if (!service) { + throw { status: 404, message: 'Service not found.' } + } + + const { destinationDockerId, destinationDocker, type, version, settings } = service; + let isRunning = false; + if (destinationDockerId) { + const host = getEngine(destinationDocker.engine); + const docker = dockerInstance({ destinationDocker }); + const baseImage = getServiceImage(type); + const images = getServiceImages(type); + docker.engine.pull(`${baseImage}:${version}`); + if (images?.length > 0) { + for (const image of images) { + docker.engine.pull(`${image}:latest`); + } + } + try { + const { stdout } = await asyncExecShell( + `DOCKER_HOST=${host} docker inspect --format '{{json .State}}' ${id}` + ); + + if (JSON.parse(stdout).Running) { + isRunning = true; + } + } catch (error) { + // + } + } + return { + isRunning, + service, + settings + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getServiceType(request: FastifyRequest) { + try { + return { + types: supportedServiceTypesAndVersions + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveServiceType(request: FastifyRequest, reply: FastifyReply) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const { type } = request.body; + await configureServiceType({ id, type }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getServiceVersions(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const { type } = await getServiceFromDB({ id, teamId }); + return { + type, + versions: supportedServiceTypesAndVersions.find((name) => name.name === type).versions + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveServiceVersion(request: FastifyRequest, reply: FastifyReply) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const { version } = request.body; + await prisma.service.update({ + where: { id }, + data: { version } + }); + return reply.code(201).send({}) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveServiceDestination(request: FastifyRequest, reply: FastifyReply) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + const { destinationId } = request.body; + await prisma.service.update({ + where: { id }, + data: { destinationDocker: { connect: { id: destinationId } } } + }); + return reply.code(201).send({}) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getServiceUsage(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + let usage = {}; + + const service = await getServiceFromDB({ id, teamId }); + if (service.destinationDockerId) { + [usage] = await Promise.all([getContainerUsage(service.destinationDocker.engine, id)]); + } + return { + usage + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } + +} +export async function getServiceLogs(request: FastifyRequest) { + try { + const teamId = request.user.teamId; + const { id } = request.params; + let { since = 0 } = request.query + if (since !== 0) { + since = day(since).unix(); + } + const { destinationDockerId, destinationDocker } = await prisma.service.findUnique({ + where: { id }, + include: { destinationDocker: true } + }); + if (destinationDockerId) { + const docker = dockerInstance({ destinationDocker }); + try { + const container = await docker.engine.getContainer(id); + if (container) { + const { default: ansi } = await import('strip-ansi') + const logs = ( + await container.logs({ + stdout: true, + stderr: true, + timestamps: true, + since, + tail: 5000 + }) + ) + .toString() + .split('\n') + .map((l) => ansi(l.slice(8))) + .filter((a) => a); + return { + logs + }; + } + } catch (error) { + const { statusCode } = error; + if (statusCode === 404) { + return { + logs: [] + }; + } + } + } + return { + message: 'No logs found.' + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteService(request: FastifyRequest) { + try { + const { id } = request.params; + await removeService({ id }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveServiceSettings(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params; + const { dualCerts } = request.body; + await prisma.service.update({ + where: { id }, + data: { dualCerts } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function checkService(request: FastifyRequest) { + try { + const { id } = request.params; + let { fqdn, exposePort, otherFqdns } = request.body; + if (fqdn) fqdn = fqdn.toLowerCase(); + if (otherFqdns && otherFqdns.length > 0) otherFqdns = otherFqdns.map((f) => f.toLowerCase()); + if (exposePort) exposePort = Number(exposePort); + let found = await isDomainConfigured({ id, fqdn }); + if (found) { + throw `Domain already configured.` + } + if (otherFqdns && otherFqdns.length > 0) { + for (const ofqdn of otherFqdns) { + found = await isDomainConfigured({ id, fqdn: ofqdn, checkOwn: true }); + if (found) { + throw "Domain already configured." + } + } + } + if (exposePort) { + const { default: getPort } = await import('get-port'); + exposePort = Number(exposePort); + + if (exposePort < 1024 || exposePort > 65535) { + throw `Exposed Port needs to be between 1024 and 65535.` + } + + const publicPort = await getPort({ port: exposePort }); + if (publicPort !== exposePort) { + throw `Port ${exposePort} is already in use.` + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveService(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params; + let { name, fqdn, exposePort, type } = request.body; + + if (fqdn) fqdn = fqdn.toLowerCase(); + if (exposePort) exposePort = Number(exposePort); + + type = fixType(type) + + const update = saveUpdateableFields(type, request.body[type]) + const data = { + fqdn, + name, + exposePort, + } + if (Object.keys(update).length > 0) { + data[type] = { update: update } + } + await prisma.service.update({ + where: { id }, data + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getServiceSecrets(request: FastifyRequest) { + try { + const { id } = request.params + let secrets = await prisma.serviceSecret.findMany({ + where: { serviceId: id }, + orderBy: { createdAt: 'desc' } + }); + secrets = secrets.map((secret) => { + secret.value = decrypt(secret.value); + return secret; + }); + + return { + secrets + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveServiceSecret(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + let { name, value, isBuildSecret, isPRMRSecret, isNew } = request.body + + if (isNew) { + const found = await prisma.serviceSecret.findFirst({ where: { name, serviceId: id } }); + if (found) { + throw `Secret ${name} already exists.` + } else { + value = encrypt(value); + await prisma.serviceSecret.create({ + data: { name, value, service: { connect: { id } } } + }); + } + } else { + value = encrypt(value); + const found = await prisma.serviceSecret.findFirst({ where: { serviceId: id, name } }); + + if (found) { + await prisma.serviceSecret.updateMany({ + where: { serviceId: id, name }, + data: { value } + }); + } else { + await prisma.serviceSecret.create({ + data: { name, value, service: { connect: { id } } } + }); + } + } + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteServiceSecret(request: FastifyRequest) { + try { + const { id } = request.params + const { name } = request.body + await prisma.serviceSecret.deleteMany({ where: { serviceId: id, name } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function getServiceStorages(request: FastifyRequest) { + try { + const { id } = request.params + const persistentStorages = await prisma.servicePersistentStorage.findMany({ + where: { serviceId: id } + }); + return { + persistentStorages + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function saveServiceStorage(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { path, newStorage, storageId } = request.body + + if (newStorage) { + await prisma.servicePersistentStorage.create({ + data: { path, service: { connect: { id } } } + }); + } else { + await prisma.servicePersistentStorage.update({ + where: { id: storageId }, + data: { path } + }); + } + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function deleteServiceStorage(request: FastifyRequest) { + try { + const { id } = request.params + const { path } = request.body + await prisma.servicePersistentStorage.deleteMany({ where: { serviceId: id, path } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function startService(request: FastifyRequest,) { + try { + const { type } = request.params + if (type === 'plausibleanalytics') { + return await startPlausibleAnalyticsService(request) + } + if (type === 'nocodb') { + return await startNocodbService(request) + } + if (type === 'minio') { + return await startMinioService(request) + } + if (type === 'vscodeserver') { + return await startVscodeService(request) + } + if (type === 'wordpress') { + return await startWordpressService(request) + } + if (type === 'vaultwarden') { + return await startVaultwardenService(request) + } + if (type === 'languagetool') { + return await startLanguageToolService(request) + } + if (type === 'n8n') { + return await startN8nService(request) + } + if (type === 'uptimekuma') { + return await startUptimekumaService(request) + } + if (type === 'ghost') { + return await startGhostService(request) + } + if (type === 'meilisearch') { + return await startMeilisearchService(request) + } + if (type === 'umami') { + return await startUmamiService(request) + } + if (type === 'hasura') { + return await startHasuraService(request) + } + if (type === 'fider') { + return await startFiderService(request) + } + throw `Service type ${type} not supported.` + } catch (error) { + throw { status: 500, message: error?.message || error } + } +} +export async function stopService(request: FastifyRequest) { + try { + const { type } = request.params + if (type === 'plausibleanalytics') { + return await stopPlausibleAnalyticsService(request) + } + if (type === 'nocodb') { + return await stopNocodbService(request) + } + if (type === 'minio') { + return await stopMinioService(request) + } + if (type === 'vscodeserver') { + return await stopVscodeService(request) + } + if (type === 'wordpress') { + return await stopWordpressService(request) + } + if (type === 'vaultwarden') { + return await stopVaultwardenService(request) + } + if (type === 'languagetool') { + return await stopLanguageToolService(request) + } + if (type === 'n8n') { + return await stopN8nService(request) + } + if (type === 'uptimekuma') { + return await stopUptimekumaService(request) + } + if (type === 'ghost') { + return await stopGhostService(request) + } + if (type === 'meilisearch') { + return await stopMeilisearchService(request) + } + if (type === 'umami') { + return await stopUmamiService(request) + } + if (type === 'hasura') { + return await stopHasuraService(request) + } + if (type === 'fider') { + return await stopFiderService(request) + } + throw `Service type ${type} not supported.` + } catch (error) { + throw { status: 500, message: error?.message || error } + } +} +export async function setSettingsService(request: FastifyRequest, reply: FastifyReply) { + try { + const { type } = request.params + if (type === 'wordpress') { + return await setWordpressSettings(request, reply) + } + throw `Service type ${type} not supported.` + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function setWordpressSettings(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { ownMysql } = request.body + await prisma.wordpress.update({ + where: { serviceId: id }, + data: { ownMysql } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startPlausibleAnalyticsService(request: FastifyRequest) { + try { + const { id } = request.params + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + fqdn, + destinationDockerId, + destinationDocker, + serviceSecret, + exposePort, + plausibleAnalytics: { + id: plausibleDbId, + username, + email, + password, + postgresqlDatabase, + postgresqlPassword, + postgresqlUser, + secretKeyBase + } + } = service; + const image = getServiceImage(type); + + const config = { + plausibleAnalytics: { + image: `${image}:${version}`, + environmentVariables: { + ADMIN_USER_EMAIL: email, + ADMIN_USER_NAME: username, + ADMIN_USER_PWD: password, + BASE_URL: fqdn, + SECRET_KEY_BASE: secretKeyBase, + DISABLE_AUTH: 'false', + DISABLE_REGISTRATION: 'true', + DATABASE_URL: `postgresql://${postgresqlUser}:${postgresqlPassword}@${id}-postgresql:5432/${postgresqlDatabase}`, + CLICKHOUSE_DATABASE_URL: `http://${id}-clickhouse:8123/plausible` + } + }, + postgresql: { + volume: `${plausibleDbId}-postgresql-data:/bitnami/postgresql/`, + image: 'bitnami/postgresql:13.2.0', + environmentVariables: { + POSTGRESQL_PASSWORD: postgresqlPassword, + POSTGRESQL_USERNAME: postgresqlUser, + POSTGRESQL_DATABASE: postgresqlDatabase + } + }, + clickhouse: { + volume: `${plausibleDbId}-clickhouse-data:/var/lib/clickhouse`, + image: 'yandex/clickhouse-server:21.3.2.5', + environmentVariables: {}, + ulimits: { + nofile: { + soft: 262144, + hard: 262144 + } + } + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.plausibleAnalytics.environmentVariables[secret.name] = secret.value; + }); + } + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('plausibleanalytics'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + + const clickhouseConfigXml = ` + + + warning + true + + + + + + + + + + `; + const clickhouseUserConfigXml = ` + + + + 0 + 0 + + + `; + + const initQuery = 'CREATE DATABASE IF NOT EXISTS plausible;'; + const initScript = 'clickhouse client --queries-file /docker-entrypoint-initdb.d/init.query'; + await fs.writeFile(`${workdir}/clickhouse-config.xml`, clickhouseConfigXml); + await fs.writeFile(`${workdir}/clickhouse-user-config.xml`, clickhouseUserConfigXml); + await fs.writeFile(`${workdir}/init.query`, initQuery); + await fs.writeFile(`${workdir}/init-db.sh`, initScript); + + const Dockerfile = ` +FROM ${config.clickhouse.image} +COPY ./clickhouse-config.xml /etc/clickhouse-server/users.d/logging.xml +COPY ./clickhouse-user-config.xml /etc/clickhouse-server/config.d/logging.xml +COPY ./init.query /docker-entrypoint-initdb.d/init.query +COPY ./init-db.sh /docker-entrypoint-initdb.d/init-db.sh`; + + await fs.writeFile(`${workdir}/Dockerfile`, Dockerfile); + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.plausibleAnalytics.image, + command: + 'sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh db init-admin && /entrypoint.sh run"', + networks: [network], + environment: config.plausibleAnalytics.environmentVariables, + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + depends_on: [`${id}-postgresql`, `${id}-clickhouse`], + labels: makeLabelForServices('plausibleAnalytics'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '10s', + max_attempts: 5, + window: '120s' + } + } + }, + [`${id}-postgresql`]: { + container_name: `${id}-postgresql`, + image: config.postgresql.image, + networks: [network], + environment: config.postgresql.environmentVariables, + volumes: [config.postgresql.volume], + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '10s', + max_attempts: 5, + window: '120s' + } + } + }, + [`${id}-clickhouse`]: { + build: workdir, + container_name: `${id}-clickhouse`, + networks: [network], + environment: config.clickhouse.environmentVariables, + volumes: [config.clickhouse.volume], + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '10s', + max_attempts: 5, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.postgresql.volume.split(':')[0]]: { + name: config.postgresql.volume.split(':')[0] + }, + [config.clickhouse.volume.split(':')[0]]: { + name: config.clickhouse.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell( + `DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up --build -d` + ); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopPlausibleAnalyticsService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + let found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + found = await checkContainer(engine, `${id}-postgresql`); + if (found) { + await removeContainer({ id: `${id}-postgresql`, engine }); + } + found = await checkContainer(engine, `${id}-clickhouse`); + if (found) { + await removeContainer({ id: `${id}-clickhouse`, engine }); + } + } + + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startNocodbService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort } = + service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('nocodb'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-nc:/usr/app/data`, + environmentVariables: {} + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + networks: [network], + volumes: [config.volume], + environment: config.environmentVariables, + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('nocodb'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopNocodbService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startMinioService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + fqdn, + destinationDockerId, + destinationDocker, + exposePort, + minio: { rootUser, rootUserPassword }, + serviceSecret + } = service; + + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('minio'); + + const publicPort = await getFreePort(); + + const consolePort = 9001; + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-minio-data:/data`, + environmentVariables: { + MINIO_ROOT_USER: rootUser, + MINIO_ROOT_PASSWORD: rootUserPassword, + MINIO_BROWSER_REDIRECT_URL: fqdn + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + command: `server /data --console-address ":${consolePort}"`, + environment: config.environmentVariables, + networks: [network], + volumes: [config.volume], + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('minio'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + await prisma.minio.update({ where: { serviceId: id }, data: { publicPort } }); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopMinioService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + await prisma.minio.update({ where: { serviceId: id }, data: { publicPort: null } }) + if (destinationDockerId) { + const engine = destinationDocker.engine; + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startVscodeService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + destinationDockerId, + destinationDocker, + serviceSecret, + persistentStorage, + exposePort, + vscodeserver: { password } + } = service; + + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('vscodeserver'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-vscodeserver-data:/home/coder`, + environmentVariables: { + PASSWORD: password + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + + const volumes = + persistentStorage?.map((storage) => { + return `${id}${storage.path.replace(/\//gi, '-')}:${storage.path}`; + }) || []; + + const composeVolumes = volumes.map((volume) => { + return { + [`${volume.split(':')[0]}`]: { + name: volume.split(':')[0] + } + }; + }); + const volumeMounts = Object.assign( + {}, + { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + }, + ...composeVolumes + ); + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + environment: config.environmentVariables, + networks: [network], + volumes: [config.volume, ...volumes], + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('vscodeServer'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: volumeMounts + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + + const changePermissionOn = persistentStorage.map((p) => p.path); + if (changePermissionOn.length > 0) { + await asyncExecShell( + `DOCKER_HOST=${host} docker exec -u root ${id} chown -R 1000:1000 ${changePermissionOn.join( + ' ' + )}` + ); + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopVscodeService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startWordpressService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + fqdn, + destinationDockerId, + serviceSecret, + destinationDocker, + exposePort, + wordpress: { + mysqlDatabase, + mysqlHost, + mysqlPort, + mysqlUser, + mysqlPassword, + extraConfig, + mysqlRootUser, + mysqlRootUserPassword, + ownMysql + } + } = service; + + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const image = getServiceImage(type); + const port = getServiceMainPort('wordpress'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const config = { + wordpress: { + image: `${image}:${version}`, + volume: `${id}-wordpress-data:/var/www/html`, + environmentVariables: { + WORDPRESS_DB_HOST: ownMysql ? `${mysqlHost}:${mysqlPort}` : `${id}-mysql`, + WORDPRESS_DB_USER: mysqlUser, + WORDPRESS_DB_PASSWORD: mysqlPassword, + WORDPRESS_DB_NAME: mysqlDatabase, + WORDPRESS_CONFIG_EXTRA: extraConfig + } + }, + mysql: { + image: `bitnami/mysql:5.7`, + volume: `${id}-mysql-data:/bitnami/mysql/data`, + environmentVariables: { + MYSQL_ROOT_PASSWORD: mysqlRootUserPassword, + MYSQL_ROOT_USER: mysqlRootUser, + MYSQL_USER: mysqlUser, + MYSQL_PASSWORD: mysqlPassword, + MYSQL_DATABASE: mysqlDatabase + } + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.wordpress.environmentVariables[secret.name] = secret.value; + }); + } + let composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.wordpress.image, + environment: config.wordpress.environmentVariables, + volumes: [config.wordpress.volume], + networks: [network], + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('wordpress'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.wordpress.volume.split(':')[0]]: { + name: config.wordpress.volume.split(':')[0] + } + } + }; + if (!ownMysql) { + composeFile.services[id].depends_on = [`${id}-mysql`]; + composeFile.services[`${id}-mysql`] = { + container_name: `${id}-mysql`, + image: config.mysql.image, + volumes: [config.mysql.volume], + environment: config.mysql.environmentVariables, + networks: [network], + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + }; + + composeFile.volumes[config.mysql.volume.split(':')[0]] = { + name: config.mysql.volume.split(':')[0] + }; + } + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopWordpressService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + destinationDockerId, + destinationDocker, + wordpress: { ftpEnabled } + } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + try { + const found = await checkContainer(engine, `${id}-mysql`); + if (found) { + await removeContainer({ id: `${id}-mysql`, engine }); + } + } catch (error) { + console.error(error); + } + try { + if (ftpEnabled) { + const found = await checkContainer(engine, `${id}-ftp`); + if (found) { + await removeContainer({ id: `${id}-ftp`, engine }); + } + await prisma.wordpress.update({ + where: { serviceId: id }, + data: { ftpEnabled: false } + }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startVaultwardenService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort } = + service; + + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('vaultwarden'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-vaultwarden-data:/data/`, + environmentVariables: {} + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + environment: config.environmentVariables, + networks: [network], + volumes: [config.volume], + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('vaultWarden'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopVaultwardenService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startLanguageToolService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort } = + service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('languagetool'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-ngrams:/ngrams`, + environmentVariables: {} + }; + + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + networks: [network], + environment: config.environmentVariables, + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + volumes: [config.volume], + labels: makeLabelForServices('languagetool'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopLanguageToolService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startN8nService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort } = + service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('n8n'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-n8n:/root/.n8n`, + environmentVariables: { + WEBHOOK_URL: `${service.fqdn}` + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + networks: [network], + volumes: [config.volume], + environment: config.environmentVariables, + restart: 'always', + labels: makeLabelForServices('n8n'), + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopN8nService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startUptimekumaService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort } = + service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('uptimekuma'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-uptimekuma:/app/data`, + environmentVariables: {} + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + networks: [network], + volumes: [config.volume], + environment: config.environmentVariables, + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('uptimekuma'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopUptimekumaService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startGhostService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + destinationDockerId, + destinationDocker, + serviceSecret, + exposePort, + fqdn, + ghost: { + defaultEmail, + defaultPassword, + mariadbRootUser, + mariadbRootUserPassword, + mariadbDatabase, + mariadbPassword, + mariadbUser + } + } = service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + const domain = getDomain(fqdn); + const isHttps = fqdn.startsWith('https://'); + const config = { + ghost: { + image: `${image}:${version}`, + volume: `${id}-ghost:/bitnami/ghost`, + environmentVariables: { + url: fqdn, + GHOST_HOST: domain, + GHOST_ENABLE_HTTPS: isHttps ? 'yes' : 'no', + GHOST_EMAIL: defaultEmail, + GHOST_PASSWORD: defaultPassword, + GHOST_DATABASE_HOST: `${id}-mariadb`, + GHOST_DATABASE_USER: mariadbUser, + GHOST_DATABASE_PASSWORD: mariadbPassword, + GHOST_DATABASE_NAME: mariadbDatabase, + GHOST_DATABASE_PORT_NUMBER: 3306 + } + }, + mariadb: { + image: `bitnami/mariadb:latest`, + volume: `${id}-mariadb:/bitnami/mariadb`, + environmentVariables: { + MARIADB_USER: mariadbUser, + MARIADB_PASSWORD: mariadbPassword, + MARIADB_DATABASE: mariadbDatabase, + MARIADB_ROOT_USER: mariadbRootUser, + MARIADB_ROOT_PASSWORD: mariadbRootUserPassword + } + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.ghost.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.ghost.image, + networks: [network], + volumes: [config.ghost.volume], + environment: config.ghost.environmentVariables, + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('ghost'), + depends_on: [`${id}-mariadb`], + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + }, + [`${id}-mariadb`]: { + container_name: `${id}-mariadb`, + image: config.mariadb.image, + networks: [network], + volumes: [config.mariadb.volume], + environment: config.mariadb.environmentVariables, + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.ghost.volume.split(':')[0]]: { + name: config.ghost.volume.split(':')[0] + }, + [config.mariadb.volume.split(':')[0]]: { + name: config.mariadb.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopGhostService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + let found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + found = await checkContainer(engine, `${id}-mariadb`); + if (found) { + await removeContainer({ id: `${id}-mariadb`, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startMeilisearchService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + meiliSearch: { masterKey } + } = service; + const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort } = + service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('meilisearch'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + image: `${image}:${version}`, + volume: `${id}-datams:/data.ms`, + environmentVariables: { + MEILI_MASTER_KEY: masterKey + } + }; + + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.environmentVariables[secret.name] = secret.value; + }); + } + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.image, + networks: [network], + environment: config.environmentVariables, + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + volumes: [config.volume], + labels: makeLabelForServices('meilisearch'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.volume.split(':')[0]]: { + name: config.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopMeilisearchService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startUmamiService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + destinationDockerId, + destinationDocker, + serviceSecret, + exposePort, + umami: { + umamiAdminPassword, + postgresqlUser, + postgresqlPassword, + postgresqlDatabase, + hashSalt + } + } = service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('umami'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + umami: { + image: `${image}:${version}`, + environmentVariables: { + DATABASE_URL: `postgresql://${postgresqlUser}:${postgresqlPassword}@${id}-postgresql:5432/${postgresqlDatabase}`, + DATABASE_TYPE: 'postgresql', + HASH_SALT: hashSalt + } + }, + postgresql: { + image: 'postgres:12-alpine', + volume: `${id}-postgresql-data:/var/lib/postgresql/data`, + environmentVariables: { + POSTGRES_USER: postgresqlUser, + POSTGRES_PASSWORD: postgresqlPassword, + POSTGRES_DB: postgresqlDatabase + } + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.umami.environmentVariables[secret.name] = secret.value; + }); + } + + const initDbSQL = ` + drop table if exists event; + drop table if exists pageview; + drop table if exists session; + drop table if exists website; + drop table if exists account; + + create table account ( + user_id serial primary key, + username varchar(255) unique not null, + password varchar(60) not null, + is_admin bool not null default false, + created_at timestamp with time zone default current_timestamp, + updated_at timestamp with time zone default current_timestamp + ); + + create table website ( + website_id serial primary key, + website_uuid uuid unique not null, + user_id int not null references account(user_id) on delete cascade, + name varchar(100) not null, + domain varchar(500), + share_id varchar(64) unique, + created_at timestamp with time zone default current_timestamp + ); + + create table session ( + session_id serial primary key, + session_uuid uuid unique not null, + website_id int not null references website(website_id) on delete cascade, + created_at timestamp with time zone default current_timestamp, + hostname varchar(100), + browser varchar(20), + os varchar(20), + device varchar(20), + screen varchar(11), + language varchar(35), + country char(2) + ); + + create table pageview ( + view_id serial primary key, + website_id int not null references website(website_id) on delete cascade, + session_id int not null references session(session_id) on delete cascade, + created_at timestamp with time zone default current_timestamp, + url varchar(500) not null, + referrer varchar(500) + ); + + create table event ( + event_id serial primary key, + website_id int not null references website(website_id) on delete cascade, + session_id int not null references session(session_id) on delete cascade, + created_at timestamp with time zone default current_timestamp, + url varchar(500) not null, + event_type varchar(50) not null, + event_value varchar(50) not null + ); + + create index website_user_id_idx on website(user_id); + + create index session_created_at_idx on session(created_at); + create index session_website_id_idx on session(website_id); + + create index pageview_created_at_idx on pageview(created_at); + create index pageview_website_id_idx on pageview(website_id); + create index pageview_session_id_idx on pageview(session_id); + create index pageview_website_id_created_at_idx on pageview(website_id, created_at); + create index pageview_website_id_session_id_created_at_idx on pageview(website_id, session_id, created_at); + + create index event_created_at_idx on event(created_at); + create index event_website_id_idx on event(website_id); + create index event_session_id_idx on event(session_id); + + insert into account (username, password, is_admin) values ('admin', '${bcrypt.hashSync( + umamiAdminPassword, + 10 + )}', true);`; + await fs.writeFile(`${workdir}/schema.postgresql.sql`, initDbSQL); + const Dockerfile = ` + FROM ${config.postgresql.image} + COPY ./schema.postgresql.sql /docker-entrypoint-initdb.d/schema.postgresql.sql`; + await fs.writeFile(`${workdir}/Dockerfile`, Dockerfile); + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.umami.image, + environment: config.umami.environmentVariables, + networks: [network], + volumes: [], + restart: 'always', + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + labels: makeLabelForServices('umami'), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + }, + depends_on: [`${id}-postgresql`] + }, + [`${id}-postgresql`]: { + build: workdir, + container_name: `${id}-postgresql`, + environment: config.postgresql.environmentVariables, + networks: [network], + volumes: [config.postgresql.volume], + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.postgresql.volume.split(':')[0]]: { + name: config.postgresql.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopUmamiService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + try { + const found = await checkContainer(engine, `${id}-postgresql`); + if (found) { + await removeContainer({ id: `${id}-postgresql`, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startHasuraService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + destinationDockerId, + destinationDocker, + serviceSecret, + exposePort, + hasura: { postgresqlUser, postgresqlPassword, postgresqlDatabase } + } = service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('hasura'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + hasura: { + image: `${image}:${version}`, + environmentVariables: { + HASURA_GRAPHQL_METADATA_DATABASE_URL: `postgresql://${postgresqlUser}:${postgresqlPassword}@${id}-postgresql:5432/${postgresqlDatabase}` + } + }, + postgresql: { + image: 'postgres:12-alpine', + volume: `${id}-postgresql-data:/var/lib/postgresql/data`, + environmentVariables: { + POSTGRES_USER: postgresqlUser, + POSTGRES_PASSWORD: postgresqlPassword, + POSTGRES_DB: postgresqlDatabase + } + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.hasura.environmentVariables[secret.name] = secret.value; + }); + } + + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.hasura.image, + environment: config.hasura.environmentVariables, + networks: [network], + volumes: [], + restart: 'always', + labels: makeLabelForServices('hasura'), + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + }, + depends_on: [`${id}-postgresql`] + }, + [`${id}-postgresql`]: { + image: config.postgresql.image, + container_name: `${id}-postgresql`, + environment: config.postgresql.environmentVariables, + networks: [network], + volumes: [config.postgresql.volume], + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.postgresql.volume.split(':')[0]]: { + name: config.postgresql.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopHasuraService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + try { + const found = await checkContainer(engine, `${id}-postgresql`); + if (found) { + await removeContainer({ id: `${id}-postgresql`, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +async function startFiderService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + type, + version, + fqdn, + destinationDockerId, + destinationDocker, + serviceSecret, + exposePort, + fider: { + postgresqlUser, + postgresqlPassword, + postgresqlDatabase, + jwtSecret, + emailNoreply, + emailMailgunApiKey, + emailMailgunDomain, + emailMailgunRegion, + emailSmtpHost, + emailSmtpPort, + emailSmtpUser, + emailSmtpPassword, + emailSmtpEnableStartTls + } + } = service; + const network = destinationDockerId && destinationDocker.network; + const host = getEngine(destinationDocker.engine); + const port = getServiceMainPort('fider'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + const domain = getDomain(fqdn); + const config = { + fider: { + image: `${image}:${version}`, + environmentVariables: { + BASE_URL: domain, + DATABASE_URL: `postgresql://${postgresqlUser}:${postgresqlPassword}@${id}-postgresql:5432/${postgresqlDatabase}?sslmode=disable`, + JWT_SECRET: `${jwtSecret.replace(/\$/g, '$$$')}`, + EMAIL_NOREPLY: emailNoreply, + EMAIL_MAILGUN_API: emailMailgunApiKey, + EMAIL_MAILGUN_REGION: emailMailgunRegion, + EMAIL_MAILGUN_DOMAIN: emailMailgunDomain, + EMAIL_SMTP_HOST: emailSmtpHost, + EMAIL_SMTP_PORT: emailSmtpPort, + EMAIL_SMTP_USER: emailSmtpUser, + EMAIL_SMTP_PASSWORD: emailSmtpPassword, + EMAIL_SMTP_ENABLE_STARTTLS: emailSmtpEnableStartTls + } + }, + postgresql: { + image: 'postgres:12-alpine', + volume: `${id}-postgresql-data:/var/lib/postgresql/data`, + environmentVariables: { + POSTGRES_USER: postgresqlUser, + POSTGRES_PASSWORD: postgresqlPassword, + POSTGRES_DB: postgresqlDatabase + } + } + }; + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.fider.environmentVariables[secret.name] = secret.value; + }); + } + + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.fider.image, + environment: config.fider.environmentVariables, + networks: [network], + volumes: [], + restart: 'always', + labels: makeLabelForServices('fider'), + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + }, + depends_on: [`${id}-postgresql`] + }, + [`${id}-postgresql`]: { + image: config.postgresql.image, + container_name: `${id}-postgresql`, + environment: config.postgresql.environmentVariables, + networks: [network], + volumes: [config.postgresql.volume], + restart: 'always', + deploy: { + restart_policy: { + condition: 'on-failure', + delay: '5s', + max_attempts: 3, + window: '120s' + } + } + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: { + [config.postgresql.volume.split(':')[0]]: { + name: config.postgresql.volume.split(':')[0] + } + } + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`); + await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`); + + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +async function stopFiderService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { destinationDockerId, destinationDocker, fqdn } = service; + if (destinationDockerId) { + const engine = destinationDocker.engine; + + try { + const found = await checkContainer(engine, id); + if (found) { + await removeContainer({ id, engine }); + } + } catch (error) { + console.error(error); + } + try { + const found = await checkContainer(engine, `${id}-postgresql`); + if (found) { + await removeContainer({ id: `${id}-postgresql`, engine }); + } + } catch (error) { + console.error(error); + } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function activatePlausibleUsers(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const teamId = request.user.teamId; + const { + destinationDockerId, + destinationDocker, + plausibleAnalytics: { postgresqlUser, postgresqlPassword, postgresqlDatabase } + } = await getServiceFromDB({ id, teamId }); + if (destinationDockerId) { + const docker = dockerInstance({ destinationDocker }); + const container = await docker.engine.getContainer(id); + const command = await container.exec({ + Cmd: [ + `psql -H postgresql://${postgresqlUser}:${postgresqlPassword}@localhost:5432/${postgresqlDatabase} -c "UPDATE users SET email_verified = true;"` + ] + }); + await command.start(); + return await reply.code(201).send() + } + throw { status: 500, message: 'Could not activate users.' } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/services/index.ts b/apps/api/src/routes/api/v1/services/index.ts new file mode 100644 index 000000000..2a6d8dff7 --- /dev/null +++ b/apps/api/src/routes/api/v1/services/index.ts @@ -0,0 +1,70 @@ +import { FastifyPluginAsync } from 'fastify'; +import { + activatePlausibleUsers, + checkService, + deleteService, + deleteServiceSecret, + deleteServiceStorage, + getService, + getServiceLogs, + getServiceSecrets, + getServiceStorages, + getServiceType, + getServiceUsage, + getServiceVersions, + listServices, + newService, + saveService, + saveServiceDestination, + saveServiceSecret, + saveServiceSettings, + saveServiceStorage, + saveServiceType, + saveServiceVersion, + setSettingsService, + startService, + stopService +} from './handlers'; + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.addHook('onRequest', async (request, reply) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listServices(request)); + fastify.post('/new', async (request, reply) => await newService(request, reply)); + + fastify.get('/:id', async (request) => await getService(request)); + fastify.post('/:id', async (request, reply) => await saveService(request, reply)); + fastify.delete('/:id', async (request) => await deleteService(request)); + + fastify.post('/:id/check', async (request) => await checkService(request)); + + fastify.post('/:id/settings', async (request, reply) => await saveServiceSettings(request, reply)); + + fastify.get('/:id/secrets', async (request) => await getServiceSecrets(request)); + fastify.post('/:id/secrets', async (request, reply) => await saveServiceSecret(request, reply)); + fastify.delete('/:id/secrets', async (request) => await deleteServiceSecret(request)); + + fastify.get('/:id/storages', async (request) => await getServiceStorages(request)); + fastify.post('/:id/storages', async (request, reply) => await saveServiceStorage(request, reply)); + fastify.delete('/:id/storages', async (request) => await deleteServiceStorage(request)); + + fastify.get('/:id/configuration/type', async (request) => await getServiceType(request)); + fastify.post('/:id/configuration/type', async (request, reply) => await saveServiceType(request, reply)); + + fastify.get('/:id/configuration/version', async (request) => await getServiceVersions(request)); + fastify.post('/:id/configuration/version', async (request, reply) => await saveServiceVersion(request, reply)); + + fastify.post('/:id/configuration/destination', async (request, reply) => await saveServiceDestination(request, reply)); + + fastify.get('/:id/usage', async (request) => await getServiceUsage(request)); + fastify.get('/:id/logs', async (request) => await getServiceLogs(request)); + + fastify.post('/:id/:type/start', async (request) => await startService(request)); + fastify.post('/:id/:type/stop', async (request) => await stopService(request)); + fastify.post('/:id/:type/settings', async (request, reply) => await setSettingsService(request, reply)); + + fastify.post('/:id/plausibleanalytics/activate', async (request, reply) => await activatePlausibleUsers(request, reply)); +}; + +export default root; diff --git a/apps/api/src/routes/api/v1/settings/handlers.ts b/apps/api/src/routes/api/v1/settings/handlers.ts new file mode 100644 index 000000000..d60ef7b6a --- /dev/null +++ b/apps/api/src/routes/api/v1/settings/handlers.ts @@ -0,0 +1,85 @@ +import { promises as dns } from 'dns'; + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { checkDomainsIsValidInDNS, errorHandler, getDomain, isDNSValid, isDomainConfigured, listSettings, prisma } from '../../../../lib/common'; + + +export async function listAllSettings(request: FastifyRequest) { + try { + const settings = await listSettings(); + return { + settings + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveSettings(request: FastifyRequest, reply: FastifyReply) { + try { + const { + fqdn, + isRegistrationEnabled, + dualCerts, + minPort, + maxPort, + isAutoUpdateEnabled, + isDNSCheckEnabled + } = request.body + const { id } = await listSettings(); + await prisma.setting.update({ + where: { id }, + data: { isRegistrationEnabled, dualCerts, isAutoUpdateEnabled, isDNSCheckEnabled } + }); + if (fqdn) { + await prisma.setting.update({ where: { id }, data: { fqdn } }); + } + if (minPort && maxPort) { + await prisma.setting.update({ where: { id }, data: { minPort, maxPort } }); + } + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function deleteDomain(request: FastifyRequest, reply: FastifyReply) { + try { + const { fqdn } = request.body + let ip; + try { + ip = await dns.resolve(fqdn); + } catch (error) { + // Do not care. + } + await prisma.setting.update({ where: { fqdn }, data: { fqdn: null } }); + return reply.redirect(302, ip ? `http://${ip[0]}:3000/settings` : undefined) + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function checkDomain(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params; + let { fqdn, forceSave, dualCerts, isDNSCheckEnabled } = request.body + if (fqdn) fqdn = fqdn.toLowerCase(); + const found = await isDomainConfigured({ id, fqdn }); + if (found) { + throw "Domain already configured"; + } + if (isDNSCheckEnabled && !forceSave) { + return await checkDomainsIsValidInDNS({ hostname: request.hostname, fqdn, dualCerts }); + } + return {}; + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function checkDNS(request: FastifyRequest, reply: FastifyReply) { + try { + const { id, domain } = request.params; + await isDNSValid(request.hostname, domain); + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/settings/index.ts b/apps/api/src/routes/api/v1/settings/index.ts new file mode 100644 index 000000000..2c7a69738 --- /dev/null +++ b/apps/api/src/routes/api/v1/settings/index.ts @@ -0,0 +1,17 @@ +import { FastifyPluginAsync } from 'fastify'; +import { checkDNS, checkDomain, deleteDomain, listAllSettings, saveSettings } from './handlers'; + + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.addHook('onRequest', async (request, reply) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listAllSettings(request)); + fastify.post('/', async (request, reply) => await saveSettings(request, reply)); + fastify.delete('/', async (request, reply) => await deleteDomain(request, reply)); + + fastify.get('/check', async (request, reply) => await checkDNS(request, reply)); + fastify.post('/check', async (request, reply) => await checkDomain(request, reply)); +}; + +export default root; diff --git a/apps/api/src/routes/api/v1/sources/handlers.ts b/apps/api/src/routes/api/v1/sources/handlers.ts new file mode 100644 index 000000000..d81a27d1e --- /dev/null +++ b/apps/api/src/routes/api/v1/sources/handlers.ts @@ -0,0 +1,182 @@ +import cuid from 'cuid'; +import type { FastifyRequest } from 'fastify'; +import { FastifyReply } from 'fastify'; +import { decrypt, encrypt, errorHandler, prisma } from '../../../../lib/common'; + +export async function listSources(request: FastifyRequest) { + try { + const teamId = request.user?.teamId; + const sources = await prisma.gitSource.findMany({ + where: { teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { teams: true, githubApp: true, gitlabApp: true } + }); + return { + sources + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveSource(request, reply) { + try { + const { id } = request.params + const { name, htmlUrl, apiUrl } = request.body + await prisma.gitSource.update({ + where: { id }, + data: { name, htmlUrl, apiUrl } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function getSource(request: FastifyRequest) { + try { + const { id } = request.params + const { teamId } = request.user + + const settings = await prisma.setting.findFirst({}); + if (settings.proxyPassword) settings.proxyPassword = decrypt(settings.proxyPassword); + + if (id === 'new') { + return { + source: { + name: null, + type: null, + htmlUrl: null, + apiUrl: null, + organization: null + }, + settings + } + } + + const source = await prisma.gitSource.findFirst({ + where: { id, teams: { some: { id: teamId === '0' ? undefined : teamId } } }, + include: { githubApp: true, gitlabApp: true } + }); + if (!source) { + throw { status: 404, message: 'Source not found.' } + } + + if (source?.githubApp?.clientSecret) + source.githubApp.clientSecret = decrypt(source.githubApp.clientSecret); + if (source?.githubApp?.webhookSecret) + source.githubApp.webhookSecret = decrypt(source.githubApp.webhookSecret); + if (source?.githubApp?.privateKey) source.githubApp.privateKey = decrypt(source.githubApp.privateKey); + if (source?.gitlabApp?.appSecret) source.gitlabApp.appSecret = decrypt(source.gitlabApp.appSecret); + + return { + source, + settings + }; + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function deleteSource(request) { + try { + const { id } = request.params + const source = await prisma.gitSource.delete({ + where: { id }, + include: { githubApp: true, gitlabApp: true } + }); + if (source.githubAppId) { + await prisma.githubApp.delete({ where: { id: source.githubAppId } }); + } + if (source.gitlabAppId) { + await prisma.gitlabApp.delete({ where: { id: source.gitlabAppId } }); + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } + +} +export async function saveGitHubSource(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { name, type, htmlUrl, apiUrl, organization } = request.body + const { teamId } = request.user + if (id === 'new') { + const newId = cuid() + await prisma.gitSource.create({ + data: { + id: newId, + name, + htmlUrl, + apiUrl, + organization, + type: 'github', + teams: { connect: { id: teamId } } + } + }); + return { + id: newId + } + } + throw { status: 500, message: 'Wrong request.' } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function saveGitLabSource(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { teamId } = request.user + let { type, name, htmlUrl, apiUrl, oauthId, appId, appSecret, groupName } = + request.body + + oauthId = Number(oauthId); + const encryptedAppSecret = encrypt(appSecret); + + if (id === 'new') { + const newId = cuid() + await prisma.gitSource.create({ data: { id: newId, type, apiUrl, htmlUrl, name, teams: { connect: { id: teamId } } } }); + await prisma.gitlabApp.create({ + data: { + teams: { connect: { id: teamId } }, + appId, + oauthId, + groupName, + appSecret: encryptedAppSecret, + gitSource: { connect: { id: newId } } + } + }); + return { + status: 201, + id: newId + } + } else { + await prisma.gitSource.update({ where: { id }, data: { type, apiUrl, htmlUrl, name } }); + await prisma.gitlabApp.update({ + where: { id }, + data: { + appId, + oauthId, + groupName, + appSecret: encryptedAppSecret, + } + }); + } + return { status: 201 }; + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function checkGitLabOAuthID(request: FastifyRequest) { + try { + const { oauthId } = request.body + const found = await prisma.gitlabApp.findFirst({ where: { oauthId: Number(oauthId) } }); + if (found) { + throw { status: 500, message: 'OAuthID already configured in Coolify.' } + } + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/sources/index.ts b/apps/api/src/routes/api/v1/sources/index.ts new file mode 100644 index 000000000..f741627f3 --- /dev/null +++ b/apps/api/src/routes/api/v1/sources/index.ts @@ -0,0 +1,21 @@ +import { FastifyPluginAsync } from 'fastify'; +import { checkGitLabOAuthID, deleteSource, getSource, listSources, saveGitHubSource, saveGitLabSource, saveSource } from './handlers'; + + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.addHook('onRequest', async (request, reply) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listSources(request)); + + fastify.get('/:id', async (request) => await getSource(request)); + fastify.post('/:id', async (request, reply) => await saveSource(request, reply)); + fastify.delete('/:id', async (request) => await deleteSource(request)); + + fastify.post('/:id/check', async (request) => await checkGitLabOAuthID(request)); + fastify.post('/:id/github', async (request, reply) => await saveGitHubSource(request, reply)); + fastify.post('/:id/gitlab', async (request, reply) => await saveGitLabSource(request, reply)); + +}; + +export default root; diff --git a/apps/api/src/routes/webhooks/github/handlers.ts b/apps/api/src/routes/webhooks/github/handlers.ts new file mode 100644 index 000000000..1ff6731ea --- /dev/null +++ b/apps/api/src/routes/webhooks/github/handlers.ts @@ -0,0 +1,222 @@ +import axios from "axios"; +import cuid from "cuid"; +import crypto from "crypto"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { encrypt, errorHandler, isDev, prisma } from "../../../lib/common"; +import { checkContainer, removeContainer } from "../../../lib/docker"; +import { scheduler } from "../../../lib/scheduler"; +import { getApplicationFromDB, getApplicationFromDBWebhook } from "../../api/v1/applications/handlers"; + +export async function installGithub(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const { gitSourceId, installation_id } = request.query; + const source = await prisma.gitSource.findUnique({ + where: { id: gitSourceId }, + include: { githubApp: true } + }); + await prisma.githubApp.update({ + where: { id: source.githubAppId }, + data: { installationId: Number(installation_id) } + }); + if (isDev) { + return reply.redirect(`http://localhost:3000/sources/${gitSourceId}`) + } else { + return reply.redirect(`/sources/${gitSourceId}`) + } + + } catch ({ status, message }) { + return errorHandler({ status, message }) + } + +} +export async function configureGitHubApp(request, reply) { + try { + const { code, state } = request.query; + const { apiUrl } = await prisma.gitSource.findFirst({ + where: { id: state }, + include: { githubApp: true, gitlabApp: true } + }); + + const { data }: any = await axios.post(`${apiUrl}/app-manifests/${code}/conversions`); + const { id, client_id, slug, client_secret, pem, webhook_secret } = data + + const encryptedClientSecret = encrypt(client_secret); + const encryptedWebhookSecret = encrypt(webhook_secret); + const encryptedPem = encrypt(pem); + await prisma.githubApp.create({ + data: { + appId: id, + name: slug, + clientId: client_id, + clientSecret: encryptedClientSecret, + webhookSecret: encryptedWebhookSecret, + privateKey: encryptedPem, + gitSource: { connect: { id: state } } + } + }); + if (isDev) { + return reply.redirect(`http://localhost:3000/sources/${state}`) + } else { + return reply.redirect(`/sources/${state}`) + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} +export async function gitHubEvents(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const buildId = cuid(); + const allowedGithubEvents = ['push', 'pull_request']; + const allowedActions = ['opened', 'reopened', 'synchronize', 'closed']; + const githubEvent = request.headers['x-github-event']?.toString().toLowerCase(); + const githubSignature = request.headers['x-hub-signature-256']?.toString().toLowerCase(); + if (!allowedGithubEvents.includes(githubEvent)) { + throw { status: 500, message: 'Event not allowed.' } + } + let repository, projectId, branch; + const body = request.body + if (githubEvent === 'push') { + repository = body.repository; + projectId = repository.id; + branch = body.ref.split('/')[2]; + } else if (githubEvent === 'pull_request') { + repository = body.pull_request.head.repo; + projectId = repository.id; + branch = body.pull_request.head.ref.split('/')[2]; + } + + const applicationFound = await getApplicationFromDBWebhook(projectId, branch); + if (applicationFound) { + const webhookSecret = applicationFound.gitSource.githubApp.webhookSecret || null; + //@ts-ignore + const hmac = crypto.createHmac('sha256', webhookSecret); + const digest = Buffer.from( + 'sha256=' + hmac.update(JSON.stringify(body)).digest('hex'), + 'utf8' + ); + if (!isDev) { + const checksum = Buffer.from(githubSignature, 'utf8'); + //@ts-ignore + if (checksum.length !== digest.length || !crypto.timingSafeEqual(digest, checksum)) { + throw { status: 500, message: 'SHA256 checksum failed. Are you doing something fishy?' } + }; + } + + + if (githubEvent === 'push') { + if (!applicationFound.configHash) { + const configHash = crypto + //@ts-ignore + .createHash('sha256') + .update( + JSON.stringify({ + buildPack: applicationFound.buildPack, + port: applicationFound.port, + exposePort: applicationFound.exposePort, + installCommand: applicationFound.installCommand, + buildCommand: applicationFound.buildCommand, + startCommand: applicationFound.startCommand + }) + ) + .digest('hex'); + await prisma.application.updateMany({ + where: { branch, projectId }, + data: { configHash } + }); + } + await prisma.application.update({ + where: { id: applicationFound.id }, + data: { updatedAt: new Date() } + }); + await prisma.build.create({ + data: { + id: buildId, + applicationId: applicationFound.id, + destinationDockerId: applicationFound.destinationDocker.id, + gitSourceId: applicationFound.gitSource.id, + githubAppId: applicationFound.gitSource.githubApp?.id, + gitlabAppId: applicationFound.gitSource.gitlabApp?.id, + status: 'queued', + type: 'webhook_commit' + } + }); + scheduler.workers.get('deployApplication').postMessage({ + build_id: buildId, + type: 'webhook_commit', + ...applicationFound + }); + + return { + message: 'Queued. Thank you!' + }; + } else if (githubEvent === 'pull_request') { + const pullmergeRequestId = body.number; + const pullmergeRequestAction = body.action; + const sourceBranch = body.pull_request.head.ref; + if (!allowedActions.includes(pullmergeRequestAction)) { + throw { status: 500, message: 'Action not allowed.' } + } + + if (applicationFound.settings.previews) { + if (applicationFound.destinationDockerId) { + const isRunning = await checkContainer( + applicationFound.destinationDocker.engine, + applicationFound.id + ); + if (!isRunning) { + throw { status: 500, message: 'Application not running.' } + } + } + if ( + pullmergeRequestAction === 'opened' || + pullmergeRequestAction === 'reopened' || + pullmergeRequestAction === 'synchronize' + ) { + await prisma.application.update({ + where: { id: applicationFound.id }, + data: { updatedAt: new Date() } + }); + await prisma.build.create({ + data: { + id: buildId, + applicationId: applicationFound.id, + destinationDockerId: applicationFound.destinationDocker.id, + gitSourceId: applicationFound.gitSource.id, + githubAppId: applicationFound.gitSource.githubApp?.id, + gitlabAppId: applicationFound.gitSource.gitlabApp?.id, + status: 'queued', + type: 'webhook_pr' + } + }); + scheduler.workers.get('deployApplication').postMessage({ + build_id: buildId, + type: 'webhook_pr', + ...applicationFound, + sourceBranch, + pullmergeRequestId + }); + + return { + message: 'Queued. Thank you!' + }; + } else if (pullmergeRequestAction === 'closed') { + if (applicationFound.destinationDockerId) { + const id = `${applicationFound.id}-${pullmergeRequestId}`; + const engine = applicationFound.destinationDocker.engine; + await removeContainer({ id, engine }); + } + return { + message: 'Removed preview. Thank you!' + }; + } + } else { + throw { status: 500, message: 'Pull request previews are not enabled.' } + } + } + } + throw { status: 500, message: 'Not handled event.' } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } + +} \ No newline at end of file diff --git a/apps/api/src/routes/webhooks/github/index.ts b/apps/api/src/routes/webhooks/github/index.ts new file mode 100644 index 000000000..2af33366a --- /dev/null +++ b/apps/api/src/routes/webhooks/github/index.ts @@ -0,0 +1,10 @@ +import { FastifyPluginAsync } from 'fastify'; +import { configureGitHubApp, gitHubEvents, installGithub } from './handlers'; + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.get('/', async (request, reply) => configureGitHubApp(request, reply)); + fastify.get('/install', async (request, reply) => installGithub(request, reply)); + fastify.post('/events', async (request, reply) => gitHubEvents(request, reply)); +}; + +export default root; diff --git a/apps/api/src/routes/webhooks/gitlab/handlers.ts b/apps/api/src/routes/webhooks/gitlab/handlers.ts new file mode 100644 index 000000000..9b1dd3e3c --- /dev/null +++ b/apps/api/src/routes/webhooks/gitlab/handlers.ts @@ -0,0 +1,178 @@ +import axios from "axios"; +import cuid from "cuid"; +import crypto from "crypto"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import { encrypt, errorHandler, isDev, listSettings, prisma } from "../../../lib/common"; +import { checkContainer, removeContainer } from "../../../lib/docker"; +import { scheduler } from "../../../lib/scheduler"; +import { getApplicationFromDB, getApplicationFromDBWebhook } from "../../api/v1/applications/handlers"; + +export async function configureGitLabApp(request: FastifyRequest, reply: FastifyReply) { + try { + const { code, state } = request.query; + const { fqdn } = await listSettings(); + const { gitSource: { gitlabApp: { appId, appSecret }, htmlUrl } } = await getApplicationFromDB(state, undefined); + + let domain = `http://${request.hostname}`; + if (fqdn) domain = fqdn; + if (isDev) { + domain = `http://localhost:3001`; + } + const params = new URLSearchParams({ + client_id: appId, + client_secret: appSecret, + code, + state, + grant_type: 'authorization_code', + redirect_uri: `${domain}/webhooks/gitlab` + }); + const { data } = await axios.post(`${htmlUrl}/oauth/token`, params) + if (isDev) { + return reply.redirect(`http://localhost:3000/webhooks/success?token=${data.access_token}`) + } + return reply.redirect(`/webhooks/success?token=${data.access_token}`) + } catch ({ status, message, ...other }) { + console.log(other) + return errorHandler({ status, message }) + } +} +export async function gitLabEvents(request: FastifyRequest, reply: FastifyReply) { + try { + const buildId = cuid(); + + const allowedActions = ['opened', 'reopen', 'close', 'open', 'update']; + const { object_kind: objectKind, ref, project_id } = request.body + const webhookToken = request.headers['x-gitlab-token']; + if (!webhookToken) { + throw { status: 500, message: 'Invalid webhookToken.' } + } + if (objectKind === 'push') { + const projectId = Number(project_id); + const branch = ref.split('/')[2]; + const applicationFound = await getApplicationFromDBWebhook(projectId, branch); + if (applicationFound) { + if (!applicationFound.configHash) { + const configHash = crypto + .createHash('sha256') + .update( + JSON.stringify({ + buildPack: applicationFound.buildPack, + port: applicationFound.port, + exposePort: applicationFound.exposePort, + installCommand: applicationFound.installCommand, + buildCommand: applicationFound.buildCommand, + startCommand: applicationFound.startCommand + }) + ) + .digest('hex'); + await prisma.application.updateMany({ + where: { branch, projectId }, + data: { configHash } + }); + } + await prisma.application.update({ + where: { id: applicationFound.id }, + data: { updatedAt: new Date() } + }); + await prisma.build.create({ + data: { + id: buildId, + applicationId: applicationFound.id, + destinationDockerId: applicationFound.destinationDocker.id, + gitSourceId: applicationFound.gitSource.id, + githubAppId: applicationFound.gitSource.githubApp?.id, + gitlabAppId: applicationFound.gitSource.gitlabApp?.id, + status: 'queued', + type: 'webhook_commit' + } + }); + + scheduler.workers.get('deployApplication').postMessage({ + build_id: buildId, + type: 'webhook_commit', + ...applicationFound + }); + + return { + message: 'Queued. Thank you!' + }; + + } + } else if (objectKind === 'merge_request') { + const { object_attributes: { work_in_progress: isDraft, action, source_branch: sourceBranch, target_branch: targetBranch, iid: pullmergeRequestId }, project: { id } } = request.body + + const projectId = Number(id); + if (!allowedActions.includes(action)) { + throw { status: 500, message: 'Action not allowed.' } + } + if (isDraft) { + throw { status: 500, message: 'Draft MR, do nothing.' } + } + + const applicationFound = await getApplicationFromDBWebhook(projectId, targetBranch); + if (applicationFound) { + if (applicationFound.settings.previews) { + if (applicationFound.destinationDockerId) { + const isRunning = await checkContainer( + applicationFound.destinationDocker.engine, + applicationFound.id + ); + if (!isRunning) { + throw { status: 500, message: 'Application not running.' } + } + } + if (!isDev && applicationFound.gitSource.gitlabApp.webhookToken !== webhookToken) { + throw { status: 500, message: 'Invalid webhookToken. Are you doing something nasty?!' } + } + if ( + action === 'opened' || + action === 'reopen' || + action === 'open' || + action === 'update' + ) { + await prisma.application.update({ + where: { id: applicationFound.id }, + data: { updatedAt: new Date() } + }); + await prisma.build.create({ + data: { + id: buildId, + applicationId: applicationFound.id, + destinationDockerId: applicationFound.destinationDocker.id, + gitSourceId: applicationFound.gitSource.id, + githubAppId: applicationFound.gitSource.githubApp?.id, + gitlabAppId: applicationFound.gitSource.gitlabApp?.id, + status: 'queued', + type: 'webhook_mr' + } + }); + scheduler.workers.get('deployApplication').postMessage({ + build_id: buildId, + type: 'webhook_mr', + ...applicationFound, + sourceBranch, + pullmergeRequestId + }); + + return { + message: 'Queued. Thank you!' + }; + } else if (action === 'close') { + if (applicationFound.destinationDockerId) { + const id = `${applicationFound.id}-${pullmergeRequestId}`; + const engine = applicationFound.destinationDocker.engine; + await removeContainer({ id, engine }); + } + return { + message: 'Removed preview. Thank you!' + }; + } + } + throw { status: 500, message: 'Merge request previews are not enabled.' } + } + } + throw { status: 500, message: 'Not handled event.' } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/webhooks/gitlab/index.ts b/apps/api/src/routes/webhooks/gitlab/index.ts new file mode 100644 index 000000000..22badd03f --- /dev/null +++ b/apps/api/src/routes/webhooks/gitlab/index.ts @@ -0,0 +1,9 @@ +import { FastifyPluginAsync } from 'fastify'; +import { configureGitLabApp, gitLabEvents } from './handlers'; + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.get('/', async (request, reply) => configureGitLabApp(request, reply)); + fastify.post('/events', async (request, reply) => gitLabEvents(request, reply)); +}; + +export default root; diff --git a/apps/api/src/routes/webhooks/traefik/handlers.ts b/apps/api/src/routes/webhooks/traefik/handlers.ts new file mode 100644 index 000000000..2c91c851d --- /dev/null +++ b/apps/api/src/routes/webhooks/traefik/handlers.ts @@ -0,0 +1,489 @@ +import { FastifyRequest } from "fastify"; +import { asyncExecShell, errorHandler, getDomain, isDev, listServicesWithIncludes, prisma, supportedServiceTypesAndVersions } from "../../../lib/common"; +import { getEngine } from "../../../lib/docker"; + +function configureMiddleware( + { id, container, port, domain, nakedDomain, isHttps, isWWW, isDualCerts, scriptName, type }, + traefik +) { + if (isHttps) { + traefik.http.routers[id] = { + entrypoints: ['web'], + rule: `Host(\`${nakedDomain}\`) || Host(\`www.${nakedDomain}\`)`, + service: `${id}`, + middlewares: ['redirect-to-https'] + }; + + traefik.http.services[id] = { + loadbalancer: { + servers: [ + { + url: `http://${container}:${port}` + } + ] + } + }; + + if (isDualCerts) { + traefik.http.routers[`${id}-secure`] = { + entrypoints: ['websecure'], + rule: `Host(\`${nakedDomain}\`) || Host(\`www.${nakedDomain}\`)`, + service: `${id}`, + tls: { + certresolver: 'letsencrypt' + }, + middlewares: [] + }; + } else { + if (isWWW) { + traefik.http.routers[`${id}-secure-www`] = { + entrypoints: ['websecure'], + rule: `Host(\`www.${nakedDomain}\`)`, + service: `${id}`, + tls: { + certresolver: 'letsencrypt' + }, + middlewares: [] + }; + traefik.http.routers[`${id}-secure`] = { + entrypoints: ['websecure'], + rule: `Host(\`${nakedDomain}\`)`, + service: `${id}`, + tls: { + domains: { + main: `${domain}` + } + }, + middlewares: ['redirect-to-www'] + }; + traefik.http.routers[`${id}`].middlewares.push('redirect-to-www'); + } else { + traefik.http.routers[`${id}-secure-www`] = { + entrypoints: ['websecure'], + rule: `Host(\`www.${nakedDomain}\`)`, + service: `${id}`, + tls: { + domains: { + main: `${domain}` + } + }, + middlewares: ['redirect-to-non-www'] + }; + traefik.http.routers[`${id}-secure`] = { + entrypoints: ['websecure'], + rule: `Host(\`${domain}\`)`, + service: `${id}`, + tls: { + certresolver: 'letsencrypt' + }, + middlewares: [] + }; + traefik.http.routers[`${id}`].middlewares.push('redirect-to-non-www'); + } + } + } else { + traefik.http.routers[id] = { + entrypoints: ['web'], + rule: `Host(\`${nakedDomain}\`) || Host(\`www.${nakedDomain}\`)`, + service: `${id}`, + middlewares: [] + }; + + traefik.http.routers[`${id}-secure`] = { + entrypoints: ['websecure'], + rule: `Host(\`${nakedDomain}\`) || Host(\`www.${nakedDomain}\`)`, + service: `${id}`, + tls: { + domains: { + main: `${nakedDomain}` + } + }, + middlewares: ['redirect-to-http'] + }; + + traefik.http.services[id] = { + loadbalancer: { + servers: [ + { + url: `http://${container}:${port}` + } + ] + } + }; + + if (!isDualCerts) { + if (isWWW) { + traefik.http.routers[`${id}`].middlewares.push('redirect-to-www'); + traefik.http.routers[`${id}-secure`].middlewares.push('redirect-to-www'); + } else { + traefik.http.routers[`${id}`].middlewares.push('redirect-to-non-www'); + traefik.http.routers[`${id}-secure`].middlewares.push('redirect-to-non-www'); + } + } + } + + if (type === 'plausibleanalytics' && scriptName && scriptName !== 'plausible.js') { + if (!traefik.http.routers[`${id}`].middlewares.includes(`${id}-redir`)) { + traefik.http.routers[`${id}`].middlewares.push(`${id}-redir`); + } + if (!traefik.http.routers[`${id}-secure`].middlewares.includes(`${id}-redir`)) { + traefik.http.routers[`${id}-secure`].middlewares.push(`${id}-redir`); + } + } +} + + +export async function traefikConfiguration(request, reply) { + try { + const traefik = { + http: { + routers: {}, + services: {}, + middlewares: { + 'redirect-to-https': { + redirectscheme: { + scheme: 'https' + } + }, + 'redirect-to-http': { + redirectscheme: { + scheme: 'http' + } + }, + 'redirect-to-non-www': { + redirectregex: { + regex: '^https?://www\\.(.+)', + replacement: 'http://${1}' + } + }, + 'redirect-to-www': { + redirectregex: { + regex: '^https?://(?:www\\.)?(.+)', + replacement: 'http://www.${1}' + } + } + } + } + }; + const applications = await prisma.application.findMany({ + include: { destinationDocker: true, settings: true } + }); + const data = { + applications: [], + services: [], + coolify: [] + }; + for (const application of applications) { + const { + fqdn, + id, + port, + destinationDocker, + destinationDockerId, + settings: { previews, dualCerts } + } = application; + if (destinationDockerId) { + const { engine, network } = destinationDocker; + const isRunning = true; + if (fqdn) { + const domain = getDomain(fqdn); + const nakedDomain = domain.replace(/^www\./, ''); + const isHttps = fqdn.startsWith('https://'); + const isWWW = fqdn.includes('www.'); + if (isRunning) { + data.applications.push({ + id, + container: id, + port: port || 3000, + domain, + nakedDomain, + isRunning, + isHttps, + isWWW, + isDualCerts: dualCerts + }); + } + if (previews) { + const host = getEngine(engine); + const { stdout } = await asyncExecShell( + `DOCKER_HOST=${host} docker container ls --filter="status=running" --filter="network=${network}" --filter="name=${id}-" --format="{{json .Names}}"` + ); + const containers = stdout + .trim() + .split('\n') + .filter((a) => a) + .map((c) => c.replace(/"/g, '')); + if (containers.length > 0) { + for (const container of containers) { + const previewDomain = `${container.split('-')[1]}.${domain}`; + const nakedDomain = previewDomain.replace(/^www\./, ''); + data.applications.push({ + id: container, + container, + port: port || 3000, + domain: previewDomain, + isRunning, + nakedDomain, + isHttps, + isWWW, + isDualCerts: dualCerts + }); + } + } + } + } + } + } + const services = await listServicesWithIncludes(); + + for (const service of services) { + const { + fqdn, + id, + type, + destinationDocker, + destinationDockerId, + dualCerts, + plausibleAnalytics + } = service; + if (destinationDockerId) { + const { engine } = destinationDocker; + const found = supportedServiceTypesAndVersions.find((a) => a.name === type); + if (found) { + const port = found.ports.main; + const publicPort = service[type]?.publicPort; + const isRunning = true; + if (fqdn) { + const domain = getDomain(fqdn); + const nakedDomain = domain.replace(/^www\./, ''); + const isHttps = fqdn.startsWith('https://'); + const isWWW = fqdn.includes('www.'); + if (isRunning) { + // Plausible Analytics custom script + let scriptName = false; + if (type === 'plausibleanalytics' && plausibleAnalytics.scriptName !== 'plausible.js') { + scriptName = plausibleAnalytics.scriptName; + } + + let container = id; + let otherDomain = null; + let otherNakedDomain = null; + let otherIsHttps = null; + let otherIsWWW = null; + + if (type === 'minio' && service.minio.apiFqdn) { + otherDomain = getDomain(service.minio.apiFqdn); + otherNakedDomain = otherDomain.replace(/^www\./, ''); + otherIsHttps = service.minio.apiFqdn.startsWith('https://'); + otherIsWWW = service.minio.apiFqdn.includes('www.'); + } + data.services.push({ + id, + container, + type, + otherDomain, + otherNakedDomain, + otherIsHttps, + otherIsWWW, + port, + publicPort, + domain, + nakedDomain, + isRunning, + isHttps, + isWWW, + isDualCerts: dualCerts, + scriptName + }); + } + } + } + } + } + + const { fqdn, dualCerts } = await prisma.setting.findFirst(); + if (fqdn) { + const domain = getDomain(fqdn); + const nakedDomain = domain.replace(/^www\./, ''); + const isHttps = fqdn.startsWith('https://'); + const isWWW = fqdn.includes('www.'); + data.coolify.push({ + id: isDev ? 'host.docker.internal' : 'coolify', + container: isDev ? 'host.docker.internal' : 'coolify', + port: 3000, + domain, + nakedDomain, + isHttps, + isWWW, + isDualCerts: dualCerts + }); + } + for (const application of data.applications) { + configureMiddleware(application, traefik); + } + for (const service of data.services) { + const { id, scriptName } = service; + + configureMiddleware(service, traefik); + if (service.type === 'minio') { + service.id = id + '-minio'; + service.container = id; + service.domain = service.otherDomain; + service.nakedDomain = service.otherNakedDomain; + service.isHttps = service.otherIsHttps; + service.isWWW = service.otherIsWWW; + service.port = 9000; + configureMiddleware(service, traefik); + } + + if (scriptName) { + traefik.http.middlewares[`${id}-redir`] = { + replacepathregex: { + regex: `/js/${scriptName}`, + replacement: '/js/plausible.js' + } + }; + } + } + for (const coolify of data.coolify) { + configureMiddleware(coolify, traefik); + } + if (Object.keys(traefik.http.routers).length === 0) { + traefik.http.routers = null; + } + if (Object.keys(traefik.http.services).length === 0) { + traefik.http.services = null; + } + return { + ...traefik + } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + +export async function traefikOtherConfiguration(request: FastifyRequest, reply) { + try { + const { id } = request.query + if (id) { + const { privatePort, publicPort, type, address = id } = request.query + let traefik = {}; + if (publicPort && type && privatePort) { + if (type === 'tcp') { + traefik = { + [type]: { + routers: { + [id]: { + entrypoints: [type], + rule: `HostSNI(\`*\`)`, + service: id + } + }, + services: { + [id]: { + loadbalancer: { + servers: [{ address: `${address}:${privatePort}` }] + } + } + } + } + }; + } else if (type === 'http') { + const service = await prisma.service.findFirst({ + where: { id }, + include: { minio: true } + }); + if (service) { + if (service.type === 'minio') { + if (service?.minio?.apiFqdn) { + const { + minio: { apiFqdn } + } = service; + const domain = getDomain(apiFqdn); + const isHttps = apiFqdn.startsWith('https://'); + traefik = { + [type]: { + routers: { + [id]: { + entrypoints: [type], + rule: `Host(\`${domain}\`)`, + service: id + } + }, + services: { + [id]: { + loadbalancer: { + servers: [{ url: `http://${id}:${privatePort}` }] + } + } + } + } + }; + if (isHttps) { + if (isDev) { + traefik[type].routers[id].tls = { + domains: { + main: `${domain}` + } + }; + } else { + traefik[type].routers[id].tls = { + certresolver: 'letsencrypt' + }; + } + } + } + } else { + if (service?.fqdn) { + const domain = getDomain(service.fqdn); + const isHttps = service.fqdn.startsWith('https://'); + traefik = { + [type]: { + routers: { + [id]: { + entrypoints: [type], + rule: `Host(\`${domain}:${privatePort}\`)`, + service: id + } + }, + services: { + [id]: { + loadbalancer: { + servers: [{ url: `http://${id}:${privatePort}` }] + } + } + } + } + }; + if (isHttps) { + if (isDev) { + traefik[type].routers[id].tls = { + domains: { + main: `${domain}` + } + }; + } else { + traefik[type].routers[id].tls = { + certresolver: 'letsencrypt' + }; + } + } + } + } + } else { + throw { status: 500 } + } + } + } else { + throw { status: 500 } + } + return { + ...traefik + }; + } + throw { status: 500 } + } catch ({ status, message }) { + console.log(status, message); + return errorHandler({ status, message }) + } +} \ No newline at end of file diff --git a/apps/api/src/routes/webhooks/traefik/index.ts b/apps/api/src/routes/webhooks/traefik/index.ts new file mode 100644 index 000000000..bb79c6b05 --- /dev/null +++ b/apps/api/src/routes/webhooks/traefik/index.ts @@ -0,0 +1,9 @@ +import { FastifyPluginAsync } from 'fastify'; +import { traefikConfiguration, traefikOtherConfiguration } from './handlers'; + +const root: FastifyPluginAsync = async (fastify, opts): Promise => { + fastify.get('/main.json', async (request, reply) => traefikConfiguration(request, reply)); + fastify.get('/other.json', async (request, reply) => traefikOtherConfiguration(request, reply)); +}; + +export default root; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 000000000..8900da4f0 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "resolveJsonModule":true, + "downlevelIteration": true + } + } \ No newline at end of file diff --git a/apps/ui/.eslintignore b/apps/ui/.eslintignore new file mode 100644 index 000000000..38972655f --- /dev/null +++ b/apps/ui/.eslintignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/.eslintrc.cjs b/apps/ui/.eslintrc.cjs similarity index 100% rename from .eslintrc.cjs rename to apps/ui/.eslintrc.cjs diff --git a/apps/ui/.npmrc b/apps/ui/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/apps/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/apps/ui/.prettierignore b/apps/ui/.prettierignore new file mode 100644 index 000000000..38972655f --- /dev/null +++ b/apps/ui/.prettierignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/apps/ui/.prettierrc b/apps/ui/.prettierrc new file mode 100644 index 000000000..ff2677efd --- /dev/null +++ b/apps/ui/.prettierrc @@ -0,0 +1,6 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/apps/ui/README.md b/apps/ui/README.md new file mode 100644 index 000000000..374efec4c --- /dev/null +++ b/apps/ui/README.md @@ -0,0 +1,38 @@ +# create-svelte + +Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npm init svelte + +# create a new project in my-app +npm init svelte my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```bash +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. diff --git a/apps/ui/package.json b/apps/ui/package.json new file mode 100644 index 000000000..5e069312f --- /dev/null +++ b/apps/ui/package.json @@ -0,0 +1,50 @@ +{ + "name": "coolify-ui", + "description": "Coolify's SvelteKit UI", + "license": "AGPL-3.0", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "package": "svelte-kit package", + "preview": "svelte-kit preview", + "prepare": "svelte-kit sync", + "test": "playwright test", + "check": "svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check --plugin-search-dir=. . && eslint .", + "format": "prettier --write --plugin-search-dir=. ." + }, + "devDependencies": { + "@playwright/test": "1.23.1", + "@sveltejs/adapter-auto": "1.0.0-next.53", + "@sveltejs/kit": "1.0.0-next.359", + "@types/js-cookie": "3.0.2", + "@typescript-eslint/eslint-plugin": "5.30.5", + "@typescript-eslint/parser": "5.30.5", + "autoprefixer": "10.4.7", + "eslint": "8.19.0", + "eslint-config-prettier": "8.5.0", + "eslint-plugin-svelte3": "4.0.0", + "postcss": "8.4.14", + "prettier": "2.7.1", + "prettier-plugin-svelte": "2.7.0", + "svelte": "3.48.0", + "svelte-check": "2.8.0", + "svelte-preprocess": "4.10.7", + "tailwindcss": "3.1.4", + "tailwindcss-scrollbar": "0.1.0", + "tslib": "2.4.0", + "typescript": "4.7.4", + "vite": "^2.9.13" + }, + "type": "module", + "dependencies": { + "@sveltejs/adapter-static": "1.0.0-next.34", + "@zerodevx/svelte-toast": "0.7.2", + "cuid": "2.1.8", + "js-cookie": "3.0.1", + "p-limit": "4.0.0", + "svelte-select": "4.4.7", + "sveltekit-i18n": "2.2.2" + } +} \ No newline at end of file diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts new file mode 100644 index 000000000..87c293e5a --- /dev/null +++ b/apps/ui/playwright.config.ts @@ -0,0 +1,10 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + webServer: { + command: 'npm run build && npm run preview', + port: 3000 + } +}; + +export default config; diff --git a/postcss.config.cjs b/apps/ui/postcss.config.cjs similarity index 100% rename from postcss.config.cjs rename to apps/ui/postcss.config.cjs diff --git a/apps/ui/src/app.d.ts b/apps/ui/src/app.d.ts new file mode 100644 index 000000000..c743bfaf6 --- /dev/null +++ b/apps/ui/src/app.d.ts @@ -0,0 +1,21 @@ +/// + +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare namespace App { + // interface Locals {} + // interface Platform {} + // interface Session {} + interface Stuff { + service: any; + application: any; + isRunning: boolean; + appId: string; + readOnly: boolean; + source: string; + settings: string; + database: Record; + versions: string; + privatePort: string; + } +} diff --git a/src/app.html b/apps/ui/src/app.html similarity index 59% rename from src/app.html rename to apps/ui/src/app.html index ab9a75726..af82b3042 100644 --- a/src/app.html +++ b/apps/ui/src/app.html @@ -2,10 +2,11 @@ + - %svelte.head% + %sveltekit.head% -
%svelte.body%
+
%sveltekit.body%
diff --git a/apps/ui/src/hooks.ts b/apps/ui/src/hooks.ts new file mode 100644 index 000000000..30c352baa --- /dev/null +++ b/apps/ui/src/hooks.ts @@ -0,0 +1,4 @@ +export async function handle({ event, resolve }) { + const response = await resolve(event, { ssr: false }); + return response; +} \ No newline at end of file diff --git a/src/lib/api.ts b/apps/ui/src/lib/api.ts similarity index 59% rename from src/lib/api.ts rename to apps/ui/src/lib/api.ts index ab145a2f7..2244d79d3 100644 --- a/src/lib/api.ts +++ b/apps/ui/src/lib/api.ts @@ -1,13 +1,27 @@ +import { browser, dev } from '$app/env'; +import Cookies from 'js-cookie'; +import { toast } from '@zerodevx/svelte-toast'; + +export function getAPIUrl() { + return dev ? 'http://localhost:3001' : 'http://localhost:3000'; +} async function send({ method, path, data = {}, headers, timeout = 120000 +}: { + method: string; + path: string; + data?: any; + headers?: any; + timeout?: number; }): Promise> { + const token = Cookies.get('token'); const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); - const opts = { method, headers: {}, body: null, signal: controller.signal }; + const opts: any = { method, headers: {}, body: null, signal: controller.signal }; if (Object.keys(data).length > 0) { const parsedData = data; for (const [key, value] of Object.entries(data)) { @@ -27,6 +41,20 @@ async function send({ ...headers }; } + if (token && !path.startsWith('https://')) { + opts.headers = { + ...opts.headers, + Authorization: `Bearer ${token}` + }; + } + if (!path.startsWith('https://')) { + path = `/api/v1${path}`; + } + + if (dev && !path.startsWith('https://')) { + path = `http://localhost:3001${path}`; + } + const response = await fetch(`${path}`, opts); clearTimeout(id); @@ -45,14 +73,17 @@ async function send({ } else { return {}; } - if (!response.ok) throw responseData; + if (!response.ok) { + if (response.status === 401 && !path.startsWith('https://api.github')) { + Cookies.remove('token'); + } + + throw responseData; + } return responseData; } -export function get( - path: string, - headers?: Record -): Promise> { +export function get(path: string, headers?: Record): Promise> { return send({ method: 'GET', path, headers }); } @@ -60,7 +91,7 @@ export function del( path: string, data: Record, headers?: Record -): Promise> { +): Promise> { return send({ method: 'DELETE', path, data, headers }); } @@ -68,7 +99,7 @@ export function post( path: string, data: Record, headers?: Record -): Promise> { +): Promise> { return send({ method: 'POST', path, data, headers }); } @@ -76,6 +107,6 @@ export function put( path: string, data: Record, headers?: Record -): Promise> { +): Promise> { return send({ method: 'PUT', path, data, headers }); } diff --git a/apps/ui/src/lib/common.ts b/apps/ui/src/lib/common.ts new file mode 100644 index 000000000..288dab986 --- /dev/null +++ b/apps/ui/src/lib/common.ts @@ -0,0 +1,420 @@ +import { toast } from '@zerodevx/svelte-toast'; +export const asyncSleep = (delay: number) => + new Promise((resolve) => setTimeout(resolve, delay)); + +export function errorNotification(error: any): void { + if (error.message) { + toast.push(error.message); + } else { + toast.push('Ooops, something is not okay, are you okay?'); + } + console.error(JSON.stringify(error)); +} + +export function getDomain(domain: string) { + return domain?.replace('https://', '').replace('http://', ''); +} +export function dashify(str: string, options?: any): string { + if (typeof str !== 'string') return str; + return str + .trim() + .replace(/\W/g, (m) => (/[À-ž]/.test(m) ? m : '-')) + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, (m) => (options && options.condense ? '-' : m)) + .toLowerCase(); +} + +export const dateOptions: any = { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false +}; + +export const staticDeployments = [ + 'react', + 'vuejs', + 'static', + 'svelte', + 'gatsby', + 'php', + 'astro', + 'eleventy' +]; +export const notNodeDeployments = ['php', 'docker', 'rust', 'python', 'deno', 'laravel']; + + +export function generateRemoteEngine(destination: any) { + return `ssh://${destination.user}@${destination.ipAddress}:${destination.port}`; +} + +export function changeQueryParams(buildId: string) { + const queryParams = new URLSearchParams(window.location.search); + queryParams.set('buildId', buildId); + // @ts-ignore + return history.pushState(null, null, '?' + queryParams.toString()); +} + +// export const supportedDatabaseTypesAndVersions = [ +// { +// name: 'mongodb', +// fancyName: 'MongoDB', +// baseImage: 'bitnami/mongodb', +// versions: ['5.0', '4.4', '4.2'] +// }, +// { name: 'mysql', fancyName: 'MySQL', baseImage: 'bitnami/mysql', versions: ['8.0', '5.7'] }, +// { +// name: 'mariadb', +// fancyName: 'MariaDB', +// baseImage: 'bitnami/mariadb', +// versions: ['10.7', '10.6', '10.5', '10.4', '10.3', '10.2'] +// }, +// { +// name: 'postgresql', +// fancyName: 'PostgreSQL', +// baseImage: 'bitnami/postgresql', +// versions: ['14.2.0', '13.6.0', '12.10.0 ', '11.15.0', '10.20.0'] +// }, +// { +// name: 'redis', +// fancyName: 'Redis', +// baseImage: 'bitnami/redis', +// versions: ['6.2', '6.0', '5.0'] +// }, +// { name: 'couchdb', fancyName: 'CouchDB', baseImage: 'bitnami/couchdb', versions: ['3.2.1'] } +// ]; +// export const supportedServiceTypesAndVersions = [ +// { +// name: 'plausibleanalytics', +// fancyName: 'Plausible Analytics', +// baseImage: 'plausible/analytics', +// images: ['bitnami/postgresql:13.2.0', 'yandex/clickhouse-server:21.3.2.5'], +// versions: ['latest', 'stable'], +// recommendedVersion: 'stable', +// ports: { +// main: 8000 +// } +// }, +// { +// name: 'nocodb', +// fancyName: 'NocoDB', +// baseImage: 'nocodb/nocodb', +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 8080 +// } +// }, +// { +// name: 'minio', +// fancyName: 'MinIO', +// baseImage: 'minio/minio', +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 9001 +// } +// }, +// { +// name: 'vscodeserver', +// fancyName: 'VSCode Server', +// baseImage: 'codercom/code-server', +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 8080 +// } +// }, +// { +// name: 'wordpress', +// fancyName: 'Wordpress', +// baseImage: 'wordpress', +// images: ['bitnami/mysql:5.7'], +// versions: ['latest', 'php8.1', 'php8.0', 'php7.4', 'php7.3'], +// recommendedVersion: 'latest', +// ports: { +// main: 80 +// } +// }, +// { +// name: 'vaultwarden', +// fancyName: 'Vaultwarden', +// baseImage: 'vaultwarden/server', +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 80 +// } +// }, +// { +// name: 'languagetool', +// fancyName: 'LanguageTool', +// baseImage: 'silviof/docker-languagetool', +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 8010 +// } +// }, +// { +// name: 'n8n', +// fancyName: 'n8n', +// baseImage: 'n8nio/n8n', +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 5678 +// } +// }, +// { +// name: 'uptimekuma', +// fancyName: 'Uptime Kuma', +// baseImage: 'louislam/uptime-kuma', +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 3001 +// } +// }, +// { +// name: 'ghost', +// fancyName: 'Ghost', +// baseImage: 'bitnami/ghost', +// images: ['bitnami/mariadb'], +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 2368 +// } +// }, +// { +// name: 'meilisearch', +// fancyName: 'Meilisearch', +// baseImage: 'getmeili/meilisearch', +// images: [], +// versions: ['latest'], +// recommendedVersion: 'latest', +// ports: { +// main: 7700 +// } +// }, +// { +// name: 'umami', +// fancyName: 'Umami', +// baseImage: 'ghcr.io/mikecao/umami', +// images: ['postgres:12-alpine'], +// versions: ['postgresql-latest'], +// recommendedVersion: 'postgresql-latest', +// ports: { +// main: 3000 +// } +// }, +// { +// name: 'hasura', +// fancyName: 'Hasura', +// baseImage: 'hasura/graphql-engine', +// images: ['postgres:12-alpine'], +// versions: ['latest', 'v2.5.1'], +// recommendedVersion: 'v2.5.1', +// ports: { +// main: 8080 +// } +// }, +// { +// name: 'fider', +// fancyName: 'Fider', +// baseImage: 'getfider/fider', +// images: ['postgres:12-alpine'], +// versions: ['stable'], +// recommendedVersion: 'stable', +// ports: { +// main: 3000 +// } +// // }, +// // { +// // name: 'appwrite', +// // fancyName: 'AppWrite', +// // baseImage: 'appwrite/appwrite', +// // images: ['appwrite/influxdb', 'appwrite/telegraf', 'mariadb:10.7', 'redis:6.0-alpine3.12'], +// // versions: ['latest', '0.13.0'], +// // recommendedVersion: '0.13.0', +// // ports: { +// // main: 3000 +// // } +// // } +// } +// ]; + +export const getServiceMainPort = (service: string) => { + const serviceType = supportedServiceTypesAndVersions.find((s) => s.name === service); + if (serviceType) { + return serviceType.ports.main; + } + return null; +}; + +export const supportedServiceTypesAndVersions = [ + { + name: 'plausibleanalytics', + fancyName: 'Plausible Analytics', + baseImage: 'plausible/analytics', + images: ['bitnami/postgresql:13.2.0', 'yandex/clickhouse-server:21.3.2.5'], + versions: ['latest', 'stable'], + recommendedVersion: 'stable', + ports: { + main: 8000 + } + }, + { + name: 'nocodb', + fancyName: 'NocoDB', + baseImage: 'nocodb/nocodb', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 8080 + } + }, + { + name: 'minio', + fancyName: 'MinIO', + baseImage: 'minio/minio', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 9001 + } + }, + { + name: 'vscodeserver', + fancyName: 'VSCode Server', + baseImage: 'codercom/code-server', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 8080 + } + }, + { + name: 'wordpress', + fancyName: 'Wordpress', + baseImage: 'wordpress', + images: ['bitnami/mysql:5.7'], + versions: ['latest', 'php8.1', 'php8.0', 'php7.4', 'php7.3'], + recommendedVersion: 'latest', + ports: { + main: 80 + } + }, + { + name: 'vaultwarden', + fancyName: 'Vaultwarden', + baseImage: 'vaultwarden/server', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 80 + } + }, + { + name: 'languagetool', + fancyName: 'LanguageTool', + baseImage: 'silviof/docker-languagetool', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 8010 + } + }, + { + name: 'n8n', + fancyName: 'n8n', + baseImage: 'n8nio/n8n', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 5678 + } + }, + { + name: 'uptimekuma', + fancyName: 'Uptime Kuma', + baseImage: 'louislam/uptime-kuma', + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 3001 + } + }, + { + name: 'ghost', + fancyName: 'Ghost', + baseImage: 'bitnami/ghost', + images: ['bitnami/mariadb'], + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 2368 + } + }, + { + name: 'meilisearch', + fancyName: 'Meilisearch', + baseImage: 'getmeili/meilisearch', + images: [], + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 7700 + } + }, + { + name: 'umami', + fancyName: 'Umami', + baseImage: 'ghcr.io/mikecao/umami', + images: ['postgres:12-alpine'], + versions: ['postgresql-latest'], + recommendedVersion: 'postgresql-latest', + ports: { + main: 3000 + } + }, + { + name: 'hasura', + fancyName: 'Hasura', + baseImage: 'hasura/graphql-engine', + images: ['postgres:12-alpine'], + versions: ['latest', 'v2.5.1'], + recommendedVersion: 'v2.5.1', + ports: { + main: 8080 + } + }, + { + name: 'fider', + fancyName: 'Fider', + baseImage: 'getfider/fider', + images: ['postgres:12-alpine'], + versions: ['stable'], + recommendedVersion: 'stable', + ports: { + main: 3000 + } + } +]; + +export function handlerNotFoundLoad(error: any, url: URL) { + if (error?.status === 404) { + return { + status: 302, + redirect: '/' + }; + } + return { + status: 500, + error: new Error(`Could not load ${url}`) + }; +} \ No newline at end of file diff --git a/src/lib/components/CopyPasswordField.svelte b/apps/ui/src/lib/components/CopyPasswordField.svelte similarity index 95% rename from src/lib/components/CopyPasswordField.svelte rename to apps/ui/src/lib/components/CopyPasswordField.svelte index 88199f6ab..103065601 100644 --- a/src/lib/components/CopyPasswordField.svelte +++ b/apps/ui/src/lib/components/CopyPasswordField.svelte @@ -1,17 +1,17 @@ - diff --git a/src/lib/components/Loading.svelte b/apps/ui/src/lib/components/Loading.svelte similarity index 100% rename from src/lib/components/Loading.svelte rename to apps/ui/src/lib/components/Loading.svelte diff --git a/src/routes/applications/[id]/logs/_Loading.svelte b/apps/ui/src/lib/components/LoadingLogs.svelte similarity index 100% rename from src/routes/applications/[id]/logs/_Loading.svelte rename to apps/ui/src/lib/components/LoadingLogs.svelte diff --git a/src/lib/components/PageLoader.svelte b/apps/ui/src/lib/components/PageLoader.svelte similarity index 91% rename from src/lib/components/PageLoader.svelte rename to apps/ui/src/lib/components/PageLoader.svelte index edbbcd80c..2b52fd917 100644 --- a/src/lib/components/PageLoader.svelte +++ b/apps/ui/src/lib/components/PageLoader.svelte @@ -1,9 +1,9 @@ - diff --git a/src/routes/_Trend.svelte b/apps/ui/src/lib/components/Trend.svelte similarity index 97% rename from src/routes/_Trend.svelte rename to apps/ui/src/lib/components/Trend.svelte index 723ba2a39..cdc363516 100644 --- a/src/routes/_Trend.svelte +++ b/apps/ui/src/lib/components/Trend.svelte @@ -1,5 +1,5 @@ {#if trend === 'up'} diff --git a/apps/ui/src/lib/components/UpdateAvailable.svelte b/apps/ui/src/lib/components/UpdateAvailable.svelte new file mode 100644 index 000000000..4171a1c5f --- /dev/null +++ b/apps/ui/src/lib/components/UpdateAvailable.svelte @@ -0,0 +1,183 @@ + + +
+ {#if $appSession.teamId === '0'} + {#if isUpdateAvailable} + + {/if} + {/if} +
diff --git a/apps/ui/src/lib/components/Usage.svelte b/apps/ui/src/lib/components/Usage.svelte new file mode 100644 index 000000000..cd4105c60 --- /dev/null +++ b/apps/ui/src/lib/components/Usage.svelte @@ -0,0 +1,147 @@ + + +{#if $appSession.teamId === '0'} +
Server Usage
+
+
+
Total Memory
+
+ {(usage?.memory.totalMemMb).toFixed(0)}MB +
+
+ +
+
Used Memory
+
+ {(usage?.memory.usedMemMb).toFixed(0)}MB +
+
+ +
+
Free Memory
+
+ {usage?.memory.freeMemPercentage}% + {#if !warning.memory} + + {/if} +
+
+
+
+
+
Total CPUs
+
+ {usage?.cpu.count} +
+
+
+
CPU Usage
+
+ {usage?.cpu.usage}% + {#if !warning.cpu} + + {/if} +
+
+
+
Load Average (5/10/30mins)
+
+ {usage?.cpu.load.join('/')} +
+
+
+
+
+
Total Disk
+
+ {usage?.disk.totalGb}GB +
+
+
+
Used Disk
+
+ {usage?.disk.usedGb}GB +
+
+
+
Free Disk
+
+ {usage?.disk.freePercentage}% + {#if !warning.disk} + + {/if} +
+
+
+
Resources
+{/if} diff --git a/src/lib/components/svg/applications/Astro.svelte b/apps/ui/src/lib/components/svg/applications/Astro.svelte similarity index 100% rename from src/lib/components/svg/applications/Astro.svelte rename to apps/ui/src/lib/components/svg/applications/Astro.svelte diff --git a/src/lib/components/svg/applications/Deno.svelte b/apps/ui/src/lib/components/svg/applications/Deno.svelte similarity index 100% rename from src/lib/components/svg/applications/Deno.svelte rename to apps/ui/src/lib/components/svg/applications/Deno.svelte diff --git a/src/lib/components/svg/applications/Docker.svelte b/apps/ui/src/lib/components/svg/applications/Docker.svelte similarity index 100% rename from src/lib/components/svg/applications/Docker.svelte rename to apps/ui/src/lib/components/svg/applications/Docker.svelte diff --git a/src/lib/components/svg/applications/Eleventy.svelte b/apps/ui/src/lib/components/svg/applications/Eleventy.svelte similarity index 100% rename from src/lib/components/svg/applications/Eleventy.svelte rename to apps/ui/src/lib/components/svg/applications/Eleventy.svelte diff --git a/src/lib/components/svg/applications/Gatsby.svelte b/apps/ui/src/lib/components/svg/applications/Gatsby.svelte similarity index 100% rename from src/lib/components/svg/applications/Gatsby.svelte rename to apps/ui/src/lib/components/svg/applications/Gatsby.svelte diff --git a/src/lib/components/svg/applications/Laravel.svelte b/apps/ui/src/lib/components/svg/applications/Laravel.svelte similarity index 100% rename from src/lib/components/svg/applications/Laravel.svelte rename to apps/ui/src/lib/components/svg/applications/Laravel.svelte diff --git a/src/lib/components/svg/applications/Nestjs.svelte b/apps/ui/src/lib/components/svg/applications/Nestjs.svelte similarity index 100% rename from src/lib/components/svg/applications/Nestjs.svelte rename to apps/ui/src/lib/components/svg/applications/Nestjs.svelte diff --git a/src/lib/components/svg/applications/Nextjs.svelte b/apps/ui/src/lib/components/svg/applications/Nextjs.svelte similarity index 100% rename from src/lib/components/svg/applications/Nextjs.svelte rename to apps/ui/src/lib/components/svg/applications/Nextjs.svelte diff --git a/src/lib/components/svg/applications/Nodejs.svelte b/apps/ui/src/lib/components/svg/applications/Nodejs.svelte similarity index 100% rename from src/lib/components/svg/applications/Nodejs.svelte rename to apps/ui/src/lib/components/svg/applications/Nodejs.svelte diff --git a/src/lib/components/svg/applications/Nuxtjs.svelte b/apps/ui/src/lib/components/svg/applications/Nuxtjs.svelte similarity index 100% rename from src/lib/components/svg/applications/Nuxtjs.svelte rename to apps/ui/src/lib/components/svg/applications/Nuxtjs.svelte diff --git a/src/lib/components/svg/applications/PHP.svelte b/apps/ui/src/lib/components/svg/applications/PHP.svelte similarity index 100% rename from src/lib/components/svg/applications/PHP.svelte rename to apps/ui/src/lib/components/svg/applications/PHP.svelte diff --git a/src/lib/components/svg/applications/Python.svelte b/apps/ui/src/lib/components/svg/applications/Python.svelte similarity index 100% rename from src/lib/components/svg/applications/Python.svelte rename to apps/ui/src/lib/components/svg/applications/Python.svelte diff --git a/src/lib/components/svg/applications/React.svelte b/apps/ui/src/lib/components/svg/applications/React.svelte similarity index 100% rename from src/lib/components/svg/applications/React.svelte rename to apps/ui/src/lib/components/svg/applications/React.svelte diff --git a/src/lib/components/svg/applications/Rust.svelte b/apps/ui/src/lib/components/svg/applications/Rust.svelte similarity index 100% rename from src/lib/components/svg/applications/Rust.svelte rename to apps/ui/src/lib/components/svg/applications/Rust.svelte diff --git a/src/lib/components/svg/applications/Static.svelte b/apps/ui/src/lib/components/svg/applications/Static.svelte similarity index 100% rename from src/lib/components/svg/applications/Static.svelte rename to apps/ui/src/lib/components/svg/applications/Static.svelte diff --git a/src/lib/components/svg/applications/Svelte.svelte b/apps/ui/src/lib/components/svg/applications/Svelte.svelte similarity index 100% rename from src/lib/components/svg/applications/Svelte.svelte rename to apps/ui/src/lib/components/svg/applications/Svelte.svelte diff --git a/src/lib/components/svg/applications/Vuejs.svelte b/apps/ui/src/lib/components/svg/applications/Vuejs.svelte similarity index 100% rename from src/lib/components/svg/applications/Vuejs.svelte rename to apps/ui/src/lib/components/svg/applications/Vuejs.svelte diff --git a/src/lib/components/svg/databases/Clickhouse.svelte b/apps/ui/src/lib/components/svg/databases/Clickhouse.svelte similarity index 100% rename from src/lib/components/svg/databases/Clickhouse.svelte rename to apps/ui/src/lib/components/svg/databases/Clickhouse.svelte diff --git a/src/lib/components/svg/databases/CouchDB.svelte b/apps/ui/src/lib/components/svg/databases/CouchDB.svelte similarity index 100% rename from src/lib/components/svg/databases/CouchDB.svelte rename to apps/ui/src/lib/components/svg/databases/CouchDB.svelte diff --git a/src/lib/components/svg/databases/MariaDB.svelte b/apps/ui/src/lib/components/svg/databases/MariaDB.svelte similarity index 100% rename from src/lib/components/svg/databases/MariaDB.svelte rename to apps/ui/src/lib/components/svg/databases/MariaDB.svelte diff --git a/src/lib/components/svg/databases/MongoDB.svelte b/apps/ui/src/lib/components/svg/databases/MongoDB.svelte similarity index 100% rename from src/lib/components/svg/databases/MongoDB.svelte rename to apps/ui/src/lib/components/svg/databases/MongoDB.svelte diff --git a/src/lib/components/svg/databases/MySQL.svelte b/apps/ui/src/lib/components/svg/databases/MySQL.svelte similarity index 100% rename from src/lib/components/svg/databases/MySQL.svelte rename to apps/ui/src/lib/components/svg/databases/MySQL.svelte diff --git a/src/lib/components/svg/databases/PostgreSQL.svelte b/apps/ui/src/lib/components/svg/databases/PostgreSQL.svelte similarity index 100% rename from src/lib/components/svg/databases/PostgreSQL.svelte rename to apps/ui/src/lib/components/svg/databases/PostgreSQL.svelte diff --git a/src/lib/components/svg/databases/Redis.svelte b/apps/ui/src/lib/components/svg/databases/Redis.svelte similarity index 100% rename from src/lib/components/svg/databases/Redis.svelte rename to apps/ui/src/lib/components/svg/databases/Redis.svelte diff --git a/src/lib/components/svg/services/Fider.svelte b/apps/ui/src/lib/components/svg/services/Fider.svelte similarity index 100% rename from src/lib/components/svg/services/Fider.svelte rename to apps/ui/src/lib/components/svg/services/Fider.svelte diff --git a/src/lib/components/svg/services/Ghost.svelte b/apps/ui/src/lib/components/svg/services/Ghost.svelte similarity index 100% rename from src/lib/components/svg/services/Ghost.svelte rename to apps/ui/src/lib/components/svg/services/Ghost.svelte diff --git a/src/lib/components/svg/services/Hasura.svelte b/apps/ui/src/lib/components/svg/services/Hasura.svelte similarity index 100% rename from src/lib/components/svg/services/Hasura.svelte rename to apps/ui/src/lib/components/svg/services/Hasura.svelte diff --git a/src/lib/components/svg/services/LanguageTool.svelte b/apps/ui/src/lib/components/svg/services/LanguageTool.svelte similarity index 100% rename from src/lib/components/svg/services/LanguageTool.svelte rename to apps/ui/src/lib/components/svg/services/LanguageTool.svelte diff --git a/src/lib/components/svg/services/MeiliSearch.svelte b/apps/ui/src/lib/components/svg/services/MeiliSearch.svelte similarity index 100% rename from src/lib/components/svg/services/MeiliSearch.svelte rename to apps/ui/src/lib/components/svg/services/MeiliSearch.svelte diff --git a/src/lib/components/svg/services/MinIO.svelte b/apps/ui/src/lib/components/svg/services/MinIO.svelte similarity index 100% rename from src/lib/components/svg/services/MinIO.svelte rename to apps/ui/src/lib/components/svg/services/MinIO.svelte diff --git a/src/lib/components/svg/services/N8n.svelte b/apps/ui/src/lib/components/svg/services/N8n.svelte similarity index 100% rename from src/lib/components/svg/services/N8n.svelte rename to apps/ui/src/lib/components/svg/services/N8n.svelte diff --git a/src/lib/components/svg/services/NocoDB.svelte b/apps/ui/src/lib/components/svg/services/NocoDB.svelte similarity index 100% rename from src/lib/components/svg/services/NocoDB.svelte rename to apps/ui/src/lib/components/svg/services/NocoDB.svelte diff --git a/src/lib/components/svg/services/PlausibleAnalytics.svelte b/apps/ui/src/lib/components/svg/services/PlausibleAnalytics.svelte similarity index 100% rename from src/lib/components/svg/services/PlausibleAnalytics.svelte rename to apps/ui/src/lib/components/svg/services/PlausibleAnalytics.svelte diff --git a/src/lib/components/svg/services/Umami.svelte b/apps/ui/src/lib/components/svg/services/Umami.svelte similarity index 100% rename from src/lib/components/svg/services/Umami.svelte rename to apps/ui/src/lib/components/svg/services/Umami.svelte diff --git a/src/lib/components/svg/services/UptimeKuma.svelte b/apps/ui/src/lib/components/svg/services/UptimeKuma.svelte similarity index 100% rename from src/lib/components/svg/services/UptimeKuma.svelte rename to apps/ui/src/lib/components/svg/services/UptimeKuma.svelte diff --git a/src/lib/components/svg/services/VSCodeServer.svelte b/apps/ui/src/lib/components/svg/services/VSCodeServer.svelte similarity index 100% rename from src/lib/components/svg/services/VSCodeServer.svelte rename to apps/ui/src/lib/components/svg/services/VSCodeServer.svelte diff --git a/src/lib/components/svg/services/VaultWarden.svelte b/apps/ui/src/lib/components/svg/services/VaultWarden.svelte similarity index 100% rename from src/lib/components/svg/services/VaultWarden.svelte rename to apps/ui/src/lib/components/svg/services/VaultWarden.svelte diff --git a/src/lib/components/svg/services/Wordpress.svelte b/apps/ui/src/lib/components/svg/services/Wordpress.svelte similarity index 100% rename from src/lib/components/svg/services/Wordpress.svelte rename to apps/ui/src/lib/components/svg/services/Wordpress.svelte diff --git a/src/lib/lang.json b/apps/ui/src/lib/lang.json similarity index 100% rename from src/lib/lang.json rename to apps/ui/src/lib/lang.json diff --git a/src/lib/locales/en.json b/apps/ui/src/lib/locales/en.json similarity index 98% rename from src/lib/locales/en.json rename to apps/ui/src/lib/locales/en.json index c455523af..2805130d2 100644 --- a/src/lib/locales/en.json +++ b/apps/ui/src/lib/locales/en.json @@ -124,7 +124,7 @@ "no_branches_found": "No branches found", "configure_build_pack": "Configure Build Pack", "scanning_repository_suggest_build_pack": "Scanning repository to suggest a build pack for you...", - "found_lock_file": "Found lock file for {{packageManager}}.Using it for predefined commands commands.", + "found_lock_file": "Found lock file for {{packageManager}}.
Using it for predefined commands commands.", "configure_destination": "Configure Destination", "no_configurable_destination": "No configurable Destination found", "select_a_repository_project": "Select a Repository / Project", @@ -289,7 +289,7 @@ } }, "services": { - "all_email_verified": "All email verified. You can login now.", + "all_email_verified": "All emails are verified. You can login now.", "generate_www_non_www_ssl": "It will generate certificates for both www and non-www.
You need to have both DNS entries set in advance.

Service needs to be restarted." }, "service": { @@ -334,7 +334,7 @@ "pending_invitation": "Pending invitation", "invite_new_member": "Invite new member", "send_invitation": "Send invitation", - "invite_only_register_explainer": "You can only invite registered users at the moment - will be extended soon.", + "invite_only_register_explainer": "You can only invite registered users.", "admin": "Admin", "read": "Read" } diff --git a/src/lib/locales/fr.json b/apps/ui/src/lib/locales/fr.json similarity index 100% rename from src/lib/locales/fr.json rename to apps/ui/src/lib/locales/fr.json diff --git a/apps/ui/src/lib/store.ts b/apps/ui/src/lib/store.ts new file mode 100644 index 000000000..01aa74f4a --- /dev/null +++ b/apps/ui/src/lib/store.ts @@ -0,0 +1,60 @@ +import { browser } from '$app/env'; +import { writable, readable, type Writable, type Readable } from 'svelte/store'; +// import { version as currentVersion } from '../../package.json'; +interface AppSession { + version: string + userId: string | null, + teamId: string | null, + permission: string, + isAdmin: boolean, + whiteLabeled: boolean, + whiteLabeledDetails: { + icon: string | null, + }, + tokens: { + github: string | null, + gitlab: string | null, + } +} +export const loginEmail: Writable = writable() +export const appSession: Writable = writable({ + version: '3.0.0', + userId: null, + teamId: null, + permission: 'read', + isAdmin: false, + whiteLabeled: false, + whiteLabeledDetails: { + icon: null + }, + tokens: { + github: null, + gitlab: null + } +}); +export const isTraefikUsed: Writable = writable(false); +export const disabledButton: Writable = writable(false); +export const status: Writable = writable({ + application: { + isRunning: false, + isExited: false, + loading: false, + initialLoading: true + }, + service: { + initialLoading: true, + loading: false, + isRunning: false + }, + database: { + initialLoading: true, + loading: false, + isRunning: false + } + +}); + +export const features = readable({ + beta: window.localStorage.getItem('beta') === 'true', + latestVersion: window.localStorage.getItem('latestVersion') +}); diff --git a/src/lib/components/templates.ts b/apps/ui/src/lib/templates.ts similarity index 97% rename from src/lib/components/templates.ts rename to apps/ui/src/lib/templates.ts index aa1ed9528..7b81dbadd 100644 --- a/src/lib/components/templates.ts +++ b/apps/ui/src/lib/templates.ts @@ -1,4 +1,4 @@ -function defaultBuildAndDeploy(packageManager) { +function defaultBuildAndDeploy(packageManager: string) { return { installCommand: packageManager === 'npm' ? `${packageManager} install` : `${packageManager} install`, @@ -8,7 +8,7 @@ function defaultBuildAndDeploy(packageManager) { packageManager === 'npm' ? `${packageManager} run start` : `${packageManager} start` }; } -export function findBuildPack(pack, packageManager = 'npm') { +export function findBuildPack(pack: string, packageManager = 'npm') { const metaData = buildPacks.find((b) => b.name === pack); if (pack === 'node') { return { diff --git a/src/lib/translations.ts b/apps/ui/src/lib/translations.ts similarity index 100% rename from src/lib/translations.ts rename to apps/ui/src/lib/translations.ts diff --git a/src/routes/__error.svelte b/apps/ui/src/routes/__error.svelte similarity index 79% rename from src/routes/__error.svelte rename to apps/ui/src/routes/__error.svelte index 1ac61d6bf..ac32b098e 100644 --- a/src/routes/__error.svelte +++ b/apps/ui/src/routes/__error.svelte @@ -1,6 +1,5 @@ - -
diff --git a/src/routes/__layout.svelte b/apps/ui/src/routes/__layout.svelte similarity index 50% rename from src/routes/__layout.svelte rename to apps/ui/src/routes/__layout.svelte index 492673a28..68b624cae 100644 --- a/src/routes/__layout.svelte +++ b/apps/ui/src/routes/__layout.svelte @@ -1,132 +1,81 @@ - + +
+
+
{title}
+ +
+
+
+
+ Use setting + + + + +
+
diff --git a/src/routes/applications/[id]/storage/_Storage.svelte b/apps/ui/src/routes/applications/[id]/_Storage.svelte similarity index 91% rename from src/routes/applications/[id]/storage/_Storage.svelte rename to apps/ui/src/routes/applications/[id]/_Storage.svelte index 363c93390..868f95e54 100644 --- a/src/routes/applications/[id]/storage/_Storage.svelte +++ b/apps/ui/src/routes/applications/[id]/_Storage.svelte @@ -1,6 +1,6 @@ @@ -165,13 +147,13 @@ {#if $status.application.isExited} {/if} -
+
+ {/if}
@@ -111,22 +134,30 @@ {/if}
{application.name}
- {#if $session.teamId === '0' && otherApplications.length > 0} + {#if $appSession.teamId === '0' && otherApplications.length > 0}
Team {application.teams[0].name}
{/if} {#if application.fqdn}
{getDomain(application.fqdn) || ''}
{/if} - {#if !application.gitSourceId || !application.destinationDockerId || !application.fqdn} + {#if !application.gitSourceId}
- Configuration missing + Git Source Missing +
+ {:else if !application.destinationDockerId} +
+ Destination Missing +
+ {:else if !application.fqdn} +
+ URL Missing
{/if}
{/each}
- {#if otherApplications.length > 0 && $session.teamId === '0'} + {#if otherApplications.length > 0 && $appSession.teamId === '0'}
Other Applications
{#each otherApplications as application} @@ -171,7 +202,7 @@ {/if}
{application.name}
- {#if $session.teamId === '0'} + {#if $appSession.teamId === '0'}
Team {application.teams[0].name}
{/if} {#if application.fqdn} diff --git a/apps/ui/src/routes/databases/[id]/_DatabaseLinks.svelte b/apps/ui/src/routes/databases/[id]/_DatabaseLinks.svelte new file mode 100644 index 000000000..50eed98b2 --- /dev/null +++ b/apps/ui/src/routes/databases/[id]/_DatabaseLinks.svelte @@ -0,0 +1,29 @@ + + + + {#if database.type === 'clickhouse'} + + {:else if database.type === 'couchdb'} + + {:else if database.type === 'mongodb'} + + {:else if database.type === 'mysql'} + + {:else if database.type === 'mariadb'} + + {:else if database.type === 'postgresql'} + + {:else if database.type === 'redis'} + + {/if} + diff --git a/src/routes/databases/[id]/_Databases/_CouchDb.svelte b/apps/ui/src/routes/databases/[id]/_Databases/_CouchDb.svelte similarity index 97% rename from src/routes/databases/[id]/_Databases/_CouchDb.svelte rename to apps/ui/src/routes/databases/[id]/_Databases/_CouchDb.svelte index f8f9ebc10..ee4c433fe 100644 --- a/src/routes/databases/[id]/_Databases/_CouchDb.svelte +++ b/apps/ui/src/routes/databases/[id]/_Databases/_CouchDb.svelte @@ -1,5 +1,5 @@ - diff --git a/src/routes/databases/[id]/_Databases/_Databases.svelte b/apps/ui/src/routes/databases/[id]/_Databases/_Databases.svelte similarity index 79% rename from src/routes/databases/[id]/_Databases/_Databases.svelte rename to apps/ui/src/routes/databases/[id]/_Databases/_Databases.svelte index 8e02393a5..598c7b62d 100644 --- a/src/routes/databases/[id]/_Databases/_Databases.svelte +++ b/apps/ui/src/routes/databases/[id]/_Databases/_Databases.svelte @@ -1,13 +1,11 @@ + + + +{#if id !== 'new'} + +{/if} + diff --git a/src/routes/databases/[id]/configuration/destination.svelte b/apps/ui/src/routes/databases/[id]/configuration/destination.svelte similarity index 73% rename from src/routes/databases/[id]/configuration/destination.svelte rename to apps/ui/src/routes/databases/[id]/configuration/destination.svelte index a7c96c145..5f0b78c25 100644 --- a/src/routes/databases/[id]/configuration/destination.svelte +++ b/apps/ui/src/routes/databases/[id]/configuration/destination.svelte @@ -1,52 +1,47 @@ @@ -106,4 +86,4 @@
- + diff --git a/src/routes/databases/[id]/logs/_Loading.svelte b/apps/ui/src/routes/databases/[id]/logs/_Loading.svelte similarity index 100% rename from src/routes/databases/[id]/logs/_Loading.svelte rename to apps/ui/src/routes/databases/[id]/logs/_Loading.svelte diff --git a/src/routes/databases/[id]/logs/index.svelte b/apps/ui/src/routes/databases/[id]/logs/index.svelte similarity index 88% rename from src/routes/databases/[id]/logs/index.svelte rename to apps/ui/src/routes/databases/[id]/logs/index.svelte index 107100503..a84ae4562 100644 --- a/src/routes/databases/[id]/logs/index.svelte +++ b/apps/ui/src/routes/databases/[id]/logs/index.svelte @@ -2,38 +2,37 @@ import type { Load } from '@sveltejs/kit'; import { onDestroy, onMount } from 'svelte'; export const load: Load = async ({ fetch, params, url, stuff }) => { - let endpoint = `/databases/${params.id}/logs.json`; - const res = await fetch(endpoint); - if (res.ok) { + try { + const response = await get(`/databases/${params.id}/logs`); return { props: { database: stuff.database, - ...(await res.json()) + ...response } }; + } catch (error) { + return { + status: 500, + error: new Error(`Could not load ${url}`) + }; } - - return { - status: res.status, - error: new Error(`Could not load ${url}`) - }; }; + + +
+
+
+ Configuration +
+ {destination.name} +
+
+ +
+ {#if destination.remoteEngine} + + {:else} + + {/if} +
\ No newline at end of file diff --git a/src/routes/destinations/[id]/_LocalDocker.svelte b/apps/ui/src/routes/destinations/[id]/_LocalDocker.svelte similarity index 87% rename from src/routes/destinations/[id]/_LocalDocker.svelte rename to apps/ui/src/routes/destinations/[id]/_LocalDocker.svelte index 30112eec8..e034b736e 100644 --- a/src/routes/destinations/[id]/_LocalDocker.svelte +++ b/apps/ui/src/routes/destinations/[id]/_LocalDocker.svelte @@ -1,16 +1,17 @@ @@ -65,7 +68,7 @@ bind:value={payload.network} /> - {#if $session.teamId === '0'} + {#if $appSession.teamId === '0'}
+ export let payload: any; + import { goto } from '$app/navigation'; - export let payload; - import { post } from '$lib/api'; - import Explainer from '$lib/components/Explainer.svelte'; + import { errorNotification } from '$lib/common'; import Setting from '$lib/components/Setting.svelte'; - import { errorNotification } from '$lib/form'; import { t } from '$lib/translations'; let loading = false; async function handleSubmit() { try { - const { id } = await post('/new/destination/docker.json', { + const { id } = await post('/new/destination/docker', { ...payload }); return await goto(`/destinations/${id}`); - } catch ({ error }) { + } catch (error) { return errorNotification(error); } } diff --git a/src/routes/destinations/[id]/_RemoteDocker.svelte b/apps/ui/src/routes/destinations/[id]/_RemoteDocker.svelte similarity index 94% rename from src/routes/destinations/[id]/_RemoteDocker.svelte rename to apps/ui/src/routes/destinations/[id]/_RemoteDocker.svelte index c17d0239b..1e23818cd 100644 --- a/src/routes/destinations/[id]/_RemoteDocker.svelte +++ b/apps/ui/src/routes/destinations/[id]/_RemoteDocker.svelte @@ -1,17 +1,17 @@ + + + +{#if id !== 'new'} + +{/if} + diff --git a/apps/ui/src/routes/destinations/[id]/index.svelte b/apps/ui/src/routes/destinations/[id]/index.svelte new file mode 100644 index 000000000..52bdccb6f --- /dev/null +++ b/apps/ui/src/routes/destinations/[id]/index.svelte @@ -0,0 +1,24 @@ + + + + +{#if id === 'new'} + +{:else} + +{/if} diff --git a/src/routes/destinations/index.svelte b/apps/ui/src/routes/destinations/index.svelte similarity index 75% rename from src/routes/destinations/index.svelte rename to apps/ui/src/routes/destinations/index.svelte index 1313cb1b6..6880ef78d 100644 --- a/src/routes/destinations/index.svelte +++ b/apps/ui/src/routes/destinations/index.svelte @@ -1,38 +1,36 @@
Identity and Access Management
- +
- +
{#if invitations.length > 0} @@ -138,7 +145,7 @@
{/if}
- {#if $session.teamId === '0' && accounts.length > 0} + {#if $appSession.teamId === '0' && accounts.length > 0}
Accounts
{:else}
Account
@@ -167,7 +174,7 @@
deleteUser(account.id)}> @@ -199,33 +206,18 @@
{$appSession.teamId === team.id ? 'Current Team' : 'Switch Team'}
{/each} - {#if $session.teamId === '0' && allTeams.length > 0} + {#if $appSession.teamId === '0' && allTeams.length > 0}
Other Teams
{#each allTeams as team} diff --git a/apps/ui/src/routes/iam/team/[id]/__layout.svelte b/apps/ui/src/routes/iam/team/[id]/__layout.svelte new file mode 100644 index 000000000..b03af61c1 --- /dev/null +++ b/apps/ui/src/routes/iam/team/[id]/__layout.svelte @@ -0,0 +1,63 @@ + + + + +{#if id !== 'new'} + +{/if} + diff --git a/src/routes/iam/team/[id]/index.svelte b/apps/ui/src/routes/iam/team/[id]/index.svelte similarity index 82% rename from src/routes/iam/team/[id]/index.svelte rename to apps/ui/src/routes/iam/team/[id]/index.svelte index 0eff7af34..ad21e0ad5 100644 --- a/src/routes/iam/team/[id]/index.svelte +++ b/apps/ui/src/routes/iam/team/[id]/index.svelte @@ -1,40 +1,28 @@ + + + +
+
{$t('index.dashboard')}
+
+ + diff --git a/apps/ui/src/routes/login.svelte b/apps/ui/src/routes/login.svelte new file mode 100644 index 000000000..5ea74f67f --- /dev/null +++ b/apps/ui/src/routes/login.svelte @@ -0,0 +1,109 @@ + + + + {$t('login.login')} + + +
+
+ + {#if $appSession.whiteLabeledDetails.icon} + Icon for white labeled version of Coolify + {:else} +
Coolify
+ {/if} + + + +
+ + + +
+ +
+ {#if browser && window.location.host === 'demo.coolify.io'} +
+ Registration is open, just fill in an email (does not need + to be live email address for the demo instance) and a password. +
+
+ All users gets an own namespace, so you won't be able to + access other users data. +
+ {/if} +
diff --git a/src/routes/register/index.svelte b/apps/ui/src/routes/register.svelte similarity index 70% rename from src/routes/register/index.svelte rename to apps/ui/src/routes/register.svelte index 52ff77180..606973bc9 100644 --- a/src/routes/register/index.svelte +++ b/apps/ui/src/routes/register.svelte @@ -1,23 +1,35 @@ + + {#if service.type === 'plausibleanalytics'} diff --git a/src/routes/services/[id]/_Services/_Fider.svelte b/apps/ui/src/routes/services/[id]/_Services/_Fider.svelte similarity index 96% rename from src/routes/services/[id]/_Services/_Fider.svelte rename to apps/ui/src/routes/services/[id]/_Services/_Fider.svelte index 180235d57..f2d316fe5 100644 --- a/src/routes/services/[id]/_Services/_Fider.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_Fider.svelte @@ -1,10 +1,9 @@
@@ -71,7 +71,6 @@
diff --git a/src/routes/services/[id]/_Services/_MeiliSearch.svelte b/apps/ui/src/routes/services/[id]/_Services/_MeiliSearch.svelte similarity index 94% rename from src/routes/services/[id]/_Services/_MeiliSearch.svelte rename to apps/ui/src/routes/services/[id]/_Services/_MeiliSearch.svelte index 383889bf2..f34f9822c 100644 --- a/src/routes/services/[id]/_Services/_MeiliSearch.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_MeiliSearch.svelte @@ -1,7 +1,7 @@
diff --git a/src/routes/services/[id]/_Services/_MinIO.svelte b/apps/ui/src/routes/services/[id]/_Services/_MinIO.svelte similarity index 97% rename from src/routes/services/[id]/_Services/_MinIO.svelte rename to apps/ui/src/routes/services/[id]/_Services/_MinIO.svelte index 10d36a252..002486d68 100644 --- a/src/routes/services/[id]/_Services/_MinIO.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_MinIO.svelte @@ -2,7 +2,7 @@ import CopyPasswordField from '$lib/components/CopyPasswordField.svelte'; import { t } from '$lib/translations'; - export let service; + export let service: any;
diff --git a/src/routes/services/[id]/_Services/_PlausibleAnalytics.svelte b/apps/ui/src/routes/services/[id]/_Services/_PlausibleAnalytics.svelte similarity index 91% rename from src/routes/services/[id]/_Services/_PlausibleAnalytics.svelte rename to apps/ui/src/routes/services/[id]/_Services/_PlausibleAnalytics.svelte index c348f123e..8532896dc 100644 --- a/src/routes/services/[id]/_Services/_PlausibleAnalytics.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_PlausibleAnalytics.svelte @@ -1,12 +1,10 @@
@@ -17,8 +15,8 @@ - import { browser } from '$app/env'; + export let service: any; + export let readOnly: any; + export let settings: any; - export let service; - export let isRunning; - export let readOnly; - export let settings; - - import { page, session } from '$app/stores'; - import { post } from '$lib/api'; - import CopyPasswordField from '$lib/components/CopyPasswordField.svelte'; - import Explainer from '$lib/components/Explainer.svelte'; - import Setting from '$lib/components/Setting.svelte'; - import { errorNotification } from '$lib/form'; - import { t } from '$lib/translations'; - import { toast } from '@zerodevx/svelte-toast'; import cuid from 'cuid'; import { onMount } from 'svelte'; + + import { browser } from '$app/env'; + import { page } from '$app/stores'; + import { toast } from '@zerodevx/svelte-toast'; + + import { get, post } from '$lib/api'; + import { errorNotification } from '$lib/common'; + import { t } from '$lib/translations'; + import { appSession, disabledButton, status } from '$lib/store'; + import CopyPasswordField from '$lib/components/CopyPasswordField.svelte'; + import Explainer from '$lib/components/Explainer.svelte'; + import Setting from '$lib/components/Setting.svelte' + import Fider from './_Fider.svelte'; import Ghost from './_Ghost.svelte'; import Hasura from './_Hasura.svelte'; @@ -36,14 +38,15 @@ if (loading) return; loading = true; try { - await post(`/services/${id}/check.json`, { + await post(`/services/${id}/check`, { fqdn: service.fqdn, otherFqdns: service.minio?.apiFqdn ? [service.minio?.apiFqdn] : [], exposePort: service.exposePort }); - await post(`/services/${id}/${service.type}.json`, { ...service }); - return window.location.reload(); - } catch ({ error }) { + await post(`/services/${id}`, { ...service }); + $disabledButton = false; + toast.push('Settings saved.'); + } catch (error) { return errorNotification(error); } finally { loading = false; @@ -52,22 +55,22 @@ async function setEmailsToVerified() { loadingVerification = true; try { - await post(`/services/${id}/${service.type}/activate.json`, { id: service.id }); + await post(`/services/${id}/${service.type}/activate`, { id: service.id }); toast.push(t.get('services.all_email_verified')); - } catch ({ error }) { + } catch (error) { return errorNotification(error); } finally { loadingVerification = false; } } - async function changeSettings(name) { + async function changeSettings(name: any) { try { if (name === 'dualCerts') { dualCerts = !dualCerts; } - await post(`/services/${id}/settings.json`, { dualCerts }); + await post(`/services/${id}/settings`, { dualCerts }); return toast.push(t.get('application.settings_saved')); - } catch ({ error }) { + } catch (error) { return errorNotification(error); } } @@ -94,12 +97,11 @@ } }); -
{$t('general')}
- {#if $session.isAdmin} + {#if $appSession.isAdmin} {/if} - {#if service.type === 'plausibleanalytics' && isRunning} + {#if service.type === 'plausibleanalytics' && $status.service.isRunning}
- {#if service.type === 'minio' && !service.minio.apiFqdn && isRunning} + {#if service.type === 'minio' && !service.minio.apiFqdn && $status.service.isRunning}
IMPORTANT! There was a small modification with Minio in the latest version of Coolify. Now you can separate the Console URL from the API URL, @@ -129,7 +131,7 @@
@@ -178,8 +180,8 @@ !isRunning && changeSettings('dualCerts')} + on:click={() => !$status.service.isRunning && changeSettings('dualCerts')} />
{#if service.type === 'plausibleanalytics'} - + {:else if service.type === 'minio'} {:else if service.type === 'vscodeserver'} {:else if service.type === 'wordpress'} - + {:else if service.type === 'ghost'} {:else if service.type === 'meilisearch'} diff --git a/src/routes/services/[id]/_Services/_Umami.svelte b/apps/ui/src/routes/services/[id]/_Services/_Umami.svelte similarity index 97% rename from src/routes/services/[id]/_Services/_Umami.svelte rename to apps/ui/src/routes/services/[id]/_Services/_Umami.svelte index 17dbeb179..831444b2d 100644 --- a/src/routes/services/[id]/_Services/_Umami.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_Umami.svelte @@ -2,7 +2,7 @@ import CopyPasswordField from '$lib/components/CopyPasswordField.svelte'; import Explainer from '$lib/components/Explainer.svelte'; - export let service; + export let service: any;
diff --git a/src/routes/services/[id]/_Services/_VSCodeServer.svelte b/apps/ui/src/routes/services/[id]/_Services/_VSCodeServer.svelte similarity index 94% rename from src/routes/services/[id]/_Services/_VSCodeServer.svelte rename to apps/ui/src/routes/services/[id]/_Services/_VSCodeServer.svelte index 43873bf4d..c4418fbf9 100644 --- a/src/routes/services/[id]/_Services/_VSCodeServer.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_VSCodeServer.svelte @@ -2,7 +2,7 @@ import CopyPasswordField from '$lib/components/CopyPasswordField.svelte'; import { t } from '$lib/translations'; - export let service; + export let service: any;
diff --git a/src/routes/services/[id]/_Services/_Wordpress.svelte b/apps/ui/src/routes/services/[id]/_Services/_Wordpress.svelte similarity index 78% rename from src/routes/services/[id]/_Services/_Wordpress.svelte rename to apps/ui/src/routes/services/[id]/_Services/_Wordpress.svelte index 13b222fc5..8ba3ae16f 100644 --- a/src/routes/services/[id]/_Services/_Wordpress.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_Wordpress.svelte @@ -1,17 +1,16 @@ - + + + {#if isNew} diff --git a/src/routes/services/[id]/__layout.svelte b/apps/ui/src/routes/services/[id]/__layout.svelte similarity index 71% rename from src/routes/services/[id]/__layout.svelte rename to apps/ui/src/routes/services/[id]/__layout.svelte index aa7026fd4..a2f174a91 100644 --- a/src/routes/services/[id]/__layout.svelte +++ b/apps/ui/src/routes/services/[id]/__layout.svelte @@ -1,6 +1,6 @@ -