feat: updates (#631)

* build(deps): bump org.springframework.boot:spring-boot-starter-parent

Bumps [org.springframework.boot:spring-boot-starter-parent](https://github.com/spring-projects/spring-boot) from 3.5.4 to 3.5.5.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.5.4...v3.5.5)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-parent
  dependency-version: 3.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump io.jsonwebtoken:jjwt-jackson in /server

Bumps io.jsonwebtoken:jjwt-jackson from 0.12.7 to 0.13.0.

---
updated-dependencies:
- dependency-name: io.jsonwebtoken:jjwt-jackson
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump io.jsonwebtoken:jjwt-impl in /server

Bumps [io.jsonwebtoken:jjwt-impl](https://github.com/jwtk/jjwt) from 0.12.7 to 0.13.0.
- [Release notes](https://github.com/jwtk/jjwt/releases)
- [Changelog](https://github.com/jwtk/jjwt/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jwtk/jjwt/compare/0.12.7...0.13.0)

---
updated-dependencies:
- dependency-name: io.jsonwebtoken:jjwt-impl
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump io.jsonwebtoken:jjwt-api in /server

Bumps [io.jsonwebtoken:jjwt-api](https://github.com/jwtk/jjwt) from 0.12.7 to 0.13.0.
- [Release notes](https://github.com/jwtk/jjwt/releases)
- [Changelog](https://github.com/jwtk/jjwt/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jwtk/jjwt/compare/0.12.7...0.13.0)

---
updated-dependencies:
- dependency-name: io.jsonwebtoken:jjwt-api
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat: add Taskfile

* feat: add files, tasks and more automations

* feat: disable email sending if not configured

* chore: rename client to tasknote-web

* feat: add devtools for faster development

* feat: Re-tag Docker image with 'latest' tag

* feat: update wording and improve taskfile

* Implement ApplicationRunner for startup logging

* fix: add reachability json file

* feat: drop old config for JJWT

* Revert "feat: drop old config for JJWT"

This reverts commit ed29cf4684454b0d5e3f1a284b479e2bb6872ad7.

* chore: JJWT finally working now

* test: fix test cases

* ci: fix permissions

* test: add more test cases

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
2025-08-31 13:43:26 -03:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
parent caa0b8b9ea
commit 2da0a9f113
26 changed files with 529 additions and 133 deletions
+8 -8
View File
@@ -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}"'",
+10 -8
View File
@@ -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
fi
+16 -11
View File
@@ -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 }}
+5 -5
View File
@@ -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 }}
+63
View File
@@ -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
+1 -1
View File
@@ -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"]
+17 -15
View File
@@ -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
+25 -21
View File
@@ -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
+12 -4
View File
@@ -119,7 +119,15 @@
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- OPs & Tools -->
<!-- Devtools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- OOps & Tools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
@@ -176,17 +184,17 @@
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<version>0.13.0</version>
</dependency>
</dependencies>
@@ -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);
}
}
@@ -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})
@@ -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);
}
}
@@ -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": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.DefaultHeader",
"allDeclaredFields": true
},
{
"type": "io.jsonwebtoken.impl.DefaultJwsHeader",
"allDeclaredFields": true
},
{
"type": "io.jsonwebtoken.impl.DefaultJwtBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.DefaultJwtHeaderBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.DefaultJwtParserBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.DefaultProtectedHeader",
"allDeclaredFields": true
},
{
"type": "io.jsonwebtoken.impl.ParameterMap",
"allDeclaredFields": true
},
{
"type": "io.jsonwebtoken.impl.io.StandardCompressionAlgorithms",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.lang.Nameable"
},
{
"type": "io.jsonwebtoken.impl.lang.ParameterReadable"
},
{
"type": "io.jsonwebtoken.impl.security.DefaultKeyOperationBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.security.DefaultKeyOperationPolicyBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.security.StandardEncryptionAlgorithms",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.security.StandardKeyAlgorithms",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.security.StandardKeyOperations",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"type": "io.jsonwebtoken.impl.security.StandardSecureDigestAlgorithms",
"methods": [
{
"name": "<init>",
"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"
}
]
}
@@ -625,5 +625,41 @@
"parameterTypes": []
}
]
},
{
"name": "io.jsonwebtoken.impl.DefaultJwtHeaderBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"name": "io.jsonwebtoken.impl.DefaultClaimsBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"name": "io.jsonwebtoken.impl.security.DefaultKeyOperationBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
},
{
"name": "io.jsonwebtoken.impl.security.DefaultKeyOperationPolicyBuilder$Supplier",
"methods": [
{
"name": "<init>",
"parameterTypes": []
}
]
}
]
@@ -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() {
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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}"'",
+9
View File
@@ -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"
+14
View File
@@ -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
+16
View File
@@ -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
+14
View File
@@ -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
+5 -3
View File
@@ -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..."
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+13 -40
View File
@@ -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:<PR-Number>
ghcr.io/ricardo-campos-org/react-typescript-todolist/tasknote-api:<PR-Number>
```
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 <user> -d <db-name> < 2025-04-26T20_00_00.114Z.sql
```
<div className="py-5 text-center">
<div className="d-block d-sm-none bg-primary text-white p-3">XS (Extra Small): less than 576px</div>
<div className="d-none d-sm-block d-md-none bg-secondary text-white p-3">SM (Small): ≥576px</div>
<div className="d-none d-md-block d-lg-none bg-success text-white p-3">MD (Medium): ≥768px</div>
<div className="d-none d-lg-block d-xl-none bg-warning text-dark p-3">LG (Large): ≥992px</div>
<div className="d-none d-xl-block d-xxl-none bg-danger text-white p-3">XL (Extra Large): ≥1200px</div>
<div className="d-none d-xxl-block bg-dark text-white p-3">XXL (Extra Extra Large): ≥1400px</div>
</div>