Quality of Life, Security
This commit is contained in:
parent
5455f8cd36
commit
0fe0810f51
7 changed files with 372 additions and 126 deletions
|
|
@ -36,6 +36,7 @@ public class SecurityConfig {
|
||||||
|
|
||||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
private final UserDetailsService userDetailsService;
|
private final UserDetailsService userDetailsService;
|
||||||
|
private final org.operaton.fitpub.security.CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configures the security filter chain.
|
* Configures the security filter chain.
|
||||||
|
|
@ -48,6 +49,9 @@ public class SecurityConfig {
|
||||||
.sessionManagement(session ->
|
.sessionManagement(session ->
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
)
|
)
|
||||||
|
.exceptionHandling(exceptions -> exceptions
|
||||||
|
.authenticationEntryPoint(customAuthenticationEntryPoint)
|
||||||
|
)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// Public endpoints - Static resources
|
// Public endpoints - Static resources
|
||||||
.requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico").permitAll()
|
.requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico").permitAll()
|
||||||
|
|
@ -56,13 +60,19 @@ public class SecurityConfig {
|
||||||
.requestMatchers("/error").permitAll()
|
.requestMatchers("/error").permitAll()
|
||||||
|
|
||||||
// Public endpoints - Web UI pages
|
// Public endpoints - Web UI pages
|
||||||
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
|
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**").permitAll()
|
||||||
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
|
|
||||||
.requestMatchers("/discover").permitAll() // User discovery page
|
.requestMatchers("/discover").permitAll() // User discovery page
|
||||||
.requestMatchers("/notifications").permitAll() // Auth checked client-side
|
|
||||||
.requestMatchers("/analytics", "/analytics/**").permitAll() // Auth checked client-side
|
// Protected view pages - require authentication
|
||||||
.requestMatchers("/heatmap").permitAll() // Auth checked client-side
|
.requestMatchers("/activities", "/activities/upload").authenticated()
|
||||||
.requestMatchers("/batch-upload").permitAll() // Batch import page (Auth checked client-side)
|
.requestMatchers("/profile", "/profile/**", "/settings").authenticated()
|
||||||
|
.requestMatchers("/notifications").authenticated()
|
||||||
|
.requestMatchers("/analytics", "/analytics/**").authenticated()
|
||||||
|
.requestMatchers("/heatmap").authenticated()
|
||||||
|
.requestMatchers("/batch-upload").authenticated()
|
||||||
|
|
||||||
|
// Public - Individual activity view pages (for public activities)
|
||||||
|
.requestMatchers("/activities/*").permitAll()
|
||||||
|
|
||||||
// Public endpoints - ActivityPub federation
|
// Public endpoints - ActivityPub federation
|
||||||
.requestMatchers("/.well-known/**").permitAll()
|
.requestMatchers("/.well-known/**").permitAll()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package org.operaton.fitpub.controller;
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
@ -32,10 +34,13 @@ public class AuthController {
|
||||||
* Register a new user account.
|
* Register a new user account.
|
||||||
*
|
*
|
||||||
* @param request Registration details
|
* @param request Registration details
|
||||||
|
* @param response HTTP response for setting cookies
|
||||||
* @return Authentication response with JWT token
|
* @return Authentication response with JWT token
|
||||||
*/
|
*/
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
public ResponseEntity<AuthResponse> register(
|
||||||
|
@Valid @RequestBody RegisterRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
// Check if registration is enabled
|
// Check if registration is enabled
|
||||||
if (!registrationEnabled) {
|
if (!registrationEnabled) {
|
||||||
log.warn("Registration attempt blocked - registration is disabled");
|
log.warn("Registration attempt blocked - registration is disabled");
|
||||||
|
|
@ -46,8 +51,12 @@ public class AuthController {
|
||||||
log.info("Registration request received for username: {}", request.getUsername());
|
log.info("Registration request received for username: {}", request.getUsername());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
AuthResponse response = userService.registerUser(request);
|
AuthResponse authResponse = userService.registerUser(request);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
|
||||||
|
// Set JWT as httpOnly cookie
|
||||||
|
setJwtCookie(response, authResponse.getToken());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(authResponse);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
log.warn("Registration failed: {}", e.getMessage());
|
log.warn("Registration failed: {}", e.getMessage());
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -68,21 +77,65 @@ public class AuthController {
|
||||||
* Authenticate user and generate JWT token.
|
* Authenticate user and generate JWT token.
|
||||||
*
|
*
|
||||||
* @param request Login credentials
|
* @param request Login credentials
|
||||||
|
* @param response HTTP response for setting cookies
|
||||||
* @return Authentication response with JWT token
|
* @return Authentication response with JWT token
|
||||||
*/
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
public ResponseEntity<AuthResponse> login(
|
||||||
|
@Valid @RequestBody LoginRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
log.info("Login request received for: {}", request.getUsernameOrEmail());
|
log.info("Login request received for: {}", request.getUsernameOrEmail());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
AuthResponse response = userService.login(request);
|
AuthResponse authResponse = userService.login(request);
|
||||||
return ResponseEntity.ok(response);
|
|
||||||
|
// Set JWT as httpOnly cookie
|
||||||
|
setJwtCookie(response, authResponse.getToken());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(authResponse);
|
||||||
} catch (BadCredentialsException e) {
|
} catch (BadCredentialsException e) {
|
||||||
log.warn("Login failed: {}", e.getMessage());
|
log.warn("Login failed: {}", e.getMessage());
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout user by clearing the JWT cookie.
|
||||||
|
*
|
||||||
|
* @param response HTTP response for clearing cookies
|
||||||
|
* @return Success response
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public ResponseEntity<Void> logout(HttpServletResponse response) {
|
||||||
|
log.info("Logout request received");
|
||||||
|
|
||||||
|
// Clear the JWT cookie
|
||||||
|
Cookie cookie = new Cookie("JWT_TOKEN", null);
|
||||||
|
cookie.setHttpOnly(true);
|
||||||
|
cookie.setSecure(false); // Set to true in production with HTTPS
|
||||||
|
cookie.setPath("/");
|
||||||
|
cookie.setMaxAge(0); // Delete cookie
|
||||||
|
response.addCookie(cookie);
|
||||||
|
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to set JWT as httpOnly cookie.
|
||||||
|
*
|
||||||
|
* @param response HTTP response
|
||||||
|
* @param token JWT token
|
||||||
|
*/
|
||||||
|
private void setJwtCookie(HttpServletResponse response, String token) {
|
||||||
|
Cookie cookie = new Cookie("JWT_TOKEN", token);
|
||||||
|
cookie.setHttpOnly(true); // Prevent JavaScript access
|
||||||
|
cookie.setSecure(false); // Set to true in production with HTTPS
|
||||||
|
cookie.setPath("/");
|
||||||
|
cookie.setMaxAge(24 * 60 * 60); // 24 hours (same as JWT expiration)
|
||||||
|
response.addCookie(cookie);
|
||||||
|
log.debug("JWT cookie set");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exception handler for IllegalArgumentException (e.g., duplicate username/email).
|
* Exception handler for IllegalArgumentException (e.g., duplicate username/email).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package org.operaton.fitpub.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom authentication entry point that handles unauthenticated requests.
|
||||||
|
* - Redirects to /login for HTML page requests (browser navigation)
|
||||||
|
* - Returns 403 Forbidden for API requests (AJAX, fetch calls)
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void commence(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
AuthenticationException authException) throws IOException, ServletException {
|
||||||
|
|
||||||
|
String requestUri = request.getRequestURI();
|
||||||
|
String accept = request.getHeader("Accept");
|
||||||
|
|
||||||
|
log.debug("Unauthenticated request to {} with Accept: {}", requestUri, accept);
|
||||||
|
|
||||||
|
// API requests should get 403 Forbidden
|
||||||
|
if (requestUri.startsWith("/api/")) {
|
||||||
|
log.debug("API request - returning 403 Forbidden");
|
||||||
|
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a JSON/API request based on Accept header
|
||||||
|
if (accept != null && (accept.contains("application/json") ||
|
||||||
|
accept.contains("application/activity+json") ||
|
||||||
|
accept.contains("application/ld+json"))) {
|
||||||
|
log.debug("JSON API request - returning 403 Forbidden");
|
||||||
|
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML page requests should redirect to login
|
||||||
|
log.debug("HTML page request - redirecting to /login");
|
||||||
|
String redirectUrl = "/login?redirect=" + requestUri;
|
||||||
|
response.sendRedirect(redirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package org.operaton.fitpub.security;
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -15,6 +16,7 @@ import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT authentication filter that validates JWT tokens on each request.
|
* JWT authentication filter that validates JWT tokens on each request.
|
||||||
|
|
@ -61,13 +63,34 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the JWT token from the Authorization header.
|
* Extracts the JWT token from cookies or Authorization header.
|
||||||
|
* Priority: 1) Cookie, 2) Authorization header
|
||||||
*
|
*
|
||||||
* @param request the HTTP request
|
* @param request the HTTP request
|
||||||
* @return the JWT token or null if not found
|
* @return the JWT token or null if not found
|
||||||
*/
|
*/
|
||||||
private String getJwtFromRequest(HttpServletRequest request) {
|
private String getJwtFromRequest(HttpServletRequest request) {
|
||||||
|
// First, try to get JWT from cookie
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies != null) {
|
||||||
|
String tokenFromCookie = Arrays.stream(cookies)
|
||||||
|
.filter(cookie -> "JWT_TOKEN".equals(cookie.getName()))
|
||||||
|
.map(Cookie::getValue)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (tokenFromCookie != null && !tokenFromCookie.isEmpty()) {
|
||||||
|
log.debug("JWT token found in cookie");
|
||||||
|
return tokenFromCookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Authorization header (for API clients)
|
||||||
String bearerToken = request.getHeader("Authorization");
|
String bearerToken = request.getHeader("Authorization");
|
||||||
return tokenProvider.resolveToken(bearerToken);
|
String tokenFromHeader = tokenProvider.resolveToken(bearerToken);
|
||||||
|
if (tokenFromHeader != null) {
|
||||||
|
log.debug("JWT token found in Authorization header");
|
||||||
|
}
|
||||||
|
return tokenFromHeader;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,40 +51,61 @@ public class WeatherService {
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
|
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
|
||||||
if (!weatherEnabled || apiKey == null || apiKey.isBlank()) {
|
log.info("=== Weather fetch requested for activity {} ===", activity.getId());
|
||||||
log.debug("Weather fetching is disabled or API key is not configured");
|
log.info("Weather enabled: {}, API key configured: {}", weatherEnabled, (apiKey != null && !apiKey.isBlank()));
|
||||||
|
|
||||||
|
if (!weatherEnabled) {
|
||||||
|
log.warn("Weather fetching is DISABLED in configuration (fitpub.weather.enabled=false)");
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
log.error("Weather API key is NOT CONFIGURED (fitpub.weather.api-key is empty)");
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Weather API key present (length: {} chars, starts with: {}...)",
|
||||||
|
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
|
||||||
|
|
||||||
// Check if weather data already exists
|
// Check if weather data already exists
|
||||||
if (weatherDataRepository.existsByActivityId(activity.getId())) {
|
if (weatherDataRepository.existsByActivityId(activity.getId())) {
|
||||||
log.debug("Weather data already exists for activity {}", activity.getId());
|
log.info("Weather data already exists for activity {}, returning cached data", activity.getId());
|
||||||
return weatherDataRepository.findByActivityId(activity.getId());
|
return weatherDataRepository.findByActivityId(activity.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract start location from track
|
// Extract start location from track
|
||||||
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
|
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
|
||||||
log.debug("No track points available for activity {}", activity.getId());
|
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug("Track points JSON length: {} chars", activity.getTrackPointsJson().length());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get first track point for location
|
// Get first track point for location
|
||||||
JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson());
|
JsonNode trackPoints = objectMapper.readTree(activity.getTrackPointsJson());
|
||||||
|
log.debug("Parsed track points, is array: {}, size: {}",
|
||||||
|
trackPoints.isArray(), trackPoints.isArray() ? trackPoints.size() : "N/A");
|
||||||
|
|
||||||
if (!trackPoints.isArray() || trackPoints.isEmpty()) {
|
if (!trackPoints.isArray() || trackPoints.isEmpty()) {
|
||||||
|
log.warn("Track points is not an array or is empty for activity {}", activity.getId());
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonNode firstPoint = trackPoints.get(0);
|
JsonNode firstPoint = trackPoints.get(0);
|
||||||
|
log.debug("First track point fields: {}", firstPoint.fieldNames().hasNext() ?
|
||||||
|
String.join(", ", () -> firstPoint.fieldNames()) : "none");
|
||||||
|
|
||||||
// Check if lat/lon fields exist
|
// Check if lat/lon fields exist
|
||||||
if (!firstPoint.has("lat") || !firstPoint.has("lon")) {
|
if (!firstPoint.has("lat") || !firstPoint.has("lon")) {
|
||||||
log.debug("First track point missing lat/lon for activity {}", activity.getId());
|
log.error("First track point missing lat/lon fields for activity {}. Available fields: {}",
|
||||||
|
activity.getId(), String.join(", ", () -> firstPoint.fieldNames()));
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
double lat = firstPoint.get("lat").asDouble();
|
double lat = firstPoint.get("lat").asDouble();
|
||||||
double lon = firstPoint.get("lon").asDouble();
|
double lon = firstPoint.get("lon").asDouble();
|
||||||
|
log.info("Extracted location: lat={}, lon={}", lat, lon);
|
||||||
|
|
||||||
// Check if activity is recent (within 5 days) - use current weather API
|
// Check if activity is recent (within 5 days) - use current weather API
|
||||||
// Otherwise use historical data API (requires paid plan)
|
// Otherwise use historical data API (requires paid plan)
|
||||||
|
|
@ -92,22 +113,29 @@ public class WeatherService {
|
||||||
long currentTimestamp = Instant.now().getEpochSecond();
|
long currentTimestamp = Instant.now().getEpochSecond();
|
||||||
long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
|
long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
|
||||||
|
|
||||||
|
log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference);
|
||||||
|
|
||||||
WeatherData weatherData;
|
WeatherData weatherData;
|
||||||
if (daysDifference <= 5) {
|
if (daysDifference <= 5) {
|
||||||
|
log.info("Activity is recent (within 5 days), fetching current weather");
|
||||||
weatherData = fetchCurrentWeather(lat, lon, activity.getId());
|
weatherData = fetchCurrentWeather(lat, lon, activity.getId());
|
||||||
} else {
|
} else {
|
||||||
log.debug("Activity is older than 5 days, historical weather data requires paid API plan");
|
log.warn("Activity is {} days old (>5 days), historical weather data requires paid API plan. Skipping.", daysDifference);
|
||||||
// For historical data, we would use the Time Machine API
|
|
||||||
// weatherData = fetchHistoricalWeather(lat, lon, activityTimestamp, activity.getId());
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (weatherData != null) {
|
if (weatherData != null) {
|
||||||
return Optional.of(weatherDataRepository.save(weatherData));
|
log.info("Successfully fetched and parsed weather data, saving to database");
|
||||||
|
WeatherData saved = weatherDataRepository.save(weatherData);
|
||||||
|
log.info("Weather data saved with ID: {}", saved.getId());
|
||||||
|
return Optional.of(saved);
|
||||||
|
} else {
|
||||||
|
log.error("Weather data fetch returned null");
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error fetching weather data for activity {}: {}", activity.getId(), e.getMessage());
|
log.error("EXCEPTION while fetching weather data for activity {}: {}",
|
||||||
|
activity.getId(), e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
|
@ -121,17 +149,50 @@ public class WeatherService {
|
||||||
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
|
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
|
||||||
OPENWEATHERMAP_API_URL, lat, lon, apiKey);
|
OPENWEATHERMAP_API_URL, lat, lon, apiKey);
|
||||||
|
|
||||||
log.debug("Fetching current weather from: {}", url.replace(apiKey, "***"));
|
String maskedUrl = url.replace(apiKey, "***API_KEY***");
|
||||||
|
log.info("Making API request to OpenWeatherMap: {}", maskedUrl);
|
||||||
|
log.debug("API URL: {}", OPENWEATHERMAP_API_URL);
|
||||||
|
log.debug("Coordinates: lat={}, lon={}", lat, lon);
|
||||||
|
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
String response = restTemplate.getForObject(URI.create(url), String.class);
|
String response = restTemplate.getForObject(URI.create(url), String.class);
|
||||||
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
|
log.info("API request completed in {}ms", duration);
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
|
log.error("API response is NULL - no data returned from OpenWeatherMap");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseWeatherResponse(response, activityId);
|
log.debug("API response length: {} chars", response.length());
|
||||||
|
log.debug("API response preview: {}", response.length() > 200 ? response.substring(0, 200) + "..." : response);
|
||||||
|
|
||||||
|
WeatherData weatherData = parseWeatherResponse(response, activityId);
|
||||||
|
|
||||||
|
if (weatherData == null) {
|
||||||
|
log.error("Failed to parse weather response");
|
||||||
|
} else {
|
||||||
|
log.info("Successfully parsed weather data: temp={}°C, condition={}",
|
||||||
|
weatherData.getTemperatureCelsius(), weatherData.getWeatherCondition());
|
||||||
|
}
|
||||||
|
|
||||||
|
return weatherData;
|
||||||
|
|
||||||
|
} catch (org.springframework.web.client.HttpClientErrorException e) {
|
||||||
|
log.error("HTTP CLIENT ERROR from OpenWeatherMap API: Status={}, Body={}",
|
||||||
|
e.getStatusCode(), e.getResponseBodyAsString(), e);
|
||||||
|
return null;
|
||||||
|
} catch (org.springframework.web.client.HttpServerErrorException e) {
|
||||||
|
log.error("HTTP SERVER ERROR from OpenWeatherMap API: Status={}, Body={}",
|
||||||
|
e.getStatusCode(), e.getResponseBodyAsString(), e);
|
||||||
|
return null;
|
||||||
|
} catch (org.springframework.web.client.RestClientException e) {
|
||||||
|
log.error("REST CLIENT EXCEPTION calling OpenWeatherMap API: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error fetching current weather: {}", e.getMessage());
|
log.error("UNEXPECTED EXCEPTION fetching current weather: {} - {}",
|
||||||
|
e.getClass().getSimpleName(), e.getMessage(), e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,30 @@
|
||||||
const FitPubAuth = {
|
const FitPubAuth = {
|
||||||
/**
|
/**
|
||||||
* Get the stored JWT token
|
* Get the stored JWT token
|
||||||
* @returns {string|null} JWT token or null if not found
|
* Note: With httpOnly cookies, the token is not accessible via JavaScript
|
||||||
|
* This method is kept for backward compatibility but returns null
|
||||||
|
* @returns {null} Always returns null (token is in httpOnly cookie)
|
||||||
*/
|
*/
|
||||||
getToken: function() {
|
getToken: function() {
|
||||||
return localStorage.getItem('jwtToken');
|
// Token is now in httpOnly cookie, not accessible to JavaScript
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store JWT token
|
* Store JWT token
|
||||||
* @param {string} token - JWT token to store
|
* Note: Not used anymore - token is stored in httpOnly cookie by server
|
||||||
|
* @param {string} token - JWT token (ignored, kept for compatibility)
|
||||||
*/
|
*/
|
||||||
setToken: function(token) {
|
setToken: function(token) {
|
||||||
localStorage.setItem('jwtToken', token);
|
// Token is automatically stored in httpOnly cookie by server
|
||||||
|
// No action needed
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove stored JWT token
|
* Remove stored JWT token and username
|
||||||
*/
|
*/
|
||||||
removeToken: function() {
|
removeToken: function() {
|
||||||
localStorage.removeItem('jwtToken');
|
// Cookie is cleared by server on logout
|
||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -46,31 +51,15 @@ const FitPubAuth = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is authenticated
|
* Check if user is authenticated
|
||||||
|
* With httpOnly cookies, we check if username is stored (set on login)
|
||||||
|
* The actual authentication is verified server-side on each request
|
||||||
* @returns {boolean} True if authenticated, false otherwise
|
* @returns {boolean} True if authenticated, false otherwise
|
||||||
*/
|
*/
|
||||||
isAuthenticated: function() {
|
isAuthenticated: function() {
|
||||||
const token = this.getToken();
|
// With httpOnly cookies, we can't access the token
|
||||||
if (!token) {
|
// We check if username exists in localStorage (set on successful login)
|
||||||
return false;
|
const username = this.getUsername();
|
||||||
}
|
return username != null && username !== '';
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
try {
|
|
||||||
const payload = this.parseJwt(token);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
if (payload.exp && payload.exp < now) {
|
|
||||||
// Token expired, remove it
|
|
||||||
this.removeToken();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing JWT:', e);
|
|
||||||
this.removeToken();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -121,28 +110,36 @@ const FitPubAuth = {
|
||||||
/**
|
/**
|
||||||
* Logout user
|
* Logout user
|
||||||
*/
|
*/
|
||||||
logout: function() {
|
logout: async function() {
|
||||||
|
try {
|
||||||
|
// Call server logout endpoint to clear the httpOnly cookie
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include' // Important: send cookies
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout request failed:', error);
|
||||||
|
// Continue with logout even if server request fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear client-side storage
|
||||||
this.removeToken();
|
this.removeToken();
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an authenticated API request
|
* Make an authenticated API request
|
||||||
|
* With httpOnly cookies, authentication is automatic via cookies
|
||||||
* @param {string} url - API endpoint URL
|
* @param {string} url - API endpoint URL
|
||||||
* @param {object} options - Fetch options
|
* @param {object} options - Fetch options
|
||||||
* @returns {Promise<Response>} Fetch response
|
* @returns {Promise<Response>} Fetch response
|
||||||
*/
|
*/
|
||||||
authenticatedFetch: async function(url, options = {}) {
|
authenticatedFetch: async function(url, options = {}) {
|
||||||
const token = this.getToken();
|
// Prepare headers
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('No authentication token found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Authorization header
|
|
||||||
const headers = {
|
const headers = {
|
||||||
...options.headers,
|
...options.headers
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// If body is an object, set Content-Type to JSON
|
// If body is an object, set Content-Type to JSON
|
||||||
|
|
@ -151,13 +148,15 @@ const FitPubAuth = {
|
||||||
options.body = JSON.stringify(options.body);
|
options.body = JSON.stringify(options.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make request with credentials to send httpOnly cookie
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers
|
headers,
|
||||||
|
credentials: 'include' // Important: send cookies
|
||||||
});
|
});
|
||||||
|
|
||||||
// If unauthorized, redirect to login
|
// If unauthorized, clear local state and redirect to login
|
||||||
if (response.status === 401) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
this.removeToken();
|
this.removeToken();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
throw new Error('Authentication failed');
|
throw new Error('Authentication failed');
|
||||||
|
|
@ -214,12 +213,12 @@ const FitPubAuth = {
|
||||||
const authUserMenu = document.getElementById('authUserMenu');
|
const authUserMenu = document.getElementById('authUserMenu');
|
||||||
const guestMenu = document.getElementById('guestMenu');
|
const guestMenu = document.getElementById('guestMenu');
|
||||||
const usernameDisplay = document.getElementById('usernameDisplay');
|
const usernameDisplay = document.getElementById('usernameDisplay');
|
||||||
|
const discoverLink = document.getElementById('discoverLink');
|
||||||
const myActivitiesLink = document.getElementById('myActivitiesLink');
|
const myActivitiesLink = document.getElementById('myActivitiesLink');
|
||||||
const uploadLink = document.getElementById('uploadLink');
|
const uploadDropdown = document.getElementById('uploadDropdown');
|
||||||
const batchUploadLink = document.getElementById('batchUploadLink');
|
const analyticsDropdown = document.getElementById('analyticsDropdown');
|
||||||
const analyticsLink = document.getElementById('analyticsLink');
|
|
||||||
const heatmapLink = document.getElementById('heatmapLink');
|
|
||||||
const notificationsBell = document.getElementById('notificationsBell');
|
const notificationsBell = document.getElementById('notificationsBell');
|
||||||
|
const notificationsBellMobile = document.getElementById('notificationsBellMobile');
|
||||||
|
|
||||||
if (this.isAuthenticated()) {
|
if (this.isAuthenticated()) {
|
||||||
// Show authenticated menu, hide guest menu
|
// Show authenticated menu, hide guest menu
|
||||||
|
|
@ -231,30 +230,31 @@ const FitPubAuth = {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show authenticated navigation links
|
// Show authenticated navigation links
|
||||||
|
if (discoverLink) {
|
||||||
|
discoverLink.style.display = '';
|
||||||
|
discoverLink.parentElement.style.display = '';
|
||||||
|
}
|
||||||
if (myActivitiesLink) {
|
if (myActivitiesLink) {
|
||||||
myActivitiesLink.style.display = '';
|
myActivitiesLink.style.display = '';
|
||||||
myActivitiesLink.parentElement.style.display = '';
|
myActivitiesLink.parentElement.style.display = '';
|
||||||
}
|
}
|
||||||
if (uploadLink) {
|
|
||||||
uploadLink.style.display = '';
|
// Show dropdown menus
|
||||||
uploadLink.parentElement.style.display = '';
|
if (uploadDropdown) {
|
||||||
|
uploadDropdown.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
if (batchUploadLink) {
|
if (analyticsDropdown) {
|
||||||
batchUploadLink.style.display = '';
|
analyticsDropdown.classList.remove('d-none');
|
||||||
batchUploadLink.parentElement.style.display = '';
|
|
||||||
}
|
|
||||||
if (analyticsLink) {
|
|
||||||
analyticsLink.style.display = '';
|
|
||||||
analyticsLink.parentElement.style.display = '';
|
|
||||||
}
|
|
||||||
if (heatmapLink) {
|
|
||||||
heatmapLink.style.display = '';
|
|
||||||
heatmapLink.parentElement.style.display = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show notifications bell
|
// Show notifications bell (desktop: visible on lg+, mobile: visible below lg)
|
||||||
if (notificationsBell) {
|
if (notificationsBell) {
|
||||||
notificationsBell.classList.remove('d-none');
|
notificationsBell.classList.remove('d-none');
|
||||||
|
notificationsBell.classList.add('d-lg-block'); // Visible on lg+, hidden below
|
||||||
|
}
|
||||||
|
if (notificationsBellMobile) {
|
||||||
|
notificationsBellMobile.classList.remove('d-none', 'd-lg-none');
|
||||||
|
notificationsBellMobile.classList.add('d-block', 'd-lg-none'); // Visible below lg, hidden on lg+
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display username
|
// Display username
|
||||||
|
|
@ -270,33 +270,37 @@ const FitPubAuth = {
|
||||||
if (authUserMenu) {
|
if (authUserMenu) {
|
||||||
authUserMenu.classList.add('d-none');
|
authUserMenu.classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide notification bells completely
|
||||||
if (notificationsBell) {
|
if (notificationsBell) {
|
||||||
|
notificationsBell.classList.remove('d-lg-block', 'd-block');
|
||||||
notificationsBell.classList.add('d-none');
|
notificationsBell.classList.add('d-none');
|
||||||
}
|
}
|
||||||
|
if (notificationsBellMobile) {
|
||||||
|
notificationsBellMobile.classList.remove('d-block', 'd-lg-none');
|
||||||
|
notificationsBellMobile.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
if (guestMenu) {
|
if (guestMenu) {
|
||||||
guestMenu.style.display = '';
|
guestMenu.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide authenticated navigation links
|
// Hide authenticated navigation links
|
||||||
|
if (discoverLink) {
|
||||||
|
discoverLink.style.display = 'none';
|
||||||
|
discoverLink.parentElement.style.display = 'none';
|
||||||
|
}
|
||||||
if (myActivitiesLink) {
|
if (myActivitiesLink) {
|
||||||
myActivitiesLink.style.display = 'none';
|
myActivitiesLink.style.display = 'none';
|
||||||
myActivitiesLink.parentElement.style.display = 'none';
|
myActivitiesLink.parentElement.style.display = 'none';
|
||||||
}
|
}
|
||||||
if (uploadLink) {
|
|
||||||
uploadLink.style.display = 'none';
|
// Hide dropdown menus
|
||||||
uploadLink.parentElement.style.display = 'none';
|
if (uploadDropdown) {
|
||||||
|
uploadDropdown.classList.add('d-none');
|
||||||
}
|
}
|
||||||
if (batchUploadLink) {
|
if (analyticsDropdown) {
|
||||||
batchUploadLink.style.display = 'none';
|
analyticsDropdown.classList.add('d-none');
|
||||||
batchUploadLink.parentElement.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (analyticsLink) {
|
|
||||||
analyticsLink.style.display = 'none';
|
|
||||||
analyticsLink.parentElement.style.display = 'none';
|
|
||||||
}
|
|
||||||
if (heatmapLink) {
|
|
||||||
heatmapLink.style.display = 'none';
|
|
||||||
heatmapLink.parentElement.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -400,14 +404,22 @@ const FitPubAuth = {
|
||||||
const response = await this.authenticatedFetch('/api/notifications/unread/count');
|
const response = await this.authenticatedFetch('/api/notifications/unread/count');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const badge = document.getElementById('navNotificationCount');
|
const badgeDesktop = document.getElementById('navNotificationCount');
|
||||||
if (badge) {
|
const badgeMobile = document.getElementById('navNotificationCountMobile');
|
||||||
if (data.count > 0) {
|
|
||||||
badge.textContent = data.count > 99 ? '99+' : data.count;
|
const displayText = data.count > 99 ? '99+' : data.count;
|
||||||
badge.style.display = 'inline-block';
|
const shouldDisplay = data.count > 0;
|
||||||
} else {
|
|
||||||
badge.style.display = 'none';
|
// Update desktop badge
|
||||||
|
if (badgeDesktop) {
|
||||||
|
badgeDesktop.textContent = displayText;
|
||||||
|
badgeDesktop.style.display = shouldDisplay ? 'inline-block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update mobile badge
|
||||||
|
if (badgeMobile) {
|
||||||
|
badgeMobile.textContent = displayText;
|
||||||
|
badgeMobile.style.display = shouldDisplay ? 'inline-block' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,20 @@
|
||||||
<i class="bi bi-activity"></i> FitPub
|
<i class="bi bi-activity"></i> FitPub
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Notifications bell - always visible on mobile, next to hamburger -->
|
||||||
|
<ul class="navbar-nav d-flex flex-row order-lg-1 ms-auto">
|
||||||
|
<li class="nav-item d-none me-2" id="notificationsBellMobile">
|
||||||
|
<a class="nav-link position-relative" th:href="@{/notifications}" title="Notifications">
|
||||||
|
<i class="bi bi-bell"></i>
|
||||||
|
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
|
||||||
|
id="navNotificationCountMobile"
|
||||||
|
style="display: none; font-size: 0.6rem;">
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<button class="navbar-toggler"
|
<button class="navbar-toggler"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
|
|
@ -56,7 +70,7 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" th:href="@{/discover}">
|
<a class="nav-link" th:href="@{/discover}" id="discoverLink" style="display: none;">
|
||||||
<i class="bi bi-people"></i> Discover
|
<i class="bi bi-people"></i> Discover
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -65,32 +79,52 @@
|
||||||
<i class="bi bi-list-task"></i> My Activities
|
<i class="bi bi-list-task"></i> My Activities
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" th:href="@{/activities/upload}" id="uploadLink" style="display: none;">
|
<!-- Upload dropdown menu -->
|
||||||
|
<li class="nav-item dropdown d-none" id="uploadDropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="bi bi-cloud-upload"></i> Upload
|
<i class="bi bi-cloud-upload"></i> Upload
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" th:href="@{/activities/upload}">
|
||||||
|
<i class="bi bi-file-earmark-arrow-up"></i> Single Activity
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li>
|
||||||
<a class="nav-link" th:href="@{/batch-upload}" id="batchUploadLink" style="display: none;">
|
<a class="dropdown-item" th:href="@{/batch-upload}">
|
||||||
<i class="bi bi-file-earmark-zip"></i> Batch Import
|
<i class="bi bi-file-earmark-zip"></i> Batch Import
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
</ul>
|
||||||
<a class="nav-link" th:href="@{/analytics}" id="analyticsLink" style="display: none;">
|
</li>
|
||||||
|
|
||||||
|
<!-- Analytics dropdown menu -->
|
||||||
|
<li class="nav-item dropdown d-none" id="analyticsDropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" role="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
<i class="bi bi-graph-up"></i> Analytics
|
<i class="bi bi-graph-up"></i> Analytics
|
||||||
</a>
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" th:href="@{/analytics}">
|
||||||
|
<i class="bi bi-bar-chart"></i> Dashboard
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li>
|
||||||
<a class="nav-link" th:href="@{/heatmap}" id="heatmapLink" style="display: none;">
|
<a class="dropdown-item" th:href="@{/heatmap}">
|
||||||
<i class="bi bi-map"></i> Heatmap
|
<i class="bi bi-map"></i> Heatmap
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<!-- Right side navigation -->
|
<!-- Right side navigation -->
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<!-- Notifications bell (hidden by default, shown by JS if JWT exists) -->
|
<!-- Notifications bell (hidden on mobile, shown on desktop) -->
|
||||||
<li class="nav-item d-none" id="notificationsBell">
|
<li class="nav-item d-none d-lg-block" id="notificationsBell">
|
||||||
<a class="nav-link position-relative" th:href="@{/notifications}" title="Notifications">
|
<a class="nav-link position-relative" th:href="@{/notifications}" title="Notifications">
|
||||||
<i class="bi bi-bell"></i>
|
<i class="bi bi-bell"></i>
|
||||||
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
|
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue