diff --git a/.github/workflows/main-client.yml b/.github/workflows/main-client.yml index 9d9ee9a..e029684 100644 --- a/.github/workflows/main-client.yml +++ b/.github/workflows/main-client.yml @@ -1,4 +1,4 @@ -name: Main Client +name: Main Tasknote-web on: workflow_dispatch: @@ -17,8 +17,8 @@ on: - 'client/Caddyfile' jobs: - client-code-checks: - name: Client Code Checks + tasknote-web-code-checks: + name: Tasknote-web Code Checks runs-on: ubuntu-latest steps: - name: Checkout @@ -74,20 +74,20 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Pull image with source tag - run: docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate + run: docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:candidate - name: Extract PR number using docker inspect id: inspect run: | - SOURCE_PR=$(docker inspect ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p') + SOURCE_PR=$(docker inspect ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p') echo "SOURCE_PR=$SOURCE_PR" >> $GITHUB_ENV echo "source_pr=$SOURCE_PR" >> $GITHUB_OUTPUT - name: Re-tag the image - run: docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/client:prod-${{ steps.inspect.outputs.source_pr }} + run: docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:prod-${{ steps.inspect.outputs.source_pr }} - name: Push new tag - run: docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/client:prod-${{ steps.inspect.outputs.source_pr }} + run: docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:prod-${{ steps.inspect.outputs.source_pr }} - name: Checking cURL version run: curl --version @@ -105,7 +105,7 @@ jobs: -H "Content-Type: application/json" \ -H "x-api-key: ${API_KEY}" \ -d '{ - "dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/client:prod-${{ steps.inspect.outputs.source_pr }}", + "dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:prod-${{ steps.inspect.outputs.source_pr }}", "applicationId": "'"${CLIENT_APPID}"'", "username": "'"${GHCR_USERNAME}"'", "password": "'"${GHCR_PASSWORD}"'", diff --git a/.github/workflows/main-server.yml b/.github/workflows/main-server.yml index 1c438fb..0c2a3c5 100644 --- a/.github/workflows/main-server.yml +++ b/.github/workflows/main-server.yml @@ -1,4 +1,4 @@ -name: Main Server +name: Main Tasknote-api on: workflow_dispatch: @@ -12,7 +12,7 @@ on: jobs: java-code-checks: - name: Server Code Checks + name: Tasknote-api Code Checks runs-on: ubuntu-latest steps: - name: Checkout @@ -77,20 +77,22 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Pull image with source tag - run: docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate + run: docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:candidate - name: Extract PR number using docker inspect id: inspect run: | - SOURCE_PR=$(docker inspect ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p') + SOURCE_PR=$(docker inspect ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p') echo "SOURCE_PR=$SOURCE_PR" >> $GITHUB_ENV echo "source_pr=$SOURCE_PR" >> $GITHUB_OUTPUT - name: Re-tag the image - run: docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/server:prod-${{ steps.inspect.outputs.source_pr }} + run: | + docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:prod-${{ steps.inspect.outputs.source_pr }} + docker tag ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:candidate ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:latest - name: Push new tag - run: docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/server:prod-${{ steps.inspect.outputs.source_pr }} + run: docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:prod-${{ steps.inspect.outputs.source_pr }} - name: Checking cURL version run: curl --version @@ -108,7 +110,7 @@ jobs: -H "Content-Type: application/json" \ -H "x-api-key: ${API_KEY}" \ -d '{ - "dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/server:prod-${{ steps.inspect.outputs.source_pr }}", + "dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:prod-${{ steps.inspect.outputs.source_pr }}", "applicationId": "'"${SERVER_APPID}"'", "username": "'"${GHCR_USERNAME}"'", "password": "'"${GHCR_PASSWORD}"'", @@ -171,4 +173,4 @@ jobs: exit 1 else echo "Prod environment is healthy" - fi \ No newline at end of file + fi diff --git a/.github/workflows/pr-client.yml b/.github/workflows/pr-client.yml index 275e429..824b6b9 100644 --- a/.github/workflows/pr-client.yml +++ b/.github/workflows/pr-client.yml @@ -1,4 +1,6 @@ -name: PR Client +name: PR Tasknote-web +permissions: + contents: read on: pull_request: @@ -6,12 +8,12 @@ on: - 'client/**' concurrency: - group: client-${{ github.event.number }} + group: tasknote-web-${{ github.event.number }} cancel-in-progress: true jobs: - client-code-checks: - name: Client Code Checks + tasknote-web-code-checks: + name: Tasknote-web Code Checks runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -46,12 +48,15 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - client-docker-build: - name: Build Client Docker image + tasknote-web-docker-build: + name: Build Tasknote-web Docker image + permissions: + contents: read + packages: write runs-on: ubuntu-latest - needs: client-code-checks + needs: tasknote-web-code-checks env: - VITE_BUILD: client:${{ github.event.number }} + VITE_BUILD: tasknote-web:${{ github.event.number }} VITE_BACKEND_SERVER: ${{ secrets.SERVER_ADDRESS }}/server steps: - uses: actions/checkout@v5 @@ -74,17 +79,17 @@ jobs: with: push: true context: ./client - tags: ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate + tags: ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:candidate build-args: | VITE_BUILD=v${{ github.event.number }}-${{ steps.date.outputs.date }} SOURCE_PR=v${{ github.event.number }}-${{ github.run_id }}-${{ steps.date.outputs.date }} cache-from: type=gha cache-to: type=gha,mode=max - client-stage-deployments: + tasknote-web-stage-deployments: name: Deploy Changes to Stage runs-on: ubuntu-latest - needs: client-docker-build + needs: tasknote-web-docker-build if: github.event.pull_request.user.login == 'rmcampos' && github.event_name == 'pull_request' env: DEPLOY_DOMAIN: ${{ vars.DEPLOY_DOMAIN }} diff --git a/.github/workflows/pr-server.yml b/.github/workflows/pr-server.yml index ce29b6f..78bb31c 100644 --- a/.github/workflows/pr-server.yml +++ b/.github/workflows/pr-server.yml @@ -1,4 +1,4 @@ -name: PR Server +name: PR Tasknote-api on: pull_request: @@ -6,12 +6,12 @@ on: - 'server/**' concurrency: - group: server-${{ github.event.number }} + group: tasknote-api-${{ github.event.number }} cancel-in-progress: true jobs: java-code-checks: - name: Server Code Checks + name: Tasknote-api Code Checks runs-on: ubuntu-latest steps: - name: Checkout @@ -61,7 +61,7 @@ jobs: ./mvnw -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=ricardo-campos-org_react-typescript-todolist_server -Dsonar.coverage.jacoco.xmlReportPaths=target/coverage-reports/merged-test-report/jacoco.xml -Dsonar.exclusions='**/config/**,**/entity/**,**/exception/**,**/filter/**,**/**Builder*,**/RestExceptionEndpoint.*,**/JavaApiApiApplication.*' java-docker-build: - name: Build Server Docker image + name: Build Tasknote-api Docker image runs-on: ubuntu-latest needs: java-code-checks steps: @@ -85,7 +85,7 @@ jobs: with: push: true context: ./server - tags: ghcr.io/ricardo-campos-org/react-typescript-todolist/server:candidate + tags: ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:candidate build-args: | BUILD=v${{ github.event.number }}-${{ steps.date.outputs.date }} SOURCE_PR=v${{ github.event.number }}-${{ github.run_id }}-${{ steps.date.outputs.date }} diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..2269d64 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,63 @@ +# https://taskfile.dev + +version: '3' + +vars: + GREETING: Hello, World! + +tasks: + docker-build-web: + desc: Build the tasknote-web prod-ready docker image, tagging it as candidate + cmd: docker build --no-cache --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:candidate ./client + + docker-build-api: + desc: Build the tasknote-api prod-ready docker image, tagging it as candidate + cmd: docker build --no-cache --build-arg BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" -t ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:candidate ./server + + prod-up-web: + desc: Speed up the tasknote-web prod-like image, building it if required + cmd: docker compose -f docker-compose.prod.yml up -d tasknote-web + + prod-up-api: + desc: Speed up the tasknote-api prod-like image, building it if required + cmd: docker compose -f docker-compose.prod.yml up -d tasknote-api + + prod-up-db: + desc: Start the database container + cmd: docker compose -f docker-compose.prod.yml up -d tasknote-db + + prod-up: + desc: Speed up the prod-like images for both tasknote-api and tasknote-web, building them if required + cmd: docker compose -f docker-compose.prod.yml up -d + + prod-stop: + desc: Stop prod containers + cmd: docker compose -f docker-compose.prod.yml stop + + prod-tier-down: + desc: Stop and remove prod containers + cmd: docker compose -f docker-compose.prod.yml down --remove-orphans + + dev-run-web: + desc: Get the tasknote-web up and running locally inside docker ready for development + cmd: docker compose -f docker-compose.dev.yml up -d tasknote-web + + dev-run-api: + desc: Get the tasknote-api up and running locally inside docker ready for development + cmd: docker compose -f docker-compose.dev.yml up -d tasknote-api + + dev-run-db: + desc: Get the tasknote-db up and running locally inside docker ready for development + cmd: docker compose -f docker-compose.dev.yml up -d tasknote-db + + dev-run: + desc: Get both tasknote-web and tasknote-api up and running locally inside docker ready for development + cmd: docker compose -f docker-compose.dev.yml up -d + + dev-stop: + desc: Stop dev containers + cmd: docker compose -f docker-compose.dev.yml stop + + dev-tier-down: + desc: Stop and remove dev containers + cmd: docker compose -f docker-compose.dev.yml down --remove-orphans diff --git a/client/Dockerfile.dev b/client/Dockerfile.dev index 6c839bb..8f0f862 100644 --- a/client/Dockerfile.dev +++ b/client/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:20.18-bookworm +FROM node:22.14-bookworm-slim WORKDIR /app HEALTHCHECK CMD timeout 10s bash -c 'true > /dev/tcp/127.0.0.1/5000' CMD ["sh", "run-from-docker.sh"] diff --git a/docker-compose.yml b/docker-compose.dev.yml similarity index 76% rename from docker-compose.yml rename to docker-compose.dev.yml index b709066..699562e 100644 --- a/docker-compose.yml +++ b/docker-compose.dev.yml @@ -1,13 +1,15 @@ --- services: - client: - container_name: client - image: node:20.18-bookworm + tasknote-web: + container_name: tasknote-web + image: node:22.14-bookworm-slim ports: - "5000:5000" entrypoint: sh -c "npm i --no-update-notifier && npm start" - user: root + environment: + VITE_BACKEND_SERVER: http://localhost:8585 + VITE_BUILD: nightly volumes: - "./client:/app" working_dir: /app @@ -18,22 +20,22 @@ services: retries: 3 start_period: 10s - server: - container_name: server + tasknote-api: + container_name: tasknote-api depends_on: - db: + tasknote-db: condition: service_started environment: POSTGRES_DB: tasknote - POSTGRES_HOST: db + POSTGRES_HOST: tasknote-db POSTGRES_USER: tasknoteuser POSTGRES_PASSWORD: default POSTGRES_PORT: 5432 - CORS_ALLOWED_ORIGINS: http://localhost:4200, http://localhost:5000 + CORS_ALLOWED_ORIGINS: http://localhost:5000, http://tasknote-web:5000 SERVER_SERVLET_CONTEXT_PATH: / - SECURITY_KEY: ${SECURITY_KEY} - MAILGUN_APIKEY: ${MAILGUN_APIKEY} TARGET_ENV: development + SECURITY_KEY: this-is-a-very-long-security-key-for-dev + MAILGUN_APIKEY: invalid-api-key-only-placeholder ports: - "8585:8585" - "5005:5005" @@ -58,13 +60,13 @@ services: - "./schemaspy/output:/output" - "./schemaspy/postgres.properties:/schemaspy.properties" depends_on: - db: + tasknote-db: condition: service_healthy - server: + tasknote-api: condition: service_healthy - db: - container_name: db + tasknote-db: + container_name: tasknote-db image: postgres:15.8-bookworm environment: POSTGRES_DB: tasknote diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 824fbbc..5325b6c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,46 +1,50 @@ --- services: - client: - container_name: client + tasknote-web: + container_name: tasknote-web depends_on: - server: + tasknote-api: condition: service_healthy + image: ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:candidate + build: + context: client + dockerfile: Dockerfile + ports: ["5000:5000"] environment: VITE_BACKEND_SERVER: http://localhost:8585 - VITE_BUILD: snapshot - image: ghcr.io/ricardo-campos-org/react-typescript-todolist/client:candidate - ports: - - "5000:5000" + VITE_BUILD: nightly - server: - container_name: server + tasknote-api: + container_name: tasknote-api depends_on: - db: + tasknote-db: condition: service_healthy environment: POSTGRES_DB: tasknote - POSTGRES_HOST: db + POSTGRES_HOST: tasknote-db POSTGRES_USER: tasknoteuser POSTGRES_PASSWORD: default POSTGRES_PORT: 5432 - CORS_ALLOWED_ORIGINS: http://localhost:5000 + CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000 SERVER_SERVLET_CONTEXT_PATH: / - SECURITY_KEY: ${SECURITY_KEY} - TARGET_ENV: production - ports: - - "8585:8585" - image: server:candidate + TARGET_ENV: development + SECURITY_KEY: this-is-a-very-long-security-key-for-dev + MAILGUN_APIKEY: invalid-api-key-only-placeholder + ports: ["8585:8585"] + image: ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:candidate + build: + context: server + dockerfile: Dockerfile - db: - container_name: db + tasknote-db: + container_name: tasknote-db image: postgres:15.8-bookworm environment: POSTGRES_DB: tasknote POSTGRES_USER: tasknoteuser POSTGRES_PASSWORD: default - ports: - - "5432:5432" + ports: ["5432:5432"] healthcheck: test: psql -q -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' interval: 1m30s diff --git a/server/pom.xml b/server/pom.xml index b32bd2d..e5f6d99 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -119,7 +119,15 @@ spring-boot-starter-mail - + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot spring-boot-starter-actuator @@ -176,17 +184,17 @@ io.jsonwebtoken jjwt-api - 0.12.6 + 0.13.0 io.jsonwebtoken jjwt-impl - 0.12.6 + 0.13.0 io.jsonwebtoken jjwt-jackson - 0.12.6 + 0.13.0 diff --git a/server/src/main/java/br/com/tasknoteapp/server/JavaApiApplication.java b/server/src/main/java/br/com/tasknoteapp/server/JavaApiApplication.java index 2579941..cd8bec3 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/JavaApiApplication.java +++ b/server/src/main/java/br/com/tasknoteapp/server/JavaApiApplication.java @@ -1,11 +1,21 @@ package br.com.tasknoteapp.server; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** Entrypoint of the Java API service application. */ @SpringBootApplication -public class JavaApiApplication { +public class JavaApiApplication implements ApplicationRunner { + + private static final Logger logger = LoggerFactory.getLogger(JavaApiApplication.class); + + @Value("${br.com.tasknote.server.version}") + private String apiBuildInfo; /** * Main method of the application. @@ -15,4 +25,9 @@ public class JavaApiApplication { public static void main(String[] args) { SpringApplication.run(JavaApiApplication.class, args); } + + @Override + public void run(ApplicationArguments args) throws Exception { + logger.info("Task Note API started successfully - Version: {}", apiBuildInfo); + } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java b/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java index e267c09..01d0cb4 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java +++ b/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java @@ -44,6 +44,9 @@ import org.springframework.context.annotation.ImportRuntimeHints; io.jsonwebtoken.impl.io.StandardCompressionAlgorithms.class, io.jsonwebtoken.impl.DefaultClaimsBuilder.class, io.jsonwebtoken.impl.DefaultJwtParserBuilder.class, + io.jsonwebtoken.impl.DefaultJwtBuilder.class, + io.jsonwebtoken.impl.DefaultJwtBuilder.Supplier.class, + io.jsonwebtoken.lang.Supplier.class, org.flywaydb.core.internal.publishing.PublishingConfigurationExtension.class, }) @ImportRuntimeHints(value = {HttpServletRequestRuntimeHint.class}) diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java index d87b03b..08ba293 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java @@ -35,6 +35,7 @@ import java.util.Optional; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.security.authentication.AuthenticationManager; @@ -64,6 +65,8 @@ public class AuthService { private final MailgunEmailService mailgunEmailService; + private final Environment environment; + /** * Create a new user in the app. * @@ -104,7 +107,9 @@ public class AuthService { user.setLang(newUser.lang()); userRepository.save(user); - mailgunEmailService.sendNewUser(user); + if (hasValidMailgunApiKey()) { + mailgunEmailService.sendNewUser(user); + } log.info("User created! ID {}", user.getId()); return UserResponseWithToken.fromEntity(user, null, getGravatarImageUrl(newUser.email())); @@ -305,7 +310,7 @@ public class AuthService { userRepository.save(currentUser); } - if (emailChanged) { + if (emailChanged && hasValidMailgunApiKey()) { // send email to older and new account log.info("Email changed from {} to {}", email, patchRequest.email()); mailgunEmailService.sendEmailChangedNotification(currentUser, email); @@ -373,7 +378,9 @@ public class AuthService { UserEntity user = userOptional.get(); - mailgunEmailService.sendNewUser(user); + if (hasValidMailgunApiKey()) { + mailgunEmailService.sendNewUser(user); + } log.info("Confirmation email re-sent!"); } @@ -400,7 +407,9 @@ public class AuthService { user.setResetPasswordExpiration(LocalDateTime.now().plusHours(2L)); userRepository.save(user); - mailgunEmailService.sendResetPassword(user); + if (hasValidMailgunApiKey()) { + mailgunEmailService.sendResetPassword(user); + } log.info("Password reset request succeeded"); } @@ -441,7 +450,9 @@ public class AuthService { user.setPassword(passwordEncoder.encode(request.password())); userRepository.save(user); - mailgunEmailService.sendPasswordResetConfirmation(user); + if (hasValidMailgunApiKey()) { + mailgunEmailService.sendPasswordResetConfirmation(user); + } log.info("New password set for token {}", request.token()); } @@ -487,4 +498,10 @@ public class AuthService { } } } + + private boolean hasValidMailgunApiKey() { + String apiKey = environment.getProperty("MAILGUN_APIKEY"); + return Optional.ofNullable(apiKey).isPresent() + && !"invalid-api-key-only-placeholder".equals(apiKey); + } } diff --git a/server/src/main/resources/META-INF/native-image/reachability-metadata.json b/server/src/main/resources/META-INF/native-image/reachability-metadata.json new file mode 100644 index 0000000..79f5b76 --- /dev/null +++ b/server/src/main/resources/META-INF/native-image/reachability-metadata.json @@ -0,0 +1,162 @@ +{ + "reflection": [ + { + "type": "io.jsonwebtoken.Claims" + }, + { + "type": "io.jsonwebtoken.Header" + }, + { + "type": "io.jsonwebtoken.Identifiable" + }, + { + "type": "io.jsonwebtoken.JwsHeader" + }, + { + "type": "io.jsonwebtoken.ProtectedHeader" + }, + { + "type": "io.jsonwebtoken.impl.DefaultClaims", + "allDeclaredFields": true + }, + { + "type": "io.jsonwebtoken.impl.DefaultClaimsBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.DefaultHeader", + "allDeclaredFields": true + }, + { + "type": "io.jsonwebtoken.impl.DefaultJwsHeader", + "allDeclaredFields": true + }, + { + "type": "io.jsonwebtoken.impl.DefaultJwtBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.DefaultJwtHeaderBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.DefaultJwtParserBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.DefaultProtectedHeader", + "allDeclaredFields": true + }, + { + "type": "io.jsonwebtoken.impl.ParameterMap", + "allDeclaredFields": true + }, + { + "type": "io.jsonwebtoken.impl.io.StandardCompressionAlgorithms", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.lang.Nameable" + }, + { + "type": "io.jsonwebtoken.impl.lang.ParameterReadable" + }, + { + "type": "io.jsonwebtoken.impl.security.DefaultKeyOperationBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.security.DefaultKeyOperationPolicyBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.security.StandardEncryptionAlgorithms", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.security.StandardKeyAlgorithms", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.security.StandardKeyOperations", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.impl.security.StandardSecureDigestAlgorithms", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "type": "io.jsonwebtoken.jackson.io.JacksonDeserializer" + }, + { + "type": "io.jsonwebtoken.jackson.io.JacksonSerializer" + }, + { + "type": "io.jsonwebtoken.security.X509Accessor" + } + ], + "resources": [ + { + "glob": "META-INF/services/io.jsonwebtoken.io.Deserializer" + }, + { + "glob": "META-INF/services/io.jsonwebtoken.io.Serializer" + } + ] +} + diff --git a/server/src/main/resources/META-INF/native-image/reflect-config.json b/server/src/main/resources/META-INF/native-image/reflect-config.json index 5f37561..4befff3 100644 --- a/server/src/main/resources/META-INF/native-image/reflect-config.json +++ b/server/src/main/resources/META-INF/native-image/reflect-config.json @@ -625,5 +625,41 @@ "parameterTypes": [] } ] + }, + { + "name": "io.jsonwebtoken.impl.DefaultJwtHeaderBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "name": "io.jsonwebtoken.impl.DefaultClaimsBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "name": "io.jsonwebtoken.impl.security.DefaultKeyOperationBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, + { + "name": "io.jsonwebtoken.impl.security.DefaultKeyOperationPolicyBuilder$Supplier", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] } ] \ No newline at end of file diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java index 5036b3a..e70d88f 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java @@ -34,6 +34,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.springframework.core.env.Environment; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.security.authentication.AuthenticationManager; @@ -59,6 +60,8 @@ class AuthServiceTest { @Mock private MailgunEmailService mailgunEmailService; + @Mock private Environment environment; + private AuthService authService; @BeforeEach @@ -71,7 +74,8 @@ class AuthServiceTest { authenticationManager, authUtil, userPwdLimitRepository, - mailgunEmailService); + mailgunEmailService, + environment); } @Test @@ -509,6 +513,7 @@ class AuthServiceTest { existing.setId(919L); existing.setEmail(email); when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + when(environment.getProperty("MAILGUN_APIKEY")).thenReturn("abc"); doNothing().when(mailgunEmailService).sendNewUser(existing); @@ -516,6 +521,22 @@ class AuthServiceTest { verify(mailgunEmailService, times(1)).sendNewUser(existing); } + @Test + @DisplayName("Resend email confirmation no mailgun api key should succeed") + void resendEmailConfirmation_noMailgunApiKey_shouldSucceed() { + String email = "user@domain.com"; + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setEmail(email); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + doNothing().when(mailgunEmailService).sendNewUser(existing); + + Assertions.assertDoesNotThrow(() -> authService.resendEmailConfirmation(email)); + verify(mailgunEmailService, times(0)).sendNewUser(existing); + } + @Test @DisplayName("Resend email confirmation with non-existent email should fail") void resendEmailConfirmation_nonExistentEmail_shouldFail() { @@ -540,6 +561,7 @@ class AuthServiceTest { doNothing().when(mailgunEmailService).sendResetPassword(any()); when(userRepository.save(any())).thenReturn(existing); + when(environment.getProperty("MAILGUN_APIKEY")).thenReturn("abc"); Assertions.assertDoesNotThrow(() -> authService.resetPasswordForUser(email)); verify(userRepository, times(1)).save(existing); @@ -574,6 +596,7 @@ class AuthServiceTest { when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user)); when(authUtil.validatePassword(newPassword)).thenReturn(Optional.empty()); + when(environment.getProperty("MAILGUN_APIKEY")).thenReturn("abc"); PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword); @@ -586,6 +609,34 @@ class AuthServiceTest { Assertions.assertNotNull(user.getPassword()); } + @Test + @DisplayName("Confirm reset password no mailgun api token should succeed") + void confirmResetPasswordForUser_noMailgunApiToken_shouldSucceed() { + String token = "validToken"; + UserEntity user = new UserEntity(); + user.setResetToken(token); + user.setResetPasswordExpiration(LocalDateTime.now().plusMinutes(30)); + + String newPassword = "NewPassword@123"; + + String encodedPassword = "hash@abcxasd123!"; + when(passwordEncoder.encode(newPassword)).thenReturn(encodedPassword); + user.setPassword(encodedPassword); + + when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user)); + when(authUtil.validatePassword(newPassword)).thenReturn(Optional.empty()); + + PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword); + + Assertions.assertDoesNotThrow(() -> authService.confirmResetPasswordForUser(request)); + + verify(userRepository, times(1)).save(user); + verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(user); + Assertions.assertNull(user.getResetToken()); + Assertions.assertNull(user.getResetPasswordExpiration()); + Assertions.assertNotNull(user.getPassword()); + } + @Test @DisplayName("Confirm reset password with expired token should fail") void confirmResetPasswordForUser_expiredToken_shouldFail() { diff --git a/tools/check-backend.sh b/tools/check-backend.sh index fc19c0c..2942d00 100755 --- a/tools/check-backend.sh +++ b/tools/check-backend.sh @@ -1,6 +1,6 @@ #!/bin/bash -DOCKER_IMG="server:nightly" +DOCKER_IMG="tasknote-api:nightly" docker image inspect $DOCKER_IMG --format="ignore me" if [ $? -eq 1 ]; then @@ -19,10 +19,10 @@ if [ $? -eq 1 ]; then echo "Done!" cd .. - docker build --file server/Dockerfile.dev --tag server:nightly . + docker build --file server/Dockerfile.dev --tag tasknote-api:nightly . else echo "Image found! Let's keep going." fi -docker run -it --rm --name server -e CHECK="TRUE" -v ./server:/app server:nightly +docker run -it --rm --name tasknote-api -e CHECK="TRUE" -v ./server:/app tasknote-api:nightly diff --git a/tools/check-frontend.sh b/tools/check-frontend.sh index 637e3f2..28ee08b 100644 --- a/tools/check-frontend.sh +++ b/tools/check-frontend.sh @@ -1,3 +1,3 @@ #!/bin/bash -docker run -it --rm --name client -e CHECK="TRUE" -v ./client:/app client:nightly +docker run -it --rm --name tasknote-web -e CHECK="TRUE" -v ./client:/app tasknote-web:nightly diff --git a/tools/curl-deploy-prod.sh b/tools/curl-deploy-prod.sh index 019797e..0090afc 100644 --- a/tools/curl-deploy-prod.sh +++ b/tools/curl-deploy-prod.sh @@ -41,7 +41,7 @@ response=$(curl -X POST \ -H "Content-Type: application/json" \ -H "x-api-key: ${API_KEY}" \ -d '{ - "dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/client:prod-v359", + "dockerImage": "ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:prod-v359", "applicationId": "'"${CLIENT_APPID}"'", "username": "'"${GHCR_USERNAME}"'", "password": "'"${GHCR_PASSWORD}"'", diff --git a/tools/get-service-docker-ip.sh b/tools/get-service-docker-ip.sh new file mode 100755 index 0000000..d4eef74 --- /dev/null +++ b/tools/get-service-docker-ip.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +SERVICE="$1" + +# Get the IP address of the Docker container running the database +IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $SERVICE) + +# Output the IP address +echo "$IP" diff --git a/tools/has-docker-image.sh b/tools/has-docker-image.sh new file mode 100755 index 0000000..05f8758 --- /dev/null +++ b/tools/has-docker-image.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Checks if a given image and tag are present locally to prevent running when it's not + +TARGET="$1" + +IS=$(docker images $TARGET:candidate | wc -l) + +if [ $IS -eq 0 ]; then + echo "Image $TARGET:candidate not found locally, please build it" + exit 1; +fi + +echo "Image $TARGET:candidate found" +exit 0 diff --git a/tools/is-docker-not-running.sh b/tools/is-docker-not-running.sh new file mode 100755 index 0000000..7529ea2 --- /dev/null +++ b/tools/is-docker-not-running.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Check if a given service is not running +# Return 0 (success) if it's not + +TARGET="$1" +echo "Check for running image for $TARGET" + +IS=$(docker ps --filter name=$TARGET --filter status=running | grep $TARGET | wc -l) + +if [ $IS -eq 0 ]; then + echo "Service $TARGET is not running" + exit 0 +fi + +echo "Service $TARGET is running" +exit 1 diff --git a/tools/is-docker-running.sh b/tools/is-docker-running.sh new file mode 100755 index 0000000..a855037 --- /dev/null +++ b/tools/is-docker-running.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +TARGET="$1" +echo "Check for running image for $TARGET" + +IS=$(docker ps --filter name=$TARGET --filter status=running | grep $TARGET | wc -l) + +if [ $IS -eq 1 ]; then + echo "Service $TARGET is running" + exit 0 +fi + +echo "Service $TARGET not running" +exit 1 diff --git a/tools/run-create-env.sh b/tools/run-create-env.sh index 3ccca39..a5584ad 100644 --- a/tools/run-create-env.sh +++ b/tools/run-create-env.sh @@ -11,13 +11,15 @@ if [ "$TARGET" == "back" ]; then echo "Creating basic env file for database and back-end" cd server - echo "POSTGRES_DB=postgres" >> .env - echo "POSTGRES_HOST=localhost" >> .env - echo "POSTGRES_USER=postgres" >> .env + echo "POSTGRES_DB=tasknote" >> .env + echo "POSTGRES_HOST=db" >> .env + echo "POSTGRES_USER=tasknoteuser" >> .env echo "POSTGRES_PASSWORD=default" >> .env echo "POSTGRES_PORT=5432" >> .env echo "SERVER_SERVLET_CONTEXT_PATH=/" >> .env echo "CORS_ALLOWED_ORIGINS=http://localhost:5000" >> .env + echo "SECURITY_KEY=this-is-a-very-long-security-key-for-dev" >> .env + echo "MAILGUN_APIKEY=invalid-api-key-only-placeholder" >> .env elif [ "$TARGET" == "front" ]; then if [ -f "client/.env" ]; then echo "Env file in place. Leaving..." diff --git a/tools/run-docker-client.sh b/tools/run-docker-client.sh index 874c0b9..2a9d574 100644 --- a/tools/run-docker-client.sh +++ b/tools/run-docker-client.sh @@ -21,10 +21,10 @@ if [ $? -eq 1 ]; then exit 1 fi -SERVER_HOST="http://"$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' server)":8585" +SERVER_HOST="http://"$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tasknote-api):8585" if [ "$SERVER_HOST" == "http://:8585" ]; then - echo "Back-end server not running. Make sure to run it before starting the web app." + echo "Back-end tasknote-api not running. Make sure to run it before starting the web app." exit 0 fi diff --git a/tools/run-docker-server.sh b/tools/run-docker-server.sh index c9074fa..4e3fe6a 100644 --- a/tools/run-docker-server.sh +++ b/tools/run-docker-server.sh @@ -14,12 +14,12 @@ echo "Done!" cd .. -docker build --file server/Dockerfile.dev --tag server:nightly . +docker build --file server/Dockerfile.dev --tag tasknote-api:nightly . DB_HOST=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' db) docker run -d --rm \ - --name server \ + --name tasknote-api \ -p 8585:8585 \ -p 5005:5005 \ -e POSTGRES_DB=$POSTGRES_DB \ @@ -30,4 +30,4 @@ docker run -d --rm \ -e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \ -e SERVER_SERVLET_CONTEXT_PATH=$SERVER_SERVLET_CONTEXT_PATH \ -v ./server:/app \ - server:nightly + tasknote-api:nightly diff --git a/tools/tools.md b/tools/tools.md index aa69fa1..6642a79 100644 --- a/tools/tools.md +++ b/tools/tools.md @@ -9,7 +9,7 @@ All kind of tools and useful links and commands can be found here! ## Building locally -**Client - Frontend Web App:** +**Tasknote-web - Frontend Web App:** Before building, you need define some env vars: @@ -30,13 +30,13 @@ If you want to build with Docker: docker build --no-cache \ --build-arg VITE_BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" \ --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" \ - -t client:candidate \ + -t tasknote-web:candidate \ ./client ``` That's it! -**Server - Backend REST API:** +**Tasknote-api - Backend REST API:** For the backend there's a Dockerfile ready, just run (from the project root): @@ -44,30 +44,12 @@ For the backend there's a Dockerfile ready, just run (from the project root): docker build --no-cache \ --build-arg BUILD="v999-$(date '+%Y-%m-%d-%H%M%S')" \ --build-arg SOURCE_PR="v999-123456789-$(date '+%Y-%m-%d-%H%M%S')" \ - -t server:candidate \ + -t tasknote-api:candidate \ ./server ``` That's it! -## Deploying manually - -1. Connect into the server via SSH -2. Run deploy scripts - -Here are a few steps: - -```bash -export SERVER_IP= -export SERVER_ADDRESS= - -npm ci --ignore-scripts --no-update-notifier --omit=dev -export VITE_BACKEND_SERVER=$SERVER_ADDRESS/server -npm run build -zip -r "client_$VERSION.zip" dist/ -scp "client_$VERSION.zip" root@$SERVER_IP:/root/ -``` - ## Running with Docker **DB:** @@ -81,23 +63,23 @@ docker run -d -p 5432:5432 --rm \ postgres:15.8-bookworm ``` -**Server:** +**Tasknote-api:** ```bash docker run -d -p 8585:8585 --rm \ - --name server \ + --name tasknote-api \ -e POSTGRES_DB=$POSTGRES_DB \ -e POSTGRES_USER=$POSTGRES_USER \ -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ -e POSTGRES_PORT=$POSTGRES_PORT \ -e POSTGRES_HOST=$POSTGRES_HOST \ -e CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS \ - ghcr.io/ricardo-campos-org/react-typescript-todolist/server: + ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api: ``` Build Cloud Native: `./mvnw -B package -Pnative -DskipTests` -**Client:** +**Tasknote-web:** The frontend app will run on Nginx. @@ -113,13 +95,13 @@ echo $CR_PAT | docker login ghcr.io -u RMCampos --password-stdin **Pulling images:** ```sh -docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/client:50 -docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/server:50 +docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-web:50 +docker pull ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:50 ``` **Pushing images:** ```sh -docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/server:316 +docker push ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:316 ``` - Get container IP @@ -133,12 +115,12 @@ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' db docker inspect --format "{{json .State.Health}}" container_name_or_id | jq # Or to see it live -while true; do clear; docker inspect --format "{{json .State.Health}}" server | jq; sleep 1; done +while true; do clear; docker inspect --format "{{json .State.Health}}" tasknote-api | jq; sleep 1; done ``` - Extract Env value from docker image (without running) ```bash -docker inspect client:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p' +docker inspect tasknote-web:candidate | jq -r '.[0].Config.Env[] | select(startswith("SOURCE_PR="))' | sed -n 's/SOURCE_PR=\(v[0-9]*\).*/\1/p' ``` ## Restoring DB for testing @@ -157,12 +139,3 @@ docker run \ docker exec -i my-postgres-db pg_restore -U -d < 2025-04-26T20_00_00.114Z.sql ``` - - - XS (Extra Small): less than 576px - SM (Small): ≥576px - MD (Medium): ≥768px - LG (Large): ≥992px - XL (Extra Large): ≥1200px - XXL (Extra Extra Large): ≥1400px - \ No newline at end of file