Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80314f22d0 | ||
|
|
c9c414d83f | ||
|
|
27c7455870 | ||
|
|
42d5eb286f | ||
|
|
25b04dd86b | ||
|
|
62e2f5536d | ||
|
|
1111f961dd | ||
|
|
95e5336e50 | ||
|
|
2c43861d49 | ||
|
|
ba0a204d75 | ||
|
|
405ebfec34 | ||
|
|
9102d88a13 | ||
|
|
573bdf39ba | ||
|
|
b08bed7977 | ||
|
|
48b9eec4be | ||
|
|
68dcb033a4 | ||
|
|
ca75291b39 | ||
|
|
1c3e6a927d | ||
|
|
3bb21e5315 | ||
|
|
19d17350fd | ||
|
|
75b2b87379 | ||
|
|
6f76c9a17a | ||
|
|
c211b5eb21 | ||
|
|
9ac3152edb | ||
|
|
3c3d8222ff | ||
|
|
5898b16089 | ||
|
|
778937254b | ||
|
|
f1b6419411 | ||
|
|
fefe3439f4 | ||
|
|
97d538ded8 | ||
|
|
6c2874de56 | ||
|
|
7b1c0ad385 | ||
|
|
80b915ad79 | ||
|
|
136f2cd4ff | ||
|
|
ad46c150df | ||
|
|
e2a4cba2b5 | ||
|
|
70dfa7b23d | ||
|
|
f68e097d0f | ||
|
|
17ca89f47d | ||
|
|
a67c0f1227 | ||
|
|
18f21d310d | ||
|
|
f0fa5d4231 | ||
|
|
96c31fdbe7 | ||
|
|
78ac204585 | ||
|
|
cfb6c5863b | ||
|
|
521d16c9ce | ||
|
|
d58b0f5564 | ||
|
|
91e2f1ecc8 | ||
|
|
8f831c35d5 | ||
|
|
2c3cc3449b | ||
|
|
820fd56fbc | ||
|
|
41f7dabbf0 | ||
|
|
5041ebeeff | ||
|
|
fa8c84bb38 | ||
|
|
c3115bc002 | ||
|
|
91e48f2cbb |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/java:25-trixie",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/java:1": {
|
||||
"installMaven": true,
|
||||
"version": "latest",
|
||||
"jdkDistro": "tem",
|
||||
"gradleVersion": "latest",
|
||||
"mavenVersion": "latest",
|
||||
"antVersion": "latest",
|
||||
"groovyVersion": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"installYarnUsingApt": true,
|
||||
"version": "lts",
|
||||
"pnpmVersion": "latest",
|
||||
"nvmVersion": "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Build App Candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push-app:
|
||||
name: Build & Push App
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/app
|
||||
tags: |
|
||||
type=raw,value=candidate
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="app-v${DATE}.${{ github.run_number }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Build Server Candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push-server:
|
||||
name: Build & Push Server
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
cache-dependency-path: 'server/pom.xml'
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Cache Buildpack layers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/reproducible-builds
|
||||
key: ${{ runner.os }}-buildpack-${{ hashFiles('server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildpack-
|
||||
|
||||
- name: Build Docker image
|
||||
working-directory: ./server
|
||||
run: |
|
||||
# Use the dynamic repo name to prevent tagging errors
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
|
||||
- name: Tag and push candidate
|
||||
run: |
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
@@ -0,0 +1,172 @@
|
||||
name: Main CD-Deploy to Prod
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
backend_image:
|
||||
description: "Backend image tag (full image reference)"
|
||||
required: false
|
||||
frontend_image:
|
||||
description: "Frontend image tag (full image reference)"
|
||||
required: false
|
||||
apply:
|
||||
description: "Apply changes after plan"
|
||||
required: false
|
||||
default: "true"
|
||||
workflow_run:
|
||||
workflows: [ "Main CI-Backend", "Main CI-Frontend" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
terraform-plan:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Validate cluster access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get namespace tasknote
|
||||
|
||||
- name: Determine deployment values
|
||||
id: deploy-vars
|
||||
run: |
|
||||
backend_image="${{ github.event.inputs.backend_image }}"
|
||||
frontend_image="${{ github.event.inputs.frontend_image }}"
|
||||
|
||||
latest_backend_tag_tmp="$(git tag --list 'api-v*' | sort -V | tail -n1)"
|
||||
latest_backend_tag="${latest_backend_tag_tmp#api-v}"
|
||||
latest_frontend_tag="$(git tag --list 'app-v*' | sort -V | tail -n1)"
|
||||
|
||||
if [ -z "$latest_backend_tag" ]; then
|
||||
echo "No backend tag found matching [0-9]*" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$latest_frontend_tag" ]; then
|
||||
echo "No frontend tag found matching app-v*" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$backend_image" ]; then
|
||||
backend_image="ghcr.io/rmcampos/tasknote/api:$latest_backend_tag"
|
||||
fi
|
||||
if [ -z "$frontend_image" ]; then
|
||||
frontend_image="ghcr.io/rmcampos/tasknote/app:$latest_frontend_tag"
|
||||
fi
|
||||
|
||||
echo "Resolved backend_image=$backend_image"
|
||||
echo "Resolved frontend_image=$frontend_image"
|
||||
|
||||
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Terraform Fmt -check -diff
|
||||
working-directory: terraform
|
||||
run: terraform fmt -check -diff
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform
|
||||
run: terraform validate
|
||||
|
||||
- name: Terraform Plan
|
||||
id: check-changes
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
timeout 1m terraform plan -input=false -out=tfplan \
|
||||
-var="db_user=${{ secrets.DB_USER }}" \
|
||||
-var="db_password=${{ secrets.DB_PASSWORD }}" \
|
||||
-var="db_name=${{ secrets.DB_NAME }}" \
|
||||
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
|
||||
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
|
||||
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: ubuntu-latest
|
||||
needs: terraform-plan
|
||||
if: >
|
||||
(github.event_name == 'push' || github.event_name == 'workflow_run' || inputs.apply == 'true')
|
||||
&& needs.terraform-plan.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: production
|
||||
url: https://tasknote.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: timeout 1m terraform apply tfplan
|
||||
@@ -0,0 +1,137 @@
|
||||
name: Pull Request CD-Deploy to Staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: [ "Pull Request CI-Backend", "Pull Request CI-Frontend" ]
|
||||
types: [ completed ]
|
||||
|
||||
jobs:
|
||||
terraform-plan-stg:
|
||||
name: Plan changs to staging
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
no_changes: ${{ steps.check-changes.outputs.no_changes }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Validate cluster access
|
||||
run: |
|
||||
kubectl cluster-info
|
||||
kubectl get namespace tasknote-stg
|
||||
|
||||
- name: Determine deployment values
|
||||
id: deploy-vars
|
||||
run: |
|
||||
backend_image="ghcr.io/rmcampos/tasknote/api:candidate"
|
||||
frontend_image="ghcr.io/rmcampos/tasknote/app:candidate"
|
||||
|
||||
echo "backend_image=$backend_image" >> "$GITHUB_OUTPUT"
|
||||
echo "frontend_image=$frontend_image" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Terraform Fmt -check -diff
|
||||
working-directory: terraform-stg
|
||||
run: terraform fmt -check -diff
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Validate
|
||||
working-directory: terraform-stg
|
||||
run: terraform validate
|
||||
|
||||
- name: Terraform Plan
|
||||
id: check-changes
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
timeout 1m terraform plan -input=false -out=tfplan \
|
||||
-var="db_user=${{ secrets.DB_USER }}" \
|
||||
-var="db_password=${{ secrets.DB_PASSWORD }}" \
|
||||
-var="db_name=${{ secrets.DB_NAME }}" \
|
||||
-var="security_key=${{ secrets.JWT_SECURITY_KEY }}" \
|
||||
-var="mailgun_apikey=${{ secrets.MAILGUN_API_KEY }}" \
|
||||
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
|
||||
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}" \
|
||||
-var="deploy_version=${{ github.run_id }}"
|
||||
terraform show -json tfplan > tfplan.json
|
||||
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
|
||||
echo "no_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to apply."
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected. Proceeding with apply"
|
||||
echo "no_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload plan artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg/tfplan
|
||||
|
||||
terraform-apply:
|
||||
runs-on: ubuntu-latest
|
||||
needs: terraform-plan-stg
|
||||
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
|
||||
environment:
|
||||
name: staging
|
||||
url: https://tasknote-stg.darkroasted.vps-kinghost.net
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
|
||||
- name: Download plan artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tfplan
|
||||
path: terraform-stg
|
||||
|
||||
- name: Setup Kubeconfig
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
|
||||
- name: Terraform Init
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: terraform init -input=false
|
||||
|
||||
- name: Terraform Apply
|
||||
working-directory: terraform-stg
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
run: timeout 1m terraform apply tfplan
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Push API Docker Image
|
||||
name: Main CI-Backend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -9,9 +9,11 @@ on:
|
||||
- 'server/**/*.java'
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
- '.github/workflows/main-server.yml'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -67,18 +69,29 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image with Spring Boot
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Tag and push Docker image
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }}
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:latest
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }}
|
||||
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
|
||||
PR_NUMBER="candidate"
|
||||
else
|
||||
PR_NUMBER="pr-${PR_NUMBER}"
|
||||
fi
|
||||
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.version.outputs.version }} \
|
||||
ghcr.io/${{ steps.repo.outputs.name }}/api:${{ steps.find_pr.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build and Push App Docker Image
|
||||
name: Main CI-Frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -15,9 +15,11 @@ on:
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
- '.github/workflows/main-client.yml'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -29,6 +31,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -47,26 +53,26 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/app
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.tag }}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
- name: Find PR number
|
||||
id: find_pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --search "${{ github.sha }}" --state merged --json number --jq '.[0].number')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "No merged PR found for this commit. Falling back to 'candidate' tag."
|
||||
PR_NUMBER="candidate"
|
||||
else
|
||||
PR_NUMBER="pr-${PR_NUMBER}"
|
||||
fi
|
||||
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=v${{ steps.version.outputs.tag }}
|
||||
- name: Promote Docker image
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/app:latest \
|
||||
--tag ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.version.outputs.tag }} \
|
||||
ghcr.io/${{ steps.repo.outputs.name }}/app:${{ steps.find_pr.outputs.tag }}
|
||||
|
||||
- name: Create and push Git tag
|
||||
run: |
|
||||
@@ -75,5 +81,3 @@ jobs:
|
||||
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
|
||||
git push origin ${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Trigger Dokploy deployment
|
||||
run: curl -X POST "${{ vars.DOKPLOY_WEBHOOK_APP }}"
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Pull Request CI-Backend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'server/**/*.java'
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
- '.github/workflows/server-ci.yml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
name: Checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
cache-dependency-path: 'server/pom.xml'
|
||||
|
||||
- name: Run Check Style
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
|
||||
|
||||
- name: Run build
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress clean compile -DskipTests
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
|
||||
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: ["run-checks"]
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set lowercase repo name
|
||||
id: repo
|
||||
run: echo "name=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
cache-dependency-path: 'server/pom.xml'
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Cache Buildpack layers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/reproducible-builds
|
||||
key: ${{ runner.os }}-buildpack-${{ hashFiles('server/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildpack-
|
||||
|
||||
- name: Build Docker image with Spring Boot
|
||||
working-directory: ./server
|
||||
run: |
|
||||
./mvnw -Pnative -DskipTests spring-boot:build-image \
|
||||
-Dspring-boot.build-image.imageName=ghcr.io/${{ steps.repo.outputs.name }}/api:latest \
|
||||
-Dspring-boot.build-image.builder=paketobuildpacks/builder-jammy-tiny:latest
|
||||
|
||||
- name: Tag and push Docker image
|
||||
run: |
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
|
||||
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Create GitHub deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const ref = context.payload.pull_request.head.sha;
|
||||
const env = 'staging';
|
||||
const resp = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref,
|
||||
required_contexts: [],
|
||||
environment: env,
|
||||
description: `PR #${context.payload.pull_request.number} preview deployment`,
|
||||
transient_environment: true,
|
||||
auto_merge: false
|
||||
});
|
||||
// create a deployment status pointing to the staging URL
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
deployment_id: resp.data.id,
|
||||
state: 'success',
|
||||
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
name: Pull Request CI-Frontend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'client/**/*.html'
|
||||
- 'client/**/*.png'
|
||||
- 'client/**/*.json'
|
||||
- 'client/**/*.txt'
|
||||
- 'client/**/*.ts'
|
||||
- 'client/**/*.tsx'
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
- '.github/workflows/client-ci.yml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
name: Checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:no-watch
|
||||
working-directory: ./client
|
||||
|
||||
build-and-push:
|
||||
name: Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: ["run-checks"]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/app
|
||||
tags: |
|
||||
type=raw,value=candidate
|
||||
type=raw,value=pr-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Generate version tag
|
||||
id: version
|
||||
run: |
|
||||
DATE=$(date +'%Y.%m.%d')
|
||||
TAG="app-v${DATE}.${{ github.run_number }}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Generated tag: ${TAG}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./client
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
VITE_BUILD=${{ steps.version.outputs.tag }}
|
||||
|
||||
- name: Create GitHub deployment for staging
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const ref = context.payload.pull_request.head.sha;
|
||||
const env = 'staging';
|
||||
const resp = await github.rest.repos.createDeployment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref,
|
||||
required_contexts: [],
|
||||
environment: env,
|
||||
description: `PR #${context.payload.pull_request.number} preview deployment`,
|
||||
transient_environment: true,
|
||||
auto_merge: false
|
||||
});
|
||||
// create a deployment status pointing to the staging URL
|
||||
await github.rest.repos.createDeploymentStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
deployment_id: resp.data.id,
|
||||
state: 'success',
|
||||
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
name: React App CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'client/**/*.html'
|
||||
- 'client/**/*.png'
|
||||
- 'client/**/*.json'
|
||||
- 'client/**/*.txt'
|
||||
- 'client/**/*.ts'
|
||||
- 'client/**/*.tsx'
|
||||
- 'client/**/*.js'
|
||||
- 'client/Dockerfile'
|
||||
- 'client/Caddyfile'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./client
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:no-watch
|
||||
working-directory: ./client
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Server API CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# run for all pushes, not only main
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- 'server/**/*.java'
|
||||
- 'server/**/*.xml'
|
||||
- 'server/pom.xml'
|
||||
|
||||
jobs:
|
||||
run-checks:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
cache: 'maven'
|
||||
|
||||
- name: Run Check Style
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
|
||||
|
||||
- name: Run build
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress clean compile -DskipTests
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./server
|
||||
run: ./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
|
||||
@@ -40,10 +40,10 @@ The project was born from a month-long technical challenge and has since grown t
|
||||
- **File Attachments**: URL attachments for tasks and notes
|
||||
- **Tagging System**: `#tag` support for better organization
|
||||
- **Mobile App**: Native mobile applications for iOS and Android with PWA plugin
|
||||
- **Collaboration**: Share tasks and notes with other users
|
||||
|
||||
### Upcoming Features
|
||||
- **Advanced Filters**: Enhanced search with date ranges, priority levels, and status filters
|
||||
- **Collaboration**: Share tasks and notes with other users
|
||||
- **Notifications**: Email and push notifications for due dates and reminders
|
||||
|
||||
## 🚀 Tech Stack
|
||||
|
||||
Generated
+347
-391
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -13,21 +13,21 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"bootstrap": "^5.3.8",
|
||||
"dompurify": "^3.3.1",
|
||||
"i18next": "^25.8.13",
|
||||
"dompurify": "^3.3.3",
|
||||
"i18next": "^25.10.10",
|
||||
"react": "^19.2.4",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-bootstrap-icons": "^1.11.6",
|
||||
"react-charts": "^3.0.0-beta.57",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router": "^7.14.0",
|
||||
"react-router-bootstrap": "^0.26.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -62,30 +62,30 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@eslint/compat": "^2.0.4",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-router-bootstrap": "^0.26.8",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^62.7.1",
|
||||
"eslint-plugin-jsdoc": "^62.9.0",
|
||||
"eslint-plugin-n": "^17.24.0",
|
||||
"eslint-plugin-promise": "^7.2.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"sass": "^1.97.3",
|
||||
"sass": "^1.99.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vitest": "^4.0.18"
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Client - Front-end
|
||||
|
||||
npm ci
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when installing dependencies. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$CHECK" ]; then
|
||||
npm start
|
||||
else
|
||||
echo "Running checks..."
|
||||
echo "1/3 - Lint started..."
|
||||
npm run lint:fix
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running lint. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "2/3 - Build started..."
|
||||
npm run build
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running build. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "3/3 - Tests started..."
|
||||
npm run test:no-watch
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running test. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "You're good to go! Good job!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -16,6 +16,7 @@ import Register from './views/Register';
|
||||
import EmailConfirmation from './views/EmailConfirmation';
|
||||
import ResetPassword from './views/ResetPassword';
|
||||
import CompleteResetPassword from './views/CompleteResetPassword';
|
||||
import SharedNote from './views/SharedNote';
|
||||
import './styles/custom.scss';
|
||||
|
||||
/**
|
||||
@@ -65,6 +66,10 @@ function App(): React.ReactNode {
|
||||
path: '/finish-reset-password',
|
||||
element: <CompleteResetPassword />
|
||||
},
|
||||
{
|
||||
path: '/public/notes/:token',
|
||||
element: <SharedNote />
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />
|
||||
@@ -86,6 +91,10 @@ function App(): React.ReactNode {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/public/notes/:token',
|
||||
element: <SharedNote />
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFound />
|
||||
|
||||
@@ -35,26 +35,31 @@ describe('Portuguese Utils unit tests', () => {
|
||||
it('should translate all server responses to pt_br', () => {
|
||||
const keys: string[] = Object.keys(serverResponses);
|
||||
|
||||
expect(translateServerResponse(keys[0], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 8 letras, 1 maiúscula, 1 caracter especial');
|
||||
expect(translateServerResponse(keys[1], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 1 maiúscula, 1 caracter especial');
|
||||
expect(translateServerResponse(keys[2], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 1 caracter especial');
|
||||
expect(translateServerResponse(keys[3], 'pt_br')).toBe('E-mail já cadastrado!');
|
||||
expect(translateServerResponse(keys[4], 'pt_br')).toBe('Proibido! Acesso negado');
|
||||
expect(translateServerResponse(keys[5], 'pt_br')).toBe('Se o endereço de e-mail informado estiver associado a uma conta, você receberá um link para resetar a senha em breve.');
|
||||
expect(translateServerResponse(keys[6], 'pt_br')).toBe('Erro Interno do Servidor!');
|
||||
expect(translateServerResponse(keys[7], 'pt_br')).toBe('Limite máximo de tentativas atingido. Por favor aguarde 30 minutos');
|
||||
expect(translateServerResponse(keys[8], 'pt_br')).toBe('Erro de rede ao tentar obter recursos.');
|
||||
expect(translateServerResponse(keys[9], 'pt_br')).toBe('Por favor, confirme seu e-mail antes de continuar');
|
||||
expect(translateServerResponse(keys[10], 'pt_br')).toBe('Por ravor, preencha todos os campos');
|
||||
expect(translateServerResponse(keys[11], 'pt_br')).toBe('Por favor, informe seu e-mail');
|
||||
expect(translateServerResponse(keys[12], 'pt_br')).toBe('Por favor, informe seu e-mail e senha!');
|
||||
expect(translateServerResponse(keys[13], 'pt_br')).toBe('Por favor, informe a nova senha');
|
||||
expect(translateServerResponse(keys[14], 'pt_br')).toBe('Por favor, digite pelo menos 3 letras');
|
||||
expect(translateServerResponse(keys[15], 'pt_br')).toBe('O tamanho máximo do texto é 2000');
|
||||
expect(translateServerResponse(keys[16], 'pt_br')).toBe('Erro desconhecido');
|
||||
expect(translateServerResponse(keys[17], 'pt_br')).toBe('Identificação incorreta ou faltando');
|
||||
expect(translateServerResponse(keys[18], 'pt_br')).toBe('Informação errada ou incompleta!');
|
||||
expect(translateServerResponse(keys[19], 'pt_br')).toBe('E-mail ou senha inválidos!');
|
||||
expect(translateServerResponse(keys[20], 'pt_br')).toBe('Nada para atualizar!');
|
||||
expect(translateServerResponse(keys[0], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 8 letras');
|
||||
expect(translateServerResponse(keys[1], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 8 letras, 1 caracter especial');
|
||||
expect(translateServerResponse(keys[2], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 8 letras, 1 número, 1 caracter especial');
|
||||
expect(translateServerResponse(keys[3], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 8 letras, 1 maiúscula, 1 número, 1 caracter especial');
|
||||
expect(translateServerResponse(keys[4], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 8 letras, 1 maiúscula, 1 caracter especial');
|
||||
expect(translateServerResponse(keys[5], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 1 maiúscula, 1 caracter especial');
|
||||
expect(translateServerResponse(keys[6], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 1 caracter especial');
|
||||
expect(translateServerResponse(keys[7], 'pt_br')).toBe('Senha fraca: Senha deve possuir pelo menos 1 número');
|
||||
expect(translateServerResponse(keys[8], 'pt_br')).toBe('E-mail já cadastrado!');
|
||||
expect(translateServerResponse(keys[9], 'pt_br')).toBe('Proibido! Acesso negado');
|
||||
expect(translateServerResponse(keys[10], 'pt_br')).toBe('Se o endereço de e-mail informado estiver associado a uma conta, você receberá um link para resetar a senha em breve.');
|
||||
expect(translateServerResponse(keys[11], 'pt_br')).toBe('Erro Interno do Servidor!');
|
||||
expect(translateServerResponse(keys[12], 'pt_br')).toBe('Limite máximo de tentativas atingido. Por favor aguarde 30 minutos');
|
||||
expect(translateServerResponse(keys[13], 'pt_br')).toBe('Erro de rede ao tentar obter recursos.');
|
||||
expect(translateServerResponse(keys[14], 'pt_br')).toBe('Por favor, confirme seu e-mail antes de continuar');
|
||||
expect(translateServerResponse(keys[15], 'pt_br')).toBe('Por favor, preencha todos os campos');
|
||||
expect(translateServerResponse(keys[16], 'pt_br')).toBe('Por favor, informe seu e-mail');
|
||||
expect(translateServerResponse(keys[17], 'pt_br')).toBe('Por favor, informe seu e-mail e senha!');
|
||||
expect(translateServerResponse(keys[18], 'pt_br')).toBe('Por favor, informe a nova senha');
|
||||
expect(translateServerResponse(keys[19], 'pt_br')).toBe('Por favor, digite pelo menos 3 letras');
|
||||
expect(translateServerResponse(keys[20], 'pt_br')).toBe('O tamanho máximo do texto é 2000');
|
||||
expect(translateServerResponse(keys[21], 'pt_br')).toBe('Erro desconhecido');
|
||||
expect(translateServerResponse(keys[22], 'pt_br')).toBe('Identificação incorreta ou faltando');
|
||||
expect(translateServerResponse(keys[23], 'pt_br')).toBe('Informação errada ou incompleta!');
|
||||
expect(translateServerResponse(keys[24], 'pt_br')).toBe('E-mail ou senha inválidos!');
|
||||
expect(translateServerResponse(keys[25], 'pt_br')).toBe('Nada para atualizar!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,26 +56,31 @@ describe('Russian Utils unit tests', () => {
|
||||
it('should translate all server responses to ru', () => {
|
||||
const keys: string[] = Object.keys(serverResponses);
|
||||
|
||||
expect(translateServerResponse(keys[0], 'ru')).toBe('Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 заглавную букву, 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[1], 'ru')).toBe('Неправильный пароль: Пароль должен содержать как минимум 1 заглавную букву и 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[2], 'ru')).toBe('Неправильный пароль: Пароль должен содержать хотя бы 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[3], 'ru')).toBe('Электронная почта уже используется!');
|
||||
expect(translateServerResponse(keys[4], 'ru')).toBe('Доступ запрещен!');
|
||||
expect(translateServerResponse(keys[5], 'ru')).toBe('Если введенный вами адрес электронной почты связан с учетной записью, вы вскоре получите ссылку для сброса пароля.');
|
||||
expect(translateServerResponse(keys[6], 'ru')).toBe('Внутренняя ошибка сервера!');
|
||||
expect(translateServerResponse(keys[7], 'ru')).toBe('Достигнут максимальный лимит попыток входа. Пожалуйста, подождите 30 минут');
|
||||
expect(translateServerResponse(keys[8], 'ru')).toBe('Ошибка сети при попытке получить ресурс.');
|
||||
expect(translateServerResponse(keys[9], 'ru')).toBe('Пожалуйста, подтвердите свой адрес электронной почты, прежде чем продолжить');
|
||||
expect(translateServerResponse(keys[10], 'ru')).toBe('Пожалуйста, заполните все поля');
|
||||
expect(translateServerResponse(keys[11], 'ru')).toBe('Пожалуйста, введите свой адрес электронной почты');
|
||||
expect(translateServerResponse(keys[12], 'ru')).toBe('Пожалуйста, введите свое имя пользователя и пароль!');
|
||||
expect(translateServerResponse(keys[13], 'ru')).toBe('Пожалуйста, введите новый пароль');
|
||||
expect(translateServerResponse(keys[14], 'ru')).toBe('Пожалуйста, введите не менее 3 символов');
|
||||
expect(translateServerResponse(keys[15], 'ru')).toBe('Максимальная длина текста — 2000 символов.');
|
||||
expect(translateServerResponse(keys[16], 'ru')).toBe('Неизвестная ошибка');
|
||||
expect(translateServerResponse(keys[17], 'ru')).toBe('Неправильная или отсутствующая идентификация');
|
||||
expect(translateServerResponse(keys[18], 'ru')).toBe('Неверная или отсутствующая информация!');
|
||||
expect(translateServerResponse(keys[19], 'ru')).toBe('Неправильный пользователь или пароль');
|
||||
expect(translateServerResponse(keys[20], 'ru')).toBe('Нечего обновлять!');
|
||||
expect(translateServerResponse(keys[0], 'ru')).toBe('Неправильный пароль: Пароль должен содержать не менее 8 символов.');
|
||||
expect(translateServerResponse(keys[1], 'ru')).toBe('Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[2], 'ru')).toBe('Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 цифру, 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[3], 'ru')).toBe('Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 заглавную букву, 1 цифру, 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[4], 'ru')).toBe('Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 заглавную букву, 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[5], 'ru')).toBe('Неправильный пароль: Пароль должен содержать как минимум 1 заглавную букву и 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[6], 'ru')).toBe('Неправильный пароль: Пароль должен содержать хотя бы 1 специальный символ.');
|
||||
expect(translateServerResponse(keys[7], 'ru')).toBe('Неправильный пароль: Пароль должен содержать не менее 1 цифры.');
|
||||
expect(translateServerResponse(keys[8], 'ru')).toBe('Электронная почта уже используется!');
|
||||
expect(translateServerResponse(keys[9], 'ru')).toBe('Доступ запрещен!');
|
||||
expect(translateServerResponse(keys[10], 'ru')).toBe('Если введенный вами адрес электронной почты связан с учетной записью, вы вскоре получите ссылку для сброса пароля.');
|
||||
expect(translateServerResponse(keys[11], 'ru')).toBe('Внутренняя ошибка сервера!');
|
||||
expect(translateServerResponse(keys[12], 'ru')).toBe('Достигнут максимальный лимит попыток входа. Пожалуйста, подождите 30 минут');
|
||||
expect(translateServerResponse(keys[13], 'ru')).toBe('Ошибка сети при попытке получить ресурс.');
|
||||
expect(translateServerResponse(keys[14], 'ru')).toBe('Пожалуйста, подтвердите свой адрес электронной почты, прежде чем продолжить');
|
||||
expect(translateServerResponse(keys[15], 'ru')).toBe('Пожалуйста, заполните все поля');
|
||||
expect(translateServerResponse(keys[16], 'ru')).toBe('Пожалуйста, введите свой адрес электронной почты');
|
||||
expect(translateServerResponse(keys[17], 'ru')).toBe('Пожалуйста, введите свое имя пользователя и пароль!');
|
||||
expect(translateServerResponse(keys[18], 'ru')).toBe('Пожалуйста, введите новый пароль');
|
||||
expect(translateServerResponse(keys[19], 'ru')).toBe('Пожалуйста, введите не менее 3 символов');
|
||||
expect(translateServerResponse(keys[20], 'ru')).toBe('Максимальная длина текста — 2000 символов.');
|
||||
expect(translateServerResponse(keys[21], 'ru')).toBe('Неизвестная ошибка');
|
||||
expect(translateServerResponse(keys[22], 'ru')).toBe('Неправильная или отсутствующая идентификация');
|
||||
expect(translateServerResponse(keys[23], 'ru')).toBe('Неверная или отсутствующая информация!');
|
||||
expect(translateServerResponse(keys[24], 'ru')).toBe('Неправильный пользователь или пароль');
|
||||
expect(translateServerResponse(keys[25], 'ru')).toBe('Нечего обновлять!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,26 +35,31 @@ describe('Spanish Utils unit tests', () => {
|
||||
it('should translate all server responses to es', () => {
|
||||
const keys: string[] = Object.keys(serverResponses);
|
||||
|
||||
expect(translateServerResponse(keys[0], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 mayúscula y 1 carácter especial');
|
||||
expect(translateServerResponse(keys[1], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 1 mayúscula y 1 carácter especial');
|
||||
expect(translateServerResponse(keys[2], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 1 carácter especial');
|
||||
expect(translateServerResponse(keys[3], 'es')).toBe('¡El correo ya está registrado!');
|
||||
expect(translateServerResponse(keys[4], 'es')).toBe('¡Prohibido! Acceso denegado');
|
||||
expect(translateServerResponse(keys[5], 'es')).toBe('Si la dirección de correo electrónico ingresada está asociada a una cuenta, recibirá un enlace para restablecer su contraseña en breve.');
|
||||
expect(translateServerResponse(keys[6], 'es')).toBe('¡Error interno del servidor!');
|
||||
expect(translateServerResponse(keys[7], 'es')).toBe('Has alcanzado el límite máximo de intentos de inicio de sesión. Por favor, espera 30 minutos');
|
||||
expect(translateServerResponse(keys[8], 'es')).toBe('Error de red al intentar obtener el recurso.');
|
||||
expect(translateServerResponse(keys[9], 'es')).toBe('Por favor, confirme su correo electrónico antes de continuar');
|
||||
expect(translateServerResponse(keys[10], 'es')).toBe('Por favor, completa todos los campos');
|
||||
expect(translateServerResponse(keys[11], 'es')).toBe('Por favor, ingresa tu correo electronico');
|
||||
expect(translateServerResponse(keys[12], 'es')).toBe('¡Por favor, ingresa tu correo electronico y contraseña!');
|
||||
expect(translateServerResponse(keys[13], 'es')).toBe('Por favor, rellene la nueva contraseña');
|
||||
expect(translateServerResponse(keys[14], 'es')).toBe('¡Por favor, escriba al menos 3 caracteres!');
|
||||
expect(translateServerResponse(keys[15], 'es')).toBe('La longitud máxima del texto es 2000');
|
||||
expect(translateServerResponse(keys[16], 'es')).toBe('Error desconocido');
|
||||
expect(translateServerResponse(keys[17], 'es')).toBe('Identificación incorrecta o faltante');
|
||||
expect(translateServerResponse(keys[18], 'es')).toBe('¡Información incorrecta o incompleta!');
|
||||
expect(translateServerResponse(keys[19], 'es')).toBe('¡Usuario o contraseña incorrectos!');
|
||||
expect(translateServerResponse(keys[20], 'es')).toBe('¡Nada que actualizar!');
|
||||
expect(translateServerResponse(keys[0], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 8 caracteres');
|
||||
expect(translateServerResponse(keys[1], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 carácter especial');
|
||||
expect(translateServerResponse(keys[2], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 número, 1 carácter especial');
|
||||
expect(translateServerResponse(keys[3], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 mayúscula, 1 número, 1 carácter especial');
|
||||
expect(translateServerResponse(keys[4], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 mayúscula y 1 carácter especial');
|
||||
expect(translateServerResponse(keys[5], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 1 mayúscula y 1 carácter especial');
|
||||
expect(translateServerResponse(keys[6], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 1 carácter especial');
|
||||
expect(translateServerResponse(keys[7], 'es')).toBe('Contraseña inválida: La contraseña debe tener al menos 1 número');
|
||||
expect(translateServerResponse(keys[8], 'es')).toBe('¡El correo ya está registrado!');
|
||||
expect(translateServerResponse(keys[9], 'es')).toBe('¡Prohibido! Acceso denegado');
|
||||
expect(translateServerResponse(keys[10], 'es')).toBe('Si la dirección de correo electrónico ingresada está asociada a una cuenta, recibirá un enlace para restablecer su contraseña en breve.');
|
||||
expect(translateServerResponse(keys[11], 'es')).toBe('¡Error interno del servidor!');
|
||||
expect(translateServerResponse(keys[12], 'es')).toBe('Has alcanzado el límite máximo de intentos de inicio de sesión. Por favor, espera 30 minutos');
|
||||
expect(translateServerResponse(keys[13], 'es')).toBe('Error de red al intentar obtener el recurso.');
|
||||
expect(translateServerResponse(keys[14], 'es')).toBe('Por favor, confirme su correo electrónico antes de continuar');
|
||||
expect(translateServerResponse(keys[15], 'es')).toBe('Por favor, completa todos los campos');
|
||||
expect(translateServerResponse(keys[16], 'es')).toBe('Por favor, ingresa tu correo electronico');
|
||||
expect(translateServerResponse(keys[17], 'es')).toBe('¡Por favor, ingresa tu correo electronico y contraseña!');
|
||||
expect(translateServerResponse(keys[18], 'es')).toBe('Por favor, rellene la nueva contraseña');
|
||||
expect(translateServerResponse(keys[19], 'es')).toBe('¡Por favor, escriba al menos 3 caracteres!');
|
||||
expect(translateServerResponse(keys[20], 'es')).toBe('La longitud máxima del texto es 2000');
|
||||
expect(translateServerResponse(keys[21], 'es')).toBe('Error desconocido');
|
||||
expect(translateServerResponse(keys[22], 'es')).toBe('Identificación incorrecta o faltante');
|
||||
expect(translateServerResponse(keys[23], 'es')).toBe('¡Información incorrecta o incompleta!');
|
||||
expect(translateServerResponse(keys[24], 'es')).toBe('¡Usuario o contraseña incorrectos!');
|
||||
expect(translateServerResponse(keys[25], 'es')).toBe('¡Nada que actualizar!');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
|
||||
describe('UrlUtils', () => {
|
||||
it('should allow http:// URLs', () => {
|
||||
expect(isSafeUrl('http://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow https:// URLs', () => {
|
||||
expect(isSafeUrl('https://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow # URLs', () => {
|
||||
expect(isSafeUrl('#section')).toBe(true);
|
||||
});
|
||||
|
||||
it('should disallow javascript: URLs', () => {
|
||||
expect(isSafeUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should disallow data: URLs', () => {
|
||||
expect(isSafeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
});
|
||||
|
||||
it('should disallow empty or null URLs', () => {
|
||||
expect(isSafeUrl('')).toBe(false);
|
||||
expect(isSafeUrl(null)).toBe(false);
|
||||
expect(isSafeUrl(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive for protocol', () => {
|
||||
expect(isSafeUrl('HTTP://example.com')).toBe(true);
|
||||
expect(isSafeUrl('HTTPS://example.com')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,9 @@ describe('NoteAdd Component', () => {
|
||||
description: 'Note content',
|
||||
url: '',
|
||||
tag: '',
|
||||
lastUpdate: ''
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
}
|
||||
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,13 @@ async function handleResponse(response: Response) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message);
|
||||
if ('message' in data && typeof data.message === 'string') {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
if ('fields' in data && Array.isArray(data.fields)) {
|
||||
const firstError = data.fields[0].fieldMessage as string;
|
||||
throw new Error(firstError);
|
||||
}
|
||||
}
|
||||
handleError(response.status);
|
||||
}
|
||||
@@ -81,6 +87,11 @@ const api = {
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
getJSONNoAuth: async (url: string) => {
|
||||
const response = await fetch(url, getRequestInit('GET', {}, false));
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
postJSON: async (url: string, payload: object) => {
|
||||
const response = await fetch(url, getRequestInit('POST', payload, isAddAuth(url)));
|
||||
return handleResponse(response);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { env } from '../env';
|
||||
|
||||
const server = env.VITE_BACKEND_SERVER;
|
||||
const server = env.VITE_BACKEND_SERVER || '/api';
|
||||
|
||||
const ApiConfig = {
|
||||
|
||||
@@ -26,6 +26,8 @@ const ApiConfig = {
|
||||
|
||||
notesUrl: `${server}/rest/notes`,
|
||||
|
||||
publicNotesUrl: `${server}/public/notes`,
|
||||
|
||||
userUrl: `${server}/rest/users`
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
|
||||
interface Props {
|
||||
readonly title: string;
|
||||
@@ -18,8 +19,8 @@ function NoteTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
<span className="task-title-icon">
|
||||
<span className="poppins-semibold">
|
||||
{props.title}
|
||||
{props.noteUrl && props.noteUrl.length > 0 && (
|
||||
<a href={props.noteUrl} target="_blank" rel="noreferrer" className="task-note-external-link">
|
||||
{isSafeUrl(props.noteUrl) && (
|
||||
<a href={props.noteUrl!} target="_blank" rel="noreferrer" className="task-note-external-link">
|
||||
<img src={ExternalLinkIcon} width={20} alt="external link" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
import './style.css';
|
||||
|
||||
interface Props {
|
||||
@@ -24,7 +25,7 @@ function TaskTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
|
||||
data-testid={`task-title-text-${props.title}`}
|
||||
>
|
||||
{props.title}
|
||||
{props.taskUrl && props.taskUrl.length > 0 && (
|
||||
{props.taskUrl && props.taskUrl.length > 0 && isSafeUrl(props.taskUrl[0]) && (
|
||||
<a href={props.taskUrl[0]} target="_blank" rel="noreferrer" className="task-note-external-link">
|
||||
<img src={ExternalLinkIcon} width={20} alt="external link" />
|
||||
</a>
|
||||
|
||||
@@ -114,6 +114,9 @@ const enTranslations = {
|
||||
note_form_submit: 'Save note',
|
||||
note_table_btn_edit: 'Edit',
|
||||
note_table_btn_delete: 'Delete',
|
||||
note_action_share: 'Share',
|
||||
note_action_unshare: 'Unshare',
|
||||
note_action_copy_link: 'Copy link',
|
||||
|
||||
about_page_title_one: 'About the',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -64,6 +64,11 @@ export const timeAgoTranslations: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const serverResponsesTranslations: Record<string, string> = {
|
||||
BAD_PASSWORD_8_pt_br: 'Senha fraca: Senha deve possuir pelo menos 8 letras',
|
||||
BAD_PASSWORD_7_pt_br: 'Senha fraca: Senha deve possuir pelo menos 8 letras, 1 caracter especial',
|
||||
BAD_PASSWORD_6_pt_br: 'Senha fraca: Senha deve possuir pelo menos 8 letras, 1 número, 1 caracter especial',
|
||||
BAD_PASSWORD_5_pt_br: 'Senha fraca: Senha deve possuir pelo menos 8 letras, 1 maiúscula, 1 número, 1 caracter especial',
|
||||
BAD_PASSWORD_4_pt_br: 'Senha fraca: Senha deve possuir pelo menos 1 número',
|
||||
BAD_PASSWORD_3_pt_br: 'Senha fraca: Senha deve possuir pelo menos 8 letras, 1 maiúscula, 1 caracter especial',
|
||||
BAD_PASSWORD_2_pt_br: 'Senha fraca: Senha deve possuir pelo menos 1 maiúscula, 1 caracter especial',
|
||||
BAD_PASSWORD_1_pt_br: 'Senha fraca: Senha deve possuir pelo menos 1 caracter especial',
|
||||
@@ -74,7 +79,7 @@ export const serverResponsesTranslations: Record<string, string> = {
|
||||
INVALID_CREDENTIALS_pt_br: 'E-mail ou senha inválidos!',
|
||||
MAX_LOGIN_ATTEMPT_pt_br: 'Limite máximo de tentativas atingido. Por favor aguarde 30 minutos',
|
||||
NETWORK_ERROR_pt_br: 'Erro de rede ao tentar obter recursos.',
|
||||
FILL_ALL_FIELDS_pt_br: 'Por ravor, preencha todos os campos',
|
||||
FILL_ALL_FIELDS_pt_br: 'Por favor, preencha todos os campos',
|
||||
FILL_NEW_PASSWORD_pt_br: 'Por favor, informe a nova senha',
|
||||
FILL_USER_pt_br: 'Por favor, informe seu e-mail',
|
||||
FILL_USER_AND_PASS_pt_br: 'Por favor, informe seu e-mail e senha!',
|
||||
@@ -86,6 +91,11 @@ export const serverResponsesTranslations: Record<string, string> = {
|
||||
WRONG_OR_MISSING_INFO_pt_br: 'Informação errada ou incompleta!',
|
||||
NOTHING_TO_UPDATE_pt_br: 'Nada para atualizar!',
|
||||
|
||||
BAD_PASSWORD_8_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres',
|
||||
BAD_PASSWORD_7_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 carácter especial',
|
||||
BAD_PASSWORD_6_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 número, 1 carácter especial',
|
||||
BAD_PASSWORD_5_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 mayúscula, 1 número, 1 carácter especial',
|
||||
BAD_PASSWORD_4_es: 'Contraseña inválida: La contraseña debe tener al menos 1 número',
|
||||
BAD_PASSWORD_3_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 mayúscula y 1 carácter especial',
|
||||
BAD_PASSWORD_2_es: 'Contraseña inválida: La contraseña debe tener al menos 1 mayúscula y 1 carácter especial',
|
||||
BAD_PASSWORD_1_es: 'Contraseña inválida: La contraseña debe tener al menos 1 carácter especial',
|
||||
@@ -108,6 +118,11 @@ export const serverResponsesTranslations: Record<string, string> = {
|
||||
WRONG_OR_MISSING_INFO_es: '¡Información incorrecta o incompleta!',
|
||||
NOTHING_TO_UPDATE_es: '¡Nada que actualizar!',
|
||||
|
||||
BAD_PASSWORD_8_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов.',
|
||||
BAD_PASSWORD_7_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 специальный символ.',
|
||||
BAD_PASSWORD_6_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 цифру, 1 специальный символ.',
|
||||
BAD_PASSWORD_5_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 заглавную букву, 1 цифру, 1 специальный символ.',
|
||||
BAD_PASSWORD_4_ru: 'Неправильный пароль: Пароль должен содержать не менее 1 цифры.',
|
||||
BAD_PASSWORD_3_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 заглавную букву, 1 специальный символ.',
|
||||
BAD_PASSWORD_2_ru: 'Неправильный пароль: Пароль должен содержать как минимум 1 заглавную букву и 1 специальный символ.',
|
||||
BAD_PASSWORD_1_ru: 'Неправильный пароль: Пароль должен содержать хотя бы 1 специальный символ.',
|
||||
|
||||
@@ -114,6 +114,9 @@ const ptBrTranslations = {
|
||||
note_form_submit: 'Salvar nota',
|
||||
note_table_btn_edit: 'Alterar',
|
||||
note_table_btn_delete: 'Excluir',
|
||||
note_action_share: 'Compartilhar',
|
||||
note_action_unshare: 'Parar de compartilhar',
|
||||
note_action_copy_link: 'Copiar link',
|
||||
|
||||
about_page_title_one: 'Sobre o',
|
||||
about_page_title_two: 'App TaskNote',
|
||||
|
||||
@@ -114,6 +114,9 @@ const ruTranslations = {
|
||||
note_form_submit: 'Сохранить заметку',
|
||||
note_table_btn_edit: 'Редактировать',
|
||||
note_table_btn_delete: 'Удалить',
|
||||
note_action_share: 'Поделиться',
|
||||
note_action_unshare: 'Закрыть доступ',
|
||||
note_action_copy_link: 'Копировать ссылку',
|
||||
|
||||
about_page_title_one: 'около',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export const serverResponses: Record<string, string> = {
|
||||
'Bad password: Password must have at least at least 8 characters': 'BAD_PASSWORD_8',
|
||||
'Bad password: Password must have at least at least 8 characters, 1 special character': 'BAD_PASSWORD_7',
|
||||
'Bad password: Password must have at least at least 8 characters, 1 number, 1 special character': 'BAD_PASSWORD_6',
|
||||
'Bad password: Password must have at least at least 8 characters, 1 uppercase, 1 number, 1 special character': 'BAD_PASSWORD_5',
|
||||
'Bad password: Password must have at least at least 8 characters, 1 uppercase, 1 special character': 'BAD_PASSWORD_3',
|
||||
'Bad password: Password must have at least 1 uppercase, 1 special character': 'BAD_PASSWORD_2',
|
||||
'Bad password: Password must have at least 1 special character': 'BAD_PASSWORD_1',
|
||||
'Bad password: Password must have at least 1 number': 'BAD_PASSWORD_4',
|
||||
'Email already exists!': 'EMAIL_EXISTS',
|
||||
'Forbidden! Access denied!': 'FORBIDDEN',
|
||||
'If the email address you entered is associated with an account, you will receive a password reset link shortly.': 'RECOVER_PASSWORD',
|
||||
|
||||
@@ -114,6 +114,9 @@ const esTranslations = {
|
||||
note_form_submit: 'Guardar nota',
|
||||
note_table_btn_edit: 'Editar',
|
||||
note_table_btn_delete: 'Eliminar',
|
||||
note_action_share: 'Compartir',
|
||||
note_action_unshare: 'Dejar de compartir',
|
||||
note_action_copy_link: 'Copiar enlace',
|
||||
|
||||
about_page_title_one: 'Acerca de',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -5,6 +5,8 @@ type NoteResponse = {
|
||||
url: string | null;
|
||||
tag: string;
|
||||
lastUpdate: string;
|
||||
shared: boolean;
|
||||
shareToken: string | null;
|
||||
};
|
||||
|
||||
export type { NoteResponse };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Validates if a URL is safe to be used in an <a> tag.
|
||||
* Only allows http, https, and # (for internal links/placeholders).
|
||||
*
|
||||
* @param {string | null | undefined} url The URL to validate.
|
||||
* @returns {boolean} True if the URL is safe, false otherwise.
|
||||
*/
|
||||
export function isSafeUrl(url: string | null | undefined): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
const safeProtocolRegex = /^(https?:\/\/|#)/i;
|
||||
return safeProtocolRegex.test(url);
|
||||
}
|
||||
@@ -104,6 +104,34 @@ function Home(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Share or unshare a note.
|
||||
*
|
||||
* @param {NoteResponse} note The note to share or unshare.
|
||||
*/
|
||||
const toggleShareNote = async (note: NoteResponse): Promise<void> => {
|
||||
try {
|
||||
const action = note.shared ? 'unshare' : 'share';
|
||||
await api.putJSON(`${ApiConfig.notesUrl}/${note.id}/${action}`, {});
|
||||
loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy share link to clipboard.
|
||||
*
|
||||
* @param {NoteResponse} note The shared note.
|
||||
*/
|
||||
const copyShareLink = (note: NoteResponse): void => {
|
||||
const link = `${window.location.origin}/public/notes/${note.shareToken}`;
|
||||
navigator.clipboard.writeText(link).catch(() => {
|
||||
setErrorMessage('Failed to copy link to clipboard.');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply filters to a given set of tasks and notes, updating displayed state.
|
||||
*
|
||||
@@ -528,6 +556,22 @@ function Home(): React.ReactNode {
|
||||
{t('task_table_action_clone')}
|
||||
</Dropdown.Item>
|
||||
</NavLink>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => toggleShareNote(note)}
|
||||
data-testid={`note-dropdown-share-item-${note.id}`}
|
||||
>
|
||||
{note.shared ? t('note_action_unshare') : t('note_action_share')}
|
||||
</Dropdown.Item>
|
||||
{note.shared && note.shareToken && (
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => copyShareLink(note)}
|
||||
data-testid={`note-dropdown-copy-link-${note.id}`}
|
||||
>
|
||||
{t('note_action_copy_link')}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => deleteNote(note.id)}
|
||||
|
||||
@@ -140,7 +140,9 @@ function NoteAdd(): React.ReactNode {
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tag: noteTag,
|
||||
lastUpdate: ''
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
const added: boolean = await addNote(payload);
|
||||
@@ -157,7 +159,9 @@ function NoteAdd(): React.ReactNode {
|
||||
description: noteContent,
|
||||
url: noteUrl,
|
||||
tag: noteTag,
|
||||
lastUpdate: ''
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
};
|
||||
|
||||
const edited: boolean = await submitEditNote(payload);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Col, Container, Row } from 'react-bootstrap';
|
||||
import { useParams } from 'react-router';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { NoteResponse } from '../../types/NoteResponse';
|
||||
import api from '../../api-service/api';
|
||||
import ApiConfig from '../../api-service/apiConfig';
|
||||
import { isSafeUrl } from '../../utils/UrlUtils';
|
||||
|
||||
/**
|
||||
* SharedNote component for displaying a publicly shared note.
|
||||
* Accessible without authentication.
|
||||
*
|
||||
* @returns {React.ReactNode} The rendered SharedNote component.
|
||||
*/
|
||||
function SharedNote(): React.ReactNode {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [note, setNote] = useState<NoteResponse | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setErrorMessage('Invalid share link.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.getJSONNoAuth(`${ApiConfig.publicNotesUrl}/${token}`)
|
||||
.then((data: NoteResponse) => {
|
||||
setNote(data);
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMessage('Note not found or no longer shared.');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container fluid className="mt-5 text-center">
|
||||
<p>Loading...</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage || !note) {
|
||||
return (
|
||||
<Container fluid className="mt-5">
|
||||
<Row className="justify-content-center">
|
||||
<Col xs={12} md={8}>
|
||||
<Card>
|
||||
<Card.Body>
|
||||
<Card.Title>Note not found</Card.Title>
|
||||
<p className="text-muted">{errorMessage || 'This note is not available.'}</p>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid className="mt-3">
|
||||
<Row className="justify-content-center">
|
||||
<Col xs={12} md={10} lg={8}>
|
||||
<Card>
|
||||
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||
<small className="text-muted">TaskNote · Shared Note (Read only)</small>
|
||||
{note.tag && (
|
||||
<small className="text-muted">
|
||||
#
|
||||
{note.tag}
|
||||
</small>
|
||||
)}
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Title>{note.title}</Card.Title>
|
||||
{isSafeUrl(note.url) && (
|
||||
<p>
|
||||
<a href={note.url!} target="_blank" rel="noopener noreferrer">
|
||||
{note.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{note.description}</Markdown>
|
||||
</Card.Body>
|
||||
{note.lastUpdate && (
|
||||
<Card.Footer className="text-muted">
|
||||
<small>
|
||||
Last updated:
|
||||
{' '}
|
||||
{note.lastUpdate}
|
||||
</small>
|
||||
</Card.Footer>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SharedNote;
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
ports:
|
||||
- "8585:8585"
|
||||
- "5005:5005"
|
||||
image: maven:3.9.9-eclipse-temurin-25
|
||||
image: maven:3.9.12-eclipse-temurin-25
|
||||
entrypoint: './mvnw -ntp spring-boot:run -Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" -Dmaven.plugin.validation=VERBOSE'
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -11,8 +11,6 @@ services:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
ports: ["5000:5000"]
|
||||
environment:
|
||||
VITE_BACKEND_SERVER: http://localhost:8585
|
||||
networks:
|
||||
- tasknote-network
|
||||
|
||||
@@ -27,7 +25,7 @@ services:
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
POSTGRES_PORT: 5432
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000
|
||||
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000, https://flattop-depth-dropper.ngrok-free.dev
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
TARGET_ENV: development
|
||||
SECURITY_KEY: this-is-a-very-long-security-key-for-dev
|
||||
@@ -62,4 +60,4 @@ services:
|
||||
|
||||
networks:
|
||||
tasknote-network:
|
||||
driver: bridge
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Email Changed</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f6f8;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
background-color: #f4f6f8;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 24px;
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.change-box {
|
||||
margin: 16px 0;
|
||||
padding: 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #4b5563;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #111827;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 18px 24px 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.wrapper {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">Your email was changed</div>
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>We are confirming that the email address on your account was updated.</p>
|
||||
|
||||
<div class="change-box">
|
||||
<div><span class="label">Old email:</span> <span class="value">{{ EMAIL_FROM }}</span></div>
|
||||
<div><span class="label">New email:</span> <span class="value">{{ EMAIL_TO }}</span></div>
|
||||
</div>
|
||||
|
||||
<p>If you made this change, no further action is needed.</p>
|
||||
<p>If you did not make this change, please secure your account immediately and contact support at <a href="mailto:ricardompcampos@gmail.com">ricardompcampos@gmail.com</a>.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
This is an automatic notification, please do not reply.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Password Changed</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f6f8;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
background-color: #f4f6f8;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 24px;
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 16px 0;
|
||||
padding: 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 18px 24px 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.wrapper {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">Your password was changed</div>
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>This is a confirmation that your account password was successfully changed.</p>
|
||||
|
||||
<div class="notice">
|
||||
If you made this change, no further action is needed. If you did not make this change,
|
||||
secure your account immediately and contact support at <a href="mailto:ricardompcampos@gmail.com">ricardompcampos@gmail.com</a>.
|
||||
</div>
|
||||
|
||||
<p>Thank you.</p>
|
||||
</div>
|
||||
<div class="footer">This is an automatic message, please do not reply.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reset Your Password</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f6f8;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
background-color: #f4f6f8;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 24px;
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button-wrap {
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 20px;
|
||||
background: #b91c1c;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.fallback-link {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 18px 24px 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.wrapper {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">Reset your password</div>
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>We received a request to reset your password. Click the button below to continue.</p>
|
||||
|
||||
<div class="button-wrap">
|
||||
<a class="button" href="{{ RESET_LINK }}" target="_blank" rel="noopener noreferrer">Reset Password</a>
|
||||
<div class="fallback-link">If the button does not work, open this link: {{ RESET_LINK }}</div>
|
||||
</div>
|
||||
|
||||
<p>If you did not request this, you can ignore this email.</p>
|
||||
</div>
|
||||
<div class="footer">This is an automatic message, please do not reply.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Confirm Your Account</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f6f8;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
background-color: #f4f6f8;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px 24px;
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button-wrap {
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 20px;
|
||||
background: #0f766e;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.fallback-link {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 18px 24px 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.wrapper {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="card">
|
||||
<div class="header">Confirm your account</div>
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>Thank you for signing up. Please confirm your account by clicking the button below.</p>
|
||||
|
||||
<div class="button-wrap">
|
||||
<a class="button" href="{{ CONFIRMATION_LINK }}" target="_blank" rel="noopener noreferrer">
|
||||
Confirm Account
|
||||
</a>
|
||||
<div class="fallback-link">If the button does not work, open this link: {{ CONFIRMATION_LINK }}</div>
|
||||
</div>
|
||||
|
||||
<p>If you did not create this account, you can ignore this email.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
This is an automatic message, please do not reply.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
events {}
|
||||
http {
|
||||
server {
|
||||
listen 8181;
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://tasknote-api:8585/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://tasknote-web:5000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
ngrok http 8181 --log=stdout > ngrok-8181.log 2>&1 &
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
docker run -d \
|
||||
--name ngrok-tasknote-proxy \
|
||||
-p 127.0.0.1:8181:8181 \
|
||||
-v ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro \
|
||||
--restart unless-stopped \
|
||||
--network tasknote-network \
|
||||
nginx:stable
|
||||
+2
-18
@@ -1,19 +1,3 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
wrapperVersion=3.3.2
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip
|
||||
|
||||
Vendored
+44
-8
@@ -8,7 +8,7 @@
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
@@ -19,7 +19,7 @@
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.2
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
@@ -105,14 +105,17 @@ trim() {
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
@@ -130,7 +133,7 @@ maven-mvnd-*bin.*)
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
@@ -227,7 +230,7 @@ if [ -n "${distributionSha256Sum-}" ]; then
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
@@ -252,8 +255,41 @@ if command -v unzip >/dev/null; then
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
|
||||
Vendored
+189
-149
@@ -1,149 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM https://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.2
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
|
||||
}
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
|
||||
+19
-12
@@ -5,13 +5,13 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.0.3</version>
|
||||
<version>4.0.5</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<groupId>br.com.tasknoteapp</groupId>
|
||||
<artifactId>server</artifactId>
|
||||
<version>9</version>
|
||||
<version>19</version>
|
||||
<name>tasknote-api</name>
|
||||
<description>Java backend REST API to serve TaskNote frontend client</description>
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
<jacoco.output.data>${project.build.directory}/coverage-reports</jacoco.output.data>
|
||||
<timestamp>${maven.build.timestamp}</timestamp>
|
||||
<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>
|
||||
<failsafe.version>3.5.5</failsafe.version>
|
||||
<surefire.version>3.5.5</surefire.version>
|
||||
<jacoco.version>0.8.14</jacoco.version>
|
||||
<checkstyle.version>3.6.0</checkstyle.version>
|
||||
<springboot.version>4.0.5</springboot.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<!-- Profiles -->
|
||||
@@ -143,17 +149,17 @@
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-gson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
@@ -173,7 +179,7 @@
|
||||
<configuration>
|
||||
<enableLazyInitialization>true</enableLazyInitialization>
|
||||
<enableDirtyTracking>true</enableDirtyTracking>
|
||||
<enableAssociationManagement>true</enableAssociationManagement>
|
||||
<enableAssociationManagement>false</enableAssociationManagement>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
@@ -181,7 +187,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<version>${failsafe.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>integration-tests</id>
|
||||
@@ -203,7 +209,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<version>${surefire.version}</version>
|
||||
<configuration>
|
||||
<argLine>@{argLine} -Xmx1024m</argLine>
|
||||
<skipTests>${skip.unit.tests}</skipTests>
|
||||
@@ -215,7 +221,7 @@
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.14</version>
|
||||
<version>${jacoco.version}</version>
|
||||
<configuration>
|
||||
<skip>${jacoco.skip}</skip>
|
||||
<excludes>
|
||||
@@ -324,12 +330,12 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<version>${checkstyle.version}</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>12.1.1</version>
|
||||
<version>13.3.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
@@ -358,7 +364,8 @@
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${springboot.version}</version>
|
||||
<configuration>
|
||||
<image>
|
||||
<name>ghcr.io/rmcampos/tasknote/api:latest</name>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Server - Back-end
|
||||
|
||||
if [ -z "$CHECK" ]; then
|
||||
./mvnw -ntp \
|
||||
spring-boot:run \
|
||||
-Dspring-boot.run.jvmArguments="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" \
|
||||
-Dmaven.plugin.validation=VERBOSE
|
||||
else
|
||||
echo "Running checks..."
|
||||
echo "1/3 - Check Style started..."
|
||||
./mvnw --no-transfer-progress checkstyle:check -Dcheckstyle.skip=false
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running Check Style. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "2/3 - Build started..."
|
||||
./mvnw --no-transfer-progress clean compile -DskipTests
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running build. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "3/3 - Tests started..."
|
||||
./mvnw --no-transfer-progress clean verify -P tests --file pom.xml
|
||||
if [ $? -eq 1 ]; then
|
||||
echo "Issues when running test. Please review.."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "You're good to go! Good job!"
|
||||
exit 0
|
||||
fi
|
||||
@@ -14,6 +14,7 @@ import org.springframework.security.config.annotation.authentication.configurati
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
@@ -51,12 +52,16 @@ public class SecurityConfig {
|
||||
.permitAll()
|
||||
.requestMatchers("/auth/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/public/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/rest/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.permitAll())
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(
|
||||
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(
|
||||
exceptionHandling ->
|
||||
exceptionHandling.authenticationEntryPoint(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package br.com.tasknoteapp.server.controller;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.request.NotePatchRequest;
|
||||
import br.com.tasknoteapp.server.request.NoteRequest;
|
||||
@@ -15,6 +14,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -76,8 +76,8 @@ public class NoteController {
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<NoteResponse> postNotes(@RequestBody @Valid NoteRequest noteRequest) {
|
||||
NoteEntity createdNote = noteService.createNote(noteRequest);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(NoteResponse.fromEntity(createdNote));
|
||||
NoteResponse createdNote = noteService.createNote(noteRequest);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(createdNote);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,4 +91,28 @@ public class NoteController {
|
||||
noteService.deleteNote(id);
|
||||
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a note publicly.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse containing the share token.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/share")
|
||||
public ResponseEntity<NoteResponse> shareNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.shareNote(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshare a note, revoking public access.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse with the updated note.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/unshare")
|
||||
public ResponseEntity<NoteResponse> unshareNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.unshareNote(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package br.com.tasknoteapp.server.controller;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.response.NoteResponse;
|
||||
import br.com.tasknoteapp.server.service.NoteService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** This class provides public (unauthenticated) resources for shared notes. */
|
||||
@RestController
|
||||
@RequestMapping("/public/notes")
|
||||
public class PublicNoteController {
|
||||
|
||||
private final NoteService noteService;
|
||||
|
||||
public PublicNoteController(NoteService noteService) {
|
||||
this.noteService = noteService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a publicly shared note by its share token.
|
||||
*
|
||||
* @param token The unique share token for the note.
|
||||
* @return NoteResponse containing the shared note data.
|
||||
* @throws NoteNotFoundException when note is not found or not shared.
|
||||
*/
|
||||
@GetMapping("/{token}")
|
||||
public ResponseEntity<NoteResponse> getSharedNote(@PathVariable String token) {
|
||||
return ResponseEntity.ok(noteService.getSharedNote(token));
|
||||
}
|
||||
}
|
||||
+54
@@ -1,6 +1,13 @@
|
||||
package br.com.tasknoteapp.server.controller;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.BaseBadRequestException;
|
||||
import br.com.tasknoteapp.server.exception.BaseNotFoundException;
|
||||
import br.com.tasknoteapp.server.exception.BaseServiceUnavailableException;
|
||||
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
|
||||
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
|
||||
import br.com.tasknoteapp.server.exception.UserForbiddenException;
|
||||
import br.com.tasknoteapp.server.response.ValidationExceptionResponse;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
@@ -15,4 +22,51 @@ public class RestExceptionController {
|
||||
MethodArgumentNotValidException ex) {
|
||||
return ResponseEntity.badRequest().body(new ValidationExceptionResponse(ex.getFieldErrors()));
|
||||
}
|
||||
|
||||
/* 400 - Bad Request */
|
||||
@ExceptionHandler(BaseBadRequestException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleBadRequestException(
|
||||
BaseBadRequestException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ValidationExceptionResponse(ex.getField(), ex.getReason()));
|
||||
}
|
||||
|
||||
/* 401 - Unauthorized */
|
||||
@ExceptionHandler(InvalidCredentialsException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleInvalidCredentialsException(
|
||||
InvalidCredentialsException ex) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(new ValidationExceptionResponse("access", ex.getReason()));
|
||||
}
|
||||
|
||||
/* 403 - Forbidden */
|
||||
@ExceptionHandler(UserForbiddenException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleUserForbiddenException(
|
||||
UserForbiddenException ex) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new ValidationExceptionResponse("access", ex.getReason()));
|
||||
}
|
||||
|
||||
/* 404 - Not Found */
|
||||
@ExceptionHandler(BaseNotFoundException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleNotFoundException(BaseNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ValidationExceptionResponse(ex.getField(), ex.getReason()));
|
||||
}
|
||||
|
||||
/* 409 - Conflict */
|
||||
@ExceptionHandler(EmailAlreadyExistsException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleEmailAlreadyExistsException(
|
||||
EmailAlreadyExistsException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(new ValidationExceptionResponse("email", ex.getReason()));
|
||||
}
|
||||
|
||||
/* 503 - Service Unavailable */
|
||||
@ExceptionHandler(BaseServiceUnavailableException.class)
|
||||
ResponseEntity<ValidationExceptionResponse> handleServiceUnavailableException(
|
||||
BaseServiceUnavailableException ex) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ValidationExceptionResponse(ex.getField(), ex.getReason()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -30,15 +29,18 @@ public class NoteEntity {
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
private UserEntity user;
|
||||
|
||||
@OneToOne(mappedBy = "note", fetch = FetchType.LAZY)
|
||||
private NoteUrlEntity noteUrl;
|
||||
|
||||
@Column(name = "tag", nullable = true, length = 30)
|
||||
private String tag;
|
||||
|
||||
@Column(name = "last_update")
|
||||
private LocalDateTime lastUpdate;
|
||||
|
||||
@Column(name = "shared", nullable = false)
|
||||
private boolean shared = false;
|
||||
|
||||
@Column(name = "share_token", length = 36)
|
||||
private String shareToken;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -71,14 +73,6 @@ public class NoteEntity {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public NoteUrlEntity getNoteUrl() {
|
||||
return noteUrl;
|
||||
}
|
||||
|
||||
public void setNoteUrl(NoteUrlEntity noteUrl) {
|
||||
this.noteUrl = noteUrl;
|
||||
}
|
||||
|
||||
public String getTag() {
|
||||
return tag;
|
||||
}
|
||||
@@ -95,6 +89,22 @@ public class NoteEntity {
|
||||
this.lastUpdate = lastUpdate;
|
||||
}
|
||||
|
||||
public boolean isShared() {
|
||||
return shared;
|
||||
}
|
||||
|
||||
public void setShared(boolean shared) {
|
||||
this.shared = shared;
|
||||
}
|
||||
|
||||
public String getShareToken() {
|
||||
return shareToken;
|
||||
}
|
||||
|
||||
public void setShareToken(String shareToken) {
|
||||
this.shareToken = shareToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
@@ -41,9 +40,6 @@ public class UserEntity implements UserDetails {
|
||||
@Column(name = "name", length = 20)
|
||||
private String name;
|
||||
|
||||
@OneToMany(mappedBy = "user")
|
||||
private List<TaskEntity> tasks;
|
||||
|
||||
@Column(name = "email_confirmed_at", nullable = true)
|
||||
private LocalDateTime emailConfirmedAt;
|
||||
|
||||
@@ -59,6 +55,9 @@ public class UserEntity implements UserDetails {
|
||||
@Column(name = "lang", nullable = true, length = 6)
|
||||
private String lang;
|
||||
|
||||
@Column(name = "last_password_change", nullable = false)
|
||||
private LocalDateTime lastPasswordChange;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of();
|
||||
@@ -147,14 +146,6 @@ public class UserEntity implements UserDetails {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public List<TaskEntity> getTasks() {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public void setTasks(List<TaskEntity> tasks) {
|
||||
this.tasks = tasks;
|
||||
}
|
||||
|
||||
public LocalDateTime getEmailConfirmedAt() {
|
||||
return emailConfirmedAt;
|
||||
}
|
||||
@@ -194,4 +185,12 @@ public class UserEntity implements UserDetails {
|
||||
public void setLang(String lang) {
|
||||
this.lang = lang;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastPasswordChange() {
|
||||
return lastPasswordChange;
|
||||
}
|
||||
|
||||
public void setLastPasswordChange(LocalDateTime lastPasswordChange) {
|
||||
this.lastPasswordChange = lastPasswordChange;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This exception represents an error when hashing. */
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public class BadAlgorithmException extends ResponseStatusException {
|
||||
public class BadAlgorithmException extends BaseServiceUnavailableException {
|
||||
|
||||
public BadAlgorithmException(String error) {
|
||||
super(HttpStatus.SERVICE_UNAVAILABLE, error);
|
||||
super("algorithm", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a Bad Language exception. */
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadLanguageException extends ResponseStatusException {
|
||||
public class BadLanguageException extends BaseBadRequestException {
|
||||
|
||||
public BadLanguageException() {
|
||||
super(HttpStatus.BAD_REQUEST, "Invalid language");
|
||||
super("lang", "Invalid language");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a Bad Password exception. */
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadPasswordException extends ResponseStatusException {
|
||||
public class BadPasswordException extends BaseBadRequestException {
|
||||
|
||||
public BadPasswordException(String message) {
|
||||
super(HttpStatus.BAD_REQUEST, String.format("Bad password: %s", message));
|
||||
super("password", String.format("Bad password: %s", message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a bad request when trying to convert to UUID. */
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadUuidException extends ResponseStatusException {
|
||||
public class BadUuidException extends BaseBadRequestException {
|
||||
|
||||
public BadUuidException() {
|
||||
super(HttpStatus.BAD_REQUEST, "Bad user identification");
|
||||
super("uuid", "Bad user identification");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a base exception for bad requests. */
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BaseBadRequestException extends ResponseStatusException {
|
||||
private final String field;
|
||||
|
||||
public BaseBadRequestException(String field, String message) {
|
||||
super(HttpStatus.BAD_REQUEST, message);
|
||||
this.field = field;
|
||||
}
|
||||
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This exception represents a resource not found error. */
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class BaseNotFoundException extends ResponseStatusException {
|
||||
private final String field;
|
||||
|
||||
public BaseNotFoundException(String field, String message) {
|
||||
super(HttpStatus.NOT_FOUND, message);
|
||||
this.field = field;
|
||||
}
|
||||
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This exception represents a service unavailable error. */
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public class BaseServiceUnavailableException extends ResponseStatusException {
|
||||
|
||||
private final String field;
|
||||
|
||||
public BaseServiceUnavailableException(String field, String reason) {
|
||||
super(HttpStatus.SERVICE_UNAVAILABLE, reason);
|
||||
this.field = field;
|
||||
}
|
||||
|
||||
public String getField() {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This exception represents an error when sending email messages. */
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public class MailServiceException extends ResponseStatusException {
|
||||
public class MailServiceException extends BaseServiceUnavailableException {
|
||||
|
||||
public MailServiceException(String error) {
|
||||
super(HttpStatus.SERVICE_UNAVAILABLE, error);
|
||||
super("mail", error);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-7
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a Max login limit exception. */
|
||||
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
|
||||
public class MaxLoginLimitAttemptException extends ResponseStatusException {
|
||||
public class MaxLoginLimitAttemptException extends BaseBadRequestException {
|
||||
|
||||
public MaxLoginLimitAttemptException() {
|
||||
super(HttpStatus.BAD_REQUEST, "Max login attempt limit reached. Please wait 30 minutes");
|
||||
super("login", "Max login attempt limit reached. Please wait 30 minutes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a Note Not Found request. */
|
||||
@ResponseStatus(code = HttpStatus.NOT_FOUND)
|
||||
public class NoteNotFoundException extends ResponseStatusException {
|
||||
public class NoteNotFoundException extends BaseNotFoundException {
|
||||
|
||||
public NoteNotFoundException() {
|
||||
super(HttpStatus.NOT_FOUND, "Task not found");
|
||||
super("note", "Note not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a Reset expired request. */
|
||||
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
|
||||
public class ResetExpiredException extends ResponseStatusException {
|
||||
public class ResetExpiredException extends BaseBadRequestException {
|
||||
|
||||
public ResetExpiredException() {
|
||||
super(HttpStatus.NOT_FOUND, "Expired reset link.");
|
||||
super("reset", "Expired reset link.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a Task Not Found request. */
|
||||
@ResponseStatus(code = HttpStatus.NOT_FOUND)
|
||||
public class TaskNotFoundException extends ResponseStatusException {
|
||||
public class TaskNotFoundException extends BaseNotFoundException {
|
||||
|
||||
public TaskNotFoundException() {
|
||||
super(HttpStatus.NOT_FOUND, "Task not found");
|
||||
super("task", "Task not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/** This class represents a User Not Found request. */
|
||||
@ResponseStatus(code = HttpStatus.NOT_FOUND)
|
||||
public class UserNotFoundException extends ResponseStatusException {
|
||||
public class UserNotFoundException extends BaseNotFoundException {
|
||||
|
||||
public UserNotFoundException() {
|
||||
super(HttpStatus.NOT_FOUND, "User not found");
|
||||
super("user", "User not found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
@@ -10,6 +11,10 @@ public interface NoteRepository extends JpaRepository<NoteEntity, Long> {
|
||||
|
||||
List<NoteEntity> findAllByUser_id(Long userId);
|
||||
|
||||
Optional<NoteEntity> findByShareToken(String shareToken);
|
||||
|
||||
Optional<NoteEntity> findByIdAndUser_id(Long id, Long userId);
|
||||
|
||||
@Query(
|
||||
"select n from NoteEntity n where (upper(n.title) like %?1% or upper(n.description) like"
|
||||
+ " %?1%) and n.user.id = ?2")
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** This interface represents a note url repository, for database access. */
|
||||
public interface NoteUrlRepository extends JpaRepository<NoteUrlEntity, Long> {
|
||||
|
||||
Optional<NoteUrlEntity> findByNote_id(Long noteId);
|
||||
|
||||
List<NoteUrlEntity> findAllByNote_idIn(List<Long> noteIds);
|
||||
|
||||
void deleteByNote_id(Long noteId);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public interface UserPwdLimitRepository extends JpaRepository<UserPwdLimitEntity
|
||||
|
||||
List<UserPwdLimitEntity> findAllByUser_id(Long userId, Sort sort);
|
||||
|
||||
List<UserPwdLimitEntity> findTop3ByUser_idOrderByWhenHappenedDesc(Long userId);
|
||||
|
||||
@Modifying
|
||||
@Query("delete UserPwdLimitEntity u where u.user.id = ?1")
|
||||
void deleteAllForUser(Long userId);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/** This record represents a note patch payload. */
|
||||
public record NotePatchRequest(String title, String description, String url, String tag) {}
|
||||
public record NotePatchRequest(
|
||||
String title,
|
||||
String description,
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/** This record represents a note request to be created. */
|
||||
public record NoteRequest(
|
||||
@NotNull String title, @NotNull String description, String url, String tag) {}
|
||||
@NotNull String title,
|
||||
@NotNull String description,
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String url,
|
||||
String tag) {}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task patch payload. */
|
||||
public record TaskPatchRequest(
|
||||
String description,
|
||||
Boolean done,
|
||||
List<String> urls,
|
||||
List<
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String>
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
|
||||
@@ -2,12 +2,18 @@ package br.com.tasknoteapp.server.request;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.util.List;
|
||||
|
||||
/** This record represents a task request to be created. */
|
||||
public record TaskRequest(
|
||||
@NotNull @NotEmpty String description,
|
||||
List<String> urls,
|
||||
List<
|
||||
@Pattern(
|
||||
regexp = "^(https?://.*|#.*)?$",
|
||||
message = "URL must start with http://, https:// or #")
|
||||
String>
|
||||
urls,
|
||||
String dueDate,
|
||||
Boolean highPriority,
|
||||
String tag) {}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package br.com.tasknoteapp.server.response;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
|
||||
import br.com.tasknoteapp.server.util.TimeAgoUtil;
|
||||
import java.util.Objects;
|
||||
|
||||
/** This record represents a task and its urls object to be returned. */
|
||||
public record NoteResponse(
|
||||
Long id, String title, String description, String url, String lastUpdate, String tag) {
|
||||
Long id, String title, String description, String url, String lastUpdate, String tag,
|
||||
boolean shared, String shareToken) {
|
||||
|
||||
/**
|
||||
* Creates a NoteResponse given a NoteEntity and its Urls.
|
||||
@@ -15,9 +14,7 @@ public record NoteResponse(
|
||||
* @param entity The NoteEntity source data.
|
||||
* @return NoteResponse instance with all note data and urls, if any.
|
||||
*/
|
||||
public static NoteResponse fromEntity(NoteEntity entity) {
|
||||
NoteUrlEntity noteUrl = entity.getNoteUrl();
|
||||
String url = Objects.isNull(noteUrl) ? null : noteUrl.getUrl();
|
||||
public static NoteResponse fromEntity(NoteEntity entity, String url) {
|
||||
String timeAgoFmt = TimeAgoUtil.format(entity.getLastUpdate());
|
||||
|
||||
return new NoteResponse(
|
||||
@@ -26,6 +23,8 @@ public record NoteResponse(
|
||||
entity.getDescription(),
|
||||
url,
|
||||
timeAgoFmt,
|
||||
entity.getTag());
|
||||
entity.getTag(),
|
||||
entity.isShared(),
|
||||
entity.getShareToken());
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -25,6 +25,11 @@ public class ValidationExceptionResponse {
|
||||
this.errorMessage = String.format(MESSAGE_TEMPLATE, fields.size());
|
||||
}
|
||||
|
||||
public ValidationExceptionResponse(String field, String errorMessage) {
|
||||
this.fields = List.of(new FieldIssueResponse(field, errorMessage));
|
||||
this.errorMessage = String.format(MESSAGE_TEMPLATE, fields.size());
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
@@ -36,8 +37,6 @@ import java.util.UUID;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -133,7 +132,8 @@ public class AuthService {
|
||||
user.setEmail(newUser.email());
|
||||
user.setPassword(passwordEncoder.encode(newUser.password()));
|
||||
user.setAdmin(false);
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
user.setCreatedAt(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
user.setEmailUuid(emailUuid);
|
||||
user.setLang(newUser.lang());
|
||||
userRepository.save(user);
|
||||
@@ -334,6 +334,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
currentUser.setPassword(passwordEncoder.encode(patchRequest.password()));
|
||||
currentUser.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
shouldUpdate = true;
|
||||
}
|
||||
|
||||
@@ -435,7 +436,8 @@ public class AuthService {
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
user.setResetToken(resetToken);
|
||||
user.setResetPasswordExpiration(LocalDateTime.now().plusHours(2L));
|
||||
user.setResetPasswordExpiration(
|
||||
LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS).plusHours(2L));
|
||||
|
||||
userRepository.save(user);
|
||||
if (hasValidMailgunApiKey()) {
|
||||
@@ -479,6 +481,7 @@ public class AuthService {
|
||||
user.setResetToken(null);
|
||||
user.setResetPasswordExpiration(null);
|
||||
user.setPassword(passwordEncoder.encode(request.password()));
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
|
||||
userRepository.save(user);
|
||||
if (hasValidMailgunApiKey()) {
|
||||
@@ -513,8 +516,10 @@ public class AuthService {
|
||||
}
|
||||
|
||||
private void checkLoginAttemptLimit(Long userId) {
|
||||
Sort sort = Sort.by(Direction.DESC, "whenHappened");
|
||||
List<UserPwdLimitEntity> userPwdList = userPwdLimitRepository.findAllByUser_id(userId, sort);
|
||||
// Fetch only the 3 most recent failed attempts to avoid loading unbounded rows for
|
||||
// targeted/brute-forced accounts.
|
||||
List<UserPwdLimitEntity> userPwdList =
|
||||
userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(userId);
|
||||
|
||||
logger.warn("login count attempt for user {}: {}", userId, userPwdList.size());
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwd;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwdConfirm;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateSignUp;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import org.slf4j.Logger;
|
||||
@@ -28,10 +29,10 @@ import org.springframework.web.client.RestTemplate;
|
||||
@Service
|
||||
public class MailgunEmailService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MailgunEmailService.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(MailgunEmailService.class.getName());
|
||||
private final RestTemplate restTemplate;
|
||||
private final String targetEnv;
|
||||
private String domain;
|
||||
private final String domain;
|
||||
private String senderEmail;
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,11 @@ public class MailgunEmailService {
|
||||
this.senderEmail = sender;
|
||||
this.targetEnv = targetEnv;
|
||||
this.restTemplate =
|
||||
templateBuilder.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth("api", apiKey)).build();
|
||||
templateBuilder
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.readTimeout(Duration.ofSeconds(10))
|
||||
.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth("api", apiKey))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +73,8 @@ public class MailgunEmailService {
|
||||
String subject = "TaskNote App confirmation email";
|
||||
String link = getBaseUrl() + "/email-confirmation?identification=%s";
|
||||
|
||||
logger.info("New user link: {}", link);
|
||||
|
||||
MailgunTemplateSignUp signUpTemplate = new MailgunTemplateSignUp();
|
||||
signUpTemplate.setConfirmationLink(String.format(link, user.getEmailUuid().toString()));
|
||||
|
||||
@@ -86,6 +93,8 @@ public class MailgunEmailService {
|
||||
String subject = "TaskNote App password reset";
|
||||
String link = getBaseUrl() + "/finish-reset-password?token=%s";
|
||||
|
||||
logger.info("Password reset link: {}", link);
|
||||
|
||||
MailgunTemplateResetPwd resetTemplate = new MailgunTemplateResetPwd();
|
||||
resetTemplate.setResetLink(String.format(link, user.getResetToken()));
|
||||
|
||||
@@ -179,7 +188,10 @@ public class MailgunEmailService {
|
||||
if ("development".equals(targetEnv) || Objects.isNull(targetEnv)) {
|
||||
return "http://localhost:5000";
|
||||
}
|
||||
String stage = targetEnv.equals("stage") ? "stage." : "";
|
||||
return String.format("https://%s%s", stage, domain);
|
||||
String baseUrl = domain;
|
||||
if (targetEnv.equals("staging")) {
|
||||
baseUrl = "tasknote-stg" + domain.substring(8);
|
||||
}
|
||||
return String.format("https://%s", baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.exception.TaskNotFoundException;
|
||||
import br.com.tasknoteapp.server.repository.NoteRepository;
|
||||
import br.com.tasknoteapp.server.repository.NoteUrlRepository;
|
||||
import br.com.tasknoteapp.server.request.NotePatchRequest;
|
||||
@@ -14,8 +13,11 @@ import br.com.tasknoteapp.server.util.AuthUtil;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -66,35 +68,39 @@ public class NoteService {
|
||||
List<NoteEntity> notes = noteRepository.findAllByUser_id(user.getId());
|
||||
logger.info(notes.size() + " notes found!");
|
||||
|
||||
return notes.stream().map(NoteResponse::fromEntity).toList();
|
||||
return getNotesUrl(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a note by its id.
|
||||
*
|
||||
* @param noteId The task id in the database.
|
||||
* @return {@link NoteResponse} with the found task or throw a {@link TaskNotFoundException}.
|
||||
* @param noteId The note id in the database.
|
||||
* @return {@link NoteResponse} with the found note or throw a {@link NoteNotFoundException}.
|
||||
*/
|
||||
public NoteResponse getNoteById(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Get note " + noteId + " to user " + user.getId());
|
||||
|
||||
Optional<NoteEntity> task = noteRepository.findById(noteId);
|
||||
if (task.isEmpty()) {
|
||||
Optional<NoteEntity> note = noteRepository.findById(noteId);
|
||||
if (note.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
if (!note.get().getUser().getId().equals(user.getId())) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
logger.info("Note found! Id " + noteId);
|
||||
return NoteResponse.fromEntity(task.get());
|
||||
return NoteResponse.fromEntity(note.get(), getNoteUrl(noteId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a note for the user.
|
||||
*
|
||||
* @param noteRequest The note content.
|
||||
* @return {@link NoteEntity} created in the database
|
||||
* @return {@link NoteResponse} with created note data.
|
||||
*/
|
||||
public NoteEntity createNote(NoteRequest noteRequest) {
|
||||
public NoteResponse createNote(NoteRequest noteRequest) {
|
||||
UserEntity user = getCurrentUser();
|
||||
|
||||
logger.info("Creating note to user " + user.getId());
|
||||
@@ -109,13 +115,14 @@ public class NoteService {
|
||||
|
||||
logger.info("Note created! Id " + created.getId());
|
||||
|
||||
String savedUrl = null;
|
||||
if (!Objects.isNull(noteRequest.url()) && !noteRequest.url().isEmpty()) {
|
||||
NoteUrlEntity urlEntity = saveUrl(note, noteRequest.url());
|
||||
note.setNoteUrl(urlEntity);
|
||||
NoteUrlEntity urlEntity = saveUrl(created, noteRequest.url());
|
||||
savedUrl = urlEntity.getUrl();
|
||||
}
|
||||
|
||||
logger.info("Finished note creation!");
|
||||
return created;
|
||||
return NoteResponse.fromEntity(created, savedUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +138,7 @@ public class NoteService {
|
||||
|
||||
logger.info("Patching task " + noteId + " to user " + user.getId());
|
||||
|
||||
Optional<NoteEntity> note = noteRepository.findById(noteId);
|
||||
Optional<NoteEntity> note = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (note.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
@@ -153,8 +160,7 @@ public class NoteService {
|
||||
logger.info("URL deleted from task " + noteId);
|
||||
|
||||
if (!Objects.isNull(patch.url()) && !patch.url().isBlank()) {
|
||||
NoteUrlEntity urlEntity = saveUrl(noteEntity, patch.url());
|
||||
noteEntity.setNoteUrl(urlEntity);
|
||||
saveUrl(noteEntity, patch.url());
|
||||
} else {
|
||||
logger.info("No urls to patch for task " + noteId);
|
||||
}
|
||||
@@ -164,7 +170,7 @@ public class NoteService {
|
||||
|
||||
logger.info("Note patched! Id " + patchedNote.getId());
|
||||
|
||||
return NoteResponse.fromEntity(patchedNote);
|
||||
return NoteResponse.fromEntity(patchedNote, getNoteUrl(patchedNote.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,20 +184,15 @@ public class NoteService {
|
||||
|
||||
logger.info("Deleting note " + noteId + " to user " + user.getId());
|
||||
|
||||
Optional<NoteEntity> note = noteRepository.findById(noteId);
|
||||
Optional<NoteEntity> note = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (note.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = note.get();
|
||||
|
||||
NoteUrlEntity noteUrl = noteEntity.getNoteUrl();
|
||||
if (!Objects.isNull(noteUrl)) {
|
||||
noteUrlRepository.delete(noteUrl);
|
||||
logger.info("URL Deleted from task " + noteId);
|
||||
} else {
|
||||
logger.info("No urls to delete for task " + noteId);
|
||||
}
|
||||
noteUrlRepository.deleteByNote_id(noteId);
|
||||
logger.info("URL deleted from task " + noteId);
|
||||
|
||||
noteRepository.delete(noteEntity);
|
||||
|
||||
@@ -212,7 +213,76 @@ public class NoteService {
|
||||
List<NoteEntity> notes =
|
||||
noteRepository.findAllBySearchTerm(searchTerm.toUpperCase(), user.getId());
|
||||
logger.info(notes.size() + " tasks found!");
|
||||
return notes.stream().map(NoteResponse::fromEntity).toList();
|
||||
return getNotesUrl(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a note publicly, generating a unique share token.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the updated note with share token.
|
||||
*/
|
||||
@Transactional
|
||||
public NoteResponse shareNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Sharing note " + noteId + " for user " + user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
|
||||
if (!noteEntity.isShared()) {
|
||||
noteEntity.setShared(true);
|
||||
noteEntity.setShareToken(UUID.randomUUID().toString());
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note " + noteId + " shared with token " + noteEntity.getShareToken());
|
||||
}
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshare a note, revoking public access.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the updated note.
|
||||
*/
|
||||
public NoteResponse unshareNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Unsharing note " + noteId + " for user " + user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
noteEntity.setShared(false);
|
||||
noteEntity.setShareToken(null);
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note " + noteId + " unshared.");
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a publicly shared note by its share token (no authentication required).
|
||||
*
|
||||
* @param shareToken The unique share token for the note.
|
||||
* @return {@link NoteResponse} containing the shared note.
|
||||
*/
|
||||
public NoteResponse getSharedNote(String shareToken) {
|
||||
logger.info("Fetching shared note with token " + shareToken);
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByShareToken(shareToken);
|
||||
if (noteOpt.isEmpty() || !noteOpt.get().isShared()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
return NoteResponse.fromEntity(noteOpt.get(), getNoteUrl(noteOpt.get().getId()));
|
||||
}
|
||||
|
||||
private UserEntity getCurrentUser() {
|
||||
@@ -221,6 +291,22 @@ public class NoteService {
|
||||
return authService.findByEmail(email).orElseThrow();
|
||||
}
|
||||
|
||||
private String getNoteUrl(Long noteId) {
|
||||
return noteUrlRepository.findByNote_id(noteId).map(NoteUrlEntity::getUrl).orElse(null);
|
||||
}
|
||||
|
||||
private List<NoteResponse> getNotesUrl(List<NoteEntity> notes) {
|
||||
List<Long> noteIds = notes.stream().map(NoteEntity::getId).toList();
|
||||
if (noteIds.isEmpty()) {
|
||||
return notes.stream().map(n -> NoteResponse.fromEntity(n, null)).toList();
|
||||
}
|
||||
List<NoteUrlEntity> urls = noteUrlRepository.findAllByNote_idIn(noteIds);
|
||||
Map<Long, String> noteUrls =
|
||||
urls.stream().collect(Collectors.toMap(nu -> nu.getNote().getId(), NoteUrlEntity::getUrl));
|
||||
|
||||
return notes.stream().map(n -> NoteResponse.fromEntity(n, noteUrls.get(n.getId()))).toList();
|
||||
}
|
||||
|
||||
private NoteUrlEntity saveUrl(NoteEntity noteEntity, String url) {
|
||||
NoteUrlEntity noteUrl = new NoteUrlEntity();
|
||||
noteUrl.setUrl(url);
|
||||
|
||||
@@ -51,6 +51,14 @@ class JwtServiceImpl implements JwtService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDateTime extractIssuedAt(String token) {
|
||||
Date date = extractClaim(token, Claims::getIssuedAt);
|
||||
if (!Objects.isNull(date)) {
|
||||
return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateToken(UserEntity user) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
@@ -91,7 +99,18 @@ class JwtServiceImpl implements JwtService {
|
||||
@Override
|
||||
public boolean validateTokenAndUser(String token, UserDetails user) {
|
||||
final String email = user.getUsername();
|
||||
return !isTokenExpired(token) && email.equals(getEmailFromToken(token));
|
||||
boolean basicValid = !isTokenExpired(token) && email.equals(getEmailFromToken(token));
|
||||
|
||||
if (basicValid && user instanceof UserEntity userEntity) {
|
||||
LocalDateTime iat = extractIssuedAt(token);
|
||||
if (iat != null && userEntity.getLastPasswordChange() != null) {
|
||||
// Token must be issued after or at the same time as last password change
|
||||
// We use isBefore to invalidate tokens issued BEFORE the change
|
||||
return !iat.isBefore(userEntity.getLastPasswordChange());
|
||||
}
|
||||
}
|
||||
|
||||
return basicValid;
|
||||
}
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.repository.UserRepository;
|
||||
import br.com.tasknoteapp.server.service.UserService;
|
||||
import java.util.Optional;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -12,24 +11,21 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
class UserServiceImpl implements UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final UserDetailsService cachedUserDetailsService;
|
||||
|
||||
public UserServiceImpl(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.cachedUserDetailsService =
|
||||
email -> {
|
||||
Optional<UserEntity> user = userRepository.findByEmail(email);
|
||||
if (user.isEmpty()) {
|
||||
throw new RuntimeException("User not found: " + email);
|
||||
}
|
||||
return user.get();
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetailsService userDetailsService() {
|
||||
return new UserDetailsService() {
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String email) {
|
||||
Optional<UserEntity> user = userRepository.findByEmail(email);
|
||||
if (user.isEmpty()) {
|
||||
throw new RuntimeException("User not found: " + email);
|
||||
}
|
||||
|
||||
return user.get();
|
||||
}
|
||||
};
|
||||
return this.cachedUserDetailsService;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import java.util.Optional;
|
||||
/** This class represents a template for the email change workflow. */
|
||||
public class MailgunTemplateEmailChanged implements MailgunTemplate {
|
||||
|
||||
private String templateName = "email changed";
|
||||
private String templateName = "email_changed";
|
||||
private String carbonCopy;
|
||||
private final Map<String, Object> props;
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import java.util.Map;
|
||||
/** This class represents a template for the password reset workflow. */
|
||||
public class MailgunTemplateResetPwd implements MailgunTemplate {
|
||||
|
||||
private String templateName = "password reset";
|
||||
private String templateName = "password_reset";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateResetPwd() {
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import java.util.Map;
|
||||
/** This class represents a template for the password reset confirmation workflow. */
|
||||
public class MailgunTemplateResetPwdConfirm implements MailgunTemplate {
|
||||
|
||||
private String templateName = "password change confirmation";
|
||||
private String templateName = "password_change_confirmation";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateResetPwdConfirm() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.util.Map;
|
||||
/** This class represents a template for the sign up workflow. */
|
||||
public class MailgunTemplateSignUp implements MailgunTemplate {
|
||||
|
||||
private String templateName = "sign up confirmation";
|
||||
private String templateName = "sign_up_confirmation";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateSignUp() {
|
||||
|
||||
@@ -36,7 +36,8 @@ spring:
|
||||
locations: classpath:db/migration
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
open-in-view: false
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: tasknote
|
||||
show-sql: true
|
||||
show-sql: false
|
||||
|
||||
@@ -13,8 +13,8 @@ logging:
|
||||
|
||||
mailgun:
|
||||
api-key: ${MAILGUN_APIKEY:abc123456}
|
||||
domain: tasknoteapp.dev.br
|
||||
sender-email: no-reply@tasknoteapp.dev.br
|
||||
domain: tasknote.darkroasted.vps-kinghost.net
|
||||
sender-email: no-reply@tasknote.darkroasted.vps-kinghost.net
|
||||
|
||||
server:
|
||||
port: 8585
|
||||
@@ -36,7 +36,8 @@ spring:
|
||||
locations: classpath:db/migration
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
open-in-view: false
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: tasknote
|
||||
show-sql: true
|
||||
show-sql: false
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE tasknote.notes
|
||||
ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN share_token VARCHAR(36) NULL;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE tasknote.users ADD COLUMN last_password_change TIMESTAMP WITHOUT TIME ZONE;
|
||||
|
||||
-- Initialize for existing users
|
||||
UPDATE tasknote.users SET last_password_change = created_at WHERE last_password_change IS NULL;
|
||||
|
||||
ALTER TABLE tasknote.users ALTER COLUMN last_password_change SET NOT NULL;
|
||||
@@ -8,10 +8,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.request.NotePatchRequest;
|
||||
import br.com.tasknoteapp.server.request.NoteRequest;
|
||||
@@ -44,7 +44,7 @@ class NoteControllerTest {
|
||||
void getAllNotes_notesFound_shouldSucceed() throws Exception {
|
||||
NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com");
|
||||
NoteResponse note =
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, "tag");
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, "tag", false, null);
|
||||
|
||||
when(noteService.getAllNotes()).thenReturn(List.of(note));
|
||||
|
||||
@@ -102,7 +102,14 @@ class NoteControllerTest {
|
||||
|
||||
NoteResponse response =
|
||||
new NoteResponse(
|
||||
noteId, patchRequest.title(), patchRequest.description(), null, null, "tag");
|
||||
noteId,
|
||||
patchRequest.title(),
|
||||
patchRequest.description(),
|
||||
null,
|
||||
null,
|
||||
"tag",
|
||||
false,
|
||||
null);
|
||||
|
||||
when(noteService.patchNote(noteId, patchRequest)).thenReturn(response);
|
||||
|
||||
@@ -191,10 +198,8 @@ class NoteControllerTest {
|
||||
void postNotes_happyPath_shouldSucceed() throws Exception {
|
||||
NoteRequest request = new NoteRequest("Title", "Description", null, null);
|
||||
|
||||
NoteEntity entity = new NoteEntity();
|
||||
entity.setId(1L);
|
||||
entity.setTitle(request.title());
|
||||
entity.setDescription(request.description());
|
||||
NoteResponse entity = new NoteResponse(1L, request.title(), request.description(),
|
||||
null, null, null, false, null);
|
||||
|
||||
when(noteService.createNote(request)).thenReturn(entity);
|
||||
|
||||
@@ -215,9 +220,9 @@ class NoteControllerTest {
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.content(payloadJson))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(entity.getId()))
|
||||
.andExpect(jsonPath("$.title").value(entity.getTitle()))
|
||||
.andExpect(jsonPath("$.description").value(entity.getDescription()))
|
||||
.andExpect(jsonPath("$.id").value(entity.id()))
|
||||
.andExpect(jsonPath("$.title").value(entity.title()))
|
||||
.andExpect(jsonPath("$.description").value(entity.description()))
|
||||
.andExpect(jsonPath("$.url", Matchers.nullValue()))
|
||||
.andReturn();
|
||||
}
|
||||
@@ -315,4 +320,75 @@ class NoteControllerTest {
|
||||
.andExpect(status().isNotFound())
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Share note happy path should succeed")
|
||||
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
|
||||
void shareNote_happyPath_shouldSucceed() throws Exception {
|
||||
final Long noteId = 1L;
|
||||
final String token = "test-token-uuid";
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, "tag", true, token);
|
||||
|
||||
when(noteService.shareNote(noteId)).thenReturn(response);
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/share", noteId)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.shared").value(true))
|
||||
.andExpect(jsonPath("$.shareToken").value(token))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Share note with 401 unauthorized should fail")
|
||||
void shareNote_unauthorized_shouldFail() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/share", 1L)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unshare note happy path should succeed")
|
||||
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
|
||||
void unshareNote_happyPath_shouldSucceed() throws Exception {
|
||||
final Long noteId = 1L;
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, "tag", false, null);
|
||||
|
||||
when(noteService.unshareNote(noteId)).thenReturn(response);
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/unshare", noteId)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.shared").value(false))
|
||||
.andExpect(jsonPath("$.shareToken", Matchers.nullValue()))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unshare note with 401 unauthorized should fail")
|
||||
void unshareNote_unauthorized_shouldFail() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/rest/notes/{id}/unshare", 1L)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andReturn();
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package br.com.tasknoteapp.server.controller;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.response.NoteResponse;
|
||||
import br.com.tasknoteapp.server.service.NoteService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class PublicNoteControllerTest {
|
||||
|
||||
@Autowired private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean private NoteService noteService;
|
||||
|
||||
@Test
|
||||
@DisplayName("Get shared note by token happy path should succeed")
|
||||
void getSharedNote_happyPath_shouldSucceed() throws Exception {
|
||||
final String token = "test-share-token";
|
||||
NoteResponse response =
|
||||
new NoteResponse(1L, "title", "description", null, null, "tag", true, token);
|
||||
|
||||
when(noteService.getSharedNote(token)).thenReturn(response);
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/public/notes/{token}", token)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(1L))
|
||||
.andExpect(jsonPath("$.title").value("title"))
|
||||
.andExpect(jsonPath("$.shared").value(true))
|
||||
.andExpect(jsonPath("$.shareToken").value(token))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Get shared note by token not found should fail with 404")
|
||||
void getSharedNote_notFound_shouldFail() throws Exception {
|
||||
final String token = "invalid-token";
|
||||
|
||||
when(noteService.getSharedNote(token)).thenThrow(new NoteNotFoundException());
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/public/notes/{token}", token)
|
||||
.with(csrf().asHeader())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isNotFound())
|
||||
.andReturn();
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ class TaskControllerTest {
|
||||
"""
|
||||
{
|
||||
"description": "Test task",
|
||||
"urls": ["www.url.com"],
|
||||
"urls": ["https://www.url.com"],
|
||||
"highPriority": true,
|
||||
"tag": "tag"
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
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;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
@@ -201,8 +199,8 @@ class AuthServiceTest {
|
||||
existing.setEmail(request.email());
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
Sort sort = Sort.by(Direction.DESC, "whenHappened");
|
||||
when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of());
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
.thenReturn(List.of());
|
||||
when(authenticationManager.authenticate(any())).thenReturn(null);
|
||||
when(jwtService.generateToken(existing)).thenReturn("a1b2c3");
|
||||
|
||||
@@ -241,8 +239,7 @@ class AuthServiceTest {
|
||||
limit1.setWhenHappened(LocalDateTime.now().minusMinutes(1));
|
||||
UserPwdLimitEntity limit2 = new UserPwdLimitEntity();
|
||||
UserPwdLimitEntity limit3 = new UserPwdLimitEntity();
|
||||
Sort sort = Sort.by(Direction.DESC, "whenHappened");
|
||||
when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort))
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
.thenReturn(List.of(limit1, limit2, limit3));
|
||||
|
||||
Assertions.assertThrows(
|
||||
@@ -261,8 +258,8 @@ class AuthServiceTest {
|
||||
existing.setId(919L);
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
|
||||
Sort sort = Sort.by(Direction.DESC, "whenHappened");
|
||||
when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of());
|
||||
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
|
||||
.thenReturn(List.of());
|
||||
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("Wrong"));
|
||||
|
||||
UserResponseWithToken token = authService.signInUser(request);
|
||||
|
||||
@@ -30,14 +30,15 @@ class MailgunEmailServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(restTemplateBuilder.connectTimeout(any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.readTimeout(any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.defaultHeader(any(), any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.build()).thenReturn(restTemplate);
|
||||
|
||||
String apiKey = "abx123";
|
||||
String domain = "domain.com";
|
||||
String sender = "no-reply@domain.com";
|
||||
String target = "development";
|
||||
|
||||
when(restTemplateBuilder.defaultHeader(any(), any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.build()).thenReturn(restTemplate);
|
||||
|
||||
mailgunEmailService =
|
||||
new MailgunEmailService(apiKey, domain, sender, target, restTemplateBuilder);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package br.com.tasknoteapp.server.service;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.anyString;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -105,9 +105,9 @@ class NoteServiceTest {
|
||||
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
|
||||
when(noteUrlRepository.save(any(NoteUrlEntity.class))).thenReturn(new NoteUrlEntity());
|
||||
|
||||
NoteEntity createdNote = noteService.createNote(noteRequest);
|
||||
NoteResponse createdNote = noteService.createNote(noteRequest);
|
||||
|
||||
assertEquals("Test Note", createdNote.getTitle());
|
||||
assertEquals("Test Note", createdNote.title());
|
||||
verify(noteRepository, times(1)).save(any(NoteEntity.class));
|
||||
verify(noteUrlRepository, times(1)).save(any(NoteUrlEntity.class));
|
||||
}
|
||||
@@ -116,13 +116,14 @@ class NoteServiceTest {
|
||||
void patchNote() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findById(note.getId())).thenReturn(Optional.of(note));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
.thenReturn(Optional.of(note));
|
||||
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
|
||||
|
||||
NoteResponse patchedNote = noteService.patchNote(note.getId(), notePatchRequest);
|
||||
|
||||
assertEquals("Updated Note", patchedNote.title());
|
||||
verify(noteRepository, times(1)).findById(note.getId());
|
||||
verify(noteRepository, times(1)).findByIdAndUser_id(note.getId(), user.getId());
|
||||
verify(noteRepository, times(1)).save(any(NoteEntity.class));
|
||||
}
|
||||
|
||||
@@ -130,7 +131,8 @@ class NoteServiceTest {
|
||||
void patchNote_notFound() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findById(note.getId())).thenReturn(Optional.empty());
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
.thenReturn(Optional.empty());
|
||||
Long noteId = note.getId();
|
||||
|
||||
assertThrows(
|
||||
@@ -141,11 +143,12 @@ class NoteServiceTest {
|
||||
void deleteNote() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findById(note.getId())).thenReturn(Optional.of(note));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
.thenReturn(Optional.of(note));
|
||||
|
||||
noteService.deleteNote(note.getId());
|
||||
|
||||
verify(noteRepository, times(1)).findById(note.getId());
|
||||
verify(noteRepository, times(1)).findByIdAndUser_id(note.getId(), user.getId());
|
||||
verify(noteRepository, times(1)).delete(note);
|
||||
}
|
||||
|
||||
@@ -162,4 +165,86 @@ class NoteServiceTest {
|
||||
assertEquals("Test Note", notes.get(0).title());
|
||||
verify(noteRepository, times(1)).findAllBySearchTerm(anyString(), eq(user.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shareNote() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
.thenReturn(Optional.of(note));
|
||||
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
|
||||
|
||||
NoteResponse response = noteService.shareNote(note.getId());
|
||||
|
||||
assertEquals("Test Note", response.title());
|
||||
verify(noteRepository, times(1)).save(any(NoteEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shareNote_notFound() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
.thenReturn(Optional.empty());
|
||||
Long noteId = note.getId();
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.shareNote(noteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unshareNote() {
|
||||
note.setShared(true);
|
||||
note.setShareToken("some-token");
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
.thenReturn(Optional.of(note));
|
||||
when(noteRepository.save(any(NoteEntity.class))).thenReturn(note);
|
||||
|
||||
NoteResponse response = noteService.unshareNote(note.getId());
|
||||
|
||||
assertEquals("Test Note", response.title());
|
||||
verify(noteRepository, times(1)).save(any(NoteEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unshareNote_notFound() {
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
.thenReturn(Optional.empty());
|
||||
Long noteId = note.getId();
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.unshareNote(noteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSharedNote() {
|
||||
final String token = "share-token-123";
|
||||
note.setShared(true);
|
||||
note.setShareToken(token);
|
||||
when(noteRepository.findByShareToken(token)).thenReturn(Optional.of(note));
|
||||
|
||||
NoteResponse response = noteService.getSharedNote(token);
|
||||
|
||||
assertEquals("Test Note", response.title());
|
||||
verify(noteRepository, times(1)).findByShareToken(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSharedNote_notFound() {
|
||||
when(noteRepository.findByShareToken("bad-token")).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.getSharedNote("bad-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSharedNote_notShared() {
|
||||
final String token = "share-token-456";
|
||||
note.setShared(false);
|
||||
note.setShareToken(token);
|
||||
when(noteRepository.findByShareToken(token)).thenReturn(Optional.of(note));
|
||||
|
||||
assertThrows(NoteNotFoundException.class, () -> noteService.getSharedNote(token));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ class UserSessionServiceTest {
|
||||
|
||||
TaskResponse task =
|
||||
new TaskResponse(1L, "Task 1", false, true, null, null, null, null, List.of());
|
||||
NoteResponse note = new NoteResponse(1L, "Note 1", "Description", null, null, null);
|
||||
NoteResponse note =
|
||||
new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null);
|
||||
|
||||
when(authService.getCurrentUser()).thenReturn(Optional.of(user));
|
||||
when(taskService.getAllTasks()).thenReturn(List.of(task));
|
||||
|
||||
@@ -166,6 +166,30 @@ class JwtServiceImplTest {
|
||||
assertFalse(valid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenAndUser_shouldReturnFalseIfTokenIssuedBeforeLastPasswordChange()
|
||||
throws InterruptedException {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setId(testUserId);
|
||||
user.setEmail(testEmail);
|
||||
user.setAdmin(false);
|
||||
user.setName(testName);
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
|
||||
// Token issued NOW
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
// Update lastPasswordChange to FUTURE (simulating a password change after token issuance)
|
||||
// We wait 1 second to ensure the new timestamp is strictly after token iat (which has second
|
||||
// precision)
|
||||
Thread.sleep(1100);
|
||||
user.setLastPasswordChange(LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS));
|
||||
|
||||
boolean valid = jwtService.validateTokenAndUser(token, user);
|
||||
|
||||
assertFalse(valid, "Token issued before password change should be invalid");
|
||||
}
|
||||
|
||||
private Claims extractClaims(String token) {
|
||||
return Jwts.parser().verifyWith(getKey()).build().parseSignedClaims(token).getPayload();
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class MailgunTemplateTest {
|
||||
MailgunTemplateEmailChanged emailChanged = new MailgunTemplateEmailChanged();
|
||||
|
||||
Assertions.assertNotNull(emailChanged.getName());
|
||||
Assertions.assertEquals("email changed", emailChanged.getName());
|
||||
Assertions.assertEquals("email_changed", emailChanged.getName());
|
||||
Assertions.assertNotNull(emailChanged.getVariables());
|
||||
Assertions.assertNotNull(emailChanged.getVariableValuesJson());
|
||||
Assertions.assertFalse(emailChanged.getVariableValuesJson().isBlank());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user