Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
c05eb9b
fix(security): secure rate limiting against X-Forwarded-For spoofing
podlLev Jul 16, 2026
a7d6e79
fix(logging): mask OpenWeatherMap API key in client error logs
podlLev Jul 16, 2026
42c4f82
refactor(docker): secure container by running as non-root user
podlLev Jul 16, 2026
7935145
security: set session cookie Secure + SameSite=Strict
podlLev Jul 17, 2026
098e0d5
security: cap concurrent sessions per user to 1
podlLev Jul 17, 2026
dd7d4c3
security: add strict Content-Security-Policy header
podlLev Jul 17, 2026
1724d43
Merge pull request #31 from podlLev/fix/security-hardening
podlLev Jul 17, 2026
f7dcb2e
feat(repository): add paginated findAll(Pageable) to UserRepository
podlLev Jul 18, 2026
38d440e
feat(service): paginate UserService.getUsers()
podlLev Jul 18, 2026
35dea4a
feat(api): expose pagination on GET /api/v1/users
podlLev Jul 18, 2026
2020565
feat(service): paginate LocationService.getLocations()
podlLev Jul 18, 2026
7d99fff
feat(api): expose pagination on GET /api/v1/locations
podlLev Jul 18, 2026
7d7b935
fix(config): cap max page size at 100
podlLev Jul 18, 2026
6848c20
test(user): update UserController/UserService tests for Page response
podlLev Jul 18, 2026
3f761d5
test(location): update LocationController/LocationService tests for P…
podlLev Jul 18, 2026
01c2629
Merge pull request #32 from podlLev/feature/paginate-admin-list-endpo…
podlLev Jul 18, 2026
22039d0
feat(db): add units column for user unit preference
podlLev Jul 19, 2026
1b49759
feat(model): add UnitSystem enum and units field to User
podlLev Jul 19, 2026
106c2cb
feat(dto): expose units on user-facing DTOs
podlLev Jul 19, 2026
5ad6cbe
feat(service): persist user's unit preference on profile update
podlLev Jul 19, 2026
648583c
feat(weather): convert weather values to the user's preferred units
podlLev Jul 19, 2026
5d6d8da
feat(web): render weather in the signed-in user's preferred units
podlLev Jul 19, 2026
a97df87
feat(profile): add unit system selector to the profile page
podlLev Jul 19, 2026
9c0a990
test: update fixtures for new SecUser/User units field
podlLev Jul 19, 2026
a2f72de
Merge pull request #33 from podlLev/feature/weather-unit-preference
podlLev Jul 19, 2026
80f38f4
ci: add GitHub Actions workflow for build and test
podlLev Jul 20, 2026
7569906
ci: add Docker Hub build and push job
podlLev Jul 20, 2026
5b8adec
fix: make mvnw executable
podlLev Jul 20, 2026
1d1a5f3
ci: bump actions to Node 24-compatible versions
podlLev Jul 20, 2026
4fe641e
fix: explicitly ignore auto-generated/default fields in mappers
podlLev Jul 20, 2026
3e67e91
Merge pull request #34 from podlLev/ci/add-github-actions-workflow
podlLev Jul 20, 2026
65205ea
feat(ui): add dark mode toggle and theme styles across pages
podlLev Jul 21, 2026
9a47a99
chore(config): add liquibase migration and properties for verificatio…
podlLev Jul 21, 2026
0b3444c
feat(auth): create VerificationToken entity, repository, DTOs, and ex…
podlLev Jul 21, 2026
4b834fe
feat(auth): implement MailService and VerificationService
podlLev Jul 21, 2026
2dbf758
feat(auth): add email verification, password reset endpoints, and UI …
podlLev Jul 21, 2026
cebf681
Merge pull request #35 from podlLev/feature/reset-and-verification-to…
podlLev Jul 21, 2026
cb9130d
db: add persistent_logins table for remember-me tokens
podlLev Jul 22, 2026
d9b06b6
config: add remember-me key and token validity properties
podlLev Jul 22, 2026
f37a925
feat(ui): add remember-me checkbox to sign-in form
podlLev Jul 22, 2026
7747d2f
feat(auth): wire up persistent-token remember-me
podlLev Jul 22, 2026
6145151
chore(docker): expose management port, point healthcheck at it
podlLev Jul 22, 2026
58455a9
Merge pull request #36 from podlLev/feature/remember-me-and-observabi…
podlLev Jul 22, 2026
a332e54
build: add resilience4j-spring-boot3 dependency
podlLev Jul 23, 2026
27d2e4c
db: add failed_login_attempts and locked_until columns to users
podlLev Jul 23, 2026
517a5dd
feat(security): lock account after repeated failed sign-in attempts
podlLev Jul 23, 2026
a31a1a9
feat(resilience): add WeatherApiRetryPredicate for weatherApi retries
podlLev Jul 23, 2026
aae5050
config: configure weatherApi retry, circuit breaker and add unit tests
podlLev Jul 23, 2026
e5f01c6
Merge pull request #37 from podlLev/fix/weather-api-resilience-and-ac…
podlLev Jul 23, 2026
61fcd98
fix(rate-limit): exclude static assets from rate limiting filter
podlLev Jul 24, 2026
c14f3ae
refactor(home): handle per-location weather fetch failures gracefully
podlLev Jul 24, 2026
50e211d
feat(location): introduce location limit constraint validation and tests
podlLev Jul 24, 2026
c62482a
Merge pull request #38 from podlLev/fix/ci-scans-weather-pool-locatio…
podlLev Jul 24, 2026
33cc22a
docs(readme): document features shipped since v1.1.0
podlLev Jul 25, 2026
43271d8
feat(mail): add VerificationEmailRequestedEvent and PasswordResetEmai…
podlLev Jul 27, 2026
8eec662
feat(mail): dispatch mail via events instead of calling MailService d…
podlLev Jul 27, 2026
191cc60
feat(mail): add MailEventListener to send mail async after transactio…
podlLev Jul 27, 2026
a286836
config: add mailTaskExecutor bean and enable @Async support
podlLev Jul 27, 2026
c3f0f86
feat(mail): retry transient SMTP failures via resilience4j
podlLev Jul 27, 2026
a415737
test(mail): update MailServiceImplTest and VerificationServiceImplTes…
podlLev Jul 27, 2026
d5ff373
test(mail): add MailEventListenerTest
podlLev Jul 27, 2026
6d50d3b
Merge pull request #39 from podlLev/fix/async-mail-dispatch
podlLev Jul 27, 2026
31ad04f
feat(config): configure MessageSource UTF-8 encoding and i18n setup
podlLev Jul 28, 2026
5f0a439
feat(i18n): add default and Ukrainian message properties bundles
podlLev Jul 28, 2026
cabe595
refactor(templates): update HTML views to use localized i18n message …
podlLev Jul 28, 2026
4856345
feat(weather-api): add WeatherApiLocale to resolve request locale to …
podlLev Jul 29, 2026
b148270
feat(weather-api): request OpenWeatherMap descriptions in the resolve…
podlLev Jul 29, 2026
1a8e236
test(weather-api): pin LocaleContextHolder in WeatherApiClientTest, a…
podlLev Jul 29, 2026
3ce61c7
test(weather-api): add WeatherApiLocaleTest
podlLev Jul 29, 2026
52b214c
feat(i18n): add weather-condition.* translated labels for the Weather…
podlLev Jul 29, 2026
ec2ec24
chore(release): bump version to 1.2.0
podlLev Jul 29, 2026
200bd87
Merge pull request #40 from podlLev/feature/i18n-uk
podlLev Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main", "dev" ]

permissions:
contents: read

jobs:

# ---------------------------------------------------------------------
# 1. Build + test on every push and every PR
# ---------------------------------------------------------------------
test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
cache: maven

- name: Make mvnw executable
run: chmod +x ./mvnw

- name: Run tests
# Tests run against the in-memory H2 DB and spring.cache.type=simple,
# so no Postgres/Redis service containers are needed here.
# WEATHER_API_KEY just needs to be non-empty; tests mock the HTTP client.
env:
WEATHER_API_KEY: dummy-ci-key
run: ./mvnw -B test

- name: Publish test report
if: always()
uses: dorny/test-reporter@v3
with:
name: Maven Tests
path: target/surefire-reports/*.xml
reporter: java-junit

- name: Upload JaCoCo coverage report
if: always()
uses: actions/upload-artifact@v7
with:
name: jacoco-report
path: target/site/jacoco/

- name: Build jar
env:
WEATHER_API_KEY: dummy-ci-key
run: ./mvnw -B package -DskipTests

- name: Upload jar artifact
uses: actions/upload-artifact@v7
with:
name: app-jar
path: target/*.jar

# ---------------------------------------------------------------------
# 2. Build + push a versioned image to Docker Hub
# - runs ONLY on: pushes to main
# - every push to main -> :<pom-version> :latest
# - pushes to dev, and all PRs -> this job does not run at all
# (dev/PRs are still covered by the `test` job above)
# ---------------------------------------------------------------------
docker:
name: Build & Push Docker Image
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Read version from pom.xml
id: pom
run: |
chmod +x ./mvnw
VERSION=$(./mvnw -q -B help:evaluate -Dexpression=project.version -DforceStdout)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Extract metadata (tags)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/weatherviewer
tags: |
type=raw,value=${{ steps.pom.outputs.version }}
type=raw,value=latest

- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
9 changes: 6 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ RUN mvn package -DskipTests -B
# Run stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080 8081
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:+UseContainerSupport", "-jar", "app.jar"]
161 changes: 118 additions & 43 deletions README.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
services:
weather_viewer:
build:
context: .
dockerfile: Dockerfile
image: podllev/weather-viewer:latest
container_name: weather_viewer
restart: always
ports:
- "8080:8080"
- "8081:8081"
env_file:
- .env
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/weather
SPRING_DATA_REDIS_HOST: redis
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health" ]
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8081/actuator/health" ]
interval: 15s
timeout: 5s
retries: 3
Expand Down
42 changes: 40 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
</parent>
<groupId>com</groupId>
<artifactId>WeatherViewer</artifactId>
<version>1.1.0-RELEASE</version>
<version>1.2.0</version>
<name>WeatherViewer</name>
<description>A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting.</description>
<description>A Spring Boot application for checking weather forecasts, managing favorite locations, and tracking request metrics with built-in rate limiting. Supports English and Ukrainian locales.</description>
<url/>
<licenses>
<license>
Expand Down Expand Up @@ -40,6 +40,7 @@
<hibernate.core.version>6.6.7.Final</hibernate.core.version>
<hibernate.jpa.version>1.0.1.Final</hibernate.jpa.version>
<jacoco.version>0.8.12</jacoco.version>
<resilience4j.version>2.2.0</resilience4j.version>
<mapstruct-processor.version>1.5.5.Final</mapstruct-processor.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
Expand Down Expand Up @@ -94,6 +95,23 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
Expand Down Expand Up @@ -206,6 +224,26 @@
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;

import java.util.TimeZone;

Expand All @@ -12,10 +13,14 @@
* Bootstraps the application context and enables Spring's caching
* abstraction ({@link EnableCaching}), which backs the weather/forecast/
* geocoding response caching in
* {@link com.weatherviewer.service.integration.WeatherApiCache}.
* {@link com.weatherviewer.service.integration.WeatherApiCache}, and
* Spring's {@code @Async} support ({@link EnableAsync}), which backs
* asynchronous mail dispatch in
* {@link com.weatherviewer.service.impl.MailEventListener}.
*/
@SpringBootApplication
@EnableCaching
@EnableAsync
public class WeatherViewerApplication {

/**
Expand Down
59 changes: 56 additions & 3 deletions src/main/java/com/weatherviewer/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package com.weatherviewer.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.web.client.RestClient;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;

import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
* General-purpose application beans that don't belong to a more specific
Expand All @@ -17,10 +22,22 @@
@Configuration
public class AppConfig {

/** HTTP client used by {@link com.weatherviewer.service.integration.WeatherApiClient} to call OpenWeatherMap. */
/**
* HTTP client used by {@link com.weatherviewer.service.integration.WeatherApiClient} to
* call OpenWeatherMap. Explicit connect/read timeouts are required here: without them the
* underlying JDK client factory has no default timeout, so a hung or slow OpenWeatherMap
* response would block the calling request thread indefinitely and, under load, exhaust
* the server's thread pool.
*/
@Bean
public RestClient restClient(RestClient.Builder restClientBuilder) {
return restClientBuilder.build();
public RestClient restClient(
RestClient.Builder restClientBuilder,
@Value("${weather.api.connect-timeout-ms:3000}") int connectTimeoutMs,
@Value("${weather.api.read-timeout-ms:5000}") int readTimeoutMs) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(connectTimeoutMs);
requestFactory.setReadTimeout(readTimeoutMs);
return restClientBuilder.requestFactory(requestFactory).build();
}

/** Default Spring Security redirect strategy, used by {@link com.weatherviewer.security.CustomAuthSuccessHandler}. */
Expand All @@ -37,4 +54,40 @@ public LocaleResolver localeResolver() {
return slr;
}

/**
* Dedicated, bounded thread pool used by
* {@link com.weatherviewer.controller.HomeController} to fetch weather
* for a user's saved locations concurrently.
* <p>
* Deliberately separate from the JVM-wide common {@code ForkJoinPool}
* (what a bare {@code parallelStream()} would use): that pool is shared
* with unrelated parallel streams elsewhere in the JVM and has no
* request-scoped bound, so a user with many saved locations could
* starve it for everyone. Sized via {@code weather.dashboard.fetch-pool-size}
* (default 20) and shut down automatically on context close.
*/
@Bean(destroyMethod = "shutdown")
public ExecutorService weatherFetchExecutor(
@Value("${weather.dashboard.fetch-pool-size:20}") int poolSize) {
return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("weather-fetch-"));
}

/**
* Dedicated, bounded thread pool used by
* {@link com.weatherviewer.service.impl.MailEventListener} to send
* verification/password-reset emails off the request thread.
* <p>
* Kept separate from {@link #weatherFetchExecutor} for the same reason
* that one is kept separate from the JVM-wide common pool: a burst of
* sign-ups or reset requests (each possibly retried a few times against
* a slow SMTP server) shouldn't be able to starve weather-dashboard
* fetches, or vice versa. Sized via {@code mail.async.pool-size}
* (default 5) and shut down automatically on context close.
*/
@Bean(name = "mailTaskExecutor", destroyMethod = "shutdown")
public ExecutorService mailTaskExecutor(
@Value("${mail.async.pool-size:5}") int poolSize) {
return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("mail-send-"));
}

}
Loading