├── .editorconfig ├── .git-blame-ignore-revs ├── .github ├── helm-ct.yml ├── labeler.yml ├── release-drafter.yaml └── workflows │ ├── bootzooka-ci.yml │ ├── bootzooka-helm-ci.yaml │ └── scala-steward.yml ├── .gitignore ├── .mergify.yml ├── .scala-steward.conf ├── .scalafix.conf ├── .scalafmt.conf ├── CHANGELOG.md ├── LICENSE ├── README.md ├── backend-start.bat ├── backend-start.sh ├── backend └── src │ ├── main │ ├── resources │ │ ├── application.conf │ │ ├── db │ │ │ └── migration │ │ │ │ └── V1__create_schema.sql │ │ ├── logback.xml │ │ ├── psw4j.properties │ │ └── templates │ │ │ └── email │ │ │ ├── emailSignature.txt │ │ │ ├── passwordChangeNotification.txt │ │ │ ├── profileDetailsChangeNotification.txt │ │ │ ├── registrationConfirmation.txt │ │ │ └── resetPassword.txt │ └── scala │ │ └── com │ │ └── softwaremill │ │ └── bootzooka │ │ ├── Dependencies.scala │ │ ├── Fail.scala │ │ ├── Main.scala │ │ ├── OpenAPIDescription.scala │ │ ├── admin │ │ └── VersionApi.scala │ │ ├── config │ │ ├── Config.scala │ │ └── Sensitive.scala │ │ ├── email │ │ ├── EmailConfig.scala │ │ ├── EmailModel.scala │ │ ├── EmailService.scala │ │ ├── EmailTemplateRenderer.scala │ │ ├── EmailTemplates.scala │ │ └── sender │ │ │ ├── DummyEmailSender.scala │ │ │ ├── EmailSender.scala │ │ │ ├── MailgunEmailSender.scala │ │ │ └── SmtpEmailSender.scala │ │ ├── http │ │ ├── Http.scala │ │ ├── HttpApi.scala │ │ ├── HttpConfig.scala │ │ └── package.scala │ │ ├── infrastructure │ │ ├── DB.scala │ │ ├── DBConfig.scala │ │ ├── Magnum.scala │ │ └── SetTraceIdInMDCInterceptor.scala │ │ ├── logging │ │ └── Logging.scala │ │ ├── metrics │ │ └── Metrics.scala │ │ ├── passwordreset │ │ ├── PasswordResetApi.scala │ │ ├── PasswordResetCodeModel.scala │ │ ├── PasswordResetConfig.scala │ │ └── PasswordResetService.scala │ │ ├── security │ │ ├── ApiKeyModel.scala │ │ ├── ApiKeyService.scala │ │ └── Auth.scala │ │ ├── user │ │ ├── UserApi.scala │ │ ├── UserConfig.scala │ │ ├── UserModel.scala │ │ └── UserService.scala │ │ └── util │ │ ├── Clock.scala │ │ ├── IdGenerator.scala │ │ ├── PasswordVerificationStatus.scala │ │ ├── SecureRandomIdGenerator.scala │ │ └── Strings.scala │ └── test │ └── scala │ └── com │ └── softwaremill │ └── bootzooka │ ├── email │ ├── EmailTemplatesTest.scala │ └── sender │ │ └── DummyEmailSenderTest.scala │ ├── passwordreset │ └── PasswordResetApiTest.scala │ ├── test │ ├── BaseTest.scala │ ├── RegisteredUser.scala │ ├── Requests.scala │ ├── TestClock.scala │ ├── TestDependencies.scala │ ├── TestEmbeddedPostgres.scala │ ├── TestSupport.scala │ └── package.scala │ └── user │ ├── UserApiTest.scala │ └── UserValidatorSpec.scala ├── banner.png ├── build.sbt ├── docker-compose.yml ├── docs ├── _config.yml ├── _layouts │ └── default.html ├── architecture.md ├── backend.md ├── configuration.md ├── devtips.md ├── frontend.md ├── getting-started.md ├── index.md ├── javascripts │ └── scale.fix.js ├── production.md ├── stack.md ├── stylesheets │ ├── github-light.css │ └── styles.css └── testing.md ├── frontend-start.sh ├── helm └── bootzooka │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── README.md.gotmpl │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── ingress.yaml │ ├── secrets.yaml │ ├── service.yaml │ └── tests │ │ └── test-postgresql-connection.yaml │ └── values.yaml ├── integration-tests └── docker-compose.yml ├── project ├── RenameProject.scala ├── build.properties └── plugins.sbt ├── ui ├── .env.example ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── eslint.config.js ├── index.html ├── openapi-codegen.config.ts ├── package.json ├── public │ ├── favicon.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── api │ │ ├── apiComponents.ts │ │ ├── apiContext.ts │ │ ├── apiFetcher.ts │ │ ├── apiSchemas.ts │ │ └── apiUtils.ts │ ├── assets │ │ ├── forkme_orange.png │ │ ├── sml-logo-vertical-rgb-trans.png │ │ └── sml-logo-vertical-white-all-trans.png │ ├── components │ │ ├── ErrorMessage │ │ │ └── ErrorMessage.tsx │ │ ├── FeedbackButton │ │ │ ├── FeedbackButton.tsx │ │ │ └── useFormikValuesChanged.tsx │ │ ├── FormikInput │ │ │ └── FormikInput.tsx │ │ ├── TwoColumnHero │ │ │ └── TwoColumnHero.tsx │ │ └── index.ts │ ├── contexts │ │ ├── UserContext │ │ │ ├── User.context.ts │ │ │ ├── UserContext.constants.ts │ │ │ ├── UserContext.test.tsx │ │ │ └── UserContext.tsx │ │ └── index.ts │ ├── index.css │ ├── main.tsx │ ├── main │ │ ├── Footer │ │ │ ├── Footer.test.tsx │ │ │ └── Footer.tsx │ │ ├── ForkMe │ │ │ ├── ForkMe.test.tsx │ │ │ └── ForkMe.tsx │ │ ├── Loader │ │ │ ├── Loader.test.tsx │ │ │ └── Loader.tsx │ │ ├── Main │ │ │ ├── Main.test.tsx │ │ │ ├── Main.tsx │ │ │ ├── useLocalStorageApiKey.test.tsx │ │ │ ├── useLocalStorageApiKey.tsx │ │ │ ├── useLoginOnApiKey.test.tsx │ │ │ └── useLoginOnApiKey.tsx │ │ ├── Routes │ │ │ ├── ProtectedRoute.test.tsx │ │ │ ├── ProtectedRoute.tsx │ │ │ ├── Routes.test.tsx │ │ │ └── Routes.tsx │ │ ├── Top │ │ │ ├── Top.test.tsx │ │ │ └── Top.tsx │ │ └── index.ts │ ├── pages │ │ ├── Login │ │ │ ├── Login.test.tsx │ │ │ ├── Login.tsx │ │ │ └── Login.validations.ts │ │ ├── NotFound │ │ │ ├── NotFound.test.tsx │ │ │ └── NotFound.tsx │ │ ├── Profile │ │ │ ├── Profile.test.tsx │ │ │ ├── Profile.tsx │ │ │ └── components │ │ │ │ ├── PasswordDetails.test.tsx │ │ │ │ ├── PasswordDetails.tsx │ │ │ │ ├── PasswordDetails.validations.ts │ │ │ │ ├── ProfileDetails.test.tsx │ │ │ │ ├── ProfileDetails.tsx │ │ │ │ └── ProfileDetails.validations.ts │ │ ├── RecoverLostPassword │ │ │ ├── RecoverLostPassword.test.tsx │ │ │ ├── RecoverLostPassword.tsx │ │ │ └── RecoverLostPassword.validations.ts │ │ ├── Register │ │ │ ├── Register.test.tsx │ │ │ ├── Register.tsx │ │ │ ├── Register.utils.ts │ │ │ └── Register.validations.ts │ │ ├── SecretMain │ │ │ ├── SecretMain.test.tsx │ │ │ └── SecretMain.tsx │ │ ├── Welcome │ │ │ ├── Welcome.test.tsx │ │ │ └── Welcome.tsx │ │ └── index.ts │ ├── setupTest.ts │ ├── tests │ │ ├── index.ts │ │ └── utils.tsx │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── vitest.config.ts └── yarn.lock ├── version.sbt └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # all files 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | max_line_length = 120 15 | 16 | [*.js] 17 | quote_type = single 18 | curly_bracket_next_line = false 19 | spaces_around_operators = true 20 | spaces_around_brackets = inside 21 | indent_brace_style = BSD KNF 22 | 23 | # HTML 24 | [*.html] 25 | quote_type = double 26 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Scala Steward: Reformat with scalafmt 3.6.0 2 | c28b856063143ba908043a4cdce797cd3a20f394 3 | 4 | # Scala Steward: Reformat with scalafmt 3.8.1 5 | 13cfe8a8bc538f317563ba6902a922d0819f2871 6 | -------------------------------------------------------------------------------- /.github/helm-ct.yml: -------------------------------------------------------------------------------- 1 | remote: origin 2 | chart-dirs: 3 | - helm 4 | chart-repos: 5 | - bitnami=https://charts.bitnami.com/bitnami 6 | debug: false 7 | helm-extra-args: --timeout 600s 8 | check-version-increment: false 9 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | labels: 3 | - label: "automerge" 4 | authors: ["softwaremill-ci"] 5 | files: 6 | - "build.sbt" 7 | - "project/plugins.sbt" 8 | - "project/build.properties" 9 | - label: "dependency" 10 | authors: ["softwaremill-ci"] 11 | files: 12 | - "build.sbt" 13 | - "project/plugins.sbt" 14 | - "project/build.properties" 15 | -------------------------------------------------------------------------------- /.github/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /.github/workflows/bootzooka-ci.yml: -------------------------------------------------------------------------------- 1 | name: Bootzooka CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: [v*] 7 | branches: 8 | - master 9 | paths-ignore: 10 | - "helm/**" 11 | release: 12 | types: 13 | - released 14 | 15 | jobs: 16 | verify: 17 | runs-on: ubuntu-24.04 18 | 19 | steps: 20 | - name: Check-out repository 21 | id: repo-checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up JDK 25 | uses: actions/setup-java@v4 26 | with: 27 | distribution: 'zulu' 28 | java-version: '21' 29 | cache: 'sbt' 30 | 31 | - uses: sbt/setup-sbt@v1 32 | 33 | - name: Set up Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: 22 37 | 38 | - name: Generate OpenAPI Spec 39 | id: generate-openapi-spec 40 | run: sbt "backend/generateOpenAPIDescription" 41 | 42 | - name: Run tests 43 | id: run-tests 44 | run: sbt test 45 | 46 | - name: Test UI build 47 | id: test-ui-build 48 | run: yarn build 49 | working-directory: ./ui 50 | 51 | deploy: 52 | if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/v') 53 | needs: [ verify ] 54 | runs-on: ubuntu-24.04 55 | 56 | steps: 57 | - name: Check-out repository 58 | id: repo-checkout 59 | uses: actions/checkout@v4 60 | 61 | - name: Set up JDK 62 | uses: actions/setup-java@v4 63 | with: 64 | distribution: 'zulu' 65 | java-version: '21' 66 | cache: 'sbt' 67 | 68 | - name: Login to DockerHub 69 | uses: docker/login-action@v1 70 | with: 71 | username: ${{ secrets.DOCKERHUB_USERNAME }} 72 | password: ${{ secrets.DOCKERHUB_TOKEN }} 73 | 74 | - name: Extract version 75 | run: | 76 | version=${GITHUB_REF/refs\/tags\/v/} 77 | echo "VERSION=$version" >> $GITHUB_ENV 78 | 79 | - name: Publish release notes 80 | uses: release-drafter/release-drafter@v5 81 | with: 82 | config-name: release-drafter.yaml 83 | publish: true 84 | name: "v${{ env.VERSION }}" 85 | tag: "v${{ env.VERSION }}" 86 | version: "v${{ env.VERSION }}" 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | - name: Publish docker image 91 | run: sbt backend/docker:publish 92 | 93 | label: 94 | # only for PRs by softwaremill-ci 95 | if: github.event.pull_request.user.login == 'softwaremill-ci' 96 | uses: softwaremill/github-actions-workflows/.github/workflows/label.yml@main 97 | 98 | auto-merge: 99 | # only for PRs by softwaremill-ci 100 | if: github.event.pull_request.user.login == 'softwaremill-ci' 101 | needs: [ verify, label ] 102 | uses: softwaremill/github-actions-workflows/.github/workflows/auto-merge.yml@main 103 | -------------------------------------------------------------------------------- /.github/workflows/bootzooka-helm-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Bootzooka Helm Chart CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - "helm/**" 7 | - ".github/**" 8 | branches: 9 | - master 10 | pull_request: 11 | paths: 12 | - "helm/**" 13 | - ".github/**" 14 | 15 | jobs: 16 | lint-chart: 17 | name: Lint Helm Chart 18 | runs-on: ubuntu-24.04 19 | steps: 20 | - name: Check-out repository 21 | id: repo-checkout 22 | uses: actions/checkout@v2 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Run chart-testing (lint) 27 | id: ct-lint 28 | uses: helm/chart-testing-action@v1.1.0 29 | with: 30 | command: lint 31 | config: .github/helm-ct.yml 32 | 33 | install-test-chart: 34 | needs: 35 | - lint-chart 36 | name: Install & Test Helm Chart 37 | runs-on: ubuntu-24.04 38 | strategy: 39 | matrix: 40 | k8s: 41 | - v1.20.7 42 | - v1.21.2 43 | - v1.22.4 44 | steps: 45 | - name: Check-out repository 46 | id: repo-checkout 47 | uses: actions/checkout@v2 48 | with: 49 | fetch-depth: 0 50 | 51 | - name: Create kind ${{ matrix.k8s }} cluster 52 | id: kind-cluster-setup 53 | uses: helm/kind-action@v1.2.0 54 | with: 55 | node_image: kindest/node:${{ matrix.k8s }} 56 | wait: "120s" 57 | 58 | - name: Run chart-testing (install) 59 | id: ct-install 60 | uses: helm/chart-testing-action@v1.1.0 61 | with: 62 | command: install 63 | config: .github/helm-ct.yml 64 | 65 | validate-chart-docs: 66 | needs: 67 | - lint-chart 68 | - install-test-chart 69 | name: Validate Helm Chart Docs 70 | runs-on: ubuntu-24.04 71 | steps: 72 | - name: Check-out repository 73 | id: repo-checkout 74 | uses: actions/checkout@v2 75 | with: 76 | fetch-depth: 0 77 | 78 | - name: Run helm-docs 79 | id: helm-docs-run 80 | uses: softwaremill/helm-docs-action@main 81 | 82 | - name: Validate there's no diff 83 | id: git-no-diff 84 | run: git diff --exit-code 85 | 86 | publish-chart: 87 | # run only on push to master 88 | if: github.event_name == 'push' 89 | needs: 90 | - lint-chart 91 | - install-test-chart 92 | - validate-chart-docs 93 | name: Publish Helm Chart 94 | runs-on: ubuntu-24.04 95 | steps: 96 | - name: Check-out repository 97 | id: repo-checkout 98 | uses: actions/checkout@v2 99 | with: 100 | fetch-depth: 0 101 | 102 | - name: Publish Helm Chart 103 | id: helm-publish-chart 104 | uses: stefanprodan/helm-gh-pages@v1.2.0 105 | with: 106 | token: ${{ secrets.CR_TOKEN }} 107 | charts_dir: helm 108 | charts_url: https://softwaremill.github.io/sml-helm-public-repo 109 | owner: softwaremill 110 | repository: sml-helm-public-repo 111 | helm_version: 3.7.1 112 | linting: off 113 | -------------------------------------------------------------------------------- /.github/workflows/scala-steward.yml: -------------------------------------------------------------------------------- 1 | name: Scala Steward 2 | 3 | # This workflow will launch at 00:00 every day 4 | on: 5 | schedule: 6 | - cron: '0 0 * * *' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | scala-steward: 11 | uses: softwaremill/github-actions-workflows/.github/workflows/scala-steward.yml@main 12 | secrets: 13 | repo-github-token: ${{secrets.REPO_GITHUB_TOKEN}} 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | build 13 | target 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | ui/.env 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | *.log 28 | work.txt 29 | 30 | # generated code 31 | /ui/src/api-client/openapi.d.ts 32 | 33 | # IDE 34 | *.iml 35 | *.ipr 36 | *.iws 37 | .idea/ 38 | *.bloop 39 | *.metals 40 | .vscode 41 | metals.sbt 42 | .bsp 43 | 44 | metals.sbt 45 | .vscode 46 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: delete head branch after merge 3 | conditions: [] 4 | actions: 5 | delete_head_branch: {} 6 | - name: automatic merge for softwaremill-ci pull requests affecting build.sbt 7 | conditions: 8 | - author=softwaremill-ci 9 | - check-success=verify 10 | - "#files=1" 11 | - files=build.sbt 12 | actions: 13 | merge: 14 | method: merge 15 | - name: automatic merge for softwaremill-ci pull requests affecting project plugins.sbt 16 | conditions: 17 | - author=softwaremill-ci 18 | - check-success=verify 19 | - "#files=1" 20 | - files=project/plugins.sbt 21 | actions: 22 | merge: 23 | method: merge 24 | - name: semi-automatic merge for softwaremill-ci pull requests 25 | conditions: 26 | - author=softwaremill-ci 27 | - check-success=verify 28 | - "#approved-reviews-by>=1" 29 | actions: 30 | merge: 31 | method: merge 32 | - name: automatic merge for softwaremill-ci pull requests affecting project build.properties 33 | conditions: 34 | - author=softwaremill-ci 35 | - check-success=verify 36 | - "#files=1" 37 | - files=project/build.properties 38 | actions: 39 | merge: 40 | method: merge 41 | - name: automatic merge for softwaremill-ci pull requests affecting .scalafmt.conf 42 | conditions: 43 | - author=softwaremill-ci 44 | - check-success=verify 45 | - "#files=1" 46 | - files=.scalafmt.conf 47 | actions: 48 | merge: 49 | method: merge 50 | -------------------------------------------------------------------------------- /.scala-steward.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/.scala-steward.conf -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | OrganizeImports.groupedImports = AggressiveMerge 2 | OrganizeImports.targetDialect = Scala3 -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version=3.9.4 2 | maxColumn = 140 3 | runner.dialect = scala3 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## 2019-07-11 5 | - rewrite of the backend using http4s, tapir, monix and doobie 6 | 7 | ## 2015-01-22 8 | - MongoDB replaced by Slick & H2 with Flyway for easy database schema management 9 | 10 | ## 2014-12-16 11 | - Adding mongo docker container start to backend-start.sh 12 | - Dependency version update 13 | - Adding changelog file 14 | - explicit mongo-java-driver version (not inherited from lift's mongo record) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Bootzooka](https://github.com/softwaremill/bootzooka/raw/master/banner.png) 2 | 3 | [See the docs](http://softwaremill.github.io/bootzooka/) for more information. 4 | 5 | [![ CI ](https://github.com/softwaremill/bootzooka/workflows/Bootzooka%20CI/badge.svg)](https://github.com/softwaremill/bootzooka/actions?query=workflow%3A%22Bootzooka+CI%22) 6 | 7 | ## Quick start 8 | 9 | ### Using Docker compose 10 | 11 | The fastest way to experiment with Bootzooka is using the provided Docker compose setup. It starts three images: 12 | Bootzooka itself (either locally built or downloaded), PostgreSQL server and Graphana LGTM for observability. 13 | 14 | ### Backend: PostgreSQL & API 15 | 16 | To run Bootzooka's backend locally, you'll still need a running instance of PostgreSQL with a `bootzooka` database. 17 | You can spin up one easily using docker: 18 | 19 | ```sh 20 | # use "bootzooka" as a password 21 | docker run --name bootzooka-postgres -p 5432:5432 -e POSTGRES_PASSWORD=bootzooka -e POSTGRES_DB=bootzooka -d postgres 22 | ``` 23 | 24 | Then, you can start the backend: 25 | 26 | ```sh 27 | OTEL_SDK_DISABLED=true SQL_PASSWORD=bootzooka ./backend-start.sh 28 | ``` 29 | 30 | Unless you've got an OpenTelemetry collector running, OpenTelemetry should be disabled to avoid telemetry export 31 | exceptions. 32 | 33 | ### Frontend: Yarn & webapp 34 | 35 | You will need the [yarn package manager](https://yarnpkg.com) to run the UI. Install it using your package manager or: 36 | 37 | ```sh 38 | curl -o- -L https://yarnpkg.com/install.sh | bash 39 | ``` 40 | 41 | Then, you can start the frontend: 42 | 43 | ```sh 44 | ./frontend-start.sh 45 | ``` 46 | 47 | And open `http://localhost:8081`. 48 | 49 | ## Commercial Support 50 | 51 | We offer commercial support for Bootzooka and related technologies, as well as development services. [Contact us](https://softwaremill.com) to learn more about our offer! 52 | 53 | ## Copyright 54 | 55 | Copyright (C) 2013-2025 SoftwareMill [https://softwaremill.com](https://softwaremill.com). 56 | -------------------------------------------------------------------------------- /backend-start.bat: -------------------------------------------------------------------------------- 1 | @setlocal 2 | sbt "~backend/reStart" 3 | -------------------------------------------------------------------------------- /backend-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sbt "~backend/reStart" 4 | -------------------------------------------------------------------------------- /backend/src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | api { 2 | host = "0.0.0.0" 3 | host = ${?API_HOST} 4 | 5 | port = 8080 6 | port = ${?API_PORT} 7 | port = ${?PORT} 8 | } 9 | 10 | db { 11 | username = "postgres" 12 | username = ${?SQL_USERNAME} 13 | 14 | password = "bootzooka" 15 | password = ${?SQL_PASSWORD} 16 | 17 | name = "bootzooka" 18 | name = ${?SQL_DBNAME} 19 | host = "localhost" 20 | host = ${?SQL_HOST} 21 | port = 5432 22 | port = ${?SQL_PORT} 23 | 24 | url = "jdbc:postgresql://"${db.host}":"${db.port}"/"${db.name} 25 | url = ${?DATABASE_URL} 26 | 27 | migrate-on-start = true 28 | migrate-on-start = ${?MIGRATE_ON_START} 29 | 30 | driver = "org.postgresql.Driver" 31 | } 32 | 33 | email { 34 | mailgun { 35 | enabled = false 36 | enabled = ${?MAILGUN_ENABLED} 37 | 38 | domain = "XXXX.mailgun.org" 39 | domain = ${?MAILGUN_DOMAIN} 40 | 41 | url = "https://api.eu.mailgun.net/v3/"${email.mailgun.domain}"/messages" 42 | url = ${?MAILGUN_URL} 43 | 44 | api-key = "XXX-XXX-XXX" 45 | api-key = ${?MAILGUN_API_KEY} 46 | 47 | sender-name = "bootzooka" 48 | sender-name = ${?MAILGUN_SENDER_NAME} 49 | 50 | sender-display-name = "Bootzooka" 51 | sender-display-name = ${?MAILGUN_SENDER_DISPLAY_NAME} 52 | } 53 | 54 | smtp { 55 | enabled = false 56 | enabled = ${?SMTP_ENABLED} 57 | 58 | host = "" 59 | host = ${?SMTP_HOST} 60 | 61 | port = 25 62 | port = ${?SMTP_PORT} 63 | 64 | username = "" 65 | username = ${?SMTP_USERNAME} 66 | 67 | password = "" 68 | password = ${?SMTP_PASSWORD} 69 | 70 | ssl-connection = false 71 | ssl-connection = ${?SMTP_SSL_CONNECTION} 72 | 73 | verify-ssl-certificate = true 74 | verify-ssl-certificate = ${?SMTP_VERIFY_SSL_CERTIFICATE} 75 | 76 | encoding = "UTF-8" 77 | encoding = ${?SMTP_ENCODING} 78 | 79 | from = "info@bootzooka.com" 80 | from = ${?SMTP_FROM} 81 | } 82 | 83 | batch-size = 10 84 | batch-size = ${?EMAIL_BATCH_SIZE} 85 | 86 | email-send-interval = 1 second 87 | email-send-interval = ${?EMAIL_SEND_INTERVAL} 88 | } 89 | 90 | password-reset { 91 | reset-link-pattern = "http://localhost:8081/password-reset?code=%s" 92 | reset-link-pattern = ${?PASSWORD_RESET_LINK_PATTERN} 93 | 94 | code-valid = 1 day 95 | code-valid = ${?PASSWORD_RESET_CODE_VALID} 96 | } 97 | 98 | user { 99 | default-api-key-valid = 1 day 100 | default-api-key-valid = ${?USER_DEFAULT_API_KEY_VALID} 101 | } 102 | -------------------------------------------------------------------------------- /backend/src/main/resources/db/migration/V1__create_schema.sql: -------------------------------------------------------------------------------- 1 | -- USERS 2 | CREATE TABLE "users" 3 | ( 4 | "id" TEXT NOT NULL, 5 | "login" TEXT NOT NULL, 6 | "login_lowercase" TEXT NOT NULL, 7 | "email_lowercase" TEXT NOT NULL, 8 | "password" TEXT NOT NULL, 9 | "created_on" TIMESTAMPTZ NOT NULL 10 | ); 11 | ALTER TABLE "users" 12 | ADD CONSTRAINT "users_id" PRIMARY KEY ("id"); 13 | CREATE UNIQUE INDEX "users_login_lowercase" ON "users" ("login_lowercase"); 14 | CREATE UNIQUE INDEX "users_email_lowercase" ON "users" ("email_lowercase"); 15 | 16 | -- API KEYS 17 | CREATE TABLE "api_keys" 18 | ( 19 | "id" TEXT NOT NULL, 20 | "user_id" TEXT NOT NULL, 21 | "created_on" TIMESTAMPTZ NOT NULL, 22 | "valid_until" TIMESTAMPTZ NOT NULL 23 | ); 24 | ALTER TABLE "api_keys" 25 | ADD CONSTRAINT "api_keys_id" PRIMARY KEY ("id"); 26 | ALTER TABLE "api_keys" 27 | ADD CONSTRAINT "api_keys_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE; 28 | 29 | -- PASSWORD RESET CODES 30 | CREATE TABLE "password_reset_codes" 31 | ( 32 | "id" TEXT NOT NULL, 33 | "user_id" TEXT NOT NULL, 34 | "valid_until" TIMESTAMPTZ NOT NULL 35 | ); 36 | ALTER TABLE "password_reset_codes" 37 | ADD CONSTRAINT "password_reset_codes_id" PRIMARY KEY ("id"); 38 | ALTER TABLE "password_reset_codes" 39 | ADD CONSTRAINT "password_reset_codes_user_fk" 40 | FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE; 41 | 42 | -- EMAILS 43 | CREATE TABLE "scheduled_emails" 44 | ( 45 | "id" TEXT NOT NULL, 46 | "recipient" TEXT NOT NULL, 47 | "subject" TEXT NOT NULL, 48 | "content" TEXT NOT NULL 49 | ); 50 | ALTER TABLE "scheduled_emails" 51 | ADD CONSTRAINT "scheduled_emails_id" PRIMARY KEY ("id"); 52 | -------------------------------------------------------------------------------- /backend/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /backend/src/main/resources/psw4j.properties: -------------------------------------------------------------------------------- 1 | global.salt.length=64 2 | -------------------------------------------------------------------------------- /backend/src/main/resources/templates/email/emailSignature.txt: -------------------------------------------------------------------------------- 1 | 2 | -- 3 | Regards, 4 | SoftwareMill Bootzooka Dev Team 5 | http://softwaremill.com 6 | -------------------------------------------------------------------------------- /backend/src/main/resources/templates/email/passwordChangeNotification.txt: -------------------------------------------------------------------------------- 1 | SoftwareMill Bootzooka password change notification 2 | Dear {{userName}}, 3 | 4 | Your password has been changed. 5 | -------------------------------------------------------------------------------- /backend/src/main/resources/templates/email/profileDetailsChangeNotification.txt: -------------------------------------------------------------------------------- 1 | SoftwareMill Bootzooka profile details change notification 2 | Dear {{userName}}, 3 | 4 | Your user name and/or email has been changed. 5 | -------------------------------------------------------------------------------- /backend/src/main/resources/templates/email/registrationConfirmation.txt: -------------------------------------------------------------------------------- 1 | SoftwareMill Bootzooka - registration confirmation for user {{userName}} 2 | Dear {{userName}}, 3 | 4 | Thank you for registering in our application. -------------------------------------------------------------------------------- /backend/src/main/resources/templates/email/resetPassword.txt: -------------------------------------------------------------------------------- 1 | SoftwareMill Bootzooka password reset 2 | Dear {{userName}}, 3 | 4 | To be able to set a new password, please visit the link below: 5 | 6 | {{resetLink}} 7 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/Dependencies.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka 2 | 3 | import com.softwaremill.bootzooka.admin.VersionApi 4 | import com.softwaremill.bootzooka.config.Config 5 | import com.softwaremill.bootzooka.email.EmailService 6 | import com.softwaremill.bootzooka.email.sender.EmailSender 7 | import com.softwaremill.bootzooka.http.{HttpApi, HttpConfig} 8 | import com.softwaremill.bootzooka.infrastructure.DB 9 | import com.softwaremill.bootzooka.metrics.Metrics 10 | import com.softwaremill.bootzooka.passwordreset.{PasswordResetApi, PasswordResetAuthToken} 11 | import com.softwaremill.bootzooka.security.{ApiKeyAuthToken, ApiKeyService, Auth} 12 | import com.softwaremill.bootzooka.user.UserApi 13 | import com.softwaremill.bootzooka.util.{Clock, DefaultClock, DefaultIdGenerator, IdGenerator} 14 | import com.softwaremill.macwire.{autowire, autowireMembersOf} 15 | import io.opentelemetry.api.OpenTelemetry 16 | import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender 17 | import io.opentelemetry.instrumentation.runtimemetrics.java8.{Classes, Cpu, GarbageCollector, MemoryPools, Threads} 18 | import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk 19 | import ox.{Ox, discard, tap, useCloseableInScope, useInScope} 20 | import sttp.client4.SyncBackend 21 | import sttp.client4.httpclient.HttpClientSyncBackend 22 | import sttp.client4.logging.slf4j.Slf4jLoggingBackend 23 | import sttp.client4.opentelemetry.{OpenTelemetryMetricsBackend, OpenTelemetryTracingBackend} 24 | import sttp.tapir.AnyEndpoint 25 | 26 | case class Dependencies(httpApi: HttpApi, emailService: EmailService) 27 | 28 | object Dependencies: 29 | val endpointsForDocs: List[AnyEndpoint] = List(UserApi, PasswordResetApi, VersionApi).flatMap(_.endpointsForDocs) 30 | 31 | private case class Apis(userApi: UserApi, passwordResetApi: PasswordResetApi, versionApi: VersionApi): 32 | def endpoints = List(userApi, passwordResetApi, versionApi).flatMap(_.endpoints) 33 | 34 | def create(using Ox): Dependencies = 35 | val config = Config.read.tap(Config.log) 36 | val otel = initializeOtel() 37 | val sttpBackend = useInScope( 38 | Slf4jLoggingBackend(OpenTelemetryMetricsBackend(OpenTelemetryTracingBackend(HttpClientSyncBackend(), otel), otel)) 39 | )(_.close()) 40 | val db: DB = useCloseableInScope(DB.createTestMigrate(config.db)) 41 | 42 | create(config, otel, sttpBackend, db, DefaultClock) 43 | 44 | /** Create the service graph using the given infrastructure services & configuration. */ 45 | def create(config: Config, otel: OpenTelemetry, sttpBackend: SyncBackend, db: DB, clock: Clock): Dependencies = 46 | autowire[Dependencies]( 47 | autowireMembersOf(config), 48 | otel, 49 | sttpBackend, 50 | db, 51 | DefaultIdGenerator, 52 | clock, 53 | EmailSender.create, 54 | (apis: Apis, otel: OpenTelemetry, httpConfig: HttpConfig) => 55 | new HttpApi(apis.endpoints, Dependencies.endpointsForDocs, otel, httpConfig), 56 | classOf[EmailService], 57 | new Auth(_: ApiKeyAuthToken, _: DB, _: Clock), 58 | new Auth(_: PasswordResetAuthToken, _: DB, _: Clock) 59 | ) 60 | 61 | private def initializeOtel(): OpenTelemetry = 62 | AutoConfiguredOpenTelemetrySdk 63 | .initialize() 64 | .getOpenTelemetrySdk() 65 | .tap { otel => 66 | // see https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/runtime-telemetry/runtime-telemetry-java8/library 67 | Classes.registerObservers(otel) 68 | Cpu.registerObservers(otel) 69 | MemoryPools.registerObservers(otel) 70 | Threads.registerObservers(otel) 71 | GarbageCollector.registerObservers(otel).discard 72 | } 73 | .tap(OpenTelemetryAppender.install) 74 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/Fail.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka 2 | 3 | /** Base class for all failures in the application. The failures are translated to HTTP API results in the 4 | * [[com.softwaremill.bootzooka.http.Http]] class. 5 | * 6 | * The class hierarchy is not sealed and can be extended as required by specific functionalities. 7 | */ 8 | abstract class Fail 9 | 10 | object Fail: 11 | case class NotFound(what: String) extends Fail 12 | case class Conflict(msg: String) extends Fail 13 | case class IncorrectInput(msg: String) extends Fail 14 | case class Unauthorized(msg: String) extends Fail 15 | case object Forbidden extends Fail 16 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/Main.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka 2 | 3 | import com.softwaremill.bootzooka.logging.Logging 4 | import ox.logback.InheritableMDC 5 | import ox.{Ox, OxApp, never} 6 | import org.slf4j.bridge.SLF4JBridgeHandler 7 | import ox.OxApp.Settings 8 | import ox.otel.context.PropagatingVirtualThreadFactory 9 | 10 | object Main extends OxApp.Simple with Logging: 11 | // route JUL to SLF4J (JUL is used by Magnum & OTEL for logging) 12 | SLF4JBridgeHandler.removeHandlersForRootLogger() 13 | SLF4JBridgeHandler.install() 14 | 15 | // https://ox.softwaremill.com/latest/integrations/mdc-logback.html 16 | InheritableMDC.init 17 | 18 | Thread.setDefaultUncaughtExceptionHandler((t, e) => logger.error("Uncaught exception in thread: " + t, e)) 19 | 20 | // https://ox.softwaremill.com/latest/integrations/otel-context.html 21 | override protected def settings: Settings = Settings.Default.copy(threadFactory = Some(PropagatingVirtualThreadFactory())) 22 | 23 | override def run(using Ox): Unit = 24 | val deps = Dependencies.create 25 | 26 | deps.emailService.startProcesses() 27 | deps.httpApi.start() 28 | logger.info(s"Bootzooka started") 29 | 30 | // blocking until the application is shut down 31 | never 32 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/OpenAPIDescription.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka 2 | 3 | import sttp.apispec.openapi.circe.yaml.* 4 | import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter 5 | 6 | import java.nio.file.* 7 | 8 | object OpenAPIDescription: 9 | val Title = "Bootzooka" 10 | val Version = "1.0" 11 | 12 | @main def writeOpenAPIDescription(path: String): Unit = 13 | val yaml = OpenAPIDocsInterpreter().toOpenAPI(Dependencies.endpointsForDocs, OpenAPIDescription.Title, OpenAPIDescription.Version).toYaml 14 | Files.writeString(Paths.get(path), yaml) 15 | println(s"OpenAPI description document written to: $path") 16 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/admin/VersionApi.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.admin 2 | 3 | import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec 4 | import com.softwaremill.bootzooka.http.Http.* 5 | import com.softwaremill.bootzooka.version.BuildInfo 6 | import sttp.shared.Identity 7 | import sttp.tapir.Schema 8 | import sttp.tapir.server.ServerEndpoint 9 | import com.softwaremill.bootzooka.http.EndpointsForDocs 10 | import com.softwaremill.bootzooka.http.ServerEndpoints 11 | 12 | /** Defines an endpoint which exposes the current application version information. */ 13 | class VersionApi extends ServerEndpoints: 14 | import VersionApi._ 15 | 16 | private val versionServerEndpoint: ServerEndpoint[Any, Identity] = versionEndpoint.handleSuccess { _ => 17 | Version_OUT(BuildInfo.lastCommitHash) 18 | } 19 | 20 | override val endpoints = List(versionServerEndpoint) 21 | 22 | object VersionApi extends EndpointsForDocs: 23 | private val AdminPath = "admin" 24 | 25 | private val versionEndpoint = baseEndpoint.get 26 | .in(AdminPath / "version") 27 | .out(jsonBody[Version_OUT]) 28 | 29 | override val endpointsForDocs = List(versionEndpoint).map(_.tag("admin")) 30 | 31 | case class Version_OUT(buildSha: String) derives ConfiguredJsonValueCodec, Schema 32 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/config/Config.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.config 2 | 3 | import com.softwaremill.bootzooka.email.EmailConfig 4 | import com.softwaremill.bootzooka.http.HttpConfig 5 | import com.softwaremill.bootzooka.infrastructure.DBConfig 6 | import com.softwaremill.bootzooka.logging.Logging 7 | import com.softwaremill.bootzooka.passwordreset.PasswordResetConfig 8 | import com.softwaremill.bootzooka.user.UserConfig 9 | import com.softwaremill.bootzooka.version.BuildInfo 10 | import pureconfig.{ConfigReader, ConfigSource} 11 | import pureconfig.generic.derivation.default.* 12 | 13 | import scala.collection.immutable.TreeMap 14 | 15 | /** Maps to the `application.conf` file. Configuration for all modules of the application. */ 16 | case class Config(db: DBConfig, api: HttpConfig, email: EmailConfig, passwordReset: PasswordResetConfig, user: UserConfig) 17 | derives ConfigReader 18 | 19 | object Config extends Logging: 20 | def log(config: Config): Unit = 21 | val baseInfo = s""" 22 | |Bootzooka configuration: 23 | |----------------------- 24 | |DB: ${config.db} 25 | |API: ${config.api} 26 | |Email: ${config.email} 27 | |Password reset: ${config.passwordReset} 28 | |User: ${config.user} 29 | | 30 | |Build & env info: 31 | |----------------- 32 | |""".stripMargin 33 | 34 | val info = TreeMap(BuildInfo.toMap.toSeq*).foldLeft(baseInfo) { case (str, (k, v)) => 35 | str + s"$k: $v\n" 36 | } 37 | 38 | logger.info(info) 39 | end log 40 | 41 | def read: Config = ConfigSource.default.loadOrThrow[Config] 42 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/config/Sensitive.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.config 2 | 3 | import pureconfig.ConfigReader 4 | 5 | /** Wrapper class for any configuration strings which shouldn't be logged verbatim. */ 6 | case class Sensitive(value: String): 7 | override def toString: String = "***" 8 | 9 | object Sensitive: 10 | given ConfigReader[Sensitive] = pureconfig.ConfigReader[String].map(Sensitive(_)) 11 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/EmailConfig.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email 2 | 3 | import com.softwaremill.bootzooka.config.Sensitive 4 | import pureconfig.ConfigReader 5 | import pureconfig.generic.derivation.default.* 6 | 7 | import scala.concurrent.duration.FiniteDuration 8 | 9 | case class EmailConfig( 10 | mailgun: MailgunConfig, 11 | smtp: SmtpConfig, 12 | batchSize: Int, 13 | emailSendInterval: FiniteDuration 14 | ) derives ConfigReader 15 | 16 | case class SmtpConfig( 17 | enabled: Boolean, 18 | host: String, 19 | port: Int, 20 | username: String, 21 | password: Sensitive, 22 | sslConnection: Boolean, 23 | verifySslCertificate: Boolean, 24 | from: String, 25 | encoding: String 26 | ) derives ConfigReader 27 | 28 | case class MailgunConfig(enabled: Boolean, apiKey: Sensitive, url: String, domain: String, senderName: String, senderDisplayName: String) 29 | derives ConfigReader 30 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email 2 | 3 | import com.augustnagro.magnum.{PostgresDbType, Repo, Spec, SqlNameMapper, Table} 4 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given} 5 | import com.softwaremill.bootzooka.util.Strings.Id 6 | import ox.discard 7 | 8 | /** Model for storing and retrieving scheduled emails. */ 9 | class EmailModel: 10 | private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]] 11 | 12 | def insert(email: Email)(using DbTx): Unit = emailRepo.insert(ScheduledEmails(email)) 13 | def find(limit: Int)(using DbTx): Vector[Email] = emailRepo.findAll(Spec[ScheduledEmails].limit(limit)).map(_.toEmail) 14 | def count()(using DbTx): Long = emailRepo.count 15 | def delete(ids: Vector[Id[Email]])(using DbTx): Unit = emailRepo.deleteAllById(ids).discard 16 | 17 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) 18 | private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String): 19 | def toEmail: Email = Email(id, EmailData(recipient, EmailSubjectContent(subject, content))) 20 | 21 | private object ScheduledEmails: 22 | def apply(email: Email): ScheduledEmails = ScheduledEmails(email.id, email.data.recipient, email.data.subject, email.data.content) 23 | 24 | case class Email(id: Id[Email], data: EmailData) 25 | 26 | case class EmailData(recipient: String, subject: String, content: String) 27 | object EmailData: 28 | def apply(recipient: String, subjectContent: EmailSubjectContent): EmailData = 29 | EmailData(recipient, subjectContent.subject, subjectContent.content) 30 | 31 | case class EmailSubjectContent(subject: String, content: String) 32 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email 2 | 3 | import com.softwaremill.bootzooka.email.sender.EmailSender 4 | import com.softwaremill.bootzooka.infrastructure.DB 5 | import com.softwaremill.bootzooka.infrastructure.Magnum.* 6 | import com.softwaremill.bootzooka.logging.Logging 7 | import com.softwaremill.bootzooka.metrics.Metrics 8 | import com.softwaremill.bootzooka.util.IdGenerator 9 | import ox.{Fork, Ox, discard, forever, fork, sleep} 10 | 11 | import scala.util.control.NonFatal 12 | 13 | /** Schedules emails to be sent asynchronously, in the background, as well as manages sending of emails in batches. */ 14 | class EmailService( 15 | emailModel: EmailModel, 16 | idGenerator: IdGenerator, 17 | emailSender: EmailSender, 18 | config: EmailConfig, 19 | db: DB, 20 | metrics: Metrics 21 | ) extends EmailScheduler 22 | with Logging: 23 | 24 | def schedule(data: EmailData)(using DbTx): Unit = 25 | logger.debug(s"Scheduling email to be sent to: ${data.recipient}") 26 | val id = idGenerator.nextId[Email]() 27 | emailModel.insert(Email(id, data)) 28 | 29 | def sendBatch(): Unit = 30 | val emails = db.transact(emailModel.find(config.batchSize)) 31 | if emails.nonEmpty then logger.info(s"Sending ${emails.size} emails") 32 | emails.map(_.data).foreach(emailSender.apply) 33 | db.transact(emailModel.delete(emails.map(_.id))) 34 | 35 | /** Starts an asynchronous process which attempts to send batches of emails in defined intervals, as well as updates a metric which holds 36 | * the size of the email queue. 37 | */ 38 | def startProcesses()(using Ox): Unit = 39 | foreverPeriodically("Exception when sending emails") { 40 | sendBatch() 41 | } 42 | 43 | foreverPeriodically("Exception when counting emails") { 44 | val count = db.transact(emailModel.count()) 45 | metrics.emailQueueGauge.set(count.toDouble) 46 | }.discard 47 | 48 | private def foreverPeriodically(errorMsg: String)(t: => Unit)(using Ox): Fork[Nothing] = 49 | fork { 50 | forever { 51 | sleep(config.emailSendInterval) 52 | try t 53 | catch case NonFatal(e) => logger.error(errorMsg, e) 54 | } 55 | } 56 | end EmailService 57 | 58 | trait EmailScheduler: 59 | def schedule(data: EmailData)(using DbTx): Unit 60 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/EmailTemplateRenderer.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email 2 | 3 | import scala.io.Source 4 | 5 | object EmailTemplateRenderer: 6 | def apply(templateNameWithoutExtension: String, params: Map[String, String]): EmailSubjectContent = 7 | val template = prepareTemplate(templateNameWithoutExtension, params) 8 | addSignature(splitToContentAndSubject(template)) 9 | 10 | private def prepareTemplate(templateNameWithoutExtension: String, params: Map[String, String]): String = 11 | val source = Source 12 | .fromURL(getClass.getResource(s"/templates/email/$templateNameWithoutExtension.txt"), "UTF-8") 13 | 14 | try { 15 | val rawTemplate = source.getLines().mkString("\n") 16 | params.foldLeft(rawTemplate) { case (template, (param, paramValue)) => 17 | template.replaceAll(s"\\{\\{$param\\}\\}", paramValue.toString) 18 | } 19 | } finally source.close() 20 | end prepareTemplate 21 | 22 | private def splitToContentAndSubject(template: String): EmailSubjectContent = 23 | // First line of template is used as an email subject, rest of the template goes to content 24 | val emailLines = template.split('\n') 25 | require( 26 | emailLines.length > 1, 27 | "Invalid email template. It should consist of at least two lines: one for subject and one for content" 28 | ) 29 | 30 | EmailSubjectContent(emailLines.head, emailLines.tail.mkString("\n")) 31 | end splitToContentAndSubject 32 | 33 | private lazy val signature = prepareTemplate("emailSignature", Map()) 34 | 35 | private def addSignature(email: EmailSubjectContent): EmailSubjectContent = 36 | email.copy(content = s"${email.content}\n$signature") 37 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/EmailTemplates.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email 2 | 3 | class EmailTemplates: 4 | def registrationConfirmation(userName: String): EmailSubjectContent = 5 | EmailTemplateRenderer("registrationConfirmation", Map("userName" -> userName)) 6 | 7 | def passwordReset(userName: String, resetLink: String): EmailSubjectContent = 8 | EmailTemplateRenderer("resetPassword", Map("userName" -> userName, "resetLink" -> resetLink)) 9 | 10 | def passwordChangeNotification(userName: String): EmailSubjectContent = 11 | EmailTemplateRenderer("passwordChangeNotification", Map("userName" -> userName)) 12 | 13 | def profileDetailsChangeNotification(userName: String): EmailSubjectContent = 14 | EmailTemplateRenderer("profileDetailsChangeNotification", Map("userName" -> userName)) 15 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSender.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email.sender 2 | 3 | import com.softwaremill.bootzooka.email.EmailData 4 | import com.softwaremill.bootzooka.logging.Logging 5 | 6 | import java.util.concurrent.{BlockingQueue, LinkedBlockingQueue} 7 | import scala.jdk.CollectionConverters.* 8 | 9 | object DummyEmailSender extends EmailSender with Logging: 10 | private val sentEmails: BlockingQueue[EmailData] = new LinkedBlockingQueue[EmailData]() 11 | 12 | def reset(): Unit = sentEmails.clear() 13 | 14 | override def apply(email: EmailData): Unit = 15 | sentEmails.put(email) 16 | logger.info(s"Would send email, if this wasn't a dummy email service implementation: $email") 17 | 18 | def findSentEmail(recipient: String, subjectContains: String): Option[EmailData] = 19 | sentEmails.iterator().asScala.find(email => email.recipient == recipient && email.subject.contains(subjectContains)) 20 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/sender/EmailSender.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email.sender 2 | 3 | import com.softwaremill.bootzooka.email.{EmailConfig, EmailData} 4 | import sttp.client4.SyncBackend 5 | 6 | trait EmailSender: 7 | def apply(email: EmailData): Unit 8 | 9 | object EmailSender: 10 | def create(sttpBackend: SyncBackend, config: EmailConfig): EmailSender = if config.mailgun.enabled then 11 | new MailgunEmailSender(config.mailgun, sttpBackend) 12 | else if config.smtp.enabled then new SmtpEmailSender(config.smtp) 13 | else DummyEmailSender 14 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/email/sender/MailgunEmailSender.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email.sender 2 | 3 | import com.softwaremill.bootzooka.email.{EmailData, MailgunConfig} 4 | import com.softwaremill.bootzooka.logging.Logging 5 | import sttp.client4.* 6 | 7 | /** Sends emails using the [[https://www.mailgun.com Mailgun]] service. The external http call is done using 8 | * [[sttp https://github.com/softwaremill/sttp]]. 9 | */ 10 | class MailgunEmailSender(config: MailgunConfig, sttpBackend: SyncBackend) extends EmailSender with Logging: 11 | override def apply(email: EmailData): Unit = 12 | basicRequest.auth 13 | .basic("api", config.apiKey.value) 14 | .post(uri"${config.url}") 15 | .body( 16 | Map( 17 | "from" -> s"${config.senderDisplayName} <${config.senderName}@${config.domain}>", 18 | "to" -> email.recipient, 19 | "subject" -> email.subject, 20 | "html" -> email.content 21 | ) 22 | ) 23 | .send(sttpBackend) 24 | logger.debug(s"Email to: ${email.recipient} sent") 25 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/http/Http.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.http 2 | 3 | import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec 4 | import com.softwaremill.bootzooka.* 5 | import com.softwaremill.bootzooka.logging.Logging 6 | import com.softwaremill.bootzooka.util.Strings.{Id, asId} 7 | import sttp.model.StatusCode 8 | import sttp.tapir.json.jsoniter.TapirJsonJsoniter 9 | import sttp.tapir.{Codec, Endpoint, EndpointOutput, PublicEndpoint, Schema, SchemaType, Tapir} 10 | 11 | /** Helper object for defining HTTP endpoints. Import as `Http.*` to gain access to Tapir's API and customizations. */ 12 | object Http extends Tapir with TapirJsonJsoniter with Logging: 13 | private val internalServerError = (StatusCode.InternalServerError, "Internal server error") 14 | private val failToResponseData: Fail => (StatusCode, String) = { 15 | case Fail.NotFound(what) => (StatusCode.NotFound, what) 16 | case Fail.Conflict(msg) => (StatusCode.Conflict, msg) 17 | case Fail.IncorrectInput(msg) => (StatusCode.BadRequest, msg) 18 | case Fail.Forbidden => (StatusCode.Forbidden, "Forbidden") 19 | case Fail.Unauthorized(msg) => (StatusCode.Unauthorized, msg) 20 | case _ => internalServerError 21 | } 22 | 23 | // 24 | 25 | val jsonErrorOutOutput: EndpointOutput[Error_OUT] = jsonBody[Error_OUT] 26 | 27 | /** Description of the output, that is used to represent an error that occurred during endpoint invocation. */ 28 | private val failOutput: EndpointOutput[Fail] = 29 | statusCode 30 | .and(jsonErrorOutOutput.map(_.error)(Error_OUT.apply)) 31 | // we're not interpreting the endpoints as clients, so we don't need the mapping in one direction 32 | .map((_, _) => throw new UnsupportedOperationException())(failToResponseData) 33 | 34 | /** Base endpoint description for non-secured endpoints. Specifies that errors are always returned as JSON values corresponding to the 35 | * [[Error_OUT]] class, translated from a [[Fail]] instance. 36 | */ 37 | val baseEndpoint: PublicEndpoint[Unit, Fail, Unit, Any] = 38 | endpoint 39 | .errorOut(failOutput) 40 | // Prevent clickjacking attacks: https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html 41 | .out(header("X-Frame-Options", "DENY")) 42 | .out(header("Content-Security-Policy", "frame-ancestors 'none'")) 43 | 44 | /** Base endpoint description for secured endpoints. Specifies that errors are always returned as JSON values corresponding to the 45 | * [[Error_OUT]] class, translated from a [[Fail]] instance, and that authentication is read from the `Authorization: Bearer` header. 46 | */ 47 | def secureEndpoint[T]: Endpoint[Id[T], Unit, Fail, Unit, Any] = 48 | baseEndpoint.securityIn(auth.bearer[String]().map(_.asId[T])(_.toString)) 49 | end Http 50 | 51 | case class Error_OUT(error: String) derives ConfiguredJsonValueCodec, Schema 52 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/http/HttpConfig.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.http 2 | 3 | import pureconfig.ConfigReader 4 | import pureconfig.generic.derivation.default.* 5 | 6 | case class HttpConfig(host: String, port: Int) derives ConfigReader 7 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/http/package.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.http 2 | 3 | import sttp.shared.Identity 4 | import sttp.tapir.AnyEndpoint 5 | import sttp.tapir.server.ServerEndpoint 6 | 7 | trait EndpointsForDocs: 8 | /** The list of endpoints which should appear in the generated OpenAPI description (used for docs and to generate frontend code). 9 | * 10 | * Usually, each endpoint should have the same [[sttp.tapir.Endpoint.tag]], and corresponds to exactly one endpoint defined in 11 | * [[ServerEndpoints.endpoints]]. 12 | */ 13 | def endpointsForDocs: List[AnyEndpoint] 14 | 15 | trait ServerEndpoints: 16 | /** The list of server endpoints which should be exposed by the HTTP server. */ 17 | def endpoints: List[ServerEndpoint[Any, Identity]] 18 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.infrastructure 2 | 3 | import java.net.URI 4 | import org.flywaydb.core.Flyway 5 | 6 | import scala.concurrent.duration.* 7 | import Magnum.* 8 | import com.augustnagro.magnum.{SqlLogger, Transactor, connect} 9 | import com.softwaremill.bootzooka.config.Sensitive 10 | import com.softwaremill.bootzooka.infrastructure.DB.LeftException 11 | import com.softwaremill.bootzooka.logging.Logging 12 | import com.zaxxer.hikari.{HikariConfig, HikariDataSource} 13 | import ox.{discard, sleep} 14 | 15 | import java.io.Closeable 16 | import javax.sql.DataSource 17 | import scala.annotation.tailrec 18 | import scala.util.NotGiven 19 | import scala.util.control.{NoStackTrace, NonFatal} 20 | 21 | class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable: 22 | private val transactor = Transactor( 23 | dataSource = dataSource, 24 | sqlLogger = SqlLogger.logSlowQueries(200.millis) 25 | ) 26 | 27 | /** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */ 28 | def transactEither[E, T](f: DbTx ?=> Either[E, T]): Either[E, T] = 29 | try com.augustnagro.magnum.transact(transactor)(Right(f.fold(e => throw LeftException(e), identity))) 30 | catch case e: LeftException[E] @unchecked => Left(e.left) 31 | 32 | /** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if 33 | * no exception is thrown. 34 | */ 35 | def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = 36 | com.augustnagro.magnum.transact(transactor)(f) 37 | 38 | override def close(): Unit = dataSource.close() 39 | 40 | object DB extends Logging: 41 | private class LeftException[E](val left: E) extends RuntimeException with NoStackTrace 42 | 43 | /** Configures the database, setting up the connection pool and performing migrations. */ 44 | def createTestMigrate(_config: DBConfig): DB = 45 | val config: DBConfig = 46 | if (_config.url.startsWith("postgres://")) { 47 | val dbUri = URI.create(_config.url) 48 | val usernamePassword = dbUri.getUserInfo.split(":") 49 | _config.copy( 50 | username = usernamePassword(0), 51 | password = Sensitive(if (usernamePassword.length > 1) usernamePassword(1) else ""), 52 | url = "jdbc:postgresql://" + dbUri.getHost + ':' + dbUri.getPort + dbUri.getPath 53 | ) 54 | } else _config 55 | end config 56 | 57 | val hikariConfig = new HikariConfig() 58 | hikariConfig.setJdbcUrl(_config.url) 59 | hikariConfig.setUsername(config.username) 60 | hikariConfig.setPassword(config.password.value) 61 | hikariConfig.addDataSourceProperty("cachePrepStmts", "true") 62 | hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250") 63 | hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048") 64 | hikariConfig.setThreadFactory(Thread.ofVirtual().factory()) 65 | 66 | val flyway = Flyway 67 | .configure() 68 | .dataSource(config.url, config.username, config.password.value) 69 | .load() 70 | 71 | def migrate(): Unit = if config.migrateOnStart then flyway.migrate().discard 72 | def testConnection(ds: DataSource): Unit = connect(ds)(sql"SELECT 1".query[Int].run()).discard 73 | 74 | @tailrec 75 | def connectAndMigrate(ds: DataSource): Unit = 76 | try 77 | migrate() 78 | testConnection(ds) 79 | logger.info("Database migration & connection test complete") 80 | catch 81 | case NonFatal(e) => 82 | logger.warn("Database not available, waiting 5 seconds to retry...", e) 83 | sleep(5.seconds) 84 | connectAndMigrate(ds) 85 | 86 | val ds = new HikariDataSource(hikariConfig) 87 | connectAndMigrate(ds) 88 | DB(ds) 89 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DBConfig.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.infrastructure 2 | 3 | import com.softwaremill.bootzooka.config.Sensitive 4 | import pureconfig.ConfigReader 5 | import pureconfig.generic.derivation.default.* 6 | 7 | case class DBConfig(username: String, password: Sensitive, url: String, migrateOnStart: Boolean, driver: String) derives ConfigReader 8 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.infrastructure 2 | 3 | import com.augustnagro.magnum.{DbCodec, Frag} 4 | import com.softwaremill.bootzooka.logging.Logging 5 | import com.softwaremill.bootzooka.util.Strings.* 6 | 7 | import java.time.{Instant, OffsetDateTime, ZoneOffset} 8 | 9 | /** Import the members of this object when defining SQL queries using Magnum. */ 10 | object Magnum extends Logging: 11 | given DbCodec[Instant] = summon[DbCodec[OffsetDateTime]].biMap(_.toInstant, _.atOffset(ZoneOffset.UTC)) 12 | 13 | given idCodec[T]: DbCodec[Id[T]] = DbCodec.StringCodec.biMap(_.asId[T], _.toString) 14 | given DbCodec[Hashed] = DbCodec.StringCodec.biMap(_.asHashed, _.toString) 15 | given DbCodec[LowerCased] = DbCodec.StringCodec.biMap(_.toLowerCased, _.toString) 16 | 17 | // proxies to the magnum functions/types, so that we can have only one import 18 | export com.augustnagro.magnum.{sql, DbTx, DbCon, DbCodec} 19 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/SetTraceIdInMDCInterceptor.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.infrastructure 2 | 3 | import com.softwaremill.bootzooka.logging.Logging 4 | import io.opentelemetry.api.trace.Span 5 | import ox.logback.InheritableMDC 6 | import sttp.shared.Identity 7 | import sttp.tapir.server.interceptor.{EndpointInterceptor, RequestHandler, RequestInterceptor, Responder} 8 | 9 | /** A Tapir interceptor, which sets the current trace id in the MDC, so that the logs that are printed to the console can be easily 10 | * correlated as well. This interceptor should come after the OpenTelemetry tracing interceptor. 11 | */ 12 | object SetTraceIdInMDCInterceptor extends RequestInterceptor[Identity] with Logging: 13 | val MDCKey = "traceId" 14 | 15 | override def apply[R, B]( 16 | responder: Responder[Identity, B], 17 | requestHandler: EndpointInterceptor[Identity] => RequestHandler[Identity, R, B] 18 | ): RequestHandler[Identity, R, B] = 19 | RequestHandler.from { case (request, endpoints, monad) => 20 | val traceId = Span.current().getSpanContext().getTraceId() 21 | InheritableMDC.unsupervisedWhere(MDCKey -> traceId) { 22 | requestHandler(EndpointInterceptor.noop)(request, endpoints)(monad) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/logging/Logging.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.logging 2 | 3 | import org.slf4j.{Logger, LoggerFactory} 4 | 5 | trait Logging: 6 | protected val logger: Logger = LoggerFactory.getLogger(getClass.getName) 7 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/metrics/Metrics.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.metrics 2 | 3 | import io.opentelemetry.api.OpenTelemetry 4 | import io.opentelemetry.api.metrics.{DoubleGauge, LongCounter} 5 | 6 | class Metrics(otel: OpenTelemetry): 7 | private val meter = otel.meterBuilder("bootzooka").setInstrumentationVersion("1.0").build() 8 | 9 | lazy val registeredUsersCounter: LongCounter = 10 | meter 11 | .counterBuilder("bootzooka_registered_users_counter") 12 | .setDescription("How many users registered on this instance since it was started") 13 | .build() 14 | 15 | lazy val emailQueueGauge: DoubleGauge = 16 | meter 17 | .gaugeBuilder("bootzooka_email_queue_gauge") 18 | .setDescription("How many emails are waiting to be sent") 19 | .build() 20 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetApi.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.passwordreset 2 | 3 | import com.github.plokhotnyuk.jsoniter_scala.macros.ConfiguredJsonValueCodec 4 | import com.softwaremill.bootzooka.http.Http.* 5 | import com.softwaremill.bootzooka.infrastructure.DB 6 | import com.softwaremill.bootzooka.http.{EndpointsForDocs, ServerEndpoints} 7 | import sttp.tapir.Schema 8 | 9 | class PasswordResetApi(passwordResetService: PasswordResetService, db: DB) extends ServerEndpoints: 10 | import PasswordResetApi._ 11 | 12 | private val passwordResetServerEndpoint = passwordResetEndpoint.handle { data => 13 | passwordResetService.resetPassword(data.code, data.password).map(_ => PasswordReset_OUT()) 14 | } 15 | 16 | private val forgotPasswordServerEndpoint = forgotPasswordEndpoint.handleSuccess { data => 17 | db.transact(passwordResetService.forgotPassword(data.loginOrEmail)) 18 | ForgotPassword_OUT() 19 | } 20 | 21 | override val endpoints = List( 22 | passwordResetServerEndpoint, 23 | forgotPasswordServerEndpoint 24 | ) 25 | 26 | object PasswordResetApi extends EndpointsForDocs: 27 | private val PasswordResetPath = "passwordreset" 28 | 29 | private val passwordResetEndpoint = baseEndpoint.post 30 | .in(PasswordResetPath / "reset") 31 | .in(jsonBody[PasswordReset_IN]) 32 | .out(jsonBody[PasswordReset_OUT]) 33 | 34 | private val forgotPasswordEndpoint = baseEndpoint.post 35 | .in(PasswordResetPath / "forgot") 36 | .in(jsonBody[ForgotPassword_IN]) 37 | .out(jsonBody[ForgotPassword_OUT]) 38 | 39 | override val endpointsForDocs = List( 40 | passwordResetEndpoint, 41 | forgotPasswordEndpoint 42 | ).map(_.tag("passwordreset")) 43 | 44 | // 45 | 46 | case class PasswordReset_IN(code: String, password: String) derives ConfiguredJsonValueCodec, Schema 47 | case class PasswordReset_OUT() derives ConfiguredJsonValueCodec, Schema 48 | 49 | case class ForgotPassword_IN(loginOrEmail: String) derives ConfiguredJsonValueCodec, Schema 50 | case class ForgotPassword_OUT() derives ConfiguredJsonValueCodec, Schema 51 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.passwordreset 2 | 3 | import com.augustnagro.magnum.{PostgresDbType, Repo, SqlName, SqlNameMapper, Table} 4 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given} 5 | import com.softwaremill.bootzooka.security.AuthTokenOps 6 | import com.softwaremill.bootzooka.user.User 7 | import com.softwaremill.bootzooka.util.Strings.Id 8 | 9 | import java.time.Instant 10 | 11 | class PasswordResetCodeModel: 12 | private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]] 13 | 14 | def insert(pr: PasswordResetCode)(using DbTx): Unit = passwordResetCodeRepo.insert(pr) 15 | def delete(id: Id[PasswordResetCode])(using DbTx): Unit = passwordResetCodeRepo.deleteById(id) 16 | def findById(id: Id[PasswordResetCode])(using DbTx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id) 17 | 18 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) 19 | @SqlName("password_reset_codes") 20 | case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant) 21 | 22 | class PasswordResetAuthToken(passwordResetCodeModel: PasswordResetCodeModel) extends AuthTokenOps[PasswordResetCode]: 23 | override def tokenName: String = "PasswordResetCode" 24 | override def findById: DbTx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById 25 | override def delete: DbTx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id) 26 | override def userId: PasswordResetCode => Id[User] = _.userId 27 | override def validUntil: PasswordResetCode => Instant = _.validUntil 28 | // password reset code is a one-time token 29 | override def deleteWhenValid: Boolean = true 30 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetConfig.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.passwordreset 2 | 3 | import pureconfig.ConfigReader 4 | import pureconfig.generic.derivation.default.* 5 | 6 | import scala.concurrent.duration.Duration 7 | 8 | case class PasswordResetConfig(resetLinkPattern: String, codeValid: Duration) derives ConfigReader: 9 | validate() 10 | 11 | def validate(): Unit = 12 | val testCode = "TEST_123" 13 | assert( 14 | String.format(resetLinkPattern, testCode).contains(testCode), 15 | s"Invalid reset link pattern: $resetLinkPattern. Formatting with a test code didn't contain the code in the result." 16 | ) 17 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.passwordreset 2 | 3 | import com.softwaremill.bootzooka.Fail 4 | import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplates} 5 | import com.softwaremill.bootzooka.infrastructure.DB 6 | import com.softwaremill.bootzooka.infrastructure.Magnum.* 7 | import com.softwaremill.bootzooka.logging.Logging 8 | import com.softwaremill.bootzooka.security.Auth 9 | import com.softwaremill.bootzooka.user.{User, UserModel} 10 | import com.softwaremill.bootzooka.util.* 11 | import com.softwaremill.bootzooka.util.Strings.{Id, asId, toLowerCased} 12 | import ox.either 13 | import ox.either.* 14 | 15 | class PasswordResetService( 16 | userModel: UserModel, 17 | passwordResetCodeModel: PasswordResetCodeModel, 18 | emailScheduler: EmailScheduler, 19 | emailTemplates: EmailTemplates, 20 | auth: Auth[PasswordResetCode], 21 | idGenerator: IdGenerator, 22 | config: PasswordResetConfig, 23 | clock: Clock, 24 | db: DB 25 | ) extends Logging: 26 | def forgotPassword(loginOrEmail: String)(using DbTx): Unit = 27 | userModel.findByLoginOrEmail(loginOrEmail.toLowerCased) match { 28 | case None => logger.debug(s"Could not find user with $loginOrEmail login/email") 29 | case Some(user) => 30 | val pcr = createCode(user) 31 | sendCode(user, pcr) 32 | } 33 | 34 | private def createCode(user: User)(using DbTx): PasswordResetCode = 35 | logger.debug(s"Creating password reset code for user: ${user.id}") 36 | val id = idGenerator.nextId[PasswordResetCode]() 37 | val validUntil = clock.now().plusMillis(config.codeValid.toMillis) 38 | val passwordResetCode = PasswordResetCode(id, user.id, validUntil) 39 | passwordResetCodeModel.insert(passwordResetCode) 40 | passwordResetCode 41 | 42 | private def sendCode(user: User, code: PasswordResetCode)(using DbTx): Unit = 43 | logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}") 44 | emailScheduler.schedule(EmailData(user.emailLowerCase, prepareResetEmail(user, code))) 45 | 46 | private def prepareResetEmail(user: User, code: PasswordResetCode): EmailSubjectContent = 47 | val resetLink = String.format(config.resetLinkPattern, code.id) 48 | emailTemplates.passwordReset(user.login, resetLink) 49 | 50 | def resetPassword(code: String, newPassword: String): Either[Fail, Unit] = either { 51 | val userId = auth(code.asId[PasswordResetCode]).ok() 52 | logger.debug(s"Resetting password for user: $userId") 53 | db.transact(userModel.updatePassword(userId, User.hashPassword(newPassword))) 54 | } 55 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.security 2 | 3 | import com.augustnagro.magnum.{PostgresDbType, Repo, SqlName, SqlNameMapper, Table, TableInfo} 4 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given} 5 | import com.softwaremill.bootzooka.user.User 6 | import com.softwaremill.bootzooka.util.Strings.Id 7 | import ox.discard 8 | 9 | import java.time.Instant 10 | 11 | class ApiKeyModel: 12 | private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]] 13 | private val a = TableInfo[ApiKey, ApiKey, Id[ApiKey]] 14 | 15 | def insert(apiKey: ApiKey)(using DbTx): Unit = apiKeyRepo.insert(apiKey) 16 | def findById(id: Id[ApiKey])(using DbTx): Option[ApiKey] = apiKeyRepo.findById(id) 17 | def deleteAllForUser(id: Id[User])(using DbTx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard 18 | def delete(id: Id[ApiKey])(using DbTx): Unit = apiKeyRepo.deleteById(id) 19 | 20 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) 21 | @SqlName("api_keys") 22 | case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant) 23 | 24 | class ApiKeyAuthToken(apiKeyModel: ApiKeyModel) extends AuthTokenOps[ApiKey]: 25 | override def tokenName: String = "ApiKey" 26 | override def findById: DbTx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById 27 | override def delete: DbTx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id) 28 | override def userId: ApiKey => Id[User] = _.userId 29 | override def validUntil: ApiKey => Instant = _.validUntil 30 | override def deleteWhenValid: Boolean = false 31 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.security 2 | 3 | import com.softwaremill.bootzooka.infrastructure.Magnum.DbTx 4 | import com.softwaremill.bootzooka.logging.Logging 5 | import com.softwaremill.bootzooka.user.User 6 | import com.softwaremill.bootzooka.util.Strings.Id 7 | import com.softwaremill.bootzooka.util.{Clock, IdGenerator} 8 | 9 | import java.time.temporal.ChronoUnit 10 | import scala.concurrent.duration.Duration 11 | 12 | class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: Clock) extends Logging: 13 | def create(userId: Id[User], valid: Duration)(using DbTx): ApiKey = 14 | val id = idGenerator.nextId[ApiKey]() 15 | val now = clock.now() 16 | val validUntil = now.plus(valid.toMillis, ChronoUnit.MILLIS) 17 | val apiKey = ApiKey(id, userId, now, validUntil) 18 | logger.debug(s"Creating a new api key for user $userId, valid until: $validUntil") 19 | apiKeyModel.insert(apiKey) 20 | apiKey 21 | 22 | def invalidate(id: Id[ApiKey])(using DbTx): Unit = 23 | logger.debug(s"Invalidating api key $id") 24 | apiKeyModel.delete(id) 25 | 26 | def invalidateAllForUser(userId: Id[User])(using DbTx): Unit = 27 | logger.debug(s"Invalidating all api keys for user $userId") 28 | apiKeyModel.deleteAllForUser(userId) 29 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.security 2 | 3 | import com.softwaremill.bootzooka.* 4 | import com.softwaremill.bootzooka.infrastructure.DB 5 | import com.softwaremill.bootzooka.infrastructure.Magnum.* 6 | import com.softwaremill.bootzooka.logging.Logging 7 | import com.softwaremill.bootzooka.user.User 8 | import com.softwaremill.bootzooka.util.* 9 | import com.softwaremill.bootzooka.util.Strings.Id 10 | import ox.sleep 11 | import ox.either.{fail, ok} 12 | 13 | import java.security.SecureRandom 14 | import java.time.Instant 15 | import scala.concurrent.duration.* 16 | 17 | class Auth[T](authTokenOps: AuthTokenOps[T], db: DB, clock: Clock) extends Logging: 18 | 19 | // see https://hackernoon.com/hack-how-to-use-securerandom-with-kubernetes-and-docker-a375945a7b21 20 | private val random = SecureRandom.getInstance("NativePRNGNonBlocking") 21 | 22 | /** Authenticates using the given authentication token. If the token is invalid, a [[Fail.Unauthorized]] error is returned. Otherwise, 23 | * returns the id of the authenticated user . 24 | */ 25 | def apply(id: Id[T]): Either[Fail.Unauthorized, Id[User]] = 26 | db.transact(authTokenOps.findById(id)) match { 27 | case None => 28 | logger.debug(s"Auth failed for: ${authTokenOps.tokenName} $id") 29 | // random sleep to prevent timing attacks 30 | sleep(random.nextInt(1000).millis) 31 | Left(Fail.Unauthorized("Unauthorized")) 32 | case Some(token) if expired(token) => 33 | logger.info(s"${authTokenOps.tokenName} expired: $token") 34 | db.transact(authTokenOps.delete(token)) 35 | Left(Fail.Unauthorized("Unauthorized")) 36 | case Some(token) => 37 | if (authTokenOps.deleteWhenValid) db.transact(authTokenOps.delete(token)) 38 | Right(authTokenOps.userId(token)) 39 | } 40 | 41 | private def expired(token: T): Boolean = clock.now().isAfter(authTokenOps.validUntil(token)) 42 | 43 | /** A set of operations on an authentication token, which are performed during authentication. Supports both one-time tokens (when 44 | * `deleteWhenValid=true`) and multi-use tokens. 45 | */ 46 | trait AuthTokenOps[T]: 47 | def tokenName: String 48 | def findById: DbTx ?=> Id[T] => Option[T] 49 | def delete: DbTx ?=> T => Unit 50 | def userId: T => Id[User] 51 | def validUntil: T => Instant 52 | def deleteWhenValid: Boolean 53 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/user/UserConfig.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.user 2 | 3 | import pureconfig.ConfigReader 4 | import pureconfig.generic.derivation.default.* 5 | 6 | import scala.concurrent.duration.Duration 7 | 8 | case class UserConfig(defaultApiKeyValid: Duration) derives ConfigReader 9 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.user 2 | 3 | import com.augustnagro.magnum.{Frag, PostgresDbType, Repo, Spec, SqlName, SqlNameMapper, Table, TableInfo} 4 | import com.password4j.{Argon2Function, Password} 5 | import com.softwaremill.bootzooka.infrastructure.Magnum.{*, given} 6 | import com.softwaremill.bootzooka.user.User.PasswordHashing 7 | import com.softwaremill.bootzooka.user.User.PasswordHashing.Argon2Config.* 8 | import com.softwaremill.bootzooka.util.PasswordVerificationStatus 9 | import com.softwaremill.bootzooka.util.Strings.{Hashed, Id, LowerCased, asHashed} 10 | import ox.discard 11 | 12 | import java.time.Instant 13 | 14 | class UserModel: 15 | private val userRepo = Repo[User, User, Id[User]] 16 | private val u = TableInfo[User, User, Id[User]] 17 | 18 | export userRepo.{insert, findById} 19 | 20 | def findByEmail(email: LowerCased)(using DbTx): Option[User] = findBy( 21 | Spec[User].where(sql"${u.emailLowerCase} = $email") 22 | ) 23 | def findByLogin(login: LowerCased)(using DbTx): Option[User] = findBy( 24 | Spec[User].where(sql"${u.loginLowerCase} = $login") 25 | ) 26 | def findByLoginOrEmail(loginOrEmail: LowerCased)(using DbTx): Option[User] = 27 | findBy(Spec[User].where(sql"${u.loginLowerCase} = ${loginOrEmail: String} OR ${u.emailLowerCase} = $loginOrEmail")) 28 | 29 | private def findBy(by: Spec[User])(using DbTx): Option[User] = 30 | userRepo.findAll(by).headOption 31 | 32 | def updatePassword(userId: Id[User], newPassword: Hashed)(using DbTx): Unit = 33 | sql"""UPDATE $u SET ${u.passwordHash} = $newPassword WHERE ${u.id} = $userId""".update.run().discard 34 | 35 | def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using DbTx): Unit = 36 | sql"""UPDATE $u SET ${u.login} = $newLogin, login_lowercase = ${newLoginLowerCase: String} WHERE ${u.id} = $userId""".update 37 | .run() 38 | .discard 39 | 40 | def updateEmail(userId: Id[User], newEmail: LowerCased)(using DbTx): Unit = 41 | sql"""UPDATE $u SET ${u.emailLowerCase} = $newEmail WHERE ${u.id} = $userId""".update.run().discard 42 | 43 | end UserModel 44 | 45 | @Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) 46 | @SqlName("users") 47 | case class User( 48 | id: Id[User], 49 | login: String, 50 | @SqlName("login_lowercase") loginLowerCase: LowerCased, 51 | @SqlName("email_lowercase") emailLowerCase: LowerCased, 52 | @SqlName("password") passwordHash: Hashed, 53 | createdOn: Instant 54 | ): 55 | def verifyPassword(password: String): PasswordVerificationStatus = 56 | if (Password.check(password, passwordHash) `with` PasswordHashing.Argon2) PasswordVerificationStatus.Verified 57 | else PasswordVerificationStatus.VerificationFailed 58 | end User 59 | 60 | object User: 61 | object PasswordHashing: 62 | val Argon2: Argon2Function = 63 | Argon2Function.getInstance(MemoryInKib, NumberOfIterations, LevelOfParallelism, LengthOfTheFinalHash, Type, Version) 64 | 65 | object Argon2Config: 66 | val MemoryInKib = 12 67 | val NumberOfIterations = 20 68 | val LevelOfParallelism = 2 69 | val LengthOfTheFinalHash = 32 70 | val Type = com.password4j.types.Argon2.ID 71 | val Version = 19 72 | end PasswordHashing 73 | 74 | def hashPassword(password: String): Hashed = Password.hash(password).`with`(PasswordHashing.Argon2).getResult.asHashed 75 | end User 76 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/util/Clock.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.util 2 | 3 | import java.time.Instant 4 | 5 | trait Clock: 6 | def now(): Instant 7 | 8 | object DefaultClock extends Clock: 9 | override def now(): Instant = java.time.Clock.systemUTC().instant() 10 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/util/IdGenerator.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.util 2 | 3 | import com.softwaremill.bootzooka.util.Strings.{asId, Id} 4 | 5 | trait IdGenerator: 6 | def nextId[U](): Id[U] 7 | 8 | object DefaultIdGenerator extends IdGenerator: 9 | override def nextId[U](): Id[U] = SecureRandomIdGenerator.Strong.generate.asId[U] 10 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/util/PasswordVerificationStatus.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.util 2 | 3 | enum PasswordVerificationStatus: 4 | case Verified, VerificationFailed 5 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/util/SecureRandomIdGenerator.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.util 2 | 3 | import java.security.SecureRandom 4 | 5 | /** based on TSec https://github.com/jmcardon/tsec */ 6 | 7 | case class SecureRandomIdGenerator(sizeInBytes: Int): 8 | /** Cache our random, and seed it properly as per [[https://tersesystems.com/2015/12/17/the-right-way-to-use-securerandom/]] */ 9 | private val cachedRand: SecureRandom = 10 | val r = SecureRandom.getInstance(if (scala.util.Properties.isWin) "Windows-PRNG" else "NativePRNGNonBlocking") 11 | r.nextBytes(new Array[Byte](20)) // Force reseed 12 | r 13 | 14 | private def nextBytes(bytes: Array[Byte]): Unit = cachedRand.nextBytes(bytes) 15 | 16 | private def toHexString(byteArray: Array[Byte]) = byteArray.map(b => String.format("%02x", b)).mkString 17 | 18 | def generate: String = 19 | val byteArray = new Array[Byte](sizeInBytes) 20 | nextBytes(byteArray) 21 | toHexString(byteArray) 22 | 23 | object SecureRandomIdGenerator: 24 | lazy val Strong: SecureRandomIdGenerator = SecureRandomIdGenerator(32) 25 | lazy val Interactive: SecureRandomIdGenerator = SecureRandomIdGenerator(16) 26 | -------------------------------------------------------------------------------- /backend/src/main/scala/com/softwaremill/bootzooka/util/Strings.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.util 2 | 3 | import java.util.Locale 4 | 5 | object Strings: 6 | opaque type Id[T] = String 7 | opaque type LowerCased <: String = String 8 | opaque type Hashed <: String = String 9 | 10 | extension (s: String) 11 | def asId[T]: Id[T] = s 12 | def asHashed: Hashed = s 13 | def toLowerCased[T]: LowerCased = s.toLowerCase(Locale.ENGLISH) 14 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/email/EmailTemplatesTest.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class EmailTemplatesTest extends AnyFlatSpec with Matchers: 7 | val templates = new EmailTemplates 8 | 9 | it should "generate the registration confirmation email" in { 10 | // when 11 | val email = templates.registrationConfirmation("john") 12 | 13 | // then 14 | email.subject should be("SoftwareMill Bootzooka - registration confirmation for user john") 15 | email.content should include("Dear john,") 16 | email.content should include("Regards,") 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/email/sender/DummyEmailSenderTest.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.email.sender 2 | 3 | import com.softwaremill.bootzooka.email.EmailData 4 | import com.softwaremill.bootzooka.test.BaseTest 5 | 6 | class DummyEmailSenderTest extends BaseTest: 7 | it should "send scheduled email" in { 8 | DummyEmailSender(EmailData("test@sml.com", "subject", "content")) 9 | DummyEmailSender.findSentEmail("test@sml.com", "subject").isDefined shouldBe true 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/BaseTest.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.test 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | import ox.logback.InheritableMDC 6 | 7 | trait BaseTest extends AnyFlatSpec with Matchers: 8 | InheritableMDC.init 9 | val testClock = new TestClock() 10 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/RegisteredUser.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.test 2 | 3 | case class RegisteredUser(login: String, email: String, password: String, apiKey: String) 4 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/Requests.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.test 2 | 3 | import com.github.plokhotnyuk.jsoniter_scala.core.writeToString 4 | import com.softwaremill.bootzooka.passwordreset.PasswordResetApi.{ForgotPassword_IN, PasswordReset_IN} 5 | import com.softwaremill.bootzooka.user.UserApi.* 6 | import sttp.client3.{Response, SttpBackend, UriContext, basicRequest} 7 | import sttp.shared.Identity 8 | 9 | import scala.util.Random 10 | 11 | class Requests(backend: SttpBackend[Identity, Any]) extends TestSupport: 12 | private val random = new Random() 13 | 14 | def randomLoginEmailPassword(): (String, String, String) = 15 | (random.nextString(12), s"user${random.nextInt(9000)}@bootzooka.com", random.nextString(12)) 16 | 17 | private val basePath = "http://localhost:8080/api/v1" 18 | 19 | def registerUser(login: String, email: String, password: String): Response[Either[String, String]] = 20 | basicRequest 21 | .post(uri"$basePath/user/register") 22 | .body(writeToString(Register_IN(login, email, password))) 23 | .send(backend) 24 | 25 | def newRegisteredUsed(): RegisteredUser = 26 | val (login, email, password) = randomLoginEmailPassword() 27 | val apiKey = registerUser(login, email, password).body.shouldDeserializeTo[Register_OUT].apiKey 28 | RegisteredUser(login, email, password, apiKey) 29 | 30 | def loginUser(loginOrEmail: String, password: String, apiKeyValidHours: Option[Int] = None): Response[Either[String, String]] = 31 | basicRequest 32 | .post(uri"$basePath/user/login") 33 | .body(writeToString(Login_IN(loginOrEmail, password, apiKeyValidHours))) 34 | .send(backend) 35 | 36 | def logoutUser(apiKey: String): Response[Either[String, String]] = 37 | basicRequest 38 | .post(uri"$basePath/user/logout") 39 | .body(writeToString(Logout_IN(apiKey))) 40 | .header("Authorization", s"Bearer $apiKey") 41 | .send(backend) 42 | 43 | def getUser(apiKey: String): Response[Either[String, String]] = 44 | basicRequest 45 | .get(uri"$basePath/user") 46 | .header("Authorization", s"Bearer $apiKey") 47 | .send(backend) 48 | 49 | def changePassword(apiKey: String, password: String, newPassword: String): Response[Either[String, String]] = 50 | basicRequest 51 | .post(uri"$basePath/user/changepassword") 52 | .body(writeToString(ChangePassword_IN(password, newPassword))) 53 | .header("Authorization", s"Bearer $apiKey") 54 | .send(backend) 55 | 56 | def updateUser(apiKey: String, login: String, email: String): Response[Either[String, String]] = 57 | basicRequest 58 | .post(uri"$basePath/user") 59 | .body(writeToString(UpdateUser_IN(login, email))) 60 | .header("Authorization", s"Bearer $apiKey") 61 | .send(backend) 62 | 63 | def forgotPassword(loginOrEmail: String): Response[Either[String, String]] = 64 | basicRequest 65 | .post(uri"$basePath/passwordreset/forgot") 66 | .body(writeToString(ForgotPassword_IN(loginOrEmail))) 67 | .send(backend) 68 | 69 | def resetPassword(code: String, password: String): Response[Either[String, String]] = 70 | basicRequest 71 | .post(uri"$basePath/passwordreset/reset") 72 | .body(writeToString(PasswordReset_IN(code, password))) 73 | .send(backend) 74 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/TestClock.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.test 2 | 3 | import com.softwaremill.bootzooka.logging.Logging 4 | 5 | import java.time.Instant 6 | import java.time.temporal.ChronoUnit 7 | import java.util.concurrent.atomic.AtomicReference 8 | import com.softwaremill.bootzooka.util.Clock 9 | 10 | import scala.concurrent.duration.Duration 11 | 12 | class TestClock(nowRef: AtomicReference[Instant]) extends Clock with Logging: 13 | logger.info(s"New test clock, the time is: ${nowRef.get()}") 14 | 15 | def this(now: Instant) = this(new AtomicReference(now)) 16 | 17 | def this() = this(Instant.now()) 18 | 19 | def forward(d: Duration): Unit = 20 | val newNow = nowRef.get().plus(d.toMillis, ChronoUnit.MILLIS) 21 | logger.info(s"The time is now $newNow") 22 | nowRef.set(newNow) 23 | 24 | override def now(): Instant = nowRef.get() 25 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/TestDependencies.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.test 2 | 3 | import com.softwaremill.bootzooka.Dependencies 4 | import io.opentelemetry.api.OpenTelemetry 5 | import org.scalatest.{BeforeAndAfterAll, Suite} 6 | import sttp.client3.SttpBackend 7 | import sttp.shared.Identity 8 | import sttp.tapir.server.stub.TapirStubInterpreter 9 | 10 | import scala.compiletime.uninitialized 11 | 12 | trait TestDependencies extends BeforeAndAfterAll with TestEmbeddedPostgres: 13 | self: Suite & BaseTest => 14 | 15 | var dependencies: Dependencies = uninitialized 16 | 17 | override protected def beforeAll(): Unit = { 18 | super.beforeAll() 19 | dependencies = 20 | Dependencies.create(TestConfig, OpenTelemetry.noop(), sttp.client4.httpclient.HttpClientSyncBackend.stub, currentDb, testClock) 21 | } 22 | 23 | private lazy val serverStub: SttpBackend[Identity, Any] = 24 | TapirStubInterpreter[Identity, Any](sttp.client3.HttpClientSyncBackend.stub) 25 | .whenServerEndpointsRunLogic(dependencies.httpApi.allEndpoints) 26 | .backend() 27 | 28 | lazy val requests = new Requests(serverStub) 29 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/TestEmbeddedPostgres.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.test 2 | 3 | import com.opentable.db.postgres.embedded.EmbeddedPostgres 4 | import com.softwaremill.bootzooka.config.Sensitive 5 | import com.softwaremill.bootzooka.infrastructure.{DB, DBConfig} 6 | import com.softwaremill.bootzooka.logging.Logging 7 | import org.flywaydb.core.Flyway 8 | import org.postgresql.jdbc.PgConnection 9 | import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Suite} 10 | import ox.discard 11 | 12 | import scala.compiletime.uninitialized 13 | 14 | /** Base trait for tests which use the database. The database is cleaned after each test. */ 15 | trait TestEmbeddedPostgres extends BeforeAndAfterEach with BeforeAndAfterAll with Logging { self: Suite => 16 | private var postgres: EmbeddedPostgres = uninitialized 17 | private var currentDbConfig: DBConfig = uninitialized 18 | var currentDb: DB = uninitialized 19 | 20 | // 21 | 22 | override protected def beforeAll(): Unit = { 23 | super.beforeAll() 24 | postgres = EmbeddedPostgres.builder().start() 25 | val url = postgres.getJdbcUrl("postgres") 26 | postgres.getPostgresDatabase.getConnection.asInstanceOf[PgConnection].setPrepareThreshold(100) 27 | currentDbConfig = TestConfig.db.copy( 28 | username = "postgres", 29 | password = Sensitive(""), 30 | url = url, 31 | migrateOnStart = true 32 | ) 33 | currentDb = DB.createTestMigrate(currentDbConfig) 34 | } 35 | 36 | override protected def afterAll(): Unit = 37 | currentDb.close() 38 | postgres.close() 39 | super.afterAll() 40 | 41 | // 42 | 43 | override protected def beforeEach(): Unit = 44 | super.beforeEach() 45 | flyway().migrate().discard 46 | 47 | override protected def afterEach(): Unit = 48 | clean() 49 | super.afterEach() 50 | 51 | private def clean(): Unit = flyway().clean().discard 52 | 53 | private def flyway(): Flyway = Flyway 54 | .configure() 55 | .dataSource(currentDbConfig.url, currentDbConfig.username, currentDbConfig.password.value) 56 | .cleanDisabled(false) 57 | .load() 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/TestSupport.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.test 2 | 3 | import com.github.plokhotnyuk.jsoniter_scala.core.{JsonValueCodec, readFromString} 4 | import com.softwaremill.bootzooka.http.Error_OUT 5 | 6 | trait TestSupport: 7 | extension (v: Either[String, String]) 8 | def shouldDeserializeTo[T: JsonValueCodec]: T = v.map(readFromString[T](_)).fold(s => throw new IllegalArgumentException(s), identity) 9 | def shouldDeserializeToError: String = readFromString[Error_OUT](v.fold(identity, s => throw new IllegalArgumentException(s))).error 10 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/test/package.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka 2 | 3 | import com.softwaremill.bootzooka.config.Config 4 | import com.softwaremill.quicklens._ 5 | 6 | import scala.concurrent.duration._ 7 | 8 | package object test: 9 | val DefaultConfig: Config = Config.read 10 | val TestConfig: Config = DefaultConfig.modify(_.email.emailSendInterval).setTo(100.milliseconds) 11 | -------------------------------------------------------------------------------- /backend/src/test/scala/com/softwaremill/bootzooka/user/UserValidatorSpec.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.bootzooka.user 2 | 3 | import org.scalatest.flatspec.AnyFlatSpec 4 | import org.scalatest.matchers.should.Matchers 5 | 6 | class UserValidatorSpec extends AnyFlatSpec with Matchers: 7 | private def validate(userName: String, email: String, password: String) = 8 | UserValidator(Some(userName), Some(email), Some(password)).result 9 | 10 | "validate" should "accept valid data" in { 11 | val dataIsValid = validate("login", "admin@bootzooka.com", "password") 12 | 13 | dataIsValid shouldBe Right(()) 14 | } 15 | 16 | it should "not accept login containing only empty spaces" in { 17 | val dataIsValid = validate(" ", "admin@bootzooka.com", "password") 18 | 19 | dataIsValid.isLeft shouldBe true 20 | } 21 | 22 | it should "not accept too short login" in { 23 | val tooShortLogin = "a" * (UserValidator.MinLoginLength - 1) 24 | val dataIsValid = validate(tooShortLogin, "admin@bootzooka.com", "password") 25 | 26 | dataIsValid.isLeft shouldBe true 27 | } 28 | 29 | it should "not accept too short login after trimming" in { 30 | val loginTooShortAfterTrim = "a" * (UserValidator.MinLoginLength - 1) + " " 31 | val dataIsValid = validate(loginTooShortAfterTrim, "admin@bootzooka.com", "password") 32 | 33 | dataIsValid.isLeft shouldBe true 34 | } 35 | 36 | it should "not accept missing email with spaces only" in { 37 | val dataIsValid = validate("login", " ", "password") 38 | 39 | dataIsValid.isLeft shouldBe true 40 | } 41 | 42 | it should "not accept invalid email" in { 43 | val dataIsValid = validate("login", "invalidEmail", "password") 44 | 45 | dataIsValid.isLeft shouldBe true 46 | } 47 | 48 | it should "not accept password with empty spaces only" in { 49 | val dataIsValid = validate("login", "admin@bootzooka.com", " ") 50 | 51 | dataIsValid.isLeft shouldBe true 52 | } 53 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/banner.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bootzooka: 3 | image: 'softwaremill/bootzooka:latest' 4 | ports: 5 | - '8080:8080' 6 | depends_on: 7 | - bootzooka-db 8 | environment: 9 | SQL_USERNAME: 'postgres' 10 | SQL_PASSWORD: 'b00t200k4' 11 | SQL_DBNAME: 'bootzooka' 12 | SQL_HOST: 'bootzooka-db' 13 | SQL_PORT: '5432' 14 | API_HOST: '0.0.0.0' 15 | OTEL_EXPORTER_OTLP_ENDPOINT: 'http://observability:4317' 16 | OTEL_SERVICE_NAME: 'bootzooka' 17 | OTEL_METRIC_EXPORT_INTERVAL: '500' # The default is 60s, in development it's useful to see the metrics faster 18 | OTEL_RESOURCE_ATTRIBUTES: 'service.instance.id=local,service.version=latest' 19 | 20 | bootzooka-db: 21 | image: 'postgres' 22 | ports: 23 | - '25432:5432' 24 | environment: 25 | POSTGRES_USER: 'postgres' 26 | POSTGRES_PASSWORD: 'b00t200k4' 27 | POSTGRES_DB: 'bootzooka' 28 | 29 | # OpenTelemetry Collector, Prometheus, Loki, Tempo, Grafana 30 | observability: 31 | image: 'grafana/otel-lgtm' 32 | ports: 33 | - '3000:3000' # Grafana's UI 34 | - '4317:4317' # Exporter 35 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | name: Bootzooka Documentation 2 | highlighter: rouge 3 | baseurl: "/bootzooka/" 4 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ site.name }} - {{ page.title }} 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 |
17 |
18 |

Bootzooka

19 |

Simple project to quickly start developing a Scala-based microservice or web application.

20 | 21 | 26 | 27 |
28 | 29 | 41 |
42 |
43 |

{{ page.title }}

44 | {{ content }}
45 | 49 |
50 | 51 | 55 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "High-level architecture" 4 | --- 5 | 6 | Bootzooka uses different technologies for the frontend and backend parts, taking the best of both worlds and combining in a single build. Bootzooka is structured how many modern web applications are done these days. 7 | 8 | The backend server, written using Scala, exposes a JSON API which can be consumed by any client you want. In case of Bootzooka this client is a single-page browser application built with React. Such an approach allows better scaling and independent development of the server and client parts. This separation is mirrored in how Bootzooka projects are structured. 9 | 10 | There's one sub-project for backend code and one for client-side application. They are completely unrelated in terms of code and dependencies. `ui` directory contains the browser part (JavaScript, CSS, HTML) and `backend` contains the backend application. 11 | -------------------------------------------------------------------------------- /docs/backend.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "Backend code structure" 4 | --- 5 | 6 | ## Backend code structure 7 | 8 | The backend code is divided into a number of packages, each implementing a different functionality/use-case. 9 | 10 | The classes in each package follow a similar pattern: 11 | 12 | * `XModel` class. Contains queries to access the model corresponding to the given functionality. The file also contains 13 | the model classes which are used in this functionality. 14 | * `XService` class. Implements the business logic of the given functionality. Uses other services and models as 15 | dependencies. May return results that either need to be run within a transaction (when there's a `using DbTx` parameter 16 | list), or returns result directly. Results might be wrapped in `Either[Fail, ...]`, when "expected" errors might occur 17 | (e.g. validation errors). A service implementation might include communicating with external services or performing 18 | whole database transactions. 19 | * `XApi` class. Defines descriptions of endpoints for the given functionality, as well as defines the server logic 20 | for each endpoint. The server logic usually calls the corresponding service, or reads data from the model directly. All 21 | classes used for input/output are defined in the companion object of the api class. 22 | 23 | The `email`, `passwordreset`, `security` and `user` packages directly implement user-facing functionalities. 24 | 25 | The `infrastructure` and `logging` packages contain utility classes for working with the database, logging and tracing. 26 | 27 | The `util` package contains common classes, type aliases and extension methods which are used in other classes. 28 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "Configuration" 4 | --- 5 | 6 | Configuration uses the [Typesafe Config](https://github.com/lightbend/config) file format (HOCON), but is read using [pureconfig](https://pureconfig.github.io) library. 7 | 8 | The configuration is stored in the `application.conf` file. You can either modify that file directly or override it using system properties (see Typesafe Config's readme on how to do that). 9 | 10 | ## Email configuration 11 | 12 | By default, a dummy (no-op) email sender is used. If you'd like to send real emails, you'll need to enable either the 13 | smtp sender, or the mailgun sender by specifying the appropriate configuration options (one of `email.mailgun.enabled` 14 | or `email.smtp.enabled` needs to be `true`). 15 | 16 | You can also add support for another email service by implementing the `EmailSender` trait. 17 | 18 | ## Project name customization 19 | 20 | If you want to use Bootzooka as a scaffolding for your own project, use the `renameProject` command with sbt, for example: 21 | 22 | ```` 23 | sbt "renameProject com.mycompany foobar" 24 | ```` 25 | 26 | This should rename your project to **Foobar**, move all sources to top-level package `com.mycompany.foobar`. 27 | -------------------------------------------------------------------------------- /docs/devtips.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "Development tips" 4 | --- 5 | 6 | Generally during development you'll need two processes: 7 | 8 | * sbt running the backend server 9 | * yarn server which automatically picks up any changes 10 | 11 | ## Cloning 12 | 13 | If you are planning to use Bootzooka as scaffolding for your own project, consider cloning the repo with `git clone --depth 1` in order to start the history with last commit. You can now switch to your origin repository of choice with: `git remote set-url origin https://repo.com/OTHERREPOSITORY.git` 14 | 15 | ## Useful sbt commands 16 | 17 | * `renameProject` - replace Bootzooka with your custom name and adjust scala package names 18 | * `compile` - compile the whole project 19 | * `test` - run all the tests 20 | * `project ` - switch context to the given sub-project, then all the commands will be executed only for 21 | that sub-project, this can be also achieved with e.g.: `/test` 22 | * `~backend/re-start` - runs the backend server and waits for source code changes to automatically compile changed file and to reload it 23 | 24 | ## Database schema evolution 25 | 26 | With Flyway, all you need to do is to put DDL script within bootzooka-backend/src/main/resources/db/migration/ directory. You have to obey the following [naming convention](http://flywaydb.org/documentation/migration/sql.html): `V#__your_arbitrary_description.sql` where `#` stands for *unique* version of your schema. 27 | 28 | ## Developing frontend without backend 29 | 30 | If you'd like to work only on the frontend, without starting the backend, you can proxy requests to a working, remote backend instance. In `ui/package.json` you need to edit the proxy settings. 31 | 32 | ## Imports 33 | 34 | There are two imports that are useful when developing a new functionality: 35 | 36 | ### Database 37 | 38 | If you are defining database queries or running transactions, add the following import: 39 | 40 | ```scala 41 | import com.softwaremill.bootzooka.infrastructure.Magnum.* 42 | ``` 43 | 44 | This will bring into scope custom [Magnum](https://github.com/AugustNagro/magnum) codecs. 45 | 46 | ### HTTP API 47 | 48 | If you are describing new endpoints, import all members of `Http`: 49 | 50 | ```scala 51 | import com.softwaremill.bootzooka.http.Http.* 52 | ``` 53 | 54 | This will bring into scope Tapir builder methods and schemas for documentation, along with Bootzooka-specific customizations. 55 | 56 | ### Logging 57 | 58 | Logging is performed using Slf4j. Extend `Logging` to bring into scope a `logger` value. The logs that are output to the console include the current trace id. 59 | -------------------------------------------------------------------------------- /docs/frontend.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "Frontend application" 4 | --- 5 | 6 | Bootzooka's frontend is a true Single Page Application built with React. It can be treated as a completely separate application or as a client for Bootzooka server. 7 | 8 | As a separate application it deserves its own build process handling all the details (linting, testing, minifying etc). Hence the frontend part is almost completely decoupled from server side code. The only coupling is on the level of packaging final application (which is described later in this doc). 9 | 10 | Please note, that the UI is based on [fantastic tool called Vite](https://github.com/vitejs/vite) which takes care of fine details like build configuration, minification & hot reloading under the hood, without you having to worry about it. For more details, see the project's [page](https://github.com/vitejs/vite). 11 | 12 | ## Installing Node.js & Yarn 13 | 14 | To work with the `ui` module you need to have `node.js` installed in version 22 or newer. Make sure you have `node` command available on `PATH`. 15 | 16 | As a package manager, Bootzooka's UI uses [Yarn](https://yarnpkg.com). Make sure to have it installed before the first run. 17 | 18 | ## First run 19 | 20 | If this is your first attempt to run `ui`, please go to `ui` project and run 21 | 22 | yarn install 23 | 24 | This will install all required dependencies for this project. If all is well you can start your development version of frontend by issuing `yarn start` from command line (or running the provided `frontend-start` script in the main directory). It should start your browser and point you to [Bootzooka home page](http://0.0.0.0:8081/#/). 25 | 26 | ## Development 27 | 28 | Build system exposes several tasks that can be run, you can find them in `package.json` file. 29 | 30 | The most important tasks exposed are: 31 | 32 | - `yarn start` 33 | - `yarn build` 34 | - `yarn test` 35 | - `yarn test:ci` 36 | 37 | ## `yarn start` task 38 | 39 | This task serves Bootzooka application on port `8081` on `0.0.0.0` (it is available to all hosts from the same network). Your default browser should open at this location. All requests to the backend will be proxied to port `8080` where it expects the server to be run. 40 | 41 | Hot reload is in place already (provided by the Vite stack), so every change is automatically compiled (if necessary) and browser is automatically refreshed to apply changes. No need to refresh it by hand. 42 | 43 | In this task all scripts are served in non-concatenated and non-minified version from their original locations (if possible). 44 | 45 | ## `yarn build` task 46 | 47 | It builds everything as a distribution-ready version to `dist` directory. It doesn't fire up the proxy server. 48 | 49 | ## `yarn test` task 50 | 51 | This task runs tests and watches for changes in files. When change is detected it runs tests automatically. This is especially helpful in hard-development mode. Tests are run with Vitest. 52 | 53 | ## `yarn test:ci` task 54 | 55 | This task runs tests just once (useful in CI environments, where an exit code is required). 56 | 57 | ## Distribution and deployment 58 | 59 | Although in development `ui` is separate project there is no need to deploy it separately. All files from `ui/dist` (which are generated during `yarn build`) are used by `backend` to build the final fat-jar application. All necessary integration with SBT (backend build) is provided. That means when you issue `package` in SBT, you get a complete web application which contains both server side and frontend components. 60 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "Getting started" 4 | --- 5 | 6 | ## Prerequisites 7 | 8 | In order to build and develop on Bootzooka foundations you need the following: 9 | 10 | - Java JDK >= 21 11 | - [sbt](http://www.scala-sbt.org/) >= 1.10 12 | - Node.js >= 22 (We recommend [nvm](https://github.com/nvm-sh/nvm) - node version manager) 13 | - PostgreSQL 14 | 15 | ## How to run 16 | 17 | Because (as said before) Bootzooka consists of two separate applications, in development you need to run both separately. This way the server-side code can be reloaded independently of the frontend code: if, for example, you make a small change to an HTML file, thanks to live-reload you'll see the changes immediately, rebuilding and reloading only the frontend part, while the server is running undisturbed. 18 | 19 | **NOTE: This is not the case in production by default. When the final fat-jar application package or docker image is built it contains both client and server parts.** 20 | 21 | ### Database 22 | 23 | Bootzooka uses [PostgreSQL](https://www.postgresql.org) to store data, so you will need the database running to use the application. By default, Bootzooka uses the `bootzooka` database using the `postgres` user, connecting to a server running on `localhost:5432`. This can be customised in the `application.conf` file. 24 | 25 | You can either use a stand-alone database, a docker image (see the `docker-compose.yml` file), or any other PostgreSQL instance. 26 | 27 | ### Server 28 | 29 | To run the backend server part, enter the main directory and type `./backend-start.sh` or `backend-start.bat` depending on your OS. 30 | 31 | ### Browser client 32 | 33 | To run the frontend server part, enter the main directory and type `./frontend-start.sh`. This should open `http://localhost:8081/` in your browser (frontend listens on port 8081, backend on port 8080; so all backend HTTP requests will be proxied to port 8080). 34 | 35 | For details of frontend build and architecture please refer to the [frontend docs](frontend.html). 36 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: "Overview" 4 | --- 5 | 6 | Bootzooka is a scaffolding project to allow quick start of development of a microservice or a web application. If you'd like to have a jump start developing a Scala-based project, skipping the boring parts and focusing on the real business value, this template might be for you! 7 | 8 | Bootzooka contains only the very basic features, that almost any application needs (listed below). These features are fully implemented both on server- and client- side. We hope that the implementations can also serve as blueprints for new functionalities. 9 | 10 | Current (user visible) features: 11 | 12 | * User registration 13 | * Lost password recovery (via e-mail) 14 | * Logging in/out 15 | * Profile management 16 | 17 | "Developer" features: 18 | 19 | * integrated SBT + Yarn build 20 | * unit & integration backend tests 21 | * frontend JS tests 22 | * fat-jar and Docker deployment 23 | 24 | This may not sound "cool", but the goal of Bootzooka is to be helpful when bootstrapping a new project. It contains the whole required setup and automation of build processes both for frontend and backend. You get it out of the box which means significantly less time spent on setting up infrastructure and tools and more time spent on actual coding features in project. 25 | 26 | Live demo is available on [http://bootzooka.softwaremill.com](http://bootzooka.softwaremill.com). 27 | 28 | ## License 29 | 30 | Project is licensed under [Apache 2.0 License](http://www.apache.org/licenses/LICENSE-2.0.html) which means you can freely use any part of the project. 31 | -------------------------------------------------------------------------------- /docs/javascripts/scale.fix.js: -------------------------------------------------------------------------------- 1 | var metas = document.getElementsByTagName('meta'); 2 | var i; 3 | if (navigator.userAgent.match(/iPhone/i)) { 4 | for (i=0; i=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 3 | apiVersion: networking.k8s.io/v1 4 | {{- else if semverCompare ">=1.14-0 <1.19-0" .Capabilities.KubeVersion.GitVersion -}} 5 | apiVersion: networking.k8s.io/v1beta1 6 | {{- else -}} 7 | apiVersion: extensions/v1beta1 8 | {{- end }} 9 | kind: Ingress 10 | metadata: 11 | name: {{ include "bootzooka.fullname" . }}-ingress 12 | labels: 13 | app.kubernetes.io/name: {{ include "bootzooka.name" . }} 14 | helm.sh/chart: {{ include "bootzooka.chart" . }} 15 | app.kubernetes.io/instance: {{ .Release.Name }} 16 | app.kubernetes.io/managed-by: {{ .Release.Service }} 17 | {{- with .Values.bootzooka.ingress.annotations }} 18 | annotations: 19 | {{ toYaml . | indent 4 }} 20 | {{- end }} 21 | spec: 22 | {{- if .Values.bootzooka.ingress.tls_enabled }} 23 | tls: 24 | {{- range .Values.bootzooka.ingress.tls }} 25 | - hosts: 26 | {{- range .hosts }} 27 | - {{ . | quote }} 28 | {{- end }} 29 | secretName: {{ .secretName }} 30 | {{- end }} 31 | {{- end }} 32 | rules: 33 | {{- $serviceName := include "bootzooka.fullname" . -}} 34 | {{- range .Values.bootzooka.ingress.hosts }} 35 | - host: {{ .host.domain | quote }} 36 | http: 37 | paths: 38 | - path: {{ .host.path }} 39 | {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} 40 | pathType: {{ .host.pathType }} 41 | {{- end }} 42 | backend: 43 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 44 | service: 45 | name: {{ $serviceName }} 46 | port: 47 | name: {{ .host.port }} 48 | {{- else }} 49 | serviceName: {{ $serviceName }} 50 | servicePort: {{ .host.port }} 51 | {{- end }} 52 | {{- end }} 53 | {{- end }} 54 | -------------------------------------------------------------------------------- /helm/bootzooka/templates/secrets.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | type: Opaque 4 | metadata: 5 | name: {{ include "bootzooka.fullname" . }}-secret 6 | data: 7 | SQL_PASSWORD: {{ tpl .Values.bootzooka.sql.password . | b64enc | quote }} 8 | SMTP_PASSWORD: {{ tpl .Values.bootzooka.smtp.password . | b64enc | quote }} 9 | -------------------------------------------------------------------------------- /helm/bootzooka/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "bootzooka.fullname" . }} 5 | labels: 6 | app.kubernetes.io/name: {{ include "bootzooka.name" . }} 7 | helm.sh/chart: {{ include "bootzooka.chart" . }} 8 | app.kubernetes.io/instance: {{ .Release.Name }} 9 | app.kubernetes.io/managed-by: {{ .Release.Service }} 10 | spec: 11 | type: {{ .Values.bootzooka.service.type }} 12 | ports: 13 | - port: {{ .Values.bootzooka.service.port }} 14 | targetPort: http 15 | protocol: TCP 16 | name: http 17 | selector: 18 | app.kubernetes.io/name: {{ include "bootzooka.name" . }} 19 | app.kubernetes.io/instance: {{ .Release.Name }} 20 | -------------------------------------------------------------------------------- /helm/bootzooka/templates/tests/test-postgresql-connection.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.postgresql.enabled }} 2 | apiVersion: v1 3 | kind: Pod 4 | metadata: 5 | name: "{{ .Release.Name }}-postgresql-test" 6 | annotations: 7 | "helm.sh/hook": test 8 | spec: 9 | containers: 10 | - name: {{ .Release.Name }}-postgresql-test 11 | image: "{{ .Values.postgresql.connectionTest.image.repository }}:{{ .Values.postgresql.connectionTest.image.tag }}" 12 | imagePullPolicy: {{ .Values.postgresql.connectionTest.image.pullPolicy | quote }} 13 | env: 14 | - name: DB_HOST 15 | valueFrom: 16 | configMapKeyRef: 17 | name: {{ include "bootzooka.fullname" . }}-config 18 | key: SQL_HOST 19 | - name: DB_PORT 20 | valueFrom: 21 | configMapKeyRef: 22 | name: {{ include "bootzooka.fullname" . }}-config 23 | key: SQL_PORT 24 | - name: DB_DATABASE 25 | valueFrom: 26 | configMapKeyRef: 27 | name: {{ include "bootzooka.fullname" . }}-config 28 | key: SQL_DBNAME 29 | - name: DB_USERNAME 30 | valueFrom: 31 | configMapKeyRef: 32 | name: {{ include "bootzooka.fullname" . }}-config 33 | key: SQL_USERNAME 34 | - name: DB_PASSWORD 35 | valueFrom: 36 | secretKeyRef: 37 | name: {{ include "bootzooka.fullname" . }}-secret 38 | key: SQL_PASSWORD 39 | command: 40 | - /bin/bash 41 | - -ec 42 | - | 43 | PGPASSWORD=$DB_PASSWORD psql --host $DB_HOST --port $DB_PORT -U $DB_USERNAME 44 | restartPolicy: Never 45 | {{- end }} 46 | -------------------------------------------------------------------------------- /helm/bootzooka/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for Bootzooka. 2 | 3 | postgresql: 4 | # -- Disable if you already have PostgreSQL running in cluster where Bootzooka chart is being deployed 5 | enabled: true 6 | fullnameOverride: "bootzooka-pgsql-postgresql" 7 | postgresqlUsername: "postgres" 8 | postgresqlPassword: "bootzooka" 9 | postgresqlDatabase: "bootzooka" 10 | service: 11 | port: 5432 12 | connectionTest: 13 | image: 14 | repository: bitnami/postgresql 15 | tag: 11 16 | pullPolicy: IfNotPresent 17 | 18 | bootzooka: 19 | replicaCount: 1 20 | reset_password_url: "https://bootzooka.example.com/password-reset?code=%s" 21 | sql: 22 | # -- Value will be taken from 'postgresql.fullnameOverride' setting 23 | host: '{{ .Values.postgresql.fullnameOverride }}' 24 | # -- Value will be taken from 'postgresql.service.port' setting 25 | port: '{{ .Values.postgresql.service.port }}' 26 | # -- Value will be taken from 'postgresql.postgresqlUsername' setting 27 | username: '{{ .Values.postgresql.postgresqlUsername }}' 28 | # -- Value will be taken from 'postgresql.postgresqlDatabase' setting 29 | name: '{{ .Values.postgresql.postgresqlDatabase }}' 30 | # -- Value will be taken from 'postgresql.postgresqlPassword' setting 31 | password: '{{ .Values.postgresql.postgresqlPassword }}' 32 | smtp: 33 | enabled: true 34 | host: "server.example.com" 35 | port: 465 36 | ssl: "true" 37 | ssl_ver: "false" 38 | username: "server.example.com" 39 | from: "hello@bootzooka.example.com" 40 | password: "bootzooka" 41 | 42 | image: 43 | repository: softwaremill/bootzooka 44 | tag: latest 45 | pullPolicy: Always 46 | 47 | nameOverride: "" 48 | fullnameOverride: "" 49 | 50 | service: 51 | type: ClusterIP 52 | port: 8080 53 | 54 | ingress: 55 | enabled: true 56 | tls_enabled: false 57 | annotations: 58 | kubernetes.io/ingress.class: nginx 59 | kubernetes.io/tls-acme: "true" 60 | hosts: 61 | - host: 62 | domain: bootzooka.example.com 63 | path: / 64 | pathType: ImplementationSpecific 65 | port: http 66 | tls: 67 | - secretName: bootzooka-tls 68 | hosts: 69 | - bootzooka.example.com 70 | 71 | resources: {} 72 | # We usually recommend not to specify default resources and to leave this as a conscious 73 | # choice for the user. This also increases chances charts run on environments with little 74 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 75 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 76 | # limits: 77 | # cpu: 100m 78 | # memory: 128Mi 79 | # requests: 80 | # cpu: 100m 81 | # memory: 128Mi 82 | 83 | nodeSelector: {} 84 | 85 | tolerations: [] 86 | 87 | affinity: {} 88 | -------------------------------------------------------------------------------- /integration-tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | bootzooka: 4 | image: 'softwaremill/bootzooka:latest' 5 | ports: 6 | - '8080:8080' 7 | depends_on: 8 | - bootzooka-db 9 | - bootzooka-mailhog 10 | environment: 11 | SQL_USERNAME: 'postgres' 12 | SQL_PASSWORD: 'b00t200k4' 13 | SQL_DBNAME: 'bootzooka' 14 | SQL_HOST: 'bootzooka-db' 15 | SQL_PORT: '5432' 16 | API_HOST: '0.0.0.0' 17 | SMTP_ENABLED: 'true' 18 | SMTP_HOST: 'bootzooka-mailhog' 19 | SMTP_PORT: '1025' 20 | bootzooka-db: 21 | image: 'postgres' 22 | ports: 23 | - '25432:5432' 24 | environment: 25 | POSTGRES_USER: 'postgres' 26 | POSTGRES_PASSWORD: 'b00t200k4' 27 | POSTGRES_DB: 'bootzooka' 28 | bootzooka-mailhog: 29 | image: 'mailhog/mailhog' 30 | ports: 31 | - '11025:1025' 32 | - '18025:8025' 33 | 34 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.2 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1") 2 | 3 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") 4 | 5 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") 6 | 7 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.11.1") 8 | 9 | addSbtPlugin("com.github.sbt" % "sbt-git" % "2.1.0") 10 | 11 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") 12 | -------------------------------------------------------------------------------- /ui/.env.example: -------------------------------------------------------------------------------- 1 | VITE_APP_BASE_URL = "http://localhost:8080/api/v1" 2 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | build 15 | coverage 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /ui/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public 3 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Vite](https://vite.dev/). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:8081](http://localhost:8081) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | #### API client & associated types 16 | 17 | Before running `yarn start`, make sure to run `sbt "backend/generateOpenAPIDescription"` in the project's root directory. This command will generate the `/backend/target/openapi.yaml` file. 18 | 19 | Type-safe React Query hooks are generated upon UI application start (`yarn start`), based on the `openapi.yaml` contents. 20 | 21 | These files are: 22 | 23 | - `src/api/{namespace}Fetcher.ts` - defines a function that will make requests to your API. 24 | - `src/api/{namespace}Context.ts` - the context that provides `{namespace}Fetcher` to other components. 25 | - `src/api/{namespace}Components.ts` - generated React Query components (if you selected React Query as part of initialization). 26 | - `src/api/{namespace}Schemas.ts` - the generated Typescript types from the provided Open API schemas. 27 | 28 | A file watch is engaged, re-generating types on each change to the `/backend/target/openapi.yaml` file. 29 | 30 | ### `yarn test` 31 | 32 | Launches the test runner in the interactive watch mode.
33 | See the section about [running tests](https://vitest.dev/guide/) for more information. 34 | 35 | ### `yarn build` 36 | 37 | Builds the app for production to the `dist` folder.
38 | It correctly bundles React in production mode and optimizes the build for the best performance. 39 | 40 | The build is minified and the filenames include the hashes.
41 | Your app is ready to be deployed! 42 | 43 | See the section about [deployment](https://vite.dev/guide/static-deploy) for more information. 44 | 45 | ## Learn More 46 | 47 | You can learn more in the [Vite documentation](https://vite.dev/). 48 | 49 | To learn React, check out the [React documentation](https://react.dev/). 50 | -------------------------------------------------------------------------------- /ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'; 7 | 8 | export default tseslint.config( 9 | { ignores: ['dist', 'src/api'] }, 10 | { 11 | extends: [ 12 | js.configs.recommended, 13 | ...tseslint.configs.recommended, 14 | eslintPluginPrettier, 15 | ], 16 | files: ['**/*.{ts,tsx}'], 17 | languageOptions: { 18 | ecmaVersion: 2020, 19 | globals: globals.browser, 20 | }, 21 | plugins: { 22 | 'react-hooks': reactHooks, 23 | 'react-refresh': reactRefresh, 24 | }, 25 | rules: { 26 | ...reactHooks.configs.recommended.rules, 27 | 'react-refresh/only-export-components': [ 28 | 'warn', 29 | { allowConstantExport: true }, 30 | ], 31 | '@typescript-eslint/no-explicit-any': 'off', 32 | 'prettier/prettier': ['error', { printWidth: 80 }], 33 | }, 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Bootzooka 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /ui/openapi-codegen.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateSchemaTypes, 3 | generateReactQueryComponents, 4 | } from '@openapi-codegen/typescript'; 5 | import { defineConfig } from '@openapi-codegen/cli'; 6 | export default defineConfig({ 7 | apiFile: { 8 | from: { 9 | relativePath: '../backend/target/openapi.yaml', 10 | source: 'file', 11 | }, 12 | outputDir: './src/api', 13 | to: async (context) => { 14 | const filenamePrefix = 'api'; 15 | const { schemasFiles } = await generateSchemaTypes(context, { 16 | filenamePrefix, 17 | }); 18 | await generateReactQueryComponents(context, { 19 | filenamePrefix, 20 | schemasFiles, 21 | }); 22 | }, 23 | }, 24 | apiWeb: { 25 | from: { 26 | source: 'url', 27 | url: 'http://localhost:8080/api/v1/docs/docs.yaml', 28 | }, 29 | outputDir: './src/api', 30 | to: async (context) => { 31 | const filenamePrefix = 'api'; 32 | const { schemasFiles } = await generateSchemaTypes(context, { 33 | filenamePrefix, 34 | }); 35 | await generateReactQueryComponents(context, { 36 | filenamePrefix, 37 | schemasFiles, 38 | }); 39 | }, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bootzooka-ui", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "private": true, 6 | "proxy": "http://localhost:8080", 7 | "engines": { 8 | "node": ">=22" 9 | }, 10 | "scripts": { 11 | "start": "yarn generate:openapi-types && concurrently vite \"yarn watch:openapi\"", 12 | "build": "yarn generate:openapi-types && tsc -b && vite build", 13 | "preview": "vite preview", 14 | "test": "vitest", 15 | "test:coverage": "vitest run --coverage", 16 | "test:ci": "CI=true vitest", 17 | "lint": "eslint .", 18 | "generate:openapi-types": "npx openapi-codegen gen apiWeb --source file --relativePath ../backend/target/openapi.yaml", 19 | "watch:openapi": "chokidar '../backend/target/openapi.yaml' -c 'yarn generate:openapi-types'", 20 | "start:frontend": "yarn start" 21 | }, 22 | "dependencies": { 23 | "@tanstack/react-query": "^5.62.7", 24 | "bootstrap": "^5.3.2", 25 | "formik": "^2.4.5", 26 | "immer": "^10.0.3", 27 | "react": "^18.2.0", 28 | "react-bootstrap": "^2.9.2", 29 | "react-dom": "^18.2.0", 30 | "react-icons": "^4.12.0", 31 | "react-router": "^7.1.3", 32 | "react-router-bootstrap": "^0.26.2", 33 | "yup": "^1.3.3" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.18.0", 37 | "@openapi-codegen/cli": "^2.0.2", 38 | "@openapi-codegen/typescript": "^8.0.2", 39 | "@testing-library/dom": "^10.4.0", 40 | "@testing-library/jest-dom": "^6.6.3", 41 | "@testing-library/react": "^16.2.0", 42 | "@testing-library/user-event": "^14.6.0", 43 | "@types/node": "^22.10.7", 44 | "@types/react": "^19.0.7", 45 | "@types/react-dom": "^19.0.3", 46 | "@vitejs/plugin-react-swc": "^3.7.2", 47 | "@vitest/coverage-v8": "^3.0.2", 48 | "chokidar-cli": "^3.0.0", 49 | "concurrently": "^8.2.2", 50 | "eslint": "^9.18.0", 51 | "eslint-config-prettier": "^10.0.1", 52 | "eslint-plugin-prettier": "^5.2.2", 53 | "eslint-plugin-react": "^7.37.4", 54 | "eslint-plugin-react-hooks": "^5.1.0", 55 | "eslint-plugin-react-refresh": "^0.4.18", 56 | "globals": "^15.14.0", 57 | "jsdom": "^26.0.0", 58 | "prettier": "^3.4.2", 59 | "typescript": "^5.7.3", 60 | "typescript-eslint": "^8.20.0", 61 | "vite": "^6.0.15", 62 | "vitest": "^3.0.5" 63 | }, 64 | "jest": { 65 | "collectCoverageFrom": [ 66 | "src/**/*.{ts,tsx}", 67 | "!src/index.tsx", 68 | "!src/serviceWorker.ts" 69 | ] 70 | }, 71 | "browserslist": { 72 | "production": [ 73 | ">0.2%", 74 | "not dead", 75 | "not op_mini all" 76 | ], 77 | "development": [ 78 | "last 1 chrome version", 79 | "last 1 firefox version", 80 | "last 1 safari version" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /ui/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/public/favicon.png -------------------------------------------------------------------------------- /ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Bootzooka", 3 | "name": "Bootzooka", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /ui/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { App } from './App'; 3 | 4 | test('should render', () => { 5 | render(); 6 | const header = screen.getByText('Welcome to Bootzooka!'); 7 | expect(header).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | import { Main } from 'main/Main/Main'; 4 | import { UserContextProvider } from 'contexts'; 5 | 6 | const queryClient = new QueryClient(); 7 | 8 | export const App = () => ( 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /ui/src/api/apiContext.ts: -------------------------------------------------------------------------------- 1 | import type { QueryKey, UseQueryOptions } from '@tanstack/react-query'; 2 | import { QueryOperation } from './apiComponents'; 3 | 4 | export type ApiContext = { 5 | fetcherOptions: { 6 | /** 7 | * Headers to inject in the fetcher 8 | */ 9 | headers?: {}; 10 | /** 11 | * Query params to inject in the fetcher 12 | */ 13 | queryParams?: {}; 14 | }; 15 | queryOptions: { 16 | /** 17 | * Set this to `false` to disable automatic refetching when the query mounts or changes query keys. 18 | * Defaults to `true`. 19 | */ 20 | enabled?: boolean; 21 | }; 22 | /** 23 | * Query key manager. 24 | */ 25 | queryKeyFn: (operation: QueryOperation) => QueryKey; 26 | }; 27 | 28 | /** 29 | * Context injected into every react-query hook wrappers 30 | * 31 | * @param queryOptions options from the useQuery wrapper 32 | */ 33 | export function useApiContext< 34 | TQueryFnData = unknown, 35 | TError = unknown, 36 | TData = TQueryFnData, 37 | TQueryKey extends QueryKey = QueryKey, 38 | >( 39 | _queryOptions?: Omit< 40 | UseQueryOptions, 41 | 'queryKey' | 'queryFn' 42 | > 43 | ): ApiContext { 44 | return { 45 | fetcherOptions: {}, 46 | queryOptions: {}, 47 | queryKeyFn, 48 | }; 49 | } 50 | 51 | export const queryKeyFn = (operation: QueryOperation) => { 52 | const queryKey: unknown[] = hasPathParams(operation) 53 | ? operation.path 54 | .split('/') 55 | .filter(Boolean) 56 | .map((i) => resolvePathParam(i, operation.variables.pathParams)) 57 | : operation.path.split('/').filter(Boolean); 58 | 59 | if (hasQueryParams(operation)) { 60 | queryKey.push(operation.variables.queryParams); 61 | } 62 | 63 | if (hasBody(operation)) { 64 | queryKey.push(operation.variables.body); 65 | } 66 | 67 | return queryKey; 68 | }; 69 | // Helpers 70 | const resolvePathParam = (key: string, pathParams: Record) => { 71 | if (key.startsWith('{') && key.endsWith('}')) { 72 | return pathParams[key.slice(1, -1)]; 73 | } 74 | return key; 75 | }; 76 | 77 | const hasPathParams = ( 78 | operation: QueryOperation 79 | ): operation is QueryOperation & { 80 | variables: { pathParams: Record }; 81 | } => { 82 | return Boolean((operation.variables as any).pathParams); 83 | }; 84 | 85 | const hasBody = ( 86 | operation: QueryOperation 87 | ): operation is QueryOperation & { 88 | variables: { body: Record }; 89 | } => { 90 | return Boolean((operation.variables as any).body); 91 | }; 92 | 93 | const hasQueryParams = ( 94 | operation: QueryOperation 95 | ): operation is QueryOperation & { 96 | variables: { queryParams: Record }; 97 | } => { 98 | return Boolean((operation.variables as any).queryParams); 99 | }; 100 | -------------------------------------------------------------------------------- /ui/src/api/apiFetcher.ts: -------------------------------------------------------------------------------- 1 | import { ApiContext } from './apiContext'; 2 | 3 | const baseUrl = process.env.REACT_APP_BASE_URL; 4 | 5 | export type ErrorWrapper = 6 | | TError 7 | | { status: 'unknown'; payload: string }; 8 | 9 | export type ApiFetcherOptions = { 10 | url: string; 11 | method: string; 12 | body?: TBody; 13 | headers?: THeaders; 14 | queryParams?: TQueryParams; 15 | pathParams?: TPathParams; 16 | signal?: AbortSignal; 17 | } & ApiContext['fetcherOptions']; 18 | 19 | export async function apiFetch< 20 | TData, 21 | TError, 22 | TBody extends {} | FormData | undefined | null, 23 | THeaders extends {}, 24 | TQueryParams extends {}, 25 | TPathParams extends {}, 26 | >({ 27 | url, 28 | method, 29 | body, 30 | headers, 31 | pathParams, 32 | queryParams, 33 | signal, 34 | }: ApiFetcherOptions< 35 | TBody, 36 | THeaders, 37 | TQueryParams, 38 | TPathParams 39 | >): Promise { 40 | try { 41 | const requestHeaders: HeadersInit = { 42 | 'Content-Type': 'application/json', 43 | ...headers, 44 | }; 45 | 46 | if (!requestHeaders.Authorization && localStorage.getItem('apiKey')) { 47 | requestHeaders.Authorization = `Bearer ${localStorage.getItem('apiKey')}`; 48 | } 49 | 50 | /** 51 | * As the fetch API is being used, when multipart/form-data is specified 52 | * the Content-Type header must be deleted so that the browser can set 53 | * the correct boundary. 54 | * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object 55 | */ 56 | if ( 57 | requestHeaders['Content-Type'] 58 | .toLowerCase() 59 | .includes('multipart/form-data') 60 | ) { 61 | delete requestHeaders['Content-Type']; 62 | } 63 | 64 | const response = await window.fetch( 65 | `${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, 66 | { 67 | signal, 68 | method: method.toUpperCase(), 69 | body: body 70 | ? body instanceof FormData 71 | ? body 72 | : JSON.stringify(body) 73 | : undefined, 74 | headers: requestHeaders, 75 | } 76 | ); 77 | if (!response.ok) { 78 | let error: ErrorWrapper; 79 | try { 80 | error = await response.json(); 81 | } catch (e) { 82 | error = { 83 | status: 'unknown' as const, 84 | payload: 85 | e instanceof Error 86 | ? `Unexpected error (${e.message})` 87 | : 'Unexpected error', 88 | }; 89 | } 90 | 91 | throw error; 92 | } 93 | 94 | if (response.headers.get('content-type')?.includes('json')) { 95 | return await response.json(); 96 | } else { 97 | // if it is not a json response, assume it is a blob and cast it to TData 98 | return (await response.blob()) as unknown as TData; 99 | } 100 | } catch (e) { 101 | const errorObject: Error = { 102 | name: 'unknown' as const, 103 | message: 104 | e instanceof Error ? `Network error (${e.message})` : 'Network error', 105 | stack: e as string, 106 | }; 107 | throw errorObject; 108 | } 109 | } 110 | 111 | const resolveUrl = ( 112 | url: string, 113 | queryParams: Record = {}, 114 | pathParams: Record = {} 115 | ) => { 116 | let query = new URLSearchParams(queryParams).toString(); 117 | if (query) query = `?${query}`; 118 | return url.replace(/\{\w*\}/g, (key) => pathParams[key.slice(1, -1)]) + query; 119 | }; 120 | -------------------------------------------------------------------------------- /ui/src/api/apiSchemas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generated by @openapi-codegen 3 | * 4 | * @version 1.0 5 | */ 6 | export type ChangePasswordIN = { 7 | currentPassword: string; 8 | newPassword: string; 9 | }; 10 | 11 | export type ChangePasswordOUT = { 12 | apiKey: string; 13 | }; 14 | 15 | export type ErrorOUT = { 16 | error: string; 17 | }; 18 | 19 | export type ForgotPasswordIN = { 20 | loginOrEmail: string; 21 | }; 22 | 23 | export type ForgotPasswordOUT = Record; 24 | 25 | export type GetUserOUT = { 26 | login: string; 27 | email: string; 28 | /** 29 | * @format date-time 30 | */ 31 | createdOn: string; 32 | }; 33 | 34 | export type LoginIN = { 35 | loginOrEmail: string; 36 | password: string; 37 | /** 38 | * @format int32 39 | */ 40 | apiKeyValidHours?: number; 41 | }; 42 | 43 | export type LoginOUT = { 44 | apiKey: string; 45 | }; 46 | 47 | export type LogoutIN = { 48 | apiKey: string; 49 | }; 50 | 51 | export type LogoutOUT = Record; 52 | 53 | export type PasswordResetIN = { 54 | code: string; 55 | password: string; 56 | }; 57 | 58 | export type PasswordResetOUT = Record; 59 | 60 | export type RegisterIN = { 61 | login: string; 62 | email: string; 63 | password: string; 64 | }; 65 | 66 | export type RegisterOUT = { 67 | apiKey: string; 68 | }; 69 | 70 | export type UpdateUserIN = { 71 | login: string; 72 | email: string; 73 | }; 74 | 75 | export type UpdateUserOUT = Record; 76 | 77 | export type VersionOUT = { 78 | buildSha: string; 79 | }; 80 | -------------------------------------------------------------------------------- /ui/src/api/apiUtils.ts: -------------------------------------------------------------------------------- 1 | type ComputeRange< 2 | N extends number, 3 | Result extends Array = [], 4 | > = Result["length"] extends N 5 | ? Result 6 | : ComputeRange; 7 | 8 | export type ClientErrorStatus = Exclude< 9 | ComputeRange<500>[number], 10 | ComputeRange<400>[number] 11 | >; 12 | export type ServerErrorStatus = Exclude< 13 | ComputeRange<600>[number], 14 | ComputeRange<500>[number] 15 | >; 16 | -------------------------------------------------------------------------------- /ui/src/assets/forkme_orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/src/assets/forkme_orange.png -------------------------------------------------------------------------------- /ui/src/assets/sml-logo-vertical-rgb-trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/src/assets/sml-logo-vertical-rgb-trans.png -------------------------------------------------------------------------------- /ui/src/assets/sml-logo-vertical-white-all-trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softwaremill/bootzooka/8e52b0a838225ce7eb17b88dad4b93e30919753b/ui/src/assets/sml-logo-vertical-white-all-trans.png -------------------------------------------------------------------------------- /ui/src/components/ErrorMessage/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | interface ErrorMessageProps { 2 | error: any; 3 | } 4 | 5 | export const ErrorMessage: React.FC = ({ error }) => ( 6 | 7 | {( 8 | error?.response?.data?.error || 9 | error?.message || 10 | 'Unknown error' 11 | ).toString()} 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /ui/src/components/FeedbackButton/FeedbackButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react'; 2 | import Button, { ButtonProps } from 'react-bootstrap/Button'; 3 | import Spinner from 'react-bootstrap/Spinner'; 4 | import Form from 'react-bootstrap/Form'; 5 | import { IconType } from 'react-icons'; 6 | import { BsExclamationCircle, BsCheck } from 'react-icons/bs'; 7 | import useFormikValuesChanged from './useFormikValuesChanged'; 8 | import { ErrorMessage } from '../'; 9 | import { UseMutationResult } from '@tanstack/react-query'; 10 | 11 | interface FeedbackButtonProps extends ButtonProps { 12 | label: string; 13 | Icon: IconType; 14 | mutation: UseMutationResult; 15 | successLabel?: string; 16 | } 17 | 18 | export const FeedbackButton = ({ 19 | mutation, 20 | label, 21 | Icon, 22 | successLabel = 'Success', 23 | ...buttonProps 24 | }: FeedbackButtonProps): ReactElement => { 25 | useFormikValuesChanged(() => { 26 | return !mutation.isIdle && mutation.reset(); 27 | }); 28 | 29 | if (mutation?.isPending) { 30 | return ( 31 | 35 | ); 36 | } 37 | 38 | if (mutation?.isError) { 39 | return ( 40 |
41 | 45 | 46 | 47 | 48 |
49 | ); 50 | } 51 | 52 | if (mutation?.isSuccess) { 53 | return ( 54 |
55 | 59 | 60 | {successLabel} 61 | 62 |
63 | ); 64 | } 65 | 66 | return ( 67 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /ui/src/components/FeedbackButton/useFormikValuesChanged.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { useFormikContext } from 'formik'; 3 | 4 | const useFormikValuesChanged = (onChange: () => void) => { 5 | const { values } = useFormikContext(); 6 | const onChangeRef = useRef(onChange); 7 | 8 | useEffect(() => { 9 | onChangeRef.current = onChange; 10 | }, [onChange]); 11 | 12 | useEffect(() => { 13 | onChangeRef.current(); 14 | }, [values]); 15 | }; 16 | 17 | export default useFormikValuesChanged; 18 | -------------------------------------------------------------------------------- /ui/src/components/FormikInput/FormikInput.tsx: -------------------------------------------------------------------------------- 1 | import { Field, FieldProps } from 'formik'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Row from 'react-bootstrap/Row'; 4 | import Col from 'react-bootstrap/Col'; 5 | 6 | interface FormikInputProps { 7 | type?: string; 8 | name: string; 9 | label: string; 10 | } 11 | 12 | export const FormikInput: React.FC = ({ 13 | type = 'text', 14 | name, 15 | label, 16 | }) => ( 17 | 18 | {({ field, meta }: FieldProps) => ( 19 | 20 | 21 | {label} 22 | 23 | 24 | 31 | 32 | {meta.error} 33 | 34 | 35 | 36 | )} 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /ui/src/components/TwoColumnHero/TwoColumnHero.tsx: -------------------------------------------------------------------------------- 1 | import Container from 'react-bootstrap/Container'; 2 | import Col from 'react-bootstrap/Col'; 3 | import Row from 'react-bootstrap/Row'; 4 | import Fade from 'react-bootstrap/Fade'; 5 | import Image from 'react-bootstrap/Image'; 6 | import logo from 'assets/sml-logo-vertical-white-all-trans.png'; 7 | 8 | interface TwoColumnHeroProps { 9 | children: React.ReactNode; 10 | } 11 | 12 | export const TwoColumnHero: React.FC = ({ children }) => { 13 | return ( 14 | 15 | 16 | 21 | SoftwareMill logotype 22 | 23 | 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /ui/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ErrorMessage/ErrorMessage'; 2 | export * from './FeedbackButton/FeedbackButton'; 3 | export * from './FormikInput/FormikInput'; 4 | export * from './TwoColumnHero/TwoColumnHero'; 5 | -------------------------------------------------------------------------------- /ui/src/contexts/UserContext/User.context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import { UserAction, UserState } from './UserContext'; 3 | import { initialUserState } from './UserContext.constants'; 4 | 5 | export const UserContext = createContext<{ 6 | state: UserState; 7 | dispatch: React.Dispatch; 8 | }>({ 9 | state: initialUserState, 10 | dispatch: () => {}, 11 | }); 12 | 13 | export const useUserContext = () => useContext(UserContext); 14 | -------------------------------------------------------------------------------- /ui/src/contexts/UserContext/UserContext.constants.ts: -------------------------------------------------------------------------------- 1 | import { UserState } from './UserContext'; 2 | 3 | export const initialUserState: UserState = { 4 | apiKey: null, 5 | user: null, 6 | loggedIn: null, 7 | }; 8 | -------------------------------------------------------------------------------- /ui/src/contexts/UserContext/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useReducer } from 'react'; 2 | import { produce } from 'immer'; 3 | import { initialUserState } from './UserContext.constants'; 4 | import { UserContext } from './User.context'; 5 | 6 | export interface UserDetails { 7 | createdOn: string; 8 | email: string; 9 | login: string; 10 | } 11 | 12 | export interface UserState { 13 | apiKey: string | null; 14 | user: UserDetails | null; 15 | loggedIn: boolean | null; 16 | } 17 | 18 | export type UserAction = 19 | | { type: 'SET_API_KEY'; apiKey: string | null } 20 | | { type: 'UPDATE_USER_DATA'; user: Partial } 21 | | { type: 'LOG_IN'; user: UserDetails } 22 | | { type: 'LOG_OUT' }; 23 | 24 | const userReducer = (state: UserState, action: UserAction): UserState => { 25 | switch (action.type) { 26 | case 'SET_API_KEY': 27 | return produce(state, (draftState) => { 28 | draftState.apiKey = action.apiKey; 29 | }); 30 | 31 | case 'UPDATE_USER_DATA': 32 | return produce(state, (draftState) => { 33 | if (!draftState.user) return; 34 | draftState.user = { ...draftState.user, ...action.user }; 35 | }); 36 | 37 | case 'LOG_IN': 38 | return produce(state, (draftState) => { 39 | draftState.user = action.user; 40 | draftState.loggedIn = true; 41 | }); 42 | 43 | case 'LOG_OUT': 44 | return produce(state, (draftState) => { 45 | draftState.apiKey = null; 46 | draftState.user = null; 47 | draftState.loggedIn = false; 48 | }); 49 | 50 | default: 51 | return state; 52 | } 53 | }; 54 | 55 | interface UserContextProviderProps { 56 | children: ReactNode; 57 | } 58 | 59 | export const UserContextProvider: React.FC = ({ 60 | children, 61 | }) => { 62 | const [state, dispatch] = useReducer(userReducer, initialUserState); 63 | 64 | return ( 65 | 66 | {children} 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /ui/src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './UserContext/UserContext'; 2 | -------------------------------------------------------------------------------- /ui/src/index.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root { 4 | height: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { App } from './App'; 4 | import 'bootstrap/dist/css/bootstrap.min.css'; 5 | import './index.css'; 6 | 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /ui/src/main/Footer/Footer.test.tsx: -------------------------------------------------------------------------------- 1 | import { Mock } from 'vitest'; 2 | import { screen } from '@testing-library/react'; 3 | import { renderWithClient } from 'tests'; 4 | import { useGetAdminVersion } from 'api/apiComponents'; 5 | import { Footer } from './Footer'; 6 | 7 | vi.mock('api/apiComponents', () => ({ 8 | useGetAdminVersion: vi.fn(), 9 | })); 10 | 11 | beforeEach(() => { 12 | vi.clearAllMocks(); 13 | }); 14 | 15 | test('renders version data', async () => { 16 | const mockedUseGetAdminVersion = useGetAdminVersion as Mock; 17 | 18 | mockedUseGetAdminVersion.mockReturnValue({ 19 | isPending: false, 20 | isLoading: false, 21 | isError: false, 22 | isSuccess: true, 23 | data: { buildDate: 'testDate', buildSha: 'testSha' }, 24 | }); 25 | 26 | renderWithClient(