diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4ebf060 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.gradle +.idea +build +data +.env +src/main/resources/application.properties +db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90cfdb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Image tag (latest, 1.0.1, main, ...) +IMAGE_TAG=latest + +# Database (used by the bundled postgres container and the app) +# DB_HOST/DB_PORT are set to the postgres container in docker-compose.yml +DB_NAME=operation_point +DB_USERNAME=postgres +DB_PASSWORD=changeme + +# Public URL of the application (used for file links) +APP_DOMAIN=https://example.com + +# Crawler +CRAWLER_TYROL_TOKEN=your-fastapi-token + +# Optional +# JPA_DDL_AUTO=update +# SWAGGER_ENABLED=true +# MAX_FILE_SIZE=32GB +# JAVA_OPTS=-Xmx512m diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..f912c06 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,58 @@ +name: Build & Publish Docker Image + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 196b3ea..5be92bb 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,6 @@ out/ ### Software ### /src/main/resources/application.properties -/data/* \ No newline at end of file +/data/* +.env +/db/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6fac7f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# --- Build stage --- +FROM eclipse-temurin:21-jdk AS build +WORKDIR /workspace + +COPY gradlew settings.gradle build.gradle ./ +COPY gradle gradle +RUN chmod +x gradlew && ./gradlew --no-daemon dependencies > /dev/null + +COPY src src +RUN ./gradlew --no-daemon bootJar -x test \ + && find build/libs -name '*.jar' ! -name '*-plain.jar' -exec cp {} app.jar \; + +# --- Runtime stage --- +FROM eclipse-temurin:21-jre +WORKDIR /app + +RUN groupadd --system app && useradd --system --gid app app \ + && mkdir -p /app/data/files /app/config \ + && chown -R app:app /app + +COPY --from=build /workspace/app.jar /app/app.jar +# Spring Boot loads ./config/application.properties automatically (overrides classpath config) +COPY docker/application.properties /app/config/application.properties + +USER app +EXPOSE 8080 +VOLUME ["/app/data"] + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"] diff --git a/build.gradle b/build.gradle index ec65476..bf8fa95 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation 'com.opencsv:opencsv:5.12.0' developmentOnly 'org.springframework.boot:spring-boot-devtools' implementation 'org.springframework:spring-oxm' + implementation 'org.springframework.boot:spring-boot-starter-aspectj' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e9267f1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + operation-point: + image: ghcr.io/jpromi/operation-point:${IMAGE_TAG:-latest} + container_name: operation-point + restart: unless-stopped + env_file: .env + environment: + DB_HOST: postgres + DB_PORT: 5432 + ports: + - "8080:8080" + volumes: + - ./data:/app/data + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:17-alpine + container_name: operation-point-db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-operation_point} + POSTGRES_USER: ${DB_USERNAME:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set in .env} + TZ: UTC + volumes: + - ./db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/docker/application.properties b/docker/application.properties new file mode 100644 index 0000000..6c8cba9 --- /dev/null +++ b/docker/application.properties @@ -0,0 +1,32 @@ +spring.application.name=operation-point + +# Database +spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:operation_point} +spring.datasource.username=${DB_USERNAME:postgres} +spring.datasource.password=${DB_PASSWORD:} +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.properties.hibernate.jdbc.time_zone=UTC +spring.thymeleaf.extras.spring-security.enabled=true + +# JPA Settings +spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.hibernate.ddl-auto=${JPA_DDL_AUTO:update} + +# Swagger Settings +springdoc.swagger-ui.enabled=${SWAGGER_ENABLED:true} +springdoc.api-docs.enabled=${SWAGGER_ENABLED:true} + +# Variables for the application +com.jpromi.operation_point.crawler.tyrol.token=${CRAWLER_TYROL_TOKEN:} +com.jpromi.operation_point.domain=${APP_DOMAIN:http://localhost:8080} +com.jpromi.operation_point.file.storage.path=/app/data/files + +# Server +server.address=0.0.0.0 +server.port=8080 +spring.servlet.multipart.max-file-size=${MAX_FILE_SIZE:32GB} +spring.servlet.multipart.max-request-size=${MAX_FILE_SIZE:32GB} + +# Tomcat Settings +server.tomcat.max-part-count=-1 diff --git a/src/main/java/com/jpromi/operation_point/annotations/Statistic.java b/src/main/java/com/jpromi/operation_point/annotations/Statistic.java new file mode 100644 index 0000000..785181c --- /dev/null +++ b/src/main/java/com/jpromi/operation_point/annotations/Statistic.java @@ -0,0 +1,11 @@ +package com.jpromi.operation_point.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Statistic { +} diff --git a/src/main/java/com/jpromi/operation_point/annotations/StatisticAspect.java b/src/main/java/com/jpromi/operation_point/annotations/StatisticAspect.java new file mode 100644 index 0000000..2bb6eac --- /dev/null +++ b/src/main/java/com/jpromi/operation_point/annotations/StatisticAspect.java @@ -0,0 +1,29 @@ +package com.jpromi.operation_point.annotations; + +import com.jpromi.operation_point.service.StatisticService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class StatisticAspect { + + private final HttpServletRequest request; + + private final StatisticService statisticService; + + @Around("@annotation(statistic)") + public Object track( + ProceedingJoinPoint joinPoint, + Statistic statistic + ) throws Throwable { + statisticService.setStat(); + + return joinPoint.proceed(); + } +} diff --git a/src/main/java/com/jpromi/operation_point/controller/FiredepartmentController.java b/src/main/java/com/jpromi/operation_point/controller/FiredepartmentController.java index f8a2015..4c73c77 100644 --- a/src/main/java/com/jpromi/operation_point/controller/FiredepartmentController.java +++ b/src/main/java/com/jpromi/operation_point/controller/FiredepartmentController.java @@ -1,5 +1,6 @@ package com.jpromi.operation_point.controller; +import com.jpromi.operation_point.annotations.Statistic; import com.jpromi.operation_point.entity.Firedepartment; import com.jpromi.operation_point.entity.Operation; import com.jpromi.operation_point.mapper.FiredepartmentResponseMapper; @@ -9,6 +10,7 @@ import com.jpromi.operation_point.repository.OperationRepository; import com.jpromi.operation_point.service.FiredepartmentService; import com.jpromi.operation_point.service.OperationService; +import com.jpromi.operation_point.service.StatisticService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -33,15 +35,24 @@ public class FiredepartmentController { private final FiredepartmentResponseMapper firedepartmentResponseMapper; private final OperationResponseMapper operationResponseMapper; private final OperationRepository operationRepository; + private final StatisticService statisticService; @Autowired - public FiredepartmentController(FiredepartmentService firedepartmentService, FiredepartmentResponseMapper firedepartmentResponseMapper, OperationResponseMapper operationResponseMapper, OperationRepository operationRepository) { + public FiredepartmentController( + FiredepartmentService firedepartmentService, + FiredepartmentResponseMapper firedepartmentResponseMapper, + OperationResponseMapper operationResponseMapper, + OperationRepository operationRepository, + StatisticService statisticService + ) { this.firedepartmentService = firedepartmentService; this.firedepartmentResponseMapper = firedepartmentResponseMapper; this.operationResponseMapper = operationResponseMapper; this.operationRepository = operationRepository; + this.statisticService = statisticService; } + @Statistic @GetMapping(value = "list", produces = {"application/json"}) public ResponseEntity> getList( @RequestParam(required = false, value = "q") String query, @@ -75,6 +86,7 @@ public ResponseEntity getFiredepartmentByUuid(@PathVaria if (firedepartment == null) { return ResponseEntity.notFound().build(); } + statisticService.setStatForDetail("firedepartment", firedepartment.getUuid()); FiredepartmentResponse response = firedepartmentResponseMapper.fromFiredepartment(firedepartment); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/jpromi/operation_point/controller/OperationController.java b/src/main/java/com/jpromi/operation_point/controller/OperationController.java index 217f0e8..e468acb 100644 --- a/src/main/java/com/jpromi/operation_point/controller/OperationController.java +++ b/src/main/java/com/jpromi/operation_point/controller/OperationController.java @@ -1,11 +1,13 @@ package com.jpromi.operation_point.controller; +import com.jpromi.operation_point.annotations.Statistic; import com.jpromi.operation_point.entity.Operation; import com.jpromi.operation_point.mapper.LocationStatisticResponseMapper; import com.jpromi.operation_point.mapper.OperationResponseMapper; import com.jpromi.operation_point.model.LocationStatisticResponse; import com.jpromi.operation_point.model.OperationResponse; import com.jpromi.operation_point.service.OperationService; +import com.jpromi.operation_point.service.StatisticService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -26,14 +28,21 @@ public class OperationController { private final OperationService operationService; private final OperationResponseMapper operationResponseMapper; private final LocationStatisticResponseMapper locationStatisticResponseMapper; + private final StatisticService statisticService; @Autowired - public OperationController(OperationService operationService, OperationResponseMapper operationResponseMapper, LocationStatisticResponseMapper locationStatisticResponseMapper) { + public OperationController( + OperationService operationService, + OperationResponseMapper operationResponseMapper, + LocationStatisticResponseMapper locationStatisticResponseMapper, + StatisticService statisticService) { this.operationService = operationService; this.operationResponseMapper = operationResponseMapper; this.locationStatisticResponseMapper = locationStatisticResponseMapper; + this.statisticService = statisticService; } + @Statistic @GetMapping(value = "list", produces = {"application/json"}) public ResponseEntity> getList() { List operations = operationService.getActiveOperations(); @@ -46,6 +55,7 @@ public ResponseEntity getCount() { return ResponseEntity.ok(count); } + @Statistic @GetMapping(value = "list/statistic", produces = {"application/json"}) public ResponseEntity> getStatistic() { List operations = operationService.getActiveOperations(); @@ -58,6 +68,7 @@ public ResponseEntity> getStatistic() { return ResponseEntity.ok(statisticResponses); } + @Statistic @GetMapping(value = "list/{federalState}", produces = {"application/json"}) public ResponseEntity> getListByFederalState(@PathVariable String federalState, @RequestParam(required = false) String district) { List operations; @@ -70,12 +81,14 @@ public ResponseEntity> getListByFederalState(@PathVariab return OperationService.BuildResponseFromOperations(operations, operationResponseMapper); } + @Statistic @GetMapping(value = "list/{federalState}/count", produces = {"application/json"}) public ResponseEntity getCountByFederalState(@PathVariable String federalState) { Long count = operationService.getActiveOperationsByFederalStateCount(federalState); return ResponseEntity.ok(count); } + @Statistic @GetMapping(value = "list/{federalState}/statistic", produces = {"application/json"}) public ResponseEntity> getStatisticByFederalState(@PathVariable String federalState) { List operations = operationService.getActiveOperationsByFederalState(federalState); @@ -92,6 +105,7 @@ public ResponseEntity getOperationByUuid(@PathVariable String if (operation == null) { return ResponseEntity.notFound().build(); } else { + statisticService.setStatForDetail("operation", operation.getUuid()); OperationResponse response = operationResponseMapper.fromOperation(operation); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/jpromi/operation_point/entity/Statistic.java b/src/main/java/com/jpromi/operation_point/entity/Statistic.java new file mode 100644 index 0000000..f6c6102 --- /dev/null +++ b/src/main/java/com/jpromi/operation_point/entity/Statistic.java @@ -0,0 +1,33 @@ +package com.jpromi.operation_point.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.*; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Getter +@Setter +public class Statistic { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String appVersion; + private String platform; + private String userAgent; + private String path; + private String objectType; // vehicle, firedepartment, operation,... + private UUID objectUuid; + + @Builder.Default + private OffsetDateTime timestamp = OffsetDateTime.now(); +} diff --git a/src/main/java/com/jpromi/operation_point/repository/StatisticRepository.java b/src/main/java/com/jpromi/operation_point/repository/StatisticRepository.java new file mode 100644 index 0000000..9701c06 --- /dev/null +++ b/src/main/java/com/jpromi/operation_point/repository/StatisticRepository.java @@ -0,0 +1,10 @@ +package com.jpromi.operation_point.repository; + +import com.jpromi.operation_point.entity.AppUser; +import com.jpromi.operation_point.entity.Statistic; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface StatisticRepository extends JpaRepository { +} diff --git a/src/main/java/com/jpromi/operation_point/service/StatisticService.java b/src/main/java/com/jpromi/operation_point/service/StatisticService.java new file mode 100644 index 0000000..cdd6371 --- /dev/null +++ b/src/main/java/com/jpromi/operation_point/service/StatisticService.java @@ -0,0 +1,8 @@ +package com.jpromi.operation_point.service; + +import java.util.UUID; + +public interface StatisticService { + Void setStat(); + Void setStatForDetail(String objectType, UUID objectUuid); +} diff --git a/src/main/java/com/jpromi/operation_point/service/impl/StatisticServiceImpl.java b/src/main/java/com/jpromi/operation_point/service/impl/StatisticServiceImpl.java new file mode 100644 index 0000000..3c876d0 --- /dev/null +++ b/src/main/java/com/jpromi/operation_point/service/impl/StatisticServiceImpl.java @@ -0,0 +1,53 @@ +package com.jpromi.operation_point.service.impl; + +import com.jpromi.operation_point.entity.Statistic; +import com.jpromi.operation_point.repository.StatisticRepository; +import com.jpromi.operation_point.service.StatisticService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +public class StatisticServiceImpl implements StatisticService { + + private final HttpServletRequest request; + private final StatisticRepository statisticRepository; + + @Autowired + public StatisticServiceImpl( + HttpServletRequest request, + StatisticRepository statisticRepository + ) { + this.request = request; + this.statisticRepository = statisticRepository; + } + @Override + @Async("statisticsExecutor") + public Void setStat() { + Statistic stat = generateBaseObject(); + statisticRepository.save(stat); + return null; + } + + @Override + public Void setStatForDetail(String objectType, UUID objectUuid) { + Statistic stat = generateBaseObject(); + stat.setObjectType(objectType); + stat.setObjectUuid(objectUuid); + statisticRepository.save(stat); + return null; + } + + private Statistic generateBaseObject() { + return Statistic.builder() + .appVersion(request.getHeader("X-Fw-Version")) + .platform(request.getHeader("X-Fw-Platform")) + .userAgent(request.getHeader("User-Agent")) + .path(request.getRequestURI()) + .build(); + } +}