diff --git a/.gitignore b/.gitignore
index cd38e2e7b..7f9709787 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,6 @@ target
logs
attachments
*.patch
+.env
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..0a2effd82
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+# syntax=docker/dockerfile:1.7
+FROM eclipse-temurin:17-jdk-jammy AS builder
+WORKDIR /app
+
+# Copy build files first to leverage Docker layer cache
+COPY .mvn/ .mvn/
+COPY mvnw pom.xml ./
+RUN chmod +x mvnw
+# Speed up builds by caching the Maven repo (BuildKit needed)
+RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -DskipTests dependency:go-offline
+
+# Now add sources and build
+COPY src/ src/
+RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -DskipTests clean package
+
+FROM eclipse-temurin:17-jre-jammy
+WORKDIR /app
+
+# Run as non-root
+RUN useradd -ms /bin/bash appuser
+USER appuser
+
+# Copy the fat jar
+COPY --from=builder /app/target/*.jar /app/app.jar
+
+EXPOSE 8080
+
+ENTRYPOINT ["java","-jar","/app/app.jar"]
diff --git a/README.md b/README.md
index 719b268f5..4a1cb9836 100644
--- a/README.md
+++ b/README.md
@@ -27,4 +27,27 @@
- https://habr.com/ru/articles/259055/
Список выполненных задач:
-...
\ No newline at end of file
+
+# JiraRush Project: My Contributions & Enhancements
+
+This document outlines my key contributions and improvements to the JiraRush project, covering aspects of security, testing, refactoring, new features, and deployment.
+
+## My Contributions to the Project:
+
+Here's a breakdown of the specific tasks and features I implemented:
+
+1. 📘 **Project Structure Onboarding:** I successfully familiarized myself with the existing project structure and architecture, enabling effective subsequent modifications and new feature development.
+2. 🚫 **Removal of Deprecated Social Integrations:** I removed outdated social media integrations (VK, Yandex) from the project, simplifying the codebase and mitigating potential security risks.
+3. 🔑 **Externalization of Sensitive Information:** I moved sensitive data, such as database login credentials, OAuth registration/authorization identifiers, and email settings, into separate property files. These values are now securely loaded from machine environment variables upon server startup, significantly enhancing security and configuration flexibility.
+4. 🧪 **Refactoring Tests for In-Memory H2 Database:** I adapted the test suite to utilize an in-memory H2 database instead of PostgreSQL. This involved defining two distinct Spring beans, with the active Spring profile dictating which database to use. Minor adjustments were also made to test data scripts to ensure compatibility with H2's features.
+5. ✅ **Comprehensive `ProfileRestController` Test Coverage:** I developed a robust set of unit and integration tests for all public methods of the `ProfileRestController`. These tests meticulously validate both successful and various unsuccessful execution paths to guarantee the API's reliability and resilience.
+6. 🔄 **Refactoring `FileUtil#upload` for Modern File System API:** I refactored the `com.javarush.jira.bugtracking.attachment.FileUtil#upload` method to leverage modern Java file system APIs, enhancing its efficiency, robustness, and maintainability for file operations.
+7. 🏷️ **Implementation of Task Tagging System:** I introduced a new feature allowing users to add tags to tasks. This includes developing the dedicated REST API endpoint and implementing the corresponding service-layer logic, utilizing the existing `task_tag` database table.
+8. ⏱️ **Adding Task Time Tracking Functionality:** I implemented two service-level methods to calculate the time a task spends in specific operational states:
+ * **Time in Work:** Calculated as the duration from `in_progress` to `ready_for_review` status.
+ * **Time in Testing:** Calculated as the duration from `ready_for_review` to `done` status.
+ To support this, I appended three critical `ACTIVITY` entries (with `in_progress`, `ready_for_review`, and `done` statuses) to the `changelog.sql` database initialization script.
+9. 🐳 **Dockerfile for Application Server:** I created a `Dockerfile` to containerize the main application server, ensuring easy and consistent deployment across different environments.
+10. 🚀 **Docker Compose for Full Stack Deployment:** I developed a `docker-compose.yml` file to orchestrate the entire application stack. This setup includes the application server, a PostgreSQL database, and an Nginx reverse proxy, leveraging the `config/nginx.conf` file (which can be modified as needed) for efficient routing and load balancing.
+
+---
\ No newline at end of file
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 000000000..c54cb4ef2
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,47 @@
+services:
+ db:
+ image: postgres
+ container_name: postgres
+ env_file:
+ - ./.env
+ ports:
+ - "5433:5432"
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ networks:
+ - app_network
+
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: app
+ env_file:
+ - ./.env
+ ports:
+ - "8080:8080"
+ depends_on:
+ - db
+ networks:
+ - app_network
+
+ nginx:
+ image: nginx:latest
+ container_name: nginx
+ volumes:
+ - ./config/nginx.conf:/etc/nginx/nginx.conf
+ - ./resources/static:/opt/jirarush/resources/static
+ ports:
+ - "80:80"
+ depends_on:
+ - app
+ networks:
+ - app_network
+
+networks:
+ app_network:
+ driver: bridge
+
+volumes:
+ pgdata:
+ driver: local
\ No newline at end of file
diff --git a/config/nginx.conf b/config/nginx.conf
index 82b9e234d..3ef4ca4f3 100644
--- a/config/nginx.conf
+++ b/config/nginx.conf
@@ -1,40 +1,52 @@
-# https://losst.ru/ustanovka-nginx-ubuntu-16-04
-# https://pai-bx.com/wiki/nginx/2332-useful-redirects-in-nginx/#1
-# sudo iptables -A INPUT ! -s 127.0.0.1 -p tcp -m tcp --dport 8080 -j DROP
-server {
- listen 80;
-
- # https://www.digitalocean.com/community/tutorials/how-to-optimize-nginx-configuration
- gzip on;
- gzip_types text/css application/javascript application/json;
- gzip_min_length 2048;
-
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- root /opt/jirarush/resources;
-
- if ($request_uri ~ ';') {return 404;}
-
- # proxy_cookie_flags ~ secure samesite=none;
-
- # static
- location /static/ {
- expires 30d;
- access_log off;
- }
- location /robots.txt {
- access_log off;
- }
-
- location ~ (/$|/view/|/ui/|/oauth2/) {
- expires 0m;
- proxy_pass http://localhost:8080;
- proxy_connect_timeout 30s;
- }
- location ~ (/api/|/doc|/swagger-ui/|/v3/api-docs/) {
- proxy_pass http://localhost:8080;
- proxy_connect_timeout 150s;
- }
- location / {
- try_files /view/404.html = 404;
- }
+# Основний блок налаштувань
+user nginx;
+worker_processes 1;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ # Ваш серверний блок
+ server {
+ listen 80;
+
+ # https://www.digitalocean.com/community/tutorials/how-to-optimize-nginx-configuration
+ gzip on;
+ gzip_types text/css application/javascript application/json;
+ gzip_min_length 2048;
+
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ root /opt/jirarush/resources;
+
+ if ($request_uri ~ ';') { return 404; }
+
+ # Статичні файли
+ location /static/ {
+ expires 30d;
+ access_log off;
+ }
+
+ location /robots.txt {
+ access_log off;
+ }
+
+ location ~ (/$|/view/|/ui/|/oauth2/) {
+ expires 0m;
+ proxy_pass http://localhost:8080;
+ proxy_connect_timeout 30s;
+ }
+
+ location ~ (/api/|/doc|/swagger-ui/|/v3/api-docs/) {
+ proxy_pass http://localhost:8080;
+ proxy_connect_timeout 150s;
+ }
+
+ location / {
+ try_files /view/404.html '=' 404;
+ }
+ }
}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index f6c152c68..17044851e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.0.2
+ 3.1.12
@@ -41,6 +41,19 @@
spring-boot-starter-validation
+
+ io.github.cdimascio
+ java-dotenv
+ 5.2.2
+
+
+
+ org.springframework.boot
+ spring-boot-docker-compose
+ runtime
+ true
+
+
com.fasterxml.jackson.datatype
@@ -82,6 +95,12 @@
spring-boot-starter-thymeleaf
+
+ com.h2database
+ h2
+ test
+
+
org.postgresql
postgresql
@@ -96,7 +115,7 @@
org.projectlombok
lombok
- true
+ 1.18.30
org.mapstruct
@@ -146,6 +165,29 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.30
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ 3.5.5
+
+
+
+
org.springframework.boot
spring-boot-maven-plugin
@@ -196,4 +238,4 @@
-
+
\ No newline at end of file
diff --git a/resources/static/fontawesome/css/all.css b/resources/static/fontawesome/css/all.css
index af5980828..6a16cc2f0 100644
--- a/resources/static/fontawesome/css/all.css
+++ b/resources/static/fontawesome/css/all.css
@@ -8603,10 +8603,6 @@ readers do not read off random characters that represent icons */
content: "\f3e8";
}
-.fa-vk:before {
- content: "\f189";
-}
-
.fa-untappd:before {
content: "\f405";
}
@@ -9955,10 +9951,6 @@ readers do not read off random characters that represent icons */
content: "\f3bc";
}
-.fa-yandex:before {
- content: "\f413";
-}
-
.fa-readme:before {
content: "\f4d5";
}
@@ -10183,10 +10175,6 @@ readers do not read off random characters that represent icons */
content: "\f7c6";
}
-.fa-yandex-international:before {
- content: "\f414";
-}
-
.fa-cc-amex:before {
content: "\f1f3";
}
diff --git a/resources/view/login.html b/resources/view/login.html
index 8765ca8ff..d49ce5691 100644
--- a/resources/view/login.html
+++ b/resources/view/login.html
@@ -48,14 +48,6 @@ Sign in
type="button">
-
-
-
-
-
-
diff --git a/resources/view/unauth/register.html b/resources/view/unauth/register.html
index 2ba955045..52a892bd3 100644
--- a/resources/view/unauth/register.html
+++ b/resources/view/unauth/register.html
@@ -77,14 +77,6 @@ Registration
type="button">
-
-
-
-
-
-
diff --git a/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java b/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java
index 6cffbe175..434ad5501 100644
--- a/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java
+++ b/src/main/java/com/javarush/jira/bugtracking/attachment/FileUtil.java
@@ -25,14 +25,13 @@ public static void upload(MultipartFile multipartFile, String directoryPath, Str
throw new IllegalRequestDataException("Select a file to upload.");
}
- File dir = new File(directoryPath);
- if (dir.exists() || dir.mkdirs()) {
- File file = new File(directoryPath + fileName);
- try (OutputStream outStream = new FileOutputStream(file)) {
- outStream.write(multipartFile.getBytes());
- } catch (IOException ex) {
- throw new IllegalRequestDataException("Failed to upload file" + multipartFile.getOriginalFilename());
- }
+ try {
+ Path directory = Path.of(directoryPath);
+ Files.createDirectory(directory);
+ Path targetFile = directory.resolve(fileName);
+ multipartFile.transferTo(targetFile);
+ } catch (IOException e) {
+ throw new IllegalRequestDataException("Failed to upload file" + multipartFile.getOriginalFilename());
}
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java b/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java
index 3ce8a9386..b86749dc9 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/ActivityRepository.java
@@ -1,10 +1,12 @@
package com.javarush.jira.bugtracking.task;
import com.javarush.jira.common.BaseRepository;
+import jakarta.validation.constraints.Size;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
+import java.util.Optional;
@Transactional(readOnly = true)
public interface ActivityRepository extends BaseRepository {
@@ -13,4 +15,6 @@ public interface ActivityRepository extends BaseRepository {
@Query("SELECT a FROM Activity a JOIN FETCH a.author WHERE a.taskId =:taskId AND a.comment IS NOT NULL ORDER BY a.updated DESC")
List findAllComments(long taskId);
+
+ Optional findActivityByTaskIdAndStatusCode(long taskId, @Size(min = 2, max = 32) String statusCode);
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java b/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java
index 7938541bb..308a16eab 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/ActivityService.java
@@ -8,6 +8,8 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import java.time.Duration;
+import java.time.LocalDateTime;
import java.util.List;
import static com.javarush.jira.bugtracking.task.TaskUtil.getLatestValue;
@@ -19,11 +21,9 @@ public class ActivityService {
private final Handlers.ActivityHandler handler;
- private static void checkBelong(HasAuthorId activity) {
- if (activity.getAuthorId() != AuthUser.authId()) {
- throw new DataConflictException("Activity " + activity.getId() + " doesn't belong to " + AuthUser.get());
- }
- }
+ public static final String IN_PROGRESS = "in_progress";
+ public static final String READY_FOR_REVIEW = "ready_for_review";
+ public static final String DONE = "done";
@Transactional
public Activity create(ActivityTo activityTo) {
@@ -73,4 +73,39 @@ private void updateTaskIfRequired(long taskId, String activityStatus, String act
}
}
}
+
+ private static void checkBelong(HasAuthorId activity) {
+ if (activity.getAuthorId() != AuthUser.authId()) {
+ throw new DataConflictException("Activity " + activity.getId() + " doesn't belong to " + AuthUser.get());
+ }
+ }
+
+ public Duration progressTaskDuration(Task task) {
+ LocalDateTime readyForReview = getActivityUpdatedTime(task.id(), READY_FOR_REVIEW);
+ LocalDateTime inProgress = getActivityUpdatedTime(task.id(), IN_PROGRESS);
+
+ if (readyForReview == null || inProgress == null) {
+ return Duration.ZERO;
+ }
+
+ return Duration.between(inProgress, readyForReview);
+ }
+
+ public Duration testingTaskDuration(Task task) {
+ LocalDateTime done = getActivityUpdatedTime(task.id(), DONE);
+ LocalDateTime readyForReview = getActivityUpdatedTime(task.id(), READY_FOR_REVIEW);
+
+ if (readyForReview == null || done == null) {
+ return Duration.ZERO;
+ }
+
+ return Duration.between(readyForReview, done);
+ }
+
+ private LocalDateTime getActivityUpdatedTime(long taskId, String statusCode) {
+ return handler.getRepository()
+ .findActivityByTaskIdAndStatusCode(taskId, statusCode)
+ .map(Activity::getUpdated)
+ .orElse(null);
+ }
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
index b53f7ff37..4fa2523af 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskController.java
@@ -156,4 +156,10 @@ public TaskTreeNode(TaskTo taskTo) {
this(taskTo, new LinkedList<>());
}
}
+
+ @PostMapping("/{id}/tag")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void addTag(@PathVariable long id, @RequestBody String tag) {
+ taskService.addTag(id, tag);
+ }
}
diff --git a/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java b/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
index e6f385548..e58a8031d 100644
--- a/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
+++ b/src/main/java/com/javarush/jira/bugtracking/task/TaskService.java
@@ -10,6 +10,7 @@
import com.javarush.jira.bugtracking.task.to.TaskToExt;
import com.javarush.jira.bugtracking.task.to.TaskToFull;
import com.javarush.jira.common.error.DataConflictException;
+import com.javarush.jira.common.error.IllegalRequestDataException;
import com.javarush.jira.common.error.NotFoundException;
import com.javarush.jira.common.util.Util;
import com.javarush.jira.login.AuthUser;
@@ -140,4 +141,19 @@ private void checkAssignmentActionPossible(long id, String userType, boolean ass
throw new DataConflictException(String.format(assign ? CANNOT_ASSIGN : CANNOT_UN_ASSIGN, userType, task.getStatusCode()));
}
}
+
+ @Transactional
+ public void addTag(long taskId, String tag) {
+ if (tag == null || tag.isEmpty()) {
+ throw new IllegalRequestDataException("Tag must not be null or empty");
+ }
+ if (tag.length() > 32) {
+ throw new IllegalRequestDataException("Tag must not exceed 32 characters");
+ }
+
+ Task task = handler.getRepository().getExisted(taskId);
+ task.getTags().add(tag);
+
+ handler.getRepository().saveAndFlush(task);
+ }
}
diff --git a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java b/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java
deleted file mode 100644
index e8e05be05..000000000
--- a/src/main/java/com/javarush/jira/login/internal/sociallogin/handler/VkOAuth2UserDataHandler.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.javarush.jira.login.internal.sociallogin.handler;
-
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-import java.util.Map;
-
-@Component("vk")
-public class VkOAuth2UserDataHandler implements OAuth2UserDataHandler {
- @Override
- public String getFirstName(OAuth2UserData oAuth2UserData) {
- return getAttribute(oAuth2UserData, "first_name");
- }
-
- @Override
- public String getLastName(OAuth2UserData oAuth2UserData) {
- return getAttribute(oAuth2UserData, "last_name");
- }
-
- @Override
- public String getEmail(OAuth2UserData oAuth2UserData) {
- return oAuth2UserData.getData("email");
- }
-
- private String getAttribute(OAuth2UserData oAuth2UserData, String name) {
- List