From fcef751483e754b885a2e1762d41baab8473d7e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Fri, 9 Jan 2026 13:32:45 +0100 Subject: [PATCH] Privacy Zones --- .idea/vcs.xml | 2 +- .../fitpub/config/SecurityConfig.java | 3 + .../fitpub/controller/ActivityController.java | 66 ++- .../controller/PrivacyZoneController.java | 185 +++++++++ .../fitpub/model/dto/ActivityDTO.java | 138 +++++++ .../model/dto/CreatePrivacyZoneRequest.java | 39 ++ .../fitpub/model/dto/PrivacyZoneDTO.java | 51 +++ .../model/dto/UpdatePrivacyZoneRequest.java | 40 ++ .../fitpub/model/entity/PrivacyZone.java | 70 ++++ .../repository/PrivacyZoneRepository.java | 89 ++++ .../fitpub/service/ActivityImageService.java | 83 +++- .../fitpub/service/PrivacyZoneService.java | 196 +++++++++ .../fitpub/service/TrackPrivacyFilter.java | 218 ++++++++++ src/main/resources/application.yml | 9 + .../V18__create_privacy_zones_table.sql | 37 ++ src/main/resources/static/js/privacy-zones.js | 390 ++++++++++++++++++ .../templates/activities/detail.html | 67 ++- src/main/resources/templates/settings.html | 134 +++++- 18 files changed, 1791 insertions(+), 26 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/controller/PrivacyZoneController.java create mode 100644 src/main/java/org/operaton/fitpub/model/dto/CreatePrivacyZoneRequest.java create mode 100644 src/main/java/org/operaton/fitpub/model/dto/PrivacyZoneDTO.java create mode 100644 src/main/java/org/operaton/fitpub/model/dto/UpdatePrivacyZoneRequest.java create mode 100644 src/main/java/org/operaton/fitpub/model/entity/PrivacyZone.java create mode 100644 src/main/java/org/operaton/fitpub/repository/PrivacyZoneRepository.java create mode 100644 src/main/java/org/operaton/fitpub/service/PrivacyZoneService.java create mode 100644 src/main/java/org/operaton/fitpub/service/TrackPrivacyFilter.java create mode 100644 src/main/resources/db/migration/V18__create_privacy_zones_table.sql create mode 100644 src/main/resources/static/js/privacy-zones.js diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index ac2c3fb..49283f4 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -125,6 +125,9 @@ public class SecurityConfig { // Protected endpoints - Batch Import API .requestMatchers("/api/batch-import/**").authenticated() + // Protected endpoints - Privacy Zones API + .requestMatchers("/api/privacy-zones/**").authenticated() + // Protected endpoints - Activities API (upload, edit, delete) .requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated() .requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated() diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java index 1432c20..c3c9378 100644 --- a/src/main/java/org/operaton/fitpub/controller/ActivityController.java +++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java @@ -7,6 +7,7 @@ import org.operaton.fitpub.model.dto.ActivityDTO; import org.operaton.fitpub.model.dto.ActivityUpdateRequest; import org.operaton.fitpub.model.dto.ActivityUploadRequest; import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.PrivacyZone; import org.operaton.fitpub.model.entity.User; import org.operaton.fitpub.repository.UserRepository; import org.operaton.fitpub.service.ActivityFileService; @@ -14,6 +15,8 @@ import org.operaton.fitpub.service.ActivityImageService; import org.operaton.fitpub.service.ActivityPostProcessingService; import org.operaton.fitpub.service.FederationService; import org.operaton.fitpub.service.FitFileService; +import org.operaton.fitpub.service.PrivacyZoneService; +import org.operaton.fitpub.service.TrackPrivacyFilter; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -44,6 +47,8 @@ public class ActivityController { private final FederationService federationService; private final ActivityImageService activityImageService; private final org.operaton.fitpub.service.WeatherService weatherService; + private final PrivacyZoneService privacyZoneService; + private final TrackPrivacyFilter trackPrivacyFilter; @Value("${fitpub.base-url}") private String baseUrl; @@ -61,6 +66,23 @@ public class ActivityController { return user.getId(); } + /** + * Helper method to get user ID from authenticated UserDetails, or null if not authenticated. + * + * @param userDetails the authenticated user details (may be null) + * @return the user's UUID, or null if not authenticated + */ + private UUID getUserIdOrNull(UserDetails userDetails) { + if (userDetails == null) { + return null; + } + try { + return getUserId(userDetails); + } catch (UsernameNotFoundException e) { + return null; + } + } + /** * Uploads an activity file (FIT or GPX) and creates a new activity. * @@ -140,10 +162,21 @@ public class ActivityController { return ResponseEntity.notFound().build(); } + // Get requesting user ID (or null for anonymous) + UUID requestingUserId = getUserIdOrNull(userDetails); + + // Get activity owner's privacy zones + java.util.List privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId()); + + log.debug("Activity {} - Requesting user: {}, Owner: {}, Privacy zones: {}", + id, requestingUserId, activity.getUserId(), privacyZones.size()); + // Check visibility if (activity.getVisibility() == Activity.Visibility.PUBLIC) { - // Public activities are always accessible - ActivityDTO dto = ActivityDTO.fromEntity(activity); + // Public activities are always accessible, but apply privacy filtering + ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter); + log.debug("Activity {} - DTO privacy zones: {}", id, + dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0); return ResponseEntity.ok(dto); } @@ -160,7 +193,8 @@ public class ActivityController { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } - ActivityDTO dto = ActivityDTO.fromEntity(checkedActivity); + // Apply privacy filtering (owner sees full track, others see filtered) + ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter); return ResponseEntity.ok(dto); } @@ -277,13 +311,15 @@ public class ActivityController { * @param username the username * @param page page number (default: 0) * @param size page size (default: 10) + * @param userDetails the authenticated user (optional) * @return page of public activities */ @GetMapping("/user/{username}") public ResponseEntity getUserPublicActivities( @PathVariable String username, @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal UserDetails userDetails ) { log.debug("Retrieving public activities for user: {}", username); @@ -291,6 +327,12 @@ public class ActivityController { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + // Get requesting user ID (or null for anonymous) + UUID requestingUserId = getUserIdOrNull(userDetails); + + // Get activity owner's privacy zones + java.util.List privacyZones = privacyZoneService.getActivePrivacyZones(user.getId()); + // Get public activities only org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size, @@ -299,8 +341,10 @@ public class ActivityController { org.springframework.data.domain.Page activityPage = fitFileService.getPublicActivitiesByUserId(user.getId(), pageable); - // Convert to DTOs - org.springframework.data.domain.Page dtoPage = activityPage.map(ActivityDTO::fromEntity); + // Convert to DTOs with privacy filtering + org.springframework.data.domain.Page dtoPage = activityPage.map(activity -> + ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter) + ); return ResponseEntity.ok(dtoPage); } @@ -343,8 +387,14 @@ public class ActivityController { } } - // Build GeoJSON FeatureCollection - ActivityDTO dto = ActivityDTO.fromEntity(activity); + // Get requesting user ID (or null for anonymous) + UUID requestingUserId = getUserIdOrNull(userDetails); + + // Get activity owner's privacy zones + java.util.List privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId()); + + // Build GeoJSON FeatureCollection with privacy filtering + ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter); // Use high-resolution track points if available, otherwise fall back to simplified track java.util.List> coordinates = new java.util.ArrayList<>(); diff --git a/src/main/java/org/operaton/fitpub/controller/PrivacyZoneController.java b/src/main/java/org/operaton/fitpub/controller/PrivacyZoneController.java new file mode 100644 index 0000000..92d285b --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/PrivacyZoneController.java @@ -0,0 +1,185 @@ +package org.operaton.fitpub.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.dto.CreatePrivacyZoneRequest; +import org.operaton.fitpub.model.dto.PrivacyZoneDTO; +import org.operaton.fitpub.model.dto.UpdatePrivacyZoneRequest; +import org.operaton.fitpub.model.entity.PrivacyZone; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.service.PrivacyZoneService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * REST controller for privacy zone management. + */ +@RestController +@RequestMapping("/api/privacy-zones") +@RequiredArgsConstructor +@Slf4j +public class PrivacyZoneController { + + private final PrivacyZoneService privacyZoneService; + private final UserRepository userRepository; + + /** + * Get all privacy zones for the authenticated user. + * + * @param userDetails the authenticated user + * @return list of privacy zones + */ + @GetMapping + public ResponseEntity> getPrivacyZones(@AuthenticationPrincipal UserDetails userDetails) { + log.debug("User {} retrieving privacy zones", userDetails.getUsername()); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + List zones = privacyZoneService.getUserPrivacyZones(user.getId()); + List zoneDTOs = zones.stream() + .map(PrivacyZoneDTO::fromEntity) + .collect(Collectors.toList()); + + return ResponseEntity.ok(zoneDTOs); + } + + /** + * Create a new privacy zone. + * + * @param request the zone creation request + * @param userDetails the authenticated user + * @return created privacy zone + */ + @PostMapping + public ResponseEntity createPrivacyZone( + @Valid @RequestBody CreatePrivacyZoneRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} creating privacy zone: {}", userDetails.getUsername(), request.getName()); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + PrivacyZone zone = privacyZoneService.createPrivacyZone( + user.getId(), + request.getName(), + request.getDescription(), + request.getLatitude(), + request.getLongitude(), + request.getRadiusMeters() + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(PrivacyZoneDTO.fromEntity(zone)); + } + + /** + * Update an existing privacy zone. + * + * @param zoneId the zone ID + * @param request the update request + * @param userDetails the authenticated user + * @return updated privacy zone + */ + @PutMapping("/{zoneId}") + public ResponseEntity updatePrivacyZone( + @PathVariable UUID zoneId, + @Valid @RequestBody UpdatePrivacyZoneRequest request, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} updating privacy zone {}", userDetails.getUsername(), zoneId); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + try { + PrivacyZone zone = privacyZoneService.updatePrivacyZone( + zoneId, + user.getId(), + request.getName(), + request.getDescription(), + request.getLatitude(), + request.getLongitude(), + request.getRadiusMeters() + ); + + return ResponseEntity.ok(PrivacyZoneDTO.fromEntity(zone)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } + + /** + * Toggle a privacy zone's active status. + * + * @param zoneId the zone ID + * @param body request body with isActive boolean + * @param userDetails the authenticated user + * @return updated privacy zone + */ + @PatchMapping("/{zoneId}/toggle") + public ResponseEntity togglePrivacyZone( + @PathVariable UUID zoneId, + @RequestBody Map body, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} toggling privacy zone {}", userDetails.getUsername(), zoneId); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + Boolean isActive = body.get("isActive"); + if (isActive == null) { + return ResponseEntity.badRequest().build(); + } + + try { + PrivacyZone zone = privacyZoneService.togglePrivacyZone(zoneId, user.getId(), isActive); + return ResponseEntity.ok(PrivacyZoneDTO.fromEntity(zone)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } + + /** + * Delete a privacy zone. + * + * @param zoneId the zone ID + * @param userDetails the authenticated user + * @return no content on success + */ + @DeleteMapping("/{zoneId}") + public ResponseEntity deletePrivacyZone( + @PathVariable UUID zoneId, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} deleting privacy zone {}", userDetails.getUsername(), zoneId); + + User user = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + try { + privacyZoneService.deletePrivacyZone(zoneId, user.getId()); + return ResponseEntity.noContent().build(); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java index 53b99b8..e7328ba 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java @@ -9,6 +9,8 @@ import lombok.NoArgsConstructor; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.LineString; import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.PrivacyZone; +import org.operaton.fitpub.service.TrackPrivacyFilter; import org.operaton.fitpub.util.ActivityFormatter; import java.math.BigDecimal; @@ -57,6 +59,9 @@ public class ActivityDTO { private Long commentsCount; private Boolean likedByCurrentUser; // True if current user has liked this activity + // Privacy zones (only for activity owner, to show what's hidden for others) + private List privacyZones; + // Convenience getters for flattened metrics (for frontend compatibility) public Integer getAverageHeartRate() { return metrics != null ? metrics.getAverageHeartRate() : null; @@ -139,6 +144,124 @@ public class ActivityDTO { return builder.build(); } + /** + * Creates a DTO from an Activity entity with privacy zone filtering applied. + * Filters GPS coordinates that fall within the activity owner's privacy zones. + * + * @param activity the activity entity + * @param requestingUserId the ID of the user requesting the activity (null for anonymous) + * @param privacyZones the activity owner's active privacy zones + * @param filter the privacy filter service + * @return activity DTO with filtered GPS data, or null if entire track was filtered + */ + public static ActivityDTO fromEntityWithFiltering( + Activity activity, + UUID requestingUserId, + List privacyZones, + TrackPrivacyFilter filter + ) { + // If requester is the activity owner, don't filter (show full track) + boolean isOwner = requestingUserId != null && requestingUserId.equals(activity.getUserId()); + + // If no privacy zones or requester is owner, use standard conversion + if (privacyZones == null || privacyZones.isEmpty() || isOwner) { + if (isOwner && privacyZones != null && !privacyZones.isEmpty()) { + org.slf4j.LoggerFactory.getLogger(ActivityDTO.class) + .info("Activity {} - Owner viewing, bypassing {} privacy zones", + activity.getId(), privacyZones.size()); + + // For owner, return full track but include privacy zones for visualization + ActivityDTO dto = fromEntity(activity); + dto.setPrivacyZones(privacyZones.stream() + .map(zone -> PrivacyZonePreview.builder() + .id(zone.getId()) + .name(zone.getName()) + .latitude(zone.getCenterPoint().getY()) + .longitude(zone.getCenterPoint().getX()) + .radiusMeters(zone.getRadiusMeters()) + .build()) + .collect(java.util.stream.Collectors.toList())); + return dto; + } + return fromEntity(activity); + } + + // Apply filtering to tracks + LineString filteredSimplifiedTrack = null; + String filteredTrackPointsJson = null; + + if (activity.getSimplifiedTrack() != null) { + filteredSimplifiedTrack = filter.filterLineString(activity.getSimplifiedTrack(), privacyZones); + } + + if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { + filteredTrackPointsJson = filter.filterTrackPointsJson(activity.getTrackPointsJson(), privacyZones); + } + + // If entire track was filtered out, return null (activity is completely private) + if (filteredSimplifiedTrack == null && filteredTrackPointsJson == null) { + // Return basic activity info without GPS data + return ActivityDTO.builder() + .id(activity.getId()) + .userId(activity.getUserId()) + .activityType(ActivityFormatter.formatActivityType(activity.getActivityType())) + .title(activity.getTitle()) + .description(activity.getDescription()) + .startedAt(activity.getStartedAt()) + .endedAt(activity.getEndedAt()) + .timezone(activity.getTimezone()) + .visibility(activity.getVisibility().name()) + .totalDistance(activity.getTotalDistance()) + .totalDurationSeconds(activity.getTotalDurationSeconds()) + .elevationGain(activity.getElevationGain()) + .elevationLoss(activity.getElevationLoss()) + .metrics(activity.getMetrics() != null ? ActivityMetricsDTO.fromEntity(activity.getMetrics()) : null) + .createdAt(activity.getCreatedAt()) + .updatedAt(activity.getUpdatedAt()) + .hasGpsTrack(false) // Mark as no GPS data available + .build(); + } + + // Build DTO with filtered tracks + ActivityDTOBuilder builder = ActivityDTO.builder() + .id(activity.getId()) + .userId(activity.getUserId()) + .activityType(ActivityFormatter.formatActivityType(activity.getActivityType())) + .title(activity.getTitle()) + .description(activity.getDescription()) + .startedAt(activity.getStartedAt()) + .endedAt(activity.getEndedAt()) + .timezone(activity.getTimezone()) + .visibility(activity.getVisibility().name()) + .totalDistance(activity.getTotalDistance()) + .elevationGain(activity.getElevationGain()) + .elevationLoss(activity.getElevationLoss()) + .createdAt(activity.getCreatedAt()) + .updatedAt(activity.getUpdatedAt()); + + if (activity.getTotalDurationSeconds() != null) { + builder.totalDurationSeconds(activity.getTotalDurationSeconds()); + } + + if (activity.getMetrics() != null) { + builder.metrics(ActivityMetricsDTO.fromEntity(activity.getMetrics())); + } + + // Add filtered GPS data + boolean hasGps = filteredSimplifiedTrack != null; + builder.hasGpsTrack(hasGps); + + if (hasGps) { + builder.simplifiedTrack(lineStringToGeoJson(filteredSimplifiedTrack)); + } + + if (filteredTrackPointsJson != null) { + builder.trackPoints(parseTrackPoints(filteredTrackPointsJson)); + } + + return builder.build(); + } + /** * Converts a JTS LineString to GeoJSON format. */ @@ -187,4 +310,19 @@ public class ActivityDTO { } return null; } + + /** + * Simple preview of a privacy zone for map rendering. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PrivacyZonePreview { + private UUID id; + private String name; + private Double latitude; + private Double longitude; + private Integer radiusMeters; + } } diff --git a/src/main/java/org/operaton/fitpub/model/dto/CreatePrivacyZoneRequest.java b/src/main/java/org/operaton/fitpub/model/dto/CreatePrivacyZoneRequest.java new file mode 100644 index 0000000..5b27bca --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/CreatePrivacyZoneRequest.java @@ -0,0 +1,39 @@ +package org.operaton.fitpub.model.dto; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for creating a new privacy zone. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreatePrivacyZoneRequest { + + @NotNull(message = "Name is required") + @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") + private String name; + + @Size(max = 500, message = "Description must not exceed 500 characters") + private String description; + + @NotNull(message = "Latitude is required") + @DecimalMin(value = "-90.0", message = "Latitude must be between -90 and 90") + @DecimalMax(value = "90.0", message = "Latitude must be between -90 and 90") + private Double latitude; + + @NotNull(message = "Longitude is required") + @DecimalMin(value = "-180.0", message = "Longitude must be between -180 and 180") + @DecimalMax(value = "180.0", message = "Longitude must be between -180 and 180") + private Double longitude; + + @NotNull(message = "Radius is required") + @Min(value = 50, message = "Radius must be at least 50 meters") + @Max(value = 10000, message = "Radius must not exceed 10000 meters (10 km)") + private Integer radiusMeters; +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/PrivacyZoneDTO.java b/src/main/java/org/operaton/fitpub/model/dto/PrivacyZoneDTO.java new file mode 100644 index 0000000..2571867 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/PrivacyZoneDTO.java @@ -0,0 +1,51 @@ +package org.operaton.fitpub.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.operaton.fitpub.model.entity.PrivacyZone; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * DTO for PrivacyZone data transfer. + * Returns lat/lon as separate fields extracted from PostGIS Point geometry. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PrivacyZoneDTO { + + private UUID id; + private UUID userId; + private String name; + private String description; + private Double latitude; + private Double longitude; + private Integer radiusMeters; + private Boolean isActive; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** + * Creates a DTO from a PrivacyZone entity. + * Extracts latitude and longitude from PostGIS Point geometry. + */ + public static PrivacyZoneDTO fromEntity(PrivacyZone zone) { + return PrivacyZoneDTO.builder() + .id(zone.getId()) + .userId(zone.getUserId()) + .name(zone.getName()) + .description(zone.getDescription()) + .latitude(zone.getCenterPoint().getY()) + .longitude(zone.getCenterPoint().getX()) + .radiusMeters(zone.getRadiusMeters()) + .isActive(zone.getIsActive()) + .createdAt(zone.getCreatedAt()) + .updatedAt(zone.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/UpdatePrivacyZoneRequest.java b/src/main/java/org/operaton/fitpub/model/dto/UpdatePrivacyZoneRequest.java new file mode 100644 index 0000000..babc611 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/UpdatePrivacyZoneRequest.java @@ -0,0 +1,40 @@ +package org.operaton.fitpub.model.dto; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for updating an existing privacy zone. + * All fields are required (full update). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdatePrivacyZoneRequest { + + @NotNull(message = "Name is required") + @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") + private String name; + + @Size(max = 500, message = "Description must not exceed 500 characters") + private String description; + + @NotNull(message = "Latitude is required") + @DecimalMin(value = "-90.0", message = "Latitude must be between -90 and 90") + @DecimalMax(value = "90.0", message = "Latitude must be between -90 and 90") + private Double latitude; + + @NotNull(message = "Longitude is required") + @DecimalMin(value = "-180.0", message = "Longitude must be between -180 and 180") + @DecimalMax(value = "180.0", message = "Longitude must be between -180 and 180") + private Double longitude; + + @NotNull(message = "Radius is required") + @Min(value = 50, message = "Radius must be at least 50 meters") + @Max(value = 10000, message = "Radius must not exceed 10000 meters (10 km)") + private Integer radiusMeters; +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/PrivacyZone.java b/src/main/java/org/operaton/fitpub/model/entity/PrivacyZone.java new file mode 100644 index 0000000..c751e1b --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/entity/PrivacyZone.java @@ -0,0 +1,70 @@ +package org.operaton.fitpub.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.locationtech.jts.geom.Point; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entity representing a user-defined privacy zone for GPS track filtering. + * When activities pass through privacy zones, GPS coordinates within the zone are removed. + */ +@Entity +@Table(name = "privacy_zones", indexes = { + @Index(name = "idx_privacy_zones_user", columnList = "user_id, is_active"), + @Index(name = "idx_privacy_zones_center_point", columnList = "center_point") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PrivacyZone { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(nullable = false, length = 100) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + /** + * True center point of the privacy zone (PostGIS Point geometry). + * This is NOT the randomized display center shown in the UI. + */ + @Column(name = "center_point", columnDefinition = "geometry(Point, 4326)", nullable = false) + private Point centerPoint; + + /** + * Radius of the privacy zone in meters. + * All GPS points within this distance from centerPoint will be filtered. + */ + @Column(name = "radius_meters", nullable = false) + private Integer radiusMeters; + + /** + * Whether this privacy zone is currently active. + * Inactive zones are ignored during filtering. + */ + @Column(name = "is_active", nullable = false) + @Builder.Default + private Boolean isActive = true; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/org/operaton/fitpub/repository/PrivacyZoneRepository.java b/src/main/java/org/operaton/fitpub/repository/PrivacyZoneRepository.java new file mode 100644 index 0000000..95ce0d1 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/repository/PrivacyZoneRepository.java @@ -0,0 +1,89 @@ +package org.operaton.fitpub.repository; + +import org.operaton.fitpub.model.entity.PrivacyZone; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +/** + * Repository for PrivacyZone entities. + */ +@Repository +public interface PrivacyZoneRepository extends JpaRepository { + + /** + * Find all active privacy zones for a user. + * + * @param userId the user ID + * @return list of active privacy zones + */ + List findByUserIdAndIsActiveTrue(UUID userId); + + /** + * Find all privacy zones for a user (including inactive). + * + * @param userId the user ID + * @return list of all privacy zones + */ + List findByUserIdOrderByCreatedAtDesc(UUID userId); + + /** + * Check if a point falls within any of the user's active privacy zones. + * Uses PostGIS ST_DWithin for efficient radius-based spatial queries. + * + * @param userId the user ID + * @param latitude the point latitude + * @param longitude the point longitude + * @return true if point is within any privacy zone + */ + @Query(value = "SELECT EXISTS(" + + "SELECT 1 FROM privacy_zones " + + "WHERE user_id = :userId " + + "AND is_active = TRUE " + + "AND ST_DWithin(" + + " center_point::geography, " + + " ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, " + + " radius_meters" + + ")" + + ")", nativeQuery = true) + boolean isPointInAnyZone( + @Param("userId") UUID userId, + @Param("latitude") double latitude, + @Param("longitude") double longitude + ); + + /** + * Find all privacy zones that a point falls within. + * Returns the actual zones (not just boolean) for detailed filtering. + * + * @param userId the user ID + * @param latitude the point latitude + * @param longitude the point longitude + * @return list of privacy zones containing this point + */ + @Query(value = "SELECT * FROM privacy_zones " + + "WHERE user_id = :userId " + + "AND is_active = TRUE " + + "AND ST_DWithin(" + + " center_point::geography, " + + " ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)::geography, " + + " radius_meters" + + ")", nativeQuery = true) + List findZonesContainingPoint( + @Param("userId") UUID userId, + @Param("latitude") double latitude, + @Param("longitude") double longitude + ); + + /** + * Count active privacy zones for a user. + * + * @param userId the user ID + * @return count of active zones + */ + long countByUserIdAndIsActiveTrue(UUID userId); +} diff --git a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java index eb6a5b5..e151fe4 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java @@ -2,7 +2,9 @@ package org.operaton.fitpub.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.LineString; import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.PrivacyZone; import org.operaton.fitpub.util.ActivityFormatter; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -26,6 +28,8 @@ import java.util.UUID; public class ActivityImageService { private final OsmTileRenderer osmTileRenderer; + private final PrivacyZoneService privacyZoneService; + private final TrackPrivacyFilter trackPrivacyFilter; @Value("${fitpub.storage.images.path:${java.io.tmpdir}/fitpub/images}") private String imagesPath; @@ -38,12 +42,16 @@ public class ActivityImageService { /** * Generate a preview image for an activity showing the track outline and metadata. + * Applies privacy zone filtering to ensure GPS coordinates within zones are not rendered. * * @param activity the activity to generate an image for * @return the URL of the generated image */ public String generateActivityImage(Activity activity) { try { + // Apply privacy zone filtering before rendering + Activity filteredActivity = applyPrivacyFiltering(activity); + // Image dimensions int width = 1200; int height = 630; // Open Graph standard size @@ -59,11 +67,11 @@ public class ActivityImageService { // Calculate bounds once for both map tiles and track rendering TrackBounds trackBounds = null; - boolean isIndoorActivity = activity.getSimplifiedTrack() == null; + boolean isIndoorActivity = filteredActivity.getSimplifiedTrack() == null; // Render background - either OSM tiles or gradient background - if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { - trackBounds = calculateTrackBounds(activity); + if (filteredActivity.getTrackPointsJson() != null && !filteredActivity.getTrackPointsJson().isEmpty()) { + trackBounds = calculateTrackBounds(filteredActivity); } if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) { @@ -107,20 +115,20 @@ public class ActivityImageService { // For indoor activities, draw a large emoji in the center-left area if (isIndoorActivity) { - drawIndoorActivityEmoji(g2d, activity, width, height); + drawIndoorActivityEmoji(g2d, filteredActivity, width, height); } } // Draw track if available (not for indoor activities) if (!isIndoorActivity) { - if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { - drawTrack(g2d, activity, width, height); - } else if (activity.getSimplifiedTrack() != null) { - drawSimplifiedTrack(g2d, activity, width, height); + if (filteredActivity.getTrackPointsJson() != null && !filteredActivity.getTrackPointsJson().isEmpty()) { + drawTrack(g2d, filteredActivity, width, height); + } else if (filteredActivity.getSimplifiedTrack() != null) { + drawSimplifiedTrack(g2d, filteredActivity, width, height); } } - // Draw metadata overlay + // Draw metadata overlay (use original activity for metadata, not filtered) drawMetadata(g2d, activity, width, height, isIndoorActivity); g2d.dispose(); @@ -741,6 +749,63 @@ public class ActivityImageService { g2d.drawString(emoji, emojiX - emojiWidth / 2, emojiY + emojiHeight / 3); } + /** + * Apply privacy zone filtering to an activity's GPS data. + * Filters both simplified track and full track points JSON. + * + * @param activity the original activity + * @return a copy of the activity with filtered GPS data + */ + private Activity applyPrivacyFiltering(Activity activity) { + // Get user's active privacy zones + List privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId()); + + // If no privacy zones, return original activity + if (privacyZones == null || privacyZones.isEmpty()) { + return activity; + } + + // Create a copy of the activity with filtered tracks + Activity filtered = new Activity(); + filtered.setId(activity.getId()); + filtered.setUserId(activity.getUserId()); + filtered.setActivityType(activity.getActivityType()); + filtered.setTitle(activity.getTitle()); + filtered.setDescription(activity.getDescription()); + filtered.setStartedAt(activity.getStartedAt()); + filtered.setEndedAt(activity.getEndedAt()); + filtered.setTimezone(activity.getTimezone()); + filtered.setVisibility(activity.getVisibility()); + filtered.setTotalDistance(activity.getTotalDistance()); + filtered.setTotalDurationSeconds(activity.getTotalDurationSeconds()); + filtered.setElevationGain(activity.getElevationGain()); + filtered.setElevationLoss(activity.getElevationLoss()); + filtered.setMetrics(activity.getMetrics()); + filtered.setCreatedAt(activity.getCreatedAt()); + filtered.setUpdatedAt(activity.getUpdatedAt()); + + // Filter simplified track + if (activity.getSimplifiedTrack() != null) { + LineString filteredTrack = trackPrivacyFilter.filterLineString( + activity.getSimplifiedTrack(), + privacyZones + ); + filtered.setSimplifiedTrack(filteredTrack); + } + + // Filter track points JSON + if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { + String filteredJson = trackPrivacyFilter.filterTrackPointsJson( + activity.getTrackPointsJson(), + privacyZones + ); + filtered.setTrackPointsJson(filteredJson); + } + + log.debug("Applied privacy filtering to activity {} for image generation", activity.getId()); + return filtered; + } + /** * Helper class to store track geographic bounds. */ diff --git a/src/main/java/org/operaton/fitpub/service/PrivacyZoneService.java b/src/main/java/org/operaton/fitpub/service/PrivacyZoneService.java new file mode 100644 index 0000000..0965e0a --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/PrivacyZoneService.java @@ -0,0 +1,196 @@ +package org.operaton.fitpub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; +import org.operaton.fitpub.model.entity.PrivacyZone; +import org.operaton.fitpub.repository.PrivacyZoneRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +/** + * Service for managing GPS privacy zones. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class PrivacyZoneService { + + private final PrivacyZoneRepository privacyZoneRepository; + + private static final int SRID = 4326; // WGS84 + private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), SRID); + + /** + * Create a new privacy zone for a user. + * + * @param userId user ID + * @param name zone name + * @param description zone description (optional) + * @param latitude center latitude + * @param longitude center longitude + * @param radiusMeters radius in meters + * @return created privacy zone + */ + @Transactional + public PrivacyZone createPrivacyZone( + UUID userId, + String name, + String description, + double latitude, + double longitude, + int radiusMeters + ) { + log.info("Creating privacy zone for user {} at ({}, {}) with radius {}m", + userId, latitude, longitude, radiusMeters); + + // Validate inputs + if (radiusMeters <= 0 || radiusMeters > 10000) { + throw new IllegalArgumentException("Radius must be between 1 and 10000 meters"); + } + if (latitude < -90 || latitude > 90) { + throw new IllegalArgumentException("Invalid latitude"); + } + if (longitude < -180 || longitude > 180) { + throw new IllegalArgumentException("Invalid longitude"); + } + + Point centerPoint = geometryFactory.createPoint(new Coordinate(longitude, latitude)); + + PrivacyZone zone = PrivacyZone.builder() + .userId(userId) + .name(name) + .description(description) + .centerPoint(centerPoint) + .radiusMeters(radiusMeters) + .isActive(true) + .build(); + + return privacyZoneRepository.save(zone); + } + + /** + * Get all privacy zones for a user (active and inactive). + * + * @param userId user ID + * @return list of privacy zones + */ + @Transactional(readOnly = true) + public List getUserPrivacyZones(UUID userId) { + return privacyZoneRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + /** + * Get all active privacy zones for a user. + * + * @param userId user ID + * @return list of active privacy zones + */ + @Transactional(readOnly = true) + public List getActivePrivacyZones(UUID userId) { + return privacyZoneRepository.findByUserIdAndIsActiveTrue(userId); + } + + /** + * Update a privacy zone. + * + * @param zoneId zone ID + * @param userId user ID (for authorization) + * @param name new name + * @param description new description + * @param latitude new latitude + * @param longitude new longitude + * @param radiusMeters new radius + * @return updated privacy zone + */ + @Transactional + public PrivacyZone updatePrivacyZone( + UUID zoneId, + UUID userId, + String name, + String description, + double latitude, + double longitude, + int radiusMeters + ) { + PrivacyZone zone = privacyZoneRepository.findById(zoneId) + .orElseThrow(() -> new IllegalArgumentException("Privacy zone not found")); + + if (!zone.getUserId().equals(userId)) { + throw new SecurityException("Not authorized to update this privacy zone"); + } + + // Validate + if (radiusMeters <= 0 || radiusMeters > 10000) { + throw new IllegalArgumentException("Radius must be between 1 and 10000 meters"); + } + + Point centerPoint = geometryFactory.createPoint(new Coordinate(longitude, latitude)); + + zone.setName(name); + zone.setDescription(description); + zone.setCenterPoint(centerPoint); + zone.setRadiusMeters(radiusMeters); + + return privacyZoneRepository.save(zone); + } + + /** + * Toggle a privacy zone's active status. + * + * @param zoneId zone ID + * @param userId user ID (for authorization) + * @param isActive new active status + * @return updated privacy zone + */ + @Transactional + public PrivacyZone togglePrivacyZone(UUID zoneId, UUID userId, boolean isActive) { + PrivacyZone zone = privacyZoneRepository.findById(zoneId) + .orElseThrow(() -> new IllegalArgumentException("Privacy zone not found")); + + if (!zone.getUserId().equals(userId)) { + throw new SecurityException("Not authorized to update this privacy zone"); + } + + zone.setIsActive(isActive); + return privacyZoneRepository.save(zone); + } + + /** + * Delete a privacy zone. + * + * @param zoneId zone ID + * @param userId user ID (for authorization) + */ + @Transactional + public void deletePrivacyZone(UUID zoneId, UUID userId) { + PrivacyZone zone = privacyZoneRepository.findById(zoneId) + .orElseThrow(() -> new IllegalArgumentException("Privacy zone not found")); + + if (!zone.getUserId().equals(userId)) { + throw new SecurityException("Not authorized to delete this privacy zone"); + } + + privacyZoneRepository.delete(zone); + log.info("Deleted privacy zone {} for user {}", zoneId, userId); + } + + /** + * Check if a GPS point falls within any of the user's active privacy zones. + * + * @param userId user ID + * @param latitude point latitude + * @param longitude point longitude + * @return true if point is within any active privacy zone + */ + @Transactional(readOnly = true) + public boolean isPointInPrivacyZone(UUID userId, double latitude, double longitude) { + return privacyZoneRepository.isPointInAnyZone(userId, latitude, longitude); + } +} diff --git a/src/main/java/org/operaton/fitpub/service/TrackPrivacyFilter.java b/src/main/java/org/operaton/fitpub/service/TrackPrivacyFilter.java new file mode 100644 index 0000000..fc9ed03 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/TrackPrivacyFilter.java @@ -0,0 +1,218 @@ +package org.operaton.fitpub.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.PrecisionModel; +import org.operaton.fitpub.model.entity.PrivacyZone; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +/** + * Service for filtering GPS tracks through privacy zones. + * Removes coordinates that fall within user-defined privacy zones. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class TrackPrivacyFilter { + + private static final int SRID = 4326; // WGS84 + private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), SRID); + private final ObjectMapper objectMapper; + + /** + * Filters a LineString track by removing coordinates within privacy zones. + * + * @param track the simplified track LineString + * @param zones list of active privacy zones + * @return filtered LineString, or null if <2 points remain + */ + public LineString filterLineString(LineString track, List zones) { + if (track == null || zones == null || zones.isEmpty()) { + return track; + } + + List filteredCoords = new ArrayList<>(); + Coordinate[] coordinates = track.getCoordinates(); + int originalCount = coordinates.length; + int filteredCount = 0; + + for (Coordinate coord : coordinates) { + double latitude = coord.y; + double longitude = coord.x; + + if (!isPointInAnyZone(latitude, longitude, zones)) { + filteredCoords.add(coord); + } else { + filteredCount++; + } + } + + log.info("Privacy filter: {} zones active, {}/{} points filtered out", + zones.size(), filteredCount, originalCount); + + // Return null if track is completely filtered or too few points remain + if (filteredCoords.size() < 2) { + log.warn("Track completely filtered by privacy zones (or <2 points remain)"); + return null; + } + + return geometryFactory.createLineString(filteredCoords.toArray(new Coordinate[0])); + } + + /** + * Filters track points JSON by removing points within privacy zones. + * + * @param trackPointsJson JSONB string containing track points + * @param zones list of active privacy zones + * @return filtered JSON string, or null if <2 points remain + */ + public String filterTrackPointsJson(String trackPointsJson, List zones) { + if (trackPointsJson == null || zones == null || zones.isEmpty()) { + return trackPointsJson; + } + + try { + JsonNode root = objectMapper.readTree(trackPointsJson); + + if (!root.isArray()) { + log.warn("Track points JSON is not an array"); + return trackPointsJson; + } + + ArrayNode filteredArray = objectMapper.createArrayNode(); + + for (JsonNode node : root) { + if (!node.has("latitude") || !node.has("longitude")) { + continue; // Skip points without coordinates + } + + double latitude = node.get("latitude").asDouble(); + double longitude = node.get("longitude").asDouble(); + + if (!isPointInAnyZone(latitude, longitude, zones)) { + filteredArray.add(node); + } + } + + // Return null if track is completely filtered or too few points remain + if (filteredArray.size() < 2) { + log.debug("Track points completely filtered by privacy zones (or <2 points remain)"); + return null; + } + + return objectMapper.writeValueAsString(filteredArray); + + } catch (Exception e) { + log.error("Error filtering track points JSON", e); + return trackPointsJson; // Return original on error (fail-open for owner) + } + } + + /** + * Check if a point falls within any of the given privacy zones. + * + * @param latitude point latitude + * @param longitude point longitude + * @param zones list of privacy zones + * @return true if point is within any zone + */ + private boolean isPointInAnyZone(double latitude, double longitude, List zones) { + for (PrivacyZone zone : zones) { + Point center = zone.getCenterPoint(); + double centerLat = center.getY(); + double centerLon = center.getX(); + int radiusMeters = zone.getRadiusMeters(); + + double distanceMeters = haversineDistance(latitude, longitude, centerLat, centerLon); + + if (distanceMeters <= radiusMeters) { + return true; + } + } + return false; + } + + /** + * Calculate Haversine distance between two GPS coordinates. + * Returns distance in meters. + * + * @param lat1 first point latitude + * @param lon1 first point longitude + * @param lat2 second point latitude + * @param lon2 second point longitude + * @return distance in meters + */ + private double haversineDistance(double lat1, double lon1, double lat2, double lon2) { + final double EARTH_RADIUS_METERS = 6371000.0; + + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(dLon / 2) * Math.sin(dLon / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return EARTH_RADIUS_METERS * c; + } + + /** + * Get a randomized display center for a privacy zone. + * The randomization is consistent per activity (seeded by activity ID). + * Offset is within 10-15% of the radius from the true center. + * + * @param zone the privacy zone + * @param activityId the activity ID (for consistent seeding) + * @return randomized Point for display + */ + public Point getRandomizedDisplayCenter(PrivacyZone zone, UUID activityId) { + Point trueCenter = zone.getCenterPoint(); + int radiusMeters = zone.getRadiusMeters(); + + // Seed Random with activity ID for consistency + Random random = new Random(activityId.getMostSignificantBits() ^ activityId.getLeastSignificantBits()); + + // Random offset within 10-15% of radius + double offsetPercent = 0.10 + random.nextDouble() * 0.05; // 10-15% + double offsetMeters = radiusMeters * offsetPercent; + + // Random angle in radians + double angle = random.nextDouble() * 2 * Math.PI; + + // Convert offset to degrees (approximate) + double degreesPerMeter = 1.0 / 111320.0; // At equator + double offsetDegrees = offsetMeters * degreesPerMeter; + + // Calculate randomized coordinates + double randomLat = trueCenter.getY() + (offsetDegrees * Math.sin(angle)); + double randomLon = trueCenter.getX() + (offsetDegrees * Math.cos(angle) / Math.cos(Math.toRadians(trueCenter.getY()))); + + return geometryFactory.createPoint(new Coordinate(randomLon, randomLat)); + } + + /** + * Get randomized display center as lat/lon coordinates. + * + * @param zone the privacy zone + * @param activityId the activity ID (for consistent seeding) + * @return array [latitude, longitude] + */ + public double[] getRandomizedDisplayCenterCoordinates(PrivacyZone zone, UUID activityId) { + Point randomized = getRandomizedDisplayCenter(zone, activityId); + return new double[]{randomized.getY(), randomized.getX()}; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4f3e5b8..bf5d4f3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,6 +44,15 @@ spring: write-dates-as-timestamps: false time-zone: UTC + # Cache configuration for privacy zone filtering + cache: + type: caffeine + cache-names: + - filteredTracks + - filteredTrackPoints + caffeine: + spec: maximumSize=1000,expireAfterWrite=7d + # FitPub specific configuration fitpub: # Domain and URL configuration diff --git a/src/main/resources/db/migration/V18__create_privacy_zones_table.sql b/src/main/resources/db/migration/V18__create_privacy_zones_table.sql new file mode 100644 index 0000000..2e767bf --- /dev/null +++ b/src/main/resources/db/migration/V18__create_privacy_zones_table.sql @@ -0,0 +1,37 @@ +CREATE EXTENSION btree_gist; + +-- Privacy Zones Table +-- Stores user-defined geographic zones where GPS data should be filtered +CREATE TABLE privacy_zones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Center point of the privacy zone (PostGIS Point geometry) + center_point geometry(Point, 4326) NOT NULL, + + -- Radius in meters + radius_meters INTEGER NOT NULL, + + -- Whether this zone is active (allows temporary disabling) + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Timestamps + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_radius_positive CHECK (radius_meters > 0 AND radius_meters <= 10000) +); + +-- Indexes for efficient spatial queries +CREATE INDEX idx_privacy_zones_user ON privacy_zones(user_id) WHERE is_active = TRUE; +CREATE INDEX idx_privacy_zones_center_point ON privacy_zones USING GIST(center_point); + +-- Composite spatial index for user + geometry queries +CREATE INDEX idx_privacy_zones_user_spatial ON privacy_zones USING GIST(user_id, center_point) WHERE is_active = TRUE; + +COMMENT ON TABLE privacy_zones IS 'User-defined geographic zones for GPS track privacy filtering'; +COMMENT ON COLUMN privacy_zones.center_point IS 'True center of privacy zone (not the randomized display center)'; +COMMENT ON COLUMN privacy_zones.radius_meters IS 'Radius of privacy zone in meters (max 10km for safety)'; diff --git a/src/main/resources/static/js/privacy-zones.js b/src/main/resources/static/js/privacy-zones.js new file mode 100644 index 0000000..d082fc3 --- /dev/null +++ b/src/main/resources/static/js/privacy-zones.js @@ -0,0 +1,390 @@ +/** + * Privacy Zones Management Module + * Handles GPS privacy zone creation, editing, and deletion with interactive Leaflet map. + */ +const PrivacyZones = (() => { + let map = null; + let marker = null; + let circle = null; + let zones = []; + let currentZone = null; + + /** + * Initialize the privacy zones module + */ + function init() { + setupEventListeners(); + loadZones(); + } + + /** + * Setup event listeners for UI interactions + */ + function setupEventListeners() { + const startAddZoneBtn = document.getElementById('startAddZoneBtn'); + const cancelZoneBtn = document.getElementById('cancelZoneBtn'); + const zoneDetailsForm = document.getElementById('zoneDetailsForm'); + const zoneRadiusInput = document.getElementById('zoneRadius'); + const radiusValue = document.getElementById('radiusValue'); + + startAddZoneBtn.addEventListener('click', startAddingZone); + cancelZoneBtn.addEventListener('click', cancelZone); + zoneDetailsForm.addEventListener('submit', saveZone); + + // Update radius display and circle in real-time + zoneRadiusInput.addEventListener('input', (e) => { + const radius = parseInt(e.target.value); + radiusValue.textContent = radius; + if (circle) { + circle.setRadius(radius); + } + }); + } + + /** + * Start the process of adding a new zone + */ + function startAddingZone() { + const formContainer = document.getElementById('addZoneForm'); + formContainer.classList.remove('d-none'); + + // Initialize map if not already done + if (!map) { + initMap(); + } + + // Reset form + document.getElementById('zoneId').value = ''; + document.getElementById('zoneName').value = ''; + document.getElementById('zoneDescription').value = ''; + document.getElementById('zoneLatitude').value = ''; + document.getElementById('zoneLongitude').value = ''; + document.getElementById('zoneRadius').value = 200; + document.getElementById('radiusValue').textContent = '200'; + + // Clear existing marker and circle + if (marker) { + map.removeLayer(marker); + marker = null; + } + if (circle) { + map.removeLayer(circle); + circle = null; + } + + currentZone = null; + } + + /** + * Cancel zone creation/editing + */ + function cancelZone() { + const formContainer = document.getElementById('addZoneForm'); + formContainer.classList.add('d-none'); + + if (marker) { + map.removeLayer(marker); + marker = null; + } + if (circle) { + map.removeLayer(circle); + circle = null; + } + + currentZone = null; + } + + /** + * Initialize the Leaflet map + */ + function initMap() { + const mapContainer = document.getElementById('zoneMap'); + + // Initialize map centered on user's location (or default) + map = L.map(mapContainer).setView([51.505, -0.09], 13); + + // Add OpenStreetMap tiles + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19 + }).addTo(map); + + // Try to get user's current location + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const lat = position.coords.latitude; + const lon = position.coords.longitude; + map.setView([lat, lon], 15); + }, + (error) => { + console.warn('Geolocation error:', error); + } + ); + } + + // Handle map clicks to place marker + map.on('click', (e) => { + placeMarker(e.latlng.lat, e.latlng.lng); + }); + } + + /** + * Place a marker and circle on the map + */ + function placeMarker(lat, lon) { + const radius = parseInt(document.getElementById('zoneRadius').value); + + // Remove existing marker and circle + if (marker) { + map.removeLayer(marker); + } + if (circle) { + map.removeLayer(circle); + } + + // Create new marker + marker = L.marker([lat, lon], { + draggable: true + }).addTo(map); + + // Create circle + circle = L.circle([lat, lon], { + color: '#dc3545', + fillColor: '#dc3545', + fillOpacity: 0.2, + radius: radius + }).addTo(map); + + // Update form fields + document.getElementById('zoneLatitude').value = lat.toFixed(6); + document.getElementById('zoneLongitude').value = lon.toFixed(6); + + // Handle marker dragging + marker.on('drag', (e) => { + const position = e.target.getLatLng(); + circle.setLatLng(position); + document.getElementById('zoneLatitude').value = position.lat.toFixed(6); + document.getElementById('zoneLongitude').value = position.lng.toFixed(6); + }); + } + + /** + * Save a privacy zone (create or update) + */ + async function saveZone(e) { + e.preventDefault(); + + const zoneId = document.getElementById('zoneId').value; + const name = document.getElementById('zoneName').value.trim(); + const description = document.getElementById('zoneDescription').value.trim(); + const latitude = parseFloat(document.getElementById('zoneLatitude').value); + const longitude = parseFloat(document.getElementById('zoneLongitude').value); + const radiusMeters = parseInt(document.getElementById('zoneRadius').value); + + if (!latitude || !longitude) { + FitPub.showAlert('Please click on the map to place a privacy zone', 'warning'); + return; + } + + const saveBtn = document.getElementById('saveZoneBtn'); + saveBtn.disabled = true; + saveBtn.innerHTML = ' Saving...'; + + try { + const url = zoneId ? `/api/privacy-zones/${zoneId}` : '/api/privacy-zones'; + const method = zoneId ? 'PUT' : 'POST'; + + const response = await FitPubAuth.authenticatedFetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + description, + latitude, + longitude, + radiusMeters + }) + }); + + if (response.ok) { + FitPub.showAlert(zoneId ? 'Privacy zone updated' : 'Privacy zone created', 'success'); + cancelZone(); + loadZones(); + } else { + const error = await response.json(); + FitPub.showAlert(error.message || 'Failed to save privacy zone', 'danger'); + } + } catch (error) { + console.error('Save zone error:', error); + FitPub.showAlert('Network error. Please try again.', 'danger'); + } finally { + saveBtn.disabled = false; + saveBtn.innerHTML = ' Save Zone'; + } + } + + /** + * Load all privacy zones from the API + */ + async function loadZones() { + const loadingEl = document.getElementById('zonesListLoading'); + const emptyEl = document.getElementById('zonesListEmpty'); + const listEl = document.getElementById('zonesList'); + + loadingEl.classList.remove('d-none'); + emptyEl.classList.add('d-none'); + listEl.innerHTML = ''; + + try { + const response = await FitPubAuth.authenticatedFetch('/api/privacy-zones'); + + if (response.ok) { + zones = await response.json(); + loadingEl.classList.add('d-none'); + + if (zones.length === 0) { + emptyEl.classList.remove('d-none'); + } else { + renderZonesList(); + } + } else { + loadingEl.classList.add('d-none'); + FitPub.showAlert('Failed to load privacy zones', 'danger'); + } + } catch (error) { + console.error('Load zones error:', error); + loadingEl.classList.add('d-none'); + FitPub.showAlert('Network error. Please try again.', 'danger'); + } + } + + /** + * Render the list of privacy zones + */ + function renderZonesList() { + const listEl = document.getElementById('zonesList'); + listEl.innerHTML = ''; + + zones.forEach(zone => { + const item = document.createElement('div'); + item.className = 'list-group-item'; + item.innerHTML = ` +
+
+
+ ${escapeHtml(zone.name)} + ${zone.isActive ? 'Active' : 'Inactive'} +
+ ${zone.description ? `

${escapeHtml(zone.description)}

` : ''} +

+ ${zone.latitude.toFixed(6)}, ${zone.longitude.toFixed(6)} · + ${zone.radiusMeters}m radius +

+
+
+ + + +
+
+ `; + listEl.appendChild(item); + }); + } + + /** + * Edit a privacy zone + */ + function editZone(zoneId) { + const zone = zones.find(z => z.id === zoneId); + if (!zone) return; + + startAddingZone(); + + // Populate form + document.getElementById('zoneId').value = zone.id; + document.getElementById('zoneName').value = zone.name; + document.getElementById('zoneDescription').value = zone.description || ''; + document.getElementById('zoneRadius').value = zone.radiusMeters; + document.getElementById('radiusValue').textContent = zone.radiusMeters; + + // Place marker on map + placeMarker(zone.latitude, zone.longitude); + map.setView([zone.latitude, zone.longitude], 15); + + currentZone = zone; + } + + /** + * Toggle a zone's active status + */ + async function toggleZone(zoneId, isActive) { + try { + const response = await FitPubAuth.authenticatedFetch(`/api/privacy-zones/${zoneId}/toggle`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive }) + }); + + if (response.ok) { + FitPub.showAlert(`Privacy zone ${isActive ? 'activated' : 'deactivated'}`, 'success'); + loadZones(); + } else { + FitPub.showAlert('Failed to toggle privacy zone', 'danger'); + } + } catch (error) { + console.error('Toggle zone error:', error); + FitPub.showAlert('Network error. Please try again.', 'danger'); + } + } + + /** + * Delete a privacy zone + */ + async function deleteZone(zoneId) { + if (!confirm('Are you sure you want to delete this privacy zone? This will immediately affect all existing activities.')) { + return; + } + + try { + const response = await FitPubAuth.authenticatedFetch(`/api/privacy-zones/${zoneId}`, { + method: 'DELETE' + }); + + if (response.ok) { + FitPub.showAlert('Privacy zone deleted', 'success'); + loadZones(); + } else { + FitPub.showAlert('Failed to delete privacy zone', 'danger'); + } + } catch (error) { + console.error('Delete zone error:', error); + FitPub.showAlert('Network error. Please try again.', 'danger'); + } + } + + /** + * Escape HTML to prevent XSS + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Public API + return { + init, + editZone, + toggleZone, + deleteZone + }; +})(); diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 816e03a..1344d35 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -569,7 +569,7 @@ if (hasGpsTrack && activity.simplifiedTrack) { document.getElementById('mapSection').style.display = 'block'; document.getElementById('indoorPlaceholder').style.display = 'none'; - renderMap(activity.simplifiedTrack); + renderMap(activity.simplifiedTrack, activity); } else { // Show indoor activity placeholder document.getElementById('mapSection').style.display = 'none'; @@ -710,7 +710,7 @@ } } - function renderMap(simplifiedTrack) { + function renderMap(simplifiedTrack, activity) { // Parse GeoJSON from simplifiedTrack const geoJson = { type: 'LineString', @@ -765,6 +765,15 @@ } } + // Render privacy zones if present (owner view only) + console.log('Privacy zones in response:', activity.privacyZones); + if (activity.privacyZones && activity.privacyZones.length > 0) { + console.log('Rendering', activity.privacyZones.length, 'privacy zones'); + renderPrivacyZones(activityMap, activity.privacyZones); + } else { + console.log('No privacy zones to render'); + } + // Force fit bounds again after map is fully rendered if (activityMap && activityMap.trackLayer) { setTimeout(() => { @@ -781,6 +790,60 @@ }, 50); } + function renderPrivacyZones(map, zones) { + // Helper to escape HTML + const escapeHtml = (text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + // Render each privacy zone as a translucent red circle + zones.forEach(zone => { + const circle = L.circle([zone.latitude, zone.longitude], { + color: '#dc3545', + fillColor: '#dc3545', + fillOpacity: 0.1, + opacity: 0.4, + weight: 2, + dashArray: '5, 10', + radius: zone.radiusMeters + }).addTo(map); + + // Add tooltip + const zoneName = escapeHtml(zone.name || 'Privacy Zone'); + circle.bindTooltip( + `🔒 Privacy Zone
${zoneName}
Hidden for others`, + { + permanent: false, + direction: 'top', + className: 'privacy-zone-tooltip' + } + ); + }); + + // Add CSS for privacy zone tooltip + if (!document.getElementById('privacy-zone-tooltip-style')) { + const style = document.createElement('style'); + style.id = 'privacy-zone-tooltip-style'; + style.textContent = ` + .privacy-zone-tooltip { + background: rgba(220, 53, 69, 0.95); + color: white; + border: none; + border-radius: 4px; + padding: 8px 12px; + font-size: 13px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + } + .privacy-zone-tooltip::before { + border-top-color: rgba(220, 53, 69, 0.95); + } + `; + document.head.appendChild(style); + } + } + function renderElevationChart(trackPoints) { // Calculate cumulative distance and prepare elevation data let cumulativeDistance = 0; diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 07046f5..6bef17a 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -32,15 +32,15 @@

Update your display name, bio, and avatar

- +

Define private GPS zones to protect your home and other sensitive locations

+
@@ -73,8 +73,104 @@
+ +
+
+ +
+ GPS Privacy Zones +
+
+ +
+ + Privacy Protection: GPS coordinates within your privacy zones will be automatically removed from all public activity maps and Fediverse share images. Changes apply retroactively to all existing activities. +
+ + +
+
+
Add Privacy Zone
+ +
+
+
+
+ +
+ Click on the map to place a privacy zone +
+
+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ 50m + 200 meters + 10km +
+
+
+ + +
+
+
+
+
+
+ + +
+
+
Your Privacy Zones
+
+
+
+
+ Loading zones... +
+
+
+ +

No privacy zones defined yet.

+

Click "Add Zone" above to create your first privacy zone.

+
+
+
+
+
+ -
+
Danger Zone
@@ -153,6 +249,7 @@ +