Compare commits

...
Author SHA1 Message Date
rmcamposandClaude Sonnet 4.6 0fc1cc3d65 fix: align frontend image tag with baked VITE_BUILD version
Main CI-Frontend / Build & Push (push) Successful in 40s
PR and candidate builds now push with the app-v* tag at build time,
ensuring VITE_BUILD inside the image always matches its Docker tag.
Promote step reads the version from the image instead of generating
a new run_number-based tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 21:36:22 -03:00
rmcampos 3b591ac85e hotfix: check changes condition to apply tf again 2026-06-30 21:13:39 -03:00
rmcampos b1cece9138 hotfix: check changes condition to apply tf 2026-06-30 21:10:52 -03:00
rmcampos 1ff3ba964c feat: add draft notes (#6)
Main CI-Frontend / Build & Push (push) Successful in 28s
## What

- Add draft feature for notes and tasks.
- Update deploy workflows to use a single job.

## Why

- Draft is needed when the session expires and the user is editing.
- A single job is needed to avoid exposing a tfplan file

## Mood
<img width="200" src="https://media4.giphy.com/media/BwP0jedpcSOyOyIK2h/giphy.gif?cid=36b14facttlvjhc0remdjlv65reqb1qh98wy78m23ua0avsj&ep=v1_gifs_search&rid=giphy.gif&ct=g"/>

Reviewed-on: #6
2026-07-01 00:05:46 +00:00
rmcampos eea8313f30 hotfix: flyway issue in prod 2026-06-29 23:15:31 +02:00
gitea-actions[bot] 419a33f7fc chore: bump api version to 33 [skip ci] 2026-06-29 19:24:13 +00:00
rmcampos 169f07801a Security/critical fixes (#4)
Main CI-Frontend / Build & Push (push) Successful in 39s
Main CI-Backend / Build & Push (push) Successful in 45s
## What

- Addressing security issues: critical and not so critical (more fixed will be pushed soon)

## Why

- The app needs to be secure and safe.

## Mood
<img width="200" src="https://media2.giphy.com/media/CSpfd57m9WGHnxMWXm/100.webp?cid=36b14facw100seggylsefdj0ap2oopoux3bn3jn6qu59xggq&ep=v1_gifs_search&rid=100.webp&ct=g"/>

Reviewed-on: #4
2026-06-29 19:23:41 +00:00
rmcampos a55a80e916 chore: bump frontend dependencies (#3)
Main CI-Frontend / Build & Push (push) Successful in 32s
## What

- Bump frontend dependencies;
- Backend dependencies are not ready yet. Only Spring has a new version, but it is failing to run on GraalVM;

## Why

- Codebase hygiene;

## Mood

<img width="200" src="https://media0.giphy.com/media/Pn5crrMQi6WyI5tyI0/giphy.gif?cid=36b14fackrjl2p912ox9mnnn7m0rawm1okm5d8hnaorntlgn&ep=v1_gifs_trending&rid=giphy.gif&ct=g"/>

Reviewed-on: #3
2026-06-25 00:16:35 +00:00
rmcampos 6272650ef9 revert 54eeb66f0d
revert Update README.md
2026-06-17 17:43:18 +00:00
rmcampos 54eeb66f0d Update README.md 2026-06-17 17:42:02 +00:00
rmcampos a9f2b7b3a4 docs: upadte readme 2026-06-17 12:15:45 -03:00
rmcampos d6a7ee34ef docs: update changelog 2026-06-15 19:07:26 -03:00
39 changed files with 1879 additions and 1513 deletions
+3 -1
View File
@@ -48,7 +48,9 @@ jobs:
with:
context: ./client
push: true
tags: ${{ steps.meta.outputs.tags }}
tags: |
${{ steps.meta.outputs.tags }}
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
+4 -46
View File
@@ -22,7 +22,7 @@ jobs:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: easynode-debian
outputs:
no_changes: ${{ steps.check-changes.outputs.no_changes }}
has_changes: ${{ steps.check-changes.outputs.has_changes }}
permissions:
contents: read
steps:
@@ -115,59 +115,17 @@ jobs:
-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 "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No changes to apply."
exit 0
else
echo "Changes detected. Proceeding with apply"
echo "no_changes=false" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v3
with:
name: tfplan
path: terraform/tfplan
terraform-apply:
runs-on: easynode-debian
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@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Download plan artifact
uses: actions/download-artifact@v3
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
if: steps.check-changes.outputs.has_changes == 'true'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+1 -41
View File
@@ -92,49 +92,9 @@ jobs:
echo "no_changes=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload plan artifact
uses: actions/upload-artifact@v3
with:
name: tfplan
path: terraform-stg/tfplan
terraform-apply:
runs-on: easynode-debian
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@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Download plan artifact
uses: actions/download-artifact@v3
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
if: needs.terraform-plan-stg.outputs.no_changes == 'false'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+14 -9
View File
@@ -31,14 +31,6 @@ jobs:
with:
fetch-depth: 0
- 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: Set up Docker Buildx
run: docker buildx inspect --bootstrap
@@ -63,11 +55,24 @@ jobs:
fi
echo "tag=${PR_NUMBER}" >> $GITHUB_OUTPUT
- name: Extract version from image
id: version
run: |
docker pull rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
VITE_BUILD=$(docker inspect rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }} \
--format '{{ range .Config.Env }}{{ println . }}{{ end }}' \
| grep '^VITE_BUILD=' | cut -d= -f2)
if [ -z "$VITE_BUILD" ]; then
echo "Could not extract VITE_BUILD from image" >&2
exit 1
fi
echo "tag=${VITE_BUILD}" >> $GITHUB_OUTPUT
echo "Extracted version: ${VITE_BUILD}"
- name: Promote Docker image
run: |
docker buildx imagetools create \
--tag rmcampos/tasknote-app:latest \
--tag rmcampos/tasknote-app:${{ steps.version.outputs.tag }} \
rmcampos/tasknote-app:${{ steps.find_pr.outputs.tag }}
- name: Create and push Git tag
+3 -1
View File
@@ -109,7 +109,9 @@ jobs:
with:
context: ./client
push: true
tags: ${{ steps.meta.outputs.tags }}
tags: |
${{ steps.meta.outputs.tags }}
rmcampos/tasknote-app:${{ steps.version.outputs.tag }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=rmcampos/tasknote-app:buildcache
cache-to: type=registry,ref=rmcampos/tasknote-app:buildcache,mode=max
+14 -2
View File
@@ -7,13 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## api-v29 && app
## app-v2026.06.24.? - 2026-06-25
### Changed
- Bumped Spring Boot to 4.1.0
- Bumped client minor and major dependencies.
### Docker images
- `docker.io/rmcampos/tasknote-app:app-v2026.06.24.?`
## api-v32 && app-v2026.06.15.97 - 2026-06-15
### Changed
- Bumped Spring Boot to 4.0.7
- CI/CD workflow files updated to run on Gitea.
- Container registry switched to Docker Hub.
### Docker images
- [rmcampos/tasknote-api:32](https://hub.docker.com/layers/rmcampos/tasknote-api/32/images/sha256-4b719a08dbed4a9d4a6eece0059573954ee5193ab8247787fb0e30c037f6b1c6)
- [rmcampos/tasknote-app:app-v2026.06.15.97](https://hub.docker.com/layers/rmcampos/tasknote-app/app-v2026.06.15.97/images/sha256-945a215a7105e34f97ab8e43094092e157156c0b557364260c019c4036cf845d)
## [app-v2026.06.08.22](https://github.com/RMCampos/tasknote/releases/tag/app-v2026.06.08.22) - 2026-06-08
### Added
+31 -123
View File
@@ -1,10 +1,9 @@
# TaskNote
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![React App CI](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/client-ci.yml)
[![Server API CI](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/server-ci.yml)
[![Build and Push App Docker Image](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/main-client.yml)
[![Build and Push API Docker Image](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml/badge.svg)](https://github.com/RMCampos/tasknote/actions/workflows/main-server.yml)
[![Frontend CI](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/workflows/ci-main-frontend.yml/badge.svg)](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-frontend.yml)
[![Backend CI](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/workflows/ci-main-backend.yml/badge.svg)](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=ci-main-backend.yml)
[![Deploy](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/workflows/cd-main.yml/badge.svg)](https://lightroasted.vps-kinghost.net/rmcampos/tasknote/actions/?workflow=cd-main.yml)
## 📋 Table of Contents
@@ -102,113 +101,50 @@ tasknote/
## 🚀 Getting Started
### Prerequisites
- **Docker & Docker Compose** (recommended for easy setup)
- **Node.js 20+** and **npm** (for frontend development)
- **Java 25+** and **Maven 3.6+** (for backend development)
- **PostgreSQL 15+** (if running without Docker)
- [Docker](https://docs.docker.com/engine/install/)
- [Docker Compose](https://docs.docker.com/compose/install/)
- [Task](https://taskfile.dev) (`brew install go-task` / `npm install -g @go-task/cli`)
- [Doppler CLI](https://docs.doppler.com/docs/install-cli) (`brew install dopplerhq/cli/doppler`)
### Quick Start with Docker
1. **Clone the repository**
```bash
git clone https://github.com/rmcampos/tasknote.git
cd tasknote
```
### Setup
2. **Start the database**
```bash
bash tools/run-docker-db.sh
```
3. **Start the backend server**
```bash
bash tools/run-docker-server.sh
```
4. **Start the frontend application**
```bash
bash tools/run-docker-client.sh
```
5. **Access the application**
- Frontend: http://localhost:5000
## 🛠️ Development
### Frontend Development
```bash
cd client
npm install # Install dependencies
npm start # Start development server (port 5000)
npm run build # Build for production
npm run preview # Preview production build
npm run lint # Run ESLint
npm run lint:fix # Fix ESLint issues
# 1. Authenticate with Doppler and link the project
doppler login
doppler setup # uses doppler.yaml to link to the shell-whats project
```
### Backend Development
### Running locally
```bash
cd server
./mvnw spring-boot:run # Start development server
./mvnw clean compile # Compile sources
./mvnw spring-boot:build-image # Build Docker image
./mvnw clean verify -Pnative # Build GraalVM native image
task dev-run
```
### Quality Checks
Run quality checks before submitting changes:
This exports the public vars from the `dev_tokens` Doppler config and starts the server in watch mode with secrets injected from `dev_secrets`. No `.env` file needed.
## Building the Docker images
```bash
bash tools/check-frontend.sh # Frontend linting, testing, coverage
bash tools/check-backend.sh # Backend compilation, tests, checkstyle
# Build the backend
task docker-build-api
# Build the frontend
task docker-build-web
```
## 🧪 Testing
## 🧪 Testing & Checks
### Frontend Testing
- **Framework**: Vitest with React Testing Library
- **Coverage**: Comprehensive test coverage with reports in `client/coverage/`
- **Commands**:
```bash
npm test # Run tests in watch mode
npm run test:coverage # Generate coverage report
```
```bash
./tools/check-frontend.sh
```
### Backend Testing
- **Unit Tests**: Fast, isolated tests with mocked dependencies
- **Integration Tests**: Full application context with test database
- **Coverage**: JaCoCo reporting with 75% minimum requirement
- **Commands**:
```bash
./mvnw test # Unit tests only
./mvnw clean verify -Ptests # All tests with coverage
```
## 📦 Deployment
### Production Deployment
The application supports multiple deployment strategies:
1. **Docker Containers** (recommended)
```bash
docker-compose -f docker-compose.prod.yml up -d
```
2. **Traditional JAR Deployment**
```bash
cd server && ./mvnw clean package
java -jar target/tasknote-api.jar
```
3. **GraalVM Native Image** (for optimal performance)
```bash
cd server && ./mvnw clean verify -Pnative
./target/tasknote-api
```
### Environment Configuration
- Database connection via environment variables
- JWT secret configuration for production
- Email service configuration for notifications
- CORS settings for frontend domain
```bash
./tools/check-backend.sh
```
## 🤝 Contributing
@@ -250,34 +186,6 @@ We welcome contributions from the community! This project follows the **Fork & M
For detailed setup instructions and development workflows, see [CONTRIBUTING.md](CONTRIBUTING.md).
## 👨‍💻 Developer
**Ricardo Campos** - Full-Stack Developer & Project Maintainer
- **GitHub**: [@RMCampos](https://github.com/RMCampos)
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs)
- **LinkedIn**: [Ricardo Campos](https://www.linkedin.com/in/ricardompcampos/)
### About the Developer
Ricardo is a passionate full-stack developer with expertise in modern web technologies, cloud architecture, and agile development practices. This project showcases his skills in:
- **Frontend Development**: React, TypeScript, modern CSS, responsive design
- **Backend Development**: Java, Spring Boot, RESTful APIs, microservices
- **DevOps & Infrastructure**: Docker, CI/CD, cloud deployment, monitoring
- **Software Quality**: Testing strategies, code coverage, static analysis
- **Open Source**: Community engagement, documentation, maintainership
The TaskNote project represents a commitment to clean code, comprehensive testing, and user-centered design principles.
## 📞 Contact
For questions, suggestions, or collaboration opportunities:
- **Email**: Contact via GitHub issues or discussions
- **Twitter/X**: [@RMCamposs](https://x.com/RMCamposs) for quick questions
- **GitHub Issues**: [Create an issue](https://github.com/rmcampos/tasknote/issues) for bugs or feature requests
- **GitHub Discussions**: [Join discussions](https://github.com/rmcampos/tasknote/discussions) for general questions
## 📄 License
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
+1355 -1113
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -13,12 +13,12 @@
"private": true,
"dependencies": {
"@popperjs/core": "^2.11.8",
"@types/node": "^25.9.2",
"@types/node": "^26.0.1",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-react": "^6.0.3",
"bootstrap": "^5.3.8",
"dompurify": "^3.4.8",
"i18next": "^26.3.1",
"dompurify": "^3.4.11",
"i18next": "^26.3.2",
"react": "^19.2.7",
"react-bootstrap": "^2.10.10",
"react-bootstrap-icons": "^1.11.6",
@@ -27,11 +27,11 @@
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"react-router": "^7.17.0",
"react-router": "^8.0.1",
"react-router-bootstrap": "^0.26.3",
"remark-gfm": "^4.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.16"
"vite": "^8.1.0"
},
"scripts": {
"start": "vite --host",
@@ -75,21 +75,21 @@
"@types/jest": "^30.0.0",
"@types/react": "^19.2.17",
"@types/react-router-bootstrap": "^0.26.8",
"@vitest/coverage-v8": "^4.1.8",
"cypress": "^15.16.0",
"@vitest/coverage-v8": "^4.1.9",
"cypress": "^15.18.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-jsdoc": "^62.9.0",
"eslint-plugin-import-x": "^4.17.0",
"eslint-plugin-jsdoc": "^63.0.7",
"eslint-plugin-n": "^18.1.0",
"eslint-plugin-promise": "^7.3.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.6.0",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"sass": "^1.100.0",
"prettier": "^3.8.4",
"sass": "^1.101.0",
"source-map-support": "^0.5.21",
"typescript-eslint": "^8.61.0",
"vitest": "^4.1.8"
"typescript-eslint": "^8.62.0",
"vitest": "^4.1.9"
}
}
+13
View File
@@ -127,6 +127,19 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!signed) return;
const TWENTY_FIVE_MINUTES = 25 * 60 * 1000;
const intervalId = setInterval(() => {
checkCurrentAuthUser(window.location.pathname).catch(() => {
setSigned(false);
setUser(undefined);
localStorage.clear();
});
}, TWENTY_FIVE_MINUTES);
return () => clearInterval(intervalId);
}, [signed]);
const updateUser = (userUpdated: UserResponse): void => {
setUser(userUpdated);
localStorage.setItem(USER_DATA, JSON.stringify(userUpdated));
+112 -14
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Card,
Col,
@@ -23,6 +24,13 @@ import ContentHeader from '../../components/ContentHeader';
type NoteAction = 'add' | 'edit';
interface NoteDraft {
title: string;
content: string;
noteUrl: string;
tags: string[];
}
/**
* NoteAdd component for adding and editing notes.
*
@@ -41,10 +49,15 @@ function NoteAdd(): React.ReactNode {
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const [action, setAction] = useState<NoteAction>('add');
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
const [draftBanner, setDraftBanner] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const tagContainerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasUserEdited = useRef<boolean>(false);
const draftKey = params?.id ? `draft:note:edit:${params.id}` : 'draft:note:new';
const loadTags = async (): Promise<void> => {
try {
@@ -84,7 +97,6 @@ function NoteAdd(): React.ReactNode {
catch (e) {
handleError(e);
}
return false;
};
@@ -115,28 +127,84 @@ function NoteAdd(): React.ReactNode {
setNoteContent('');
setCurrentTag('');
setSelectedTags([]);
setAction('add');
setValidated(false);
};
const saveDraft = (title: string, content: string, noteUrl: string, draftTags: string[]): void => {
if (!hasUserEdited.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const draft: NoteDraft = { title, content, noteUrl, tags: draftTags };
localStorage.setItem(draftKey, JSON.stringify(draft));
}, 1500);
};
const clearDraft = (): void => {
if (debounceRef.current) clearTimeout(debounceRef.current);
localStorage.removeItem(draftKey);
};
const applyDraft = (): void => {
const raw = localStorage.getItem(draftKey);
if (!raw) return;
try {
const draft: NoteDraft = JSON.parse(raw);
setNoteTitle(draft.title ?? '');
setNoteContent(draft.content ?? '');
setNoteUrl(draft.noteUrl ?? '');
setSelectedTags(draft.tags ?? []);
setDraftBanner(true);
}
catch {
localStorage.removeItem(draftKey);
}
};
const handleDiscardDraft = async (): Promise<void> => {
setDraftBanner(false);
if (params?.id) {
try {
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
setNoteFromServer(noteToEdit);
}
catch (e) {
handleError(e);
}
finally {
clearDraft();
}
}
else {
clearDraft();
resetInputs();
}
};
const addTag = (tagName: string): void => {
const normalized = tagName.trim().toLowerCase();
let newTags = [...selectedTags];
if (normalized && !selectedTags.includes(normalized)) {
setSelectedTags([...selectedTags, normalized]);
newTags = [...selectedTags, normalized];
setSelectedTags(newTags);
}
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, noteUrl, newTags);
setCurrentTag('');
setShowTagDropdown(false);
};
const removeTag = (tagToRemove: string): void => {
setSelectedTags(selectedTags.filter(t => t !== tagToRemove));
const newTags = selectedTags.filter(t => t !== tagToRemove);
setSelectedTags(newTags);
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, noteUrl, newTags);
};
/**
* Handles the form submission.
*
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
*/
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
@@ -149,7 +217,6 @@ function NoteAdd(): React.ReactNode {
return;
}
// Add current tag if not empty before submitting
const finalTags = [...selectedTags];
if (currentTag.trim()) {
const normalized = currentTag.trim().toLowerCase();
@@ -172,6 +239,7 @@ function NoteAdd(): React.ReactNode {
const added: boolean = await addNote(payload);
if (added) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
@@ -191,6 +259,7 @@ function NoteAdd(): React.ReactNode {
const edited: boolean = await submitEditNote(payload);
if (edited) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
@@ -207,6 +276,7 @@ function NoteAdd(): React.ReactNode {
const noteToEdit: NoteResponse = await api.getJSON(`${ApiConfig.notesUrl}/${params.id}`);
setNoteFromServer(noteToEdit);
setAction('edit');
applyDraft();
}
catch (e) {
handleError(e);
@@ -238,16 +308,16 @@ function NoteAdd(): React.ReactNode {
}
};
const setNoteFromServer = (noteContent: NoteResponse) => {
setNoteId(noteContent.id);
setNoteTitle(noteContent.title);
if (noteContent.url) {
setNoteUrl(noteContent.url);
const setNoteFromServer = (noteData: NoteResponse) => {
setNoteId(noteData.id);
setNoteTitle(noteData.title);
if (noteData.url) {
setNoteUrl(noteData.url);
}
if (noteContent.tags) {
setSelectedTags(noteContent.tags);
if (noteData.tags) {
setSelectedTags(noteData.tags);
}
setNoteContent(noteContent.description);
setNoteContent(noteData.description);
};
/**
@@ -271,6 +341,10 @@ function NoteAdd(): React.ReactNode {
checkEditUrl();
checkCloneUrl();
if (!params?.id && !window.location.search.includes('cloneFrom=')) {
applyDraft();
}
const handleClickOutside = (event: MouseEvent): void => {
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
setShowTagDropdown(false);
@@ -280,6 +354,7 @@ function NoteAdd(): React.ReactNode {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
@@ -304,6 +379,22 @@ function NoteAdd(): React.ReactNode {
onClose={() => setErrorMessage('')}
/>
{draftBanner && (
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
Draft restored from a previous session.
{' '}
<Alert.Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
void handleDiscardDraft();
}}
>
Discard draft
</Alert.Link>
</Alert>
)}
<Form
noValidate
validated={validated}
@@ -321,6 +412,8 @@ function NoteAdd(): React.ReactNode {
value={noteTitle}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteTitle(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, noteContent, noteUrl, selectedTags);
}}
/>
@@ -337,6 +430,8 @@ function NoteAdd(): React.ReactNode {
value={noteUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setNoteUrl(e.target.value);
hasUserEdited.current = true;
saveDraft(noteTitle, noteContent, e.target.value, selectedTags);
}}
/>
</Col>
@@ -438,6 +533,8 @@ function NoteAdd(): React.ReactNode {
value={noteContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNoteContent(e.target.value);
hasUserEdited.current = true;
saveDraft(noteTitle, e.target.value, noteUrl, selectedTags);
}}
data-testid="note-content-input-area"
/>
@@ -454,6 +551,7 @@ function NoteAdd(): React.ReactNode {
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
+139 -18
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Alert,
Badge,
Card,
Col,
@@ -23,6 +24,14 @@ import AlertError from '../../components/AlertError';
type TaskAction = 'add' | 'edit';
interface TaskDraft {
description: string;
taskUrl: string;
dueDate: string | null;
highPriority: boolean;
tags: string[];
}
/**
* TaskAdd component for adding and editing tasks.
*
@@ -42,10 +51,15 @@ function TaskAdd(): React.ReactNode {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [showTagDropdown, setShowTagDropdown] = useState<boolean>(false);
const [draftBanner, setDraftBanner] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const tagContainerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasUserEdited = useRef<boolean>(false);
const draftKey = params?.id ? `draft:task:edit:${params.id}` : 'draft:task:new';
const loadTags = async (): Promise<void> => {
try {
@@ -85,7 +99,6 @@ function TaskAdd(): React.ReactNode {
catch (e) {
handleError(e);
}
return false;
};
@@ -118,28 +131,112 @@ function TaskAdd(): React.ReactNode {
setHighPriority(false);
setCurrentTag('');
setSelectedTags([]);
setAction('add');
setValidated(false);
};
const saveDraft = (
description: string,
taskUrl: string,
due: Date | null,
priority: boolean,
draftTags: string[]
): void => {
if (!hasUserEdited.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const draft: TaskDraft = {
description,
taskUrl,
dueDate: due ? due.toISOString() : null,
highPriority: priority,
tags: draftTags
};
localStorage.setItem(draftKey, JSON.stringify(draft));
}, 1500);
};
const clearDraft = (): void => {
if (debounceRef.current) clearTimeout(debounceRef.current);
localStorage.removeItem(draftKey);
};
const applyDraft = (): void => {
const raw = localStorage.getItem(draftKey);
if (!raw) return;
try {
const draft: TaskDraft = JSON.parse(raw);
setTaskDescription(draft.description ?? '');
setTaskUrl(draft.taskUrl ?? '');
const parsedDate = draft.dueDate ? new Date(draft.dueDate) : null;
setDueDate(parsedDate && !isNaN(parsedDate.getTime()) ? parsedDate : null);
setHighPriority(draft.highPriority ?? false);
setSelectedTags(draft.tags ?? []);
setDraftBanner(true);
}
catch {
localStorage.removeItem(draftKey);
}
};
const setTaskFromServer = (task: TaskResponse): void => {
setTaskId(task.id);
setTaskDescription(task.description);
setTaskUrl(task.urls.length ? task.urls[0] : '');
setTaskDone(task.done);
if (task.dueDateFmt) {
setDueDate(new Date(task.dueDate));
}
setHighPriority(task.highPriority);
if (task.tags) {
setSelectedTags(task.tags);
}
};
const handleDiscardDraft = async (): Promise<void> => {
setDraftBanner(false);
if (params?.id) {
try {
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
setTaskFromServer(taskToEdit);
}
catch (e) {
handleError(e);
}
finally {
clearDraft();
}
}
else {
clearDraft();
resetInputs();
}
};
const addTag = (tagName: string): void => {
const normalized = tagName.trim().toLowerCase();
let newTags = [...selectedTags];
if (normalized && !selectedTags.includes(normalized)) {
setSelectedTags([...selectedTags, normalized]);
newTags = [...selectedTags, normalized];
setSelectedTags(newTags);
}
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
setCurrentTag('');
setShowTagDropdown(false);
};
const removeTag = (tagToRemove: string): void => {
setSelectedTags(selectedTags.filter(t => t !== tagToRemove));
const newTags = selectedTags.filter(t => t !== tagToRemove);
setSelectedTags(newTags);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, dueDate, highPriority, newTags);
};
/**
* Handles the form submission.
*
* @param {React.FormEvent<HTMLFormElement>} event - The form submission event.
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
*/
const handleSubmit = async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
@@ -157,7 +254,6 @@ function TaskAdd(): React.ReactNode {
dueDateFormatted = dueDate.toISOString().substring(0, 10);
}
// Add current tag if not empty before submitting
const finalTags = [...selectedTags];
if (currentTag.trim()) {
const normalized = currentTag.trim().toLowerCase();
@@ -177,6 +273,7 @@ function TaskAdd(): React.ReactNode {
const added: boolean = await addTask(addPayload);
if (added) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
@@ -197,6 +294,7 @@ function TaskAdd(): React.ReactNode {
const edited: boolean = await submitEditTask(editPayload);
if (edited) {
clearDraft();
form.reset();
resetInputs();
navigate('/home');
@@ -211,18 +309,9 @@ function TaskAdd(): React.ReactNode {
if (params.id) {
try {
const taskToEdit: TaskResponse = await api.getJSON(`${ApiConfig.tasksUrl}/${params.id}`);
setTaskId(taskToEdit.id);
setTaskDescription(taskToEdit.description);
setTaskUrl(taskToEdit.urls.length ? taskToEdit.urls[0] : '');
setTaskDone(taskToEdit.done);
if (taskToEdit.dueDateFmt) {
setDueDate(new Date(taskToEdit.dueDate));
}
setHighPriority(taskToEdit.highPriority);
if (taskToEdit.tags) {
setSelectedTags(taskToEdit.tags);
}
setTaskFromServer(taskToEdit);
setAction('edit');
applyDraft();
}
catch (e) {
handleError(e);
@@ -234,6 +323,10 @@ function TaskAdd(): React.ReactNode {
loadTags();
checkEditUrl();
if (!params?.id) {
applyDraft();
}
const handleClickOutside = (event: MouseEvent): void => {
if (tagContainerRef.current && !tagContainerRef.current.contains(event.target as Node)) {
setShowTagDropdown(false);
@@ -243,6 +336,7 @@ function TaskAdd(): React.ReactNode {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
@@ -268,6 +362,22 @@ function TaskAdd(): React.ReactNode {
onClose={() => setErrorMessage('')}
/>
{draftBanner && (
<Alert variant="warning" dismissible onClose={() => setDraftBanner(false)}>
Draft restored from a previous session.
{' '}
<Alert.Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
void handleDiscardDraft();
}}
>
Discard draft
</Alert.Link>
</Alert>
)}
<Form
noValidate
validated={validated}
@@ -285,6 +395,8 @@ function TaskAdd(): React.ReactNode {
value={taskDescription}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskDescription(e.target.value);
hasUserEdited.current = true;
saveDraft(e.target.value, taskUrl, dueDate, highPriority, selectedTags);
}}
/>
@@ -301,6 +413,8 @@ function TaskAdd(): React.ReactNode {
value={taskUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskUrl(e.target.value);
hasUserEdited.current = true;
saveDraft(taskDescription, e.target.value, dueDate, highPriority, selectedTags);
}}
/>
</Col>
@@ -316,6 +430,8 @@ function TaskAdd(): React.ReactNode {
valueDate={dueDate}
onChangeDate={(date: Date | null) => {
setDueDate(date);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, date, highPriority, selectedTags);
}}
/>
</Col>
@@ -402,7 +518,11 @@ function TaskAdd(): React.ReactNode {
className="mb-3"
name="highPriority"
checked={highPriority}
onChange={() => setHighPriority(!highPriority)}
onChange={() => {
setHighPriority(!highPriority);
hasUserEdited.current = true;
saveDraft(taskDescription, taskUrl, dueDate, !highPriority, selectedTags);
}}
/>
<button
@@ -416,6 +536,7 @@ function TaskAdd(): React.ReactNode {
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
onClick={() => {
clearDraft();
navigate('/home');
}}
>
+1 -1
View File
@@ -29,7 +29,7 @@ export default defineConfig(({ mode }: ConfigEnv) => {
],
build: {
outDir: 'dist',
sourcemap: true
sourcemap: mode === 'development'
},
server: {
port: 5000
+5 -6
View File
@@ -23,13 +23,13 @@ services:
POSTGRES_DB: tasknote
POSTGRES_HOST: tasknote-db
POSTGRES_USER: tasknoteuser
POSTGRES_PASSWORD: default
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PORT: 5432
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000, https://flattop-depth-dropper.ngrok-free.dev
CORS_ALLOWED_ORIGINS: http://tasknote-web:5000, http://localhost:5000
SERVER_SERVLET_CONTEXT_PATH: /
TARGET_ENV: production
SECURITY_KEY: ${SECURITY_KEY:-default-security-key}
MAILGUN_APIKEY: ${MAILGUN_APIKEY:-default-mailgun-apikey}
SECURITY_KEY: ${SECURITY_KEY}
MAILGUN_APIKEY: ${MAILGUN_APIKEY}
ports: ["8585:8585"]
image: ghcr.io/rmcampos/tasknote/api:latest
healthcheck:
@@ -47,8 +47,7 @@ services:
environment:
POSTGRES_DB: tasknote
POSTGRES_USER: tasknoteuser
POSTGRES_PASSWORD: default
ports: ["5432:5432"]
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: psql -q -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1'
interval: 1m30s
+1 -1
View File
@@ -11,7 +11,7 @@
<groupId>br.com.tasknoteapp</groupId>
<artifactId>server</artifactId>
<version>32</version>
<version>33</version>
<name>tasknote-api</name>
<description>Java backend REST API to serve TaskNote frontend client</description>
@@ -56,7 +56,7 @@ public class SecurityConfig {
.requestMatchers("/rest/**")
.authenticated()
.anyRequest()
.permitAll())
.denyAll())
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(
@@ -77,7 +77,7 @@ public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
return new BCryptPasswordEncoder(12);
}
/**
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.repository;
import br.com.tasknoteapp.server.entity.TaskEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -11,6 +12,8 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
List<TaskEntity> findAllByUser_id(Long userId);
Optional<TaskEntity> findByIdAndUser_id(Long id, Long userId);
@Query(
"""
select distinct t
@@ -78,12 +78,8 @@ public class LoginRequest {
+ "email='"
+ email
+ '\''
+ ", password='"
+ password
+ '\''
+ ", passwordAgain='"
+ passwordAgain
+ '\''
+ ", password='[REDACTED]'"
+ ", passwordAgain='[REDACTED]'"
+ ", lang='"
+ lang
+ '\''
@@ -1,13 +1,15 @@
package br.com.tasknoteapp.server.request;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/** This record represents a note patch payload. */
public record NotePatchRequest(
String title,
String description,
@Pattern(
@Size(max = 100) String title,
@Size(max = 50000) String description,
@Size(max = 200)
@Pattern(
regexp = "^(https?://.*|#.*)?$",
message = "URL must start with https:// or #")
String url,
@@ -2,13 +2,15 @@ package br.com.tasknoteapp.server.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/** This record represents a note request to be created. */
public record NoteRequest(
@NotNull String title,
@NotNull String description,
@Pattern(
@NotNull @Size(max = 100) String title,
@NotNull @Size(max = 50000) String description,
@Size(max = 200)
@Pattern(
regexp = "^(https?://.*|#.*)?$",
message = "URL must start with https:// or #")
String url,
@@ -1,13 +1,15 @@
package br.com.tasknoteapp.server.request;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/** This record represents a task patch payload. */
public record TaskPatchRequest(
String description,
@Size(max = 2000) String description,
Boolean done,
List<
@Size(max = 200)
@Pattern(
regexp = "^(https?://.*|#.*)?$",
message = "URL must start with https:// or #")
@@ -3,12 +3,14 @@ package br.com.tasknoteapp.server.request;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/** This record represents a task request to be created. */
public record TaskRequest(
@NotNull @NotEmpty String description,
@NotNull @NotEmpty @Size(max = 2000) String description,
List<
@Size(max = 200)
@Pattern(
regexp = "^(https?://.*|#.*)?$",
message = "URL must start with https:// or #")
@@ -2,4 +2,10 @@ package br.com.tasknoteapp.server.request;
/** This record represents a user patch payload. */
public record UserPatchRequest(
String name, String email, String password, String passwordAgain, String lang) {}
String name,
String email,
String password,
String passwordAgain,
String lang,
String currentPassword) {}
@@ -292,7 +292,7 @@ public class AuthService {
String token = jwtService.generateToken(currentUser);
logger.info("User refreshed! Token {}", token);
logger.info("User refreshed! Token {}...", token.substring(0, 6));
return token;
}
@@ -329,11 +329,26 @@ public class AuthService {
boolean shouldUpdate = false;
boolean emailChanged = false;
boolean changingEmail =
!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank();
boolean changingPassword =
!Objects.isNull(patchRequest.password()) && !patchRequest.password().isBlank();
if (changingEmail || changingPassword) {
if (Objects.isNull(patchRequest.currentPassword())
|| patchRequest.currentPassword().isBlank()) {
throw new BadPasswordException("Current password is required to change email or password");
}
if (!passwordEncoder.matches(patchRequest.currentPassword(), currentUser.getPassword())) {
throw new InvalidCredentialsException();
}
}
if (!Objects.isNull(patchRequest.name()) && !patchRequest.name().isBlank()) {
currentUser.setName(patchRequest.name().trim());
shouldUpdate = true;
}
if (!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank()) {
if (changingEmail) {
currentUser.setEmail(patchRequest.email().trim());
shouldUpdate = true;
emailChanged = true;
@@ -344,8 +359,7 @@ public class AuthService {
}
boolean updatePassword =
!Objects.isNull(patchRequest.password())
&& !patchRequest.password().isBlank()
changingPassword
&& !Objects.isNull(patchRequest.passwordAgain())
&& !patchRequest.passwordAgain().isBlank();
@@ -563,11 +577,11 @@ public class AuthService {
// if it's more than 3 times in the last 10 minutes, raise timer of 3 hours.
if (userPwdList.size() >= 3) {
UserPwdLimitEntity mostRecent = userPwdList.getFirst();
logger.warn("Oldest: {}", mostRecent.getWhenHappened());
Duration duration = Duration.between(mostRecent.getWhenHappened(), LocalDateTime.now());
UserPwdLimitEntity oldest = userPwdList.getLast();
logger.warn("Oldest failed attempt: {}", oldest.getWhenHappened());
Duration duration = Duration.between(oldest.getWhenHappened(), LocalDateTime.now());
if (duration.toMinutes() <= 3L) {
logger.warn("Wait more {}", 3L - duration.toMinutes());
logger.warn("Account locked, minutes remaining: {}", 3L - duration.toMinutes());
throw new MaxLoginLimitAttemptException();
}
}
@@ -95,7 +95,7 @@ public class TaskService {
UserEntity user = getCurrentUser();
logger.info("Get task ID {} to user ID {}", taskId, user.getId());
Optional<TaskEntity> task = taskRepository.findById(taskId);
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
if (task.isEmpty()) {
throw new TaskNotFoundException();
}
@@ -152,7 +152,7 @@ public class TaskService {
logger.info("Patching task ID {} to user ID {}", taskId, user.getId());
Optional<TaskEntity> task = taskRepository.findById(taskId);
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
if (task.isEmpty()) {
throw new TaskNotFoundException();
}
@@ -198,7 +198,7 @@ public class TaskService {
logger.info("Deleting task ID {} to user ID {}", taskId, user.getId());
Optional<TaskEntity> task = taskRepository.findById(taskId);
Optional<TaskEntity> task = taskRepository.findByIdAndUser_id(taskId, user.getId());
if (task.isEmpty()) {
throw new TaskNotFoundException();
}
@@ -3,8 +3,8 @@ package br.com.tasknoteapp.server.service.impl;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.service.JwtService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
@@ -29,7 +29,7 @@ class JwtServiceImpl implements JwtService {
private static final long MINUTE = SECOND * 60;
private static final long HOUR = MINUTE * 60;
private static final long DAY = HOUR * 24;
private static final long EXPIRATION_TIME = DAY * 7;
private static final long EXPIRATION_TIME = MINUTE * 30;
private final SecretKey key;
public JwtServiceImpl(@Value("${br.com.tasknote.server.jwt-secret}") String secretKey) {
@@ -122,7 +122,7 @@ class JwtServiceImpl implements JwtService {
try {
return Optional.of(
Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload());
} catch (MalformedJwtException me) {
} catch (JwtException e) {
return Optional.empty();
}
}
@@ -1,72 +1,17 @@
package br.com.tasknoteapp.server.util;
import br.com.tasknoteapp.server.exception.BadAlgorithmException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/** This class provides method to handle UUIDs. */
public class UuidUtil {
private final UUID namespaceUrl = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
/**
* Generated a unique UUID to a given email.
* Generates a cryptographically random UUID for use as an email confirmation token.
*
* @param email The email to create the UUID.
* @return The generated UUID.
* @param email The user email (unused; kept for API compatibility).
* @return A random UUID.
*/
public UUID generateEmailUuid(String email) {
return generateUuidFromName(namespaceUrl, email.toLowerCase().trim());
}
private UUID generateUuidFromName(UUID namespace, String name) {
// SHA-1 digest of namespace UUID + name
byte[] namespaceBytes = toBytes(namespace);
byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
byte[] combined = new byte[namespaceBytes.length + nameBytes.length];
System.arraycopy(namespaceBytes, 0, combined, 0, namespaceBytes.length);
System.arraycopy(nameBytes, 0, combined, namespaceBytes.length, nameBytes.length);
byte[] sha1 = sha1(combined);
// Manipulate bits to make it UUID v5 (version 5, SHA-1)
sha1[6] &= 0x0f;
sha1[6] |= 0x50;
sha1[8] &= 0x3f;
sha1[8] |= (byte) 0x80;
return bytesToUuid(sha1);
}
private byte[] toBytes(UUID uuid) {
long msb = uuid.getMostSignificantBits();
long lsb = uuid.getLeastSignificantBits();
byte[] bytes = new byte[16];
for (int i = 0; i < 8; i++) {
bytes[i] = (byte) ((msb >>> (8 * (7 - i))) & 0xFF);
bytes[8 + i] = (byte) ((lsb >>> (8 * (7 - i))) & 0xFF);
}
return bytes;
}
private byte[] sha1(byte[] input) {
try {
return java.security.MessageDigest.getInstance("SHA-1").digest(input);
} catch (Exception e) {
throw new BadAlgorithmException("SHA-1 algorithm not available");
}
}
private UUID bytesToUuid(byte[] hash) {
long msb = 0;
long lsb = 0;
for (int i = 0; i < 8; i++) {
msb = (msb << 8) | (hash[i] & 0xff);
}
for (int i = 8; i < 16; i++) {
lsb = (lsb << 8) | (hash[i] & 0xff);
}
return new UUID(msb, lsb);
return UUID.randomUUID();
}
}
@@ -0,0 +1,8 @@
[
{
"name": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlServerUntrustedCertificateSqlException",
"allDeclaredMethods": true,
"allDeclaredConstructors": true
}
]
@@ -2,14 +2,14 @@ br:
com:
tasknote:
server:
jwt-secret: ${SECURITY_KEY:empty}
jwt-secret: ${SECURITY_KEY}
target-env: ${TARGET_ENV:development}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
logging:
level:
root: ${ROOT_LOG_LEVEL:INFO}
br.com.tasknoteapp: TRACE
br.com.tasknoteapp: INFO
mailgun:
api-key: ${MAILGUN_APIKEY:abc123456}
@@ -19,7 +19,7 @@ mailgun:
server:
port: 8585
error:
include-message: always
include-message: never
servlet:
context-path: ${SERVER_SERVLET_CONTEXT_PATH:/server}
spring:
@@ -27,7 +27,7 @@ spring:
name: tasknote-api
datasource:
driver-class-name: org.postgresql.Driver
password: ${POSTGRES_PASSWORD:default}
password: ${POSTGRES_PASSWORD}
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5435}/${POSTGRES_DB:tasknote}
username: ${POSTGRES_USER:tasknoteuser}
flyway:
+4 -4
View File
@@ -2,14 +2,14 @@ br:
com:
tasknote:
server:
jwt-secret: ${SECURITY_KEY:empty}
jwt-secret: ${SECURITY_KEY}
target-env: ${TARGET_ENV:development}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
logging:
level:
root: ${ROOT_LOG_LEVEL:INFO}
br.com.tasknoteapp: TRACE
br.com.tasknoteapp: INFO
mailgun:
api-key: ${MAILGUN_APIKEY:abc123456}
@@ -19,7 +19,7 @@ mailgun:
server:
port: 8585
error:
include-message: always
include-message: never
servlet:
context-path: ${SERVER_SERVLET_CONTEXT_PATH:/server}
spring:
@@ -27,7 +27,7 @@ spring:
name: tasknote-api
datasource:
driver-class-name: org.postgresql.Driver
password: ${POSTGRES_PASSWORD:default}
password: ${POSTGRES_PASSWORD}
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5435}/${POSTGRES_DB:tasknote}
username: ${POSTGRES_USER:tasknoteuser}
flyway:
@@ -0,0 +1,2 @@
ALTER TABLE tasknote.notes
ADD CONSTRAINT chk_notes_description_max_length CHECK (length(description) <= 50000) NOT VALID;
@@ -103,7 +103,8 @@ class UserControllerTest {
void patchUserInfo_happyPath_shouldSucceed() throws Exception {
UserResponse response =
new UserResponse(1L, "John", "email@example.com", false, null, null, null, null);
UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null, null);
UserPatchRequest request =
new UserPatchRequest("John Doe", response.email(), null, null, null, null);
when(authService.patchUserInfo(request)).thenReturn(response);
String jsonString =
@@ -264,9 +264,11 @@ class AuthServiceTest {
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
UserPwdLimitEntity limit1 = new UserPwdLimitEntity();
limit1.setWhenHappened(LocalDateTime.now().minusMinutes(1));
limit1.setWhenHappened(LocalDateTime.now().minusSeconds(30));
UserPwdLimitEntity limit2 = new UserPwdLimitEntity();
limit2.setWhenHappened(LocalDateTime.now().minusMinutes(1));
UserPwdLimitEntity limit3 = new UserPwdLimitEntity();
limit3.setWhenHappened(LocalDateTime.now().minusMinutes(2));
when(userPwdLimitRepository.findTop3ByUser_idOrderByWhenHappenedDesc(existing.getId()))
.thenReturn(List.of(limit1, limit2, limit3));
@@ -417,12 +419,15 @@ class AuthServiceTest {
existing.setName(null);
existing.setEmail(email);
existing.setAdmin(false);
existing.setPassword("hashedCurrentPassword");
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
when(userRepository.save(any())).thenReturn(existing);
String currentPassword = "currentPw123@";
when(passwordEncoder.matches(currentPassword, "hashedCurrentPassword")).thenReturn(true);
UserPatchRequest patchRequest =
new UserPatchRequest("Kong", "newemail@domain.com", null, null, null);
new UserPatchRequest("Kong", "newemail@domain.com", null, null, null, currentPassword);
UserResponse response = authService.patchUserInfo(patchRequest);
Assertions.assertNotNull(response);
@@ -441,13 +446,17 @@ class AuthServiceTest {
existing.setName(null);
existing.setEmail(email);
existing.setAdmin(false);
existing.setPassword("hashedCurrentPassword");
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
when(userRepository.save(any())).thenReturn(existing);
String currentPassword = "currentPw123@";
when(passwordEncoder.matches(currentPassword, "hashedCurrentPassword")).thenReturn(true);
String newPassword = "TestHackedPw@difficult!#:)";
UserPatchRequest patchRequest =
new UserPatchRequest("Kong", "newemail@domain.com", newPassword, newPassword, "en");
new UserPatchRequest(
"Kong", "newemail@domain.com", newPassword, newPassword, "en", currentPassword);
when(authUtil.validatePassword(patchRequest.password())).thenReturn(Optional.empty());
@@ -85,7 +85,7 @@ class TaskServiceTest {
taskEntity.setHighPriority(true);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
TaskResponse taskResponse = taskService.getTaskById(taskId);
@@ -108,7 +108,7 @@ class TaskServiceTest {
Long taskId = 9976L;
when(taskRepository.findById(taskId)).thenReturn(Optional.empty());
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.empty());
assertThrows(TaskNotFoundException.class, () -> taskService.getTaskById(taskId));
}
@@ -312,7 +312,7 @@ class TaskServiceTest {
taskEntity.setHighPriority(true);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of());
@@ -335,7 +335,7 @@ class TaskServiceTest {
Long taskId = 2526L;
when(taskRepository.findById(taskId)).thenReturn(Optional.empty());
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.empty());
assertThrows(TaskNotFoundException.class, () -> taskService.deleteTask(taskId));
}
@@ -359,7 +359,7 @@ class TaskServiceTest {
taskEntity.setDone(false);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of());
@@ -410,7 +410,7 @@ class TaskServiceTest {
taskEntity.setDone(false);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
TaskUrlEntity urlEntity = new TaskUrlEntity();
urlEntity.setId(new TaskUrlEntityPk(taskId, "www.url.com"));
@@ -460,7 +460,7 @@ class TaskServiceTest {
Long taskId = 2525L;
when(taskRepository.findById(taskId)).thenReturn(Optional.empty());
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.empty());
List<String> tags = List.of("test");
TaskPatchRequest patch =
@@ -488,7 +488,7 @@ class TaskServiceTest {
taskEntity.setDone(false);
taskEntity.setTags(Set.of(new TagEntity("test", userEntity)));
taskEntity.setUser(userEntity);
when(taskRepository.findById(taskId)).thenReturn(Optional.of(taskEntity));
when(taskRepository.findByIdAndUser_id(taskId, USER_ID)).thenReturn(Optional.of(taskEntity));
when(taskUrlRepository.findAllById_taskId(taskId)).thenReturn(List.of());
@@ -3,14 +3,12 @@ package br.com.tasknoteapp.server.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import br.com.tasknoteapp.server.entity.UserEntity;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
@@ -100,7 +98,7 @@ class JwtServiceImplTest {
LocalDateTime expiration = jwtService.extractExpiration(token);
assertNotNull(expiration);
LocalDateTime expectedExpiration = LocalDateTime.now().plusDays(7).withNano(0);
LocalDateTime expectedExpiration = LocalDateTime.now().plusMinutes(30).withNano(0);
assertFalse(ChronoUnit.SECONDS.between(expiration.withNano(0), expectedExpiration) > 5);
}
@@ -131,7 +129,7 @@ class JwtServiceImplTest {
.signWith(getKey())
.compact();
assertThrows(ExpiredJwtException.class, () -> jwtService.isTokenExpired(expiredToken));
assertTrue(jwtService.isTokenExpired(expiredToken));
}
@Test
@@ -13,6 +13,6 @@ class UuidUtilTest {
UUID uuid = uuidUtil.generateEmailUuid(email);
Assertions.assertNotNull(uuid);
Assertions.assertEquals(uuid, uuidUtil.generateEmailUuid(email));
Assertions.assertNotNull(uuidUtil.generateEmailUuid(email));
}
}
+14 -4
View File
@@ -26,12 +26,22 @@ resource "google_cloud_run_v2_service" "backend" {
value = google_sql_database_instance.instance.private_ip_address
}
env {
name = "POSTGRES_USER"
value = var.db_user
name = "POSTGRES_USER"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.db_user.secret_id
version = google_secret_manager_secret_version.db_user_version.version
}
}
}
env {
name = "POSTGRES_PASSWORD"
value = var.db_password
name = "POSTGRES_PASSWORD"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.db_password.secret_id
version = google_secret_manager_secret_version.db_password_version.version
}
}
}
env {
name = "POSTGRES_PORT"
+46
View File
@@ -3,6 +3,52 @@ resource "google_service_account" "cloudrun_sa" {
display_name = "TaskNote Cloud Run Service Account"
}
resource "google_secret_manager_secret" "db_password" {
secret_id = "db-password"
replication {
user_managed {
replicas {
location = var.region
}
}
}
depends_on = [google_project_service.secretmanager]
}
resource "google_secret_manager_secret_version" "db_password_version" {
secret = google_secret_manager_secret.db_password.id
secret_data = var.db_password
}
resource "google_secret_manager_secret" "db_user" {
secret_id = "db-user"
replication {
user_managed {
replicas {
location = var.region
}
}
}
depends_on = [google_project_service.secretmanager]
}
resource "google_secret_manager_secret_version" "db_user_version" {
secret = google_secret_manager_secret.db_user.id
secret_data = var.db_user
}
resource "google_secret_manager_secret_iam_member" "db_password_access" {
secret_id = google_secret_manager_secret.db_password.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
}
resource "google_secret_manager_secret_iam_member" "db_user_access" {
secret_id = google_secret_manager_secret.db_user.id
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.cloudrun_sa.email}"
}
resource "google_secret_manager_secret" "security_key" {
secret_id = "security-key"
replication {
+1 -1
View File
@@ -2,7 +2,7 @@
set -euo pipefail
docker run --rm -i --network=host \
-e PGPASSWORD=default \
-e PGPASSWORD="${PGPASSWORD:?PGPASSWORD env var is required}" \
postgres:15.8-bookworm \
psql -h localhost -U tasknoteuser -d tasknote \
-c "UPDATE tasknote.users SET email_confirmed_at = created_at WHERE id > 0;"