diff --git a/src/main/java/com/weatherviewer/config/AppConfig.java b/src/main/java/com/weatherviewer/config/AppConfig.java
index 262fc7c..33df369 100644
--- a/src/main/java/com/weatherviewer/config/AppConfig.java
+++ b/src/main/java/com/weatherviewer/config/AppConfig.java
@@ -4,6 +4,7 @@
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;
@@ -11,6 +12,8 @@
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
@@ -51,4 +54,22 @@ 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.
+ *
+ * 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-"));
+ }
+
}
diff --git a/src/main/java/com/weatherviewer/controller/HomeController.java b/src/main/java/com/weatherviewer/controller/HomeController.java
index 04c271c..23d935c 100644
--- a/src/main/java/com/weatherviewer/controller/HomeController.java
+++ b/src/main/java/com/weatherviewer/controller/HomeController.java
@@ -8,16 +8,20 @@
import com.weatherviewer.service.helper.UnitConverter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.UUID;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -34,79 +38,128 @@ public class HomeController {
private final WeatherApiService weatherApiService;
private final LocationService locationService;
private final UnitConverter unitConverter;
+ private final ExecutorService weatherFetchExecutor;
+
+ /** Per-location timeout for the dashboard's concurrent weather fetches. */
+ @Value("${weather.dashboard.fetch-timeout-ms:4000}")
+ private long weatherFetchTimeoutMs;
+
+ /** Locations shown per dashboard page. Not user-adjustable, to keep the weather fan-out per request bounded. */
+ @Value("${location.dashboard.page-size:12}")
+ private int dashboardPageSize;
/**
- * Renders the dashboard. Locations are fetched pre-sorted by
- * {@code sort} ({@code nameAsc}, {@code nameDesc}, {@code favoriteFirst},
- * {@code favoritesOnly}, or the default {@code date}), then current
- * weather is fetched for all of them in parallel to keep page load fast
- * when a user has many saved locations.
+ * Renders one page of the dashboard. Locations are fetched pre-sorted
+ * and pre-paged by {@code sort} ({@code nameAsc}, {@code nameDesc},
+ * {@code favoriteFirst}, {@code favoritesOnly}, or the default
+ * {@code date}) and {@code page} (0-based; page size is fixed via
+ * {@code location.dashboard.page-size}), then current weather is
+ * fetched for just that page's locations concurrently on a bounded
+ * pool ({@link com.weatherviewer.config.AppConfig#weatherFetchExecutor})
+ * to keep page load fast without competing with the JVM's shared
+ * common {@code ForkJoinPool}.
+ *
+ * Each fetch is capped at {@code weather.dashboard.fetch-timeout-ms}
+ * and failures are isolated per location: a single slow or failing
+ * provider call surfaces that one location as unavailable instead of
+ * failing the whole dashboard.
*/
@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal SecUser user,
- @RequestParam(required = false, defaultValue = "date") String sort) {
- log.info("Home page requested by user '{}', sort={}", user.getUsername(), sort);
-
- List userLocations = switch (sort.toLowerCase()) {
- case "nameasc" -> locationService.getByUserIdSortedByNameAsc(user.getId());
- case "namedesc" -> locationService.getByUserIdSortedByNameDesc(user.getId());
- case "favoritefirst" -> locationService.getByUserIdSortedByFavorite(user.getId());
- case "favoritesonly" -> locationService.getFavoritesByUserId(user.getId());
- default -> locationService.getByUserIdSortedByDate(user.getId());
- };
-
- if (!userLocations.isEmpty()) {
- Map locationWeatherMap = userLocations.parallelStream()
+ @RequestParam(required = false, defaultValue = "date") String sort,
+ @RequestParam(required = false, defaultValue = "0") int page) {
+ int safePage = Math.max(page, 0);
+ log.info("Home page requested by user '{}', sort={}, page={}", user.getUsername(), sort, safePage);
+
+ Page locationPage = locationService.getByUserIdSorted(
+ user.getId(), sort, PageRequest.of(safePage, dashboardPageSize));
+ List pageLocations = locationPage.getContent();
+
+ if (!pageLocations.isEmpty()) {
+ Map> pendingWeather = pageLocations.stream()
.collect(Collectors.toMap(
Function.identity(),
- location -> unitConverter.toDisplayUnits(
- weatherApiService.getWeatherByLocation(location), user.getUnits()),
+ location -> CompletableFuture
+ .supplyAsync(() -> weatherApiService.getWeatherByLocation(location),
+ weatherFetchExecutor)
+ .orTimeout(weatherFetchTimeoutMs, TimeUnit.MILLISECONDS),
(existing, replacement) -> existing,
LinkedHashMap::new
));
+
+ Map locationWeatherMap = new LinkedHashMap<>();
+ List unavailableLocationNames = new ArrayList<>();
+
+ for (Map.Entry> entry : pendingWeather.entrySet()) {
+ LocationDto location = entry.getKey();
+ try {
+ WeatherDto weather = entry.getValue().join();
+ locationWeatherMap.put(location, unitConverter.toDisplayUnits(weather, user.getUnits()));
+ } catch (CompletionException ex) {
+ log.warn("Weather fetch failed for location '{}' (user '{}'): {}",
+ location.getName(), user.getUsername(), ex.getCause() != null
+ ? ex.getCause().getMessage() : ex.getMessage());
+ unavailableLocationNames.add(location.getName());
+ }
+ }
+
model.addAttribute("locationWeatherMap", locationWeatherMap);
- log.info("Weather data prepared for {} locations", locationWeatherMap.size());
+ if (!unavailableLocationNames.isEmpty()) {
+ model.addAttribute("errorMessages", unavailableLocationNames.stream()
+ .map(name -> "Weather for \"" + name + "\" is temporarily unavailable")
+ .toList());
+ }
+ log.info("Weather data prepared for {} of {} locations on page {}",
+ locationWeatherMap.size(), pageLocations.size(), safePage);
}
model.addAttribute("login", user.getFullName());
model.addAttribute("sort", sort);
+ model.addAttribute("currentPage", locationPage.getNumber());
+ model.addAttribute("totalPages", locationPage.getTotalPages());
+ model.addAttribute("totalLocations", locationPage.getTotalElements());
+ model.addAttribute("hasPreviousPage", locationPage.hasPrevious());
+ model.addAttribute("hasNextPage", locationPage.hasNext());
model.addAttribute("temperatureSymbol", unitConverter.temperatureSymbol(user.getUnits()));
model.addAttribute("windSpeedUnit", unitConverter.windSpeedUnit(user.getUnits()));
return "home";
}
- /** Deletes a saved location (ownership-checked) and redirects back to the dashboard preserving the current sort. */
+ /** Deletes a saved location (ownership-checked) and redirects back to the dashboard preserving the current sort/page. */
@DeleteMapping("/locations/{id}")
public String deleteLocation(@PathVariable UUID id,
@AuthenticationPrincipal SecUser user,
RedirectAttributes redirectAttributes,
- @RequestParam(required = false, defaultValue = "date") String sort) {
+ @RequestParam(required = false, defaultValue = "date") String sort,
+ @RequestParam(required = false, defaultValue = "0") int page) {
log.info("User '{}' is deleting location with id={}", user.getUsername(), id);
locationService.deleteByIdAndUserId(id, user.getId());
redirectAttributes.addFlashAttribute("successMessage", "Location deleted successfully");
- return "redirect:/?sort=" + sort;
+ return "redirect:/?sort=" + sort + "&page=" + page;
}
@PostMapping("/locations/{id}/favorite")
public String addToFavorite(@PathVariable UUID id,
@AuthenticationPrincipal SecUser user,
RedirectAttributes redirectAttributes,
- @RequestParam(required = false, defaultValue = "date") String sort) {
+ @RequestParam(required = false, defaultValue = "date") String sort,
+ @RequestParam(required = false, defaultValue = "0") int page) {
log.info("User {} is adding location {} to favorites", user.getUsername(), id);
locationService.addToFavorite(id, user.getId());
redirectAttributes.addFlashAttribute("successMessage", "Location added to favorites");
- return "redirect:/?sort=" + sort;
+ return "redirect:/?sort=" + sort + "&page=" + page;
}
@DeleteMapping("/locations/{id}/favorite")
public String removeFromFavorite(@PathVariable UUID id,
@AuthenticationPrincipal SecUser user,
RedirectAttributes redirectAttributes,
- @RequestParam(required = false, defaultValue = "date") String sort) {
+ @RequestParam(required = false, defaultValue = "date") String sort,
+ @RequestParam(required = false, defaultValue = "0") int page) {
log.info("User {} is removing location {} from favorites", user.getUsername(), id);
locationService.removeFromFavorite(id, user.getId());
redirectAttributes.addFlashAttribute("successMessage", "Location removed from favorites");
- return "redirect:/?sort=" + sort;
+ return "redirect:/?sort=" + sort + "&page=" + page;
}
}
diff --git a/src/main/java/com/weatherviewer/dto/AddLocationDto.java b/src/main/java/com/weatherviewer/dto/AddLocationDto.java
index 439e209..665afb5 100644
--- a/src/main/java/com/weatherviewer/dto/AddLocationDto.java
+++ b/src/main/java/com/weatherviewer/dto/AddLocationDto.java
@@ -1,6 +1,7 @@
package com.weatherviewer.dto;
import com.weatherviewer.validation.annotation.Latitude;
+import com.weatherviewer.validation.annotation.LocationLimit;
import com.weatherviewer.validation.annotation.Longitude;
import com.weatherviewer.validation.annotation.UniqueLocation;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -19,6 +20,7 @@
@ToString
@Accessors(chain = true)
@UniqueLocation
+@LocationLimit
@Schema(description = "Payload for creating a saved location")
public class AddLocationDto {
diff --git a/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java b/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java
index db417cc..4f6f190 100644
--- a/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java
+++ b/src/main/java/com/weatherviewer/ratelimit/RateLimitingFilter.java
@@ -21,6 +21,7 @@
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
+import java.util.Set;
/**
* Servlet filter that enforces per-client request rate limits, backed by
@@ -43,6 +44,9 @@ public class RateLimitingFilter extends OncePerRequestFilter {
private static final String RATE_LIMIT_REMAINING_HEADER = "X-RateLimit-Remaining";
private static final String API_PATH_PREFIX = "/api";
+ private static final Set STATIC_PREFIXES = Set.of("/css/", "/js/", "/images/", "/static/");
+ private static final Set STATIC_EXTENSIONS = Set.of(".svg", ".png", ".ico", ".css", ".js");
+
private final RedisFixedWindowRateLimiter rateLimiter;
private final RateLimitProperties properties;
private List trustedProxyMatchers;
@@ -61,6 +65,19 @@ void initTrustedProxies() {
.toList();
}
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String path = request.getRequestURI();
+ if (path == null) {
+ return false;
+ }
+
+ boolean isStaticPrefix = STATIC_PREFIXES.stream().anyMatch(path::startsWith);
+ boolean isStaticExt = STATIC_EXTENSIONS.stream().anyMatch(path::endsWith);
+
+ return isStaticPrefix || isStaticExt;
+ }
+
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
diff --git a/src/main/java/com/weatherviewer/repository/LocationRepository.java b/src/main/java/com/weatherviewer/repository/LocationRepository.java
index 6b6e2dd..137468a 100644
--- a/src/main/java/com/weatherviewer/repository/LocationRepository.java
+++ b/src/main/java/com/weatherviewer/repository/LocationRepository.java
@@ -1,6 +1,8 @@
package com.weatherviewer.repository;
import com.weatherviewer.model.Location;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@@ -21,6 +23,20 @@ public interface LocationRepository extends JpaRepository {
/** Returns all locations saved by the given user, in no particular order. */
List findByUserId(UUID userId);
+ /**
+ * Returns one page of a user's locations, sorted per the given
+ * {@link org.springframework.data.domain.Pageable}'s
+ * {@link org.springframework.data.domain.Sort}. Backs the paginated
+ * home dashboard.
+ */
+ Page findByUserId(UUID userId, Pageable pageable);
+
+ /** Returns one page of a user's favorited locations, sorted per the given Pageable's Sort. */
+ Page findByUserIdAndFavoriteTrue(UUID userId, Pageable pageable);
+
+ /** Counts how many locations the given user has saved, used to enforce the per-user cap. */
+ long countByUserId(UUID userId);
+
/** Returns a user's locations, most recently added first. */
List findByUserIdOrderByCreatedAtDesc(UUID userId);
diff --git a/src/main/java/com/weatherviewer/service/LocationService.java b/src/main/java/com/weatherviewer/service/LocationService.java
index 65f5257..fa1f220 100644
--- a/src/main/java/com/weatherviewer/service/LocationService.java
+++ b/src/main/java/com/weatherviewer/service/LocationService.java
@@ -48,6 +48,27 @@ public interface LocationService {
/** Returns all locations saved by the given user, unsorted. */
List getByUserId(UUID userId);
+ /**
+ * Counts how many locations the given user currently has saved.
+ * Backs {@link com.weatherviewer.validation.annotation.LocationLimit},
+ * which rejects new locations once a user hits the configured cap
+ * ({@code location.max-per-user}) — without this, a single user could
+ * save an unbounded number of locations and force the dashboard to
+ * fan out an unbounded number of weather calls on every page load.
+ */
+ long countByUserId(UUID userId);
+
+ /**
+ * Returns one page of a user's saved locations in the given dashboard
+ * sort order ({@code date}, {@code nameAsc}, {@code nameDesc},
+ * {@code favoriteFirst}, or {@code favoritesOnly} — unrecognized/blank
+ * values fall back to {@code date}). Backs the paginated home
+ * dashboard, so a user with many saved locations only loads (and
+ * fetches weather for) one page's worth per request instead of
+ * everything at once.
+ */
+ Page getByUserIdSorted(UUID userId, String sort, Pageable pageable);
+
/** Looks up a user's saved location by its exact coordinates. */
LocationDto getByCoordinatesAndUserId(Double latitude, Double longitude, UUID userId);
diff --git a/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java
index 2bac840..5d01a03 100644
--- a/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java
+++ b/src/main/java/com/weatherviewer/service/impl/LocationServiceImpl.java
@@ -9,7 +9,9 @@
import com.weatherviewer.service.LocationService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -68,6 +70,31 @@ public List getByUserId(UUID userId) {
return locationMapper.toDtoList(locations);
}
+ @Override
+ public long countByUserId(UUID userId) {
+ return locationRepository.countByUserId(userId);
+ }
+
+ @Override
+ public Page getByUserIdSorted(UUID userId, String sort, Pageable pageable) {
+ String normalizedSort = sort == null ? "date" : sort.toLowerCase();
+
+ if ("favoritesonly".equals(normalizedSort)) {
+ Pageable request = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
+ Sort.by(Sort.Direction.DESC, "createdAt"));
+ return locationRepository.findByUserIdAndFavoriteTrue(userId, request).map(locationMapper::toDto);
+ }
+
+ Sort sortOrder = switch (normalizedSort) {
+ case "nameasc" -> Sort.by(Sort.Direction.ASC, "name");
+ case "namedesc" -> Sort.by(Sort.Direction.DESC, "name");
+ case "favoritefirst" -> Sort.by(Sort.Order.desc("favorite"), Sort.Order.desc("createdAt"));
+ default -> Sort.by(Sort.Direction.DESC, "createdAt");
+ };
+ Pageable request = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortOrder);
+ return locationRepository.findByUserId(userId, request).map(locationMapper::toDto);
+ }
+
@Override
public LocationDto getByCoordinatesAndUserId(Double latitude, Double longitude, UUID userId) {
Location location = locationRepository.findByLatitudeAndLongitudeAndUserId(latitude, longitude, userId);
diff --git a/src/main/java/com/weatherviewer/validation/annotation/LocationLimit.java b/src/main/java/com/weatherviewer/validation/annotation/LocationLimit.java
new file mode 100644
index 0000000..90221f5
--- /dev/null
+++ b/src/main/java/com/weatherviewer/validation/annotation/LocationLimit.java
@@ -0,0 +1,31 @@
+package com.weatherviewer.validation.annotation;
+
+import com.weatherviewer.validation.validator.LocationLimitValidator;
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+import java.lang.annotation.*;
+
+/**
+ * Class-level constraint on {@link com.weatherviewer.dto.AddLocationDto}
+ * rejecting a new location once the owning user has reached the
+ * configured per-user cap ({@code location.max-per-user}, default 100).
+ *
+ * Without this, a single account (or a script hitting
+ * {@code /api/v1/locations/my} directly) could save an unbounded number
+ * of locations, and every dashboard load fans out one weather call per
+ * saved location. See {@link LocationLimitValidator}.
+ */
+@Documented
+@Constraint(validatedBy = LocationLimitValidator.class)
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LocationLimit {
+
+ String message() default "Maximum number of saved locations reached";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+
+}
diff --git a/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java b/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java
new file mode 100644
index 0000000..d563157
--- /dev/null
+++ b/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java
@@ -0,0 +1,51 @@
+package com.weatherviewer.validation.validator;
+
+import com.weatherviewer.dto.AddLocationDto;
+import com.weatherviewer.service.LocationService;
+import com.weatherviewer.validation.annotation.LocationLimit;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * Backs {@link LocationLimit}. Counts how many locations the owning user
+ * already has saved and rejects the new one if that count is already at
+ * or above {@code location.max-per-user}.
+ *
+ * Skips the check (returns valid) when {@code userId} is missing, since
+ * that case is already reported by the DTO's own required-field
+ * validation and shouldn't also surface a misleading "limit reached"
+ * message.
+ */
+@Component
+@RequiredArgsConstructor
+public class LocationLimitValidator implements ConstraintValidator {
+
+ private final LocationService locationService;
+
+ @Value("${location.max-per-user:100}")
+ private int maxLocationsPerUser;
+
+ @Override
+ public boolean isValid(AddLocationDto addLocationDto, ConstraintValidatorContext context) {
+ if (addLocationDto == null || addLocationDto.getUserId() == null) {
+ return true;
+ }
+
+ long currentCount = locationService.countByUserId(addLocationDto.getUserId());
+
+ if (currentCount >= maxLocationsPerUser) {
+ context.disableDefaultConstraintViolation();
+ context.buildConstraintViolationWithTemplate(
+ "You've reached the maximum of " + maxLocationsPerUser + " saved locations")
+ .addPropertyNode("location")
+ .addConstraintViolation();
+ return false;
+ }
+
+ return true;
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 54482fe..4cc8610 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -52,7 +52,13 @@ scalar.enabled=true
scalar.theme=purple
scalar.path=/scalar
+# --- Locations ---
+location.max-per-user=${LOCATION_MAX_PER_USER:100}
+location.dashboard.page-size=${LOCATION_DASHBOARD_PAGE_SIZE:4}
+
# --- Weather API (OpenWeatherMap) ---
+weather.dashboard.fetch-pool-size=${WEATHER_DASHBOARD_FETCH_POOL_SIZE:20}
+weather.dashboard.fetch-timeout-ms=${WEATHER_DASHBOARD_FETCH_TIMEOUT_MS:4000}
weather.base.api.url=https://api.openweathermap.org
weather.api.url.suffix=/data/2.5/weather
forecast.api.url.suffix=/data/2.5/forecast
diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html
index bb19669..63695ad 100644
--- a/src/main/resources/templates/home.html
+++ b/src/main/resources/templates/home.html
@@ -45,6 +45,7 @@
Location
Please add a location to view weather. You can do it by using the search bar above.