Privacy Zones
This commit is contained in:
parent
6a8598ef30
commit
fcef751483
18 changed files with 1791 additions and 26 deletions
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -125,6 +125,9 @@ public class SecurityConfig {
|
||||||
// Protected endpoints - Batch Import API
|
// Protected endpoints - Batch Import API
|
||||||
.requestMatchers("/api/batch-import/**").authenticated()
|
.requestMatchers("/api/batch-import/**").authenticated()
|
||||||
|
|
||||||
|
// Protected endpoints - Privacy Zones API
|
||||||
|
.requestMatchers("/api/privacy-zones/**").authenticated()
|
||||||
|
|
||||||
// Protected endpoints - Activities API (upload, edit, delete)
|
// Protected endpoints - Activities API (upload, edit, delete)
|
||||||
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
|
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
|
||||||
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()
|
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import org.operaton.fitpub.model.dto.ActivityDTO;
|
||||||
import org.operaton.fitpub.model.dto.ActivityUpdateRequest;
|
import org.operaton.fitpub.model.dto.ActivityUpdateRequest;
|
||||||
import org.operaton.fitpub.model.dto.ActivityUploadRequest;
|
import org.operaton.fitpub.model.dto.ActivityUploadRequest;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
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.model.entity.User;
|
||||||
import org.operaton.fitpub.repository.UserRepository;
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
import org.operaton.fitpub.service.ActivityFileService;
|
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.ActivityPostProcessingService;
|
||||||
import org.operaton.fitpub.service.FederationService;
|
import org.operaton.fitpub.service.FederationService;
|
||||||
import org.operaton.fitpub.service.FitFileService;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
@ -44,6 +47,8 @@ public class ActivityController {
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final ActivityImageService activityImageService;
|
private final ActivityImageService activityImageService;
|
||||||
private final org.operaton.fitpub.service.WeatherService weatherService;
|
private final org.operaton.fitpub.service.WeatherService weatherService;
|
||||||
|
private final PrivacyZoneService privacyZoneService;
|
||||||
|
private final TrackPrivacyFilter trackPrivacyFilter;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -61,6 +66,23 @@ public class ActivityController {
|
||||||
return user.getId();
|
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.
|
* Uploads an activity file (FIT or GPX) and creates a new activity.
|
||||||
*
|
*
|
||||||
|
|
@ -140,10 +162,21 @@ public class ActivityController {
|
||||||
return ResponseEntity.notFound().build();
|
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<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
|
||||||
|
|
||||||
|
log.debug("Activity {} - Requesting user: {}, Owner: {}, Privacy zones: {}",
|
||||||
|
id, requestingUserId, activity.getUserId(), privacyZones.size());
|
||||||
|
|
||||||
// Check visibility
|
// Check visibility
|
||||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
||||||
// Public activities are always accessible
|
// Public activities are always accessible, but apply privacy filtering
|
||||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
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);
|
return ResponseEntity.ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +193,8 @@ public class ActivityController {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
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);
|
return ResponseEntity.ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,13 +311,15 @@ public class ActivityController {
|
||||||
* @param username the username
|
* @param username the username
|
||||||
* @param page page number (default: 0)
|
* @param page page number (default: 0)
|
||||||
* @param size page size (default: 10)
|
* @param size page size (default: 10)
|
||||||
|
* @param userDetails the authenticated user (optional)
|
||||||
* @return page of public activities
|
* @return page of public activities
|
||||||
*/
|
*/
|
||||||
@GetMapping("/user/{username}")
|
@GetMapping("/user/{username}")
|
||||||
public ResponseEntity<?> getUserPublicActivities(
|
public ResponseEntity<?> getUserPublicActivities(
|
||||||
@PathVariable String username,
|
@PathVariable String username,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@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);
|
log.debug("Retrieving public activities for user: {}", username);
|
||||||
|
|
||||||
|
|
@ -291,6 +327,12 @@ public class ActivityController {
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + 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<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(user.getId());
|
||||||
|
|
||||||
// Get public activities only
|
// Get public activities only
|
||||||
org.springframework.data.domain.Pageable pageable =
|
org.springframework.data.domain.Pageable pageable =
|
||||||
org.springframework.data.domain.PageRequest.of(page, size,
|
org.springframework.data.domain.PageRequest.of(page, size,
|
||||||
|
|
@ -299,8 +341,10 @@ public class ActivityController {
|
||||||
org.springframework.data.domain.Page<Activity> activityPage =
|
org.springframework.data.domain.Page<Activity> activityPage =
|
||||||
fitFileService.getPublicActivitiesByUserId(user.getId(), pageable);
|
fitFileService.getPublicActivitiesByUserId(user.getId(), pageable);
|
||||||
|
|
||||||
// Convert to DTOs
|
// Convert to DTOs with privacy filtering
|
||||||
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity);
|
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(activity ->
|
||||||
|
ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter)
|
||||||
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(dtoPage);
|
return ResponseEntity.ok(dtoPage);
|
||||||
}
|
}
|
||||||
|
|
@ -343,8 +387,14 @@ public class ActivityController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build GeoJSON FeatureCollection
|
// Get requesting user ID (or null for anonymous)
|
||||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
UUID requestingUserId = getUserIdOrNull(userDetails);
|
||||||
|
|
||||||
|
// Get activity owner's privacy zones
|
||||||
|
java.util.List<PrivacyZone> 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
|
// Use high-resolution track points if available, otherwise fall back to simplified track
|
||||||
java.util.List<java.util.List<Double>> coordinates = new java.util.ArrayList<>();
|
java.util.List<java.util.List<Double>> coordinates = new java.util.ArrayList<>();
|
||||||
|
|
|
||||||
|
|
@ -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<List<PrivacyZoneDTO>> 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<PrivacyZone> zones = privacyZoneService.getUserPrivacyZones(user.getId());
|
||||||
|
List<PrivacyZoneDTO> 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<PrivacyZoneDTO> 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<PrivacyZoneDTO> 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<PrivacyZoneDTO> togglePrivacyZone(
|
||||||
|
@PathVariable UUID zoneId,
|
||||||
|
@RequestBody Map<String, Boolean> 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<Void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ import lombok.NoArgsConstructor;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
import org.locationtech.jts.geom.Coordinate;
|
||||||
import org.locationtech.jts.geom.LineString;
|
import org.locationtech.jts.geom.LineString;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
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 org.operaton.fitpub.util.ActivityFormatter;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
@ -57,6 +59,9 @@ public class ActivityDTO {
|
||||||
private Long commentsCount;
|
private Long commentsCount;
|
||||||
private Boolean likedByCurrentUser; // True if current user has liked this activity
|
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<PrivacyZonePreview> privacyZones;
|
||||||
|
|
||||||
// Convenience getters for flattened metrics (for frontend compatibility)
|
// Convenience getters for flattened metrics (for frontend compatibility)
|
||||||
public Integer getAverageHeartRate() {
|
public Integer getAverageHeartRate() {
|
||||||
return metrics != null ? metrics.getAverageHeartRate() : null;
|
return metrics != null ? metrics.getAverageHeartRate() : null;
|
||||||
|
|
@ -139,6 +144,124 @@ public class ActivityDTO {
|
||||||
return builder.build();
|
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<PrivacyZone> 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.
|
* Converts a JTS LineString to GeoJSON format.
|
||||||
*/
|
*/
|
||||||
|
|
@ -187,4 +310,19 @@ public class ActivityDTO {
|
||||||
}
|
}
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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<PrivacyZone, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all active privacy zones for a user.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return list of active privacy zones
|
||||||
|
*/
|
||||||
|
List<PrivacyZone> findByUserIdAndIsActiveTrue(UUID userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all privacy zones for a user (including inactive).
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @return list of all privacy zones
|
||||||
|
*/
|
||||||
|
List<PrivacyZone> 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<PrivacyZone> 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);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@ package org.operaton.fitpub.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.locationtech.jts.geom.LineString;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.model.entity.PrivacyZone;
|
||||||
import org.operaton.fitpub.util.ActivityFormatter;
|
import org.operaton.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -26,6 +28,8 @@ import java.util.UUID;
|
||||||
public class ActivityImageService {
|
public class ActivityImageService {
|
||||||
|
|
||||||
private final OsmTileRenderer osmTileRenderer;
|
private final OsmTileRenderer osmTileRenderer;
|
||||||
|
private final PrivacyZoneService privacyZoneService;
|
||||||
|
private final TrackPrivacyFilter trackPrivacyFilter;
|
||||||
|
|
||||||
@Value("${fitpub.storage.images.path:${java.io.tmpdir}/fitpub/images}")
|
@Value("${fitpub.storage.images.path:${java.io.tmpdir}/fitpub/images}")
|
||||||
private String imagesPath;
|
private String imagesPath;
|
||||||
|
|
@ -38,12 +42,16 @@ public class ActivityImageService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a preview image for an activity showing the track outline and metadata.
|
* 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
|
* @param activity the activity to generate an image for
|
||||||
* @return the URL of the generated image
|
* @return the URL of the generated image
|
||||||
*/
|
*/
|
||||||
public String generateActivityImage(Activity activity) {
|
public String generateActivityImage(Activity activity) {
|
||||||
try {
|
try {
|
||||||
|
// Apply privacy zone filtering before rendering
|
||||||
|
Activity filteredActivity = applyPrivacyFiltering(activity);
|
||||||
|
|
||||||
// Image dimensions
|
// Image dimensions
|
||||||
int width = 1200;
|
int width = 1200;
|
||||||
int height = 630; // Open Graph standard size
|
int height = 630; // Open Graph standard size
|
||||||
|
|
@ -59,11 +67,11 @@ public class ActivityImageService {
|
||||||
|
|
||||||
// Calculate bounds once for both map tiles and track rendering
|
// Calculate bounds once for both map tiles and track rendering
|
||||||
TrackBounds trackBounds = null;
|
TrackBounds trackBounds = null;
|
||||||
boolean isIndoorActivity = activity.getSimplifiedTrack() == null;
|
boolean isIndoorActivity = filteredActivity.getSimplifiedTrack() == null;
|
||||||
|
|
||||||
// Render background - either OSM tiles or gradient background
|
// Render background - either OSM tiles or gradient background
|
||||||
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
if (filteredActivity.getTrackPointsJson() != null && !filteredActivity.getTrackPointsJson().isEmpty()) {
|
||||||
trackBounds = calculateTrackBounds(activity);
|
trackBounds = calculateTrackBounds(filteredActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) {
|
if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) {
|
||||||
|
|
@ -107,20 +115,20 @@ public class ActivityImageService {
|
||||||
|
|
||||||
// For indoor activities, draw a large emoji in the center-left area
|
// For indoor activities, draw a large emoji in the center-left area
|
||||||
if (isIndoorActivity) {
|
if (isIndoorActivity) {
|
||||||
drawIndoorActivityEmoji(g2d, activity, width, height);
|
drawIndoorActivityEmoji(g2d, filteredActivity, width, height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw track if available (not for indoor activities)
|
// Draw track if available (not for indoor activities)
|
||||||
if (!isIndoorActivity) {
|
if (!isIndoorActivity) {
|
||||||
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
if (filteredActivity.getTrackPointsJson() != null && !filteredActivity.getTrackPointsJson().isEmpty()) {
|
||||||
drawTrack(g2d, activity, width, height);
|
drawTrack(g2d, filteredActivity, width, height);
|
||||||
} else if (activity.getSimplifiedTrack() != null) {
|
} else if (filteredActivity.getSimplifiedTrack() != null) {
|
||||||
drawSimplifiedTrack(g2d, activity, width, height);
|
drawSimplifiedTrack(g2d, filteredActivity, width, height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw metadata overlay
|
// Draw metadata overlay (use original activity for metadata, not filtered)
|
||||||
drawMetadata(g2d, activity, width, height, isIndoorActivity);
|
drawMetadata(g2d, activity, width, height, isIndoorActivity);
|
||||||
|
|
||||||
g2d.dispose();
|
g2d.dispose();
|
||||||
|
|
@ -741,6 +749,63 @@ public class ActivityImageService {
|
||||||
g2d.drawString(emoji, emojiX - emojiWidth / 2, emojiY + emojiHeight / 3);
|
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<PrivacyZone> 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.
|
* Helper class to store track geographic bounds.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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<PrivacyZone> 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<PrivacyZone> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PrivacyZone> zones) {
|
||||||
|
if (track == null || zones == null || zones.isEmpty()) {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Coordinate> 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<PrivacyZone> 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<PrivacyZone> 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()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,15 @@ spring:
|
||||||
write-dates-as-timestamps: false
|
write-dates-as-timestamps: false
|
||||||
time-zone: UTC
|
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 specific configuration
|
||||||
fitpub:
|
fitpub:
|
||||||
# Domain and URL configuration
|
# Domain and URL configuration
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
390
src/main/resources/static/js/privacy-zones.js
Normal file
390
src/main/resources/static/js/privacy-zones.js
Normal file
|
|
@ -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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = '<span class="spinner-border spinner-border-sm"></span> 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 = '<i class="bi bi-check-circle"></i> 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 = `
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">
|
||||||
|
${escapeHtml(zone.name)}
|
||||||
|
${zone.isActive ? '<span class="badge bg-success ms-2">Active</span>' : '<span class="badge bg-secondary ms-2">Inactive</span>'}
|
||||||
|
</h6>
|
||||||
|
${zone.description ? `<p class="mb-1 small text-muted">${escapeHtml(zone.description)}</p>` : ''}
|
||||||
|
<p class="mb-0 small text-muted">
|
||||||
|
<i class="bi bi-geo-alt"></i> ${zone.latitude.toFixed(6)}, ${zone.longitude.toFixed(6)} ·
|
||||||
|
<i class="bi bi-circle"></i> ${zone.radiusMeters}m radius
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<button class="btn btn-outline-secondary" onclick="PrivacyZones.editZone('${zone.id}')" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-${zone.isActive ? 'warning' : 'success'}" onclick="PrivacyZones.toggleZone('${zone.id}', ${!zone.isActive})" title="${zone.isActive ? 'Deactivate' : 'Activate'}">
|
||||||
|
<i class="bi bi-${zone.isActive ? 'pause' : 'play'}"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="PrivacyZones.deleteZone('${zone.id}')" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -569,7 +569,7 @@
|
||||||
if (hasGpsTrack && activity.simplifiedTrack) {
|
if (hasGpsTrack && activity.simplifiedTrack) {
|
||||||
document.getElementById('mapSection').style.display = 'block';
|
document.getElementById('mapSection').style.display = 'block';
|
||||||
document.getElementById('indoorPlaceholder').style.display = 'none';
|
document.getElementById('indoorPlaceholder').style.display = 'none';
|
||||||
renderMap(activity.simplifiedTrack);
|
renderMap(activity.simplifiedTrack, activity);
|
||||||
} else {
|
} else {
|
||||||
// Show indoor activity placeholder
|
// Show indoor activity placeholder
|
||||||
document.getElementById('mapSection').style.display = 'none';
|
document.getElementById('mapSection').style.display = 'none';
|
||||||
|
|
@ -710,7 +710,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMap(simplifiedTrack) {
|
function renderMap(simplifiedTrack, activity) {
|
||||||
// Parse GeoJSON from simplifiedTrack
|
// Parse GeoJSON from simplifiedTrack
|
||||||
const geoJson = {
|
const geoJson = {
|
||||||
type: 'LineString',
|
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
|
// Force fit bounds again after map is fully rendered
|
||||||
if (activityMap && activityMap.trackLayer) {
|
if (activityMap && activityMap.trackLayer) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -781,6 +790,60 @@
|
||||||
}, 50);
|
}, 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(
|
||||||
|
`<strong>🔒 Privacy Zone</strong><br>${zoneName}<br><small class="text-muted">Hidden for others</small>`,
|
||||||
|
{
|
||||||
|
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) {
|
function renderElevationChart(trackPoints) {
|
||||||
// Calculate cumulative distance and prepare elevation data
|
// Calculate cumulative distance and prepare elevation data
|
||||||
let cumulativeDistance = 0;
|
let cumulativeDistance = 0;
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,15 @@
|
||||||
<p class="mb-1">Update your display name, bio, and avatar</p>
|
<p class="mb-1">Update your display name, bio, and avatar</p>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="list-group-item list-group-item-action disabled">
|
<a href="#privacyZones" class="list-group-item list-group-item-action" id="privacyZonesLink">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
<h5 class="mb-1">
|
<h5 class="mb-1">
|
||||||
<i class="bi bi-shield-lock"></i> Privacy Settings
|
<i class="bi bi-shield-lock"></i> Privacy Zones
|
||||||
</h5>
|
</h5>
|
||||||
<small class="text-muted">Coming soon</small>
|
<small><i class="bi bi-chevron-right"></i></small>
|
||||||
</div>
|
|
||||||
<p class="mb-1 text-muted">Manage your privacy and data preferences</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mb-1">Define private GPS zones to protect your home and other sensitive locations</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="list-group-item list-group-item-action disabled">
|
<div class="list-group-item list-group-item-action disabled">
|
||||||
<div class="d-flex w-100 justify-content-between">
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
|
@ -73,8 +73,104 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy Zones Section (hidden by default) -->
|
||||||
|
<div id="privacyZonesSection" class="d-none mt-4">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-3" id="backToSettings">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
|
</button>
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-shield-lock"></i> GPS Privacy Zones
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
<strong>Privacy Protection:</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Zone Form -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0">Add Privacy Zone</h6>
|
||||||
|
<button class="btn btn-sm btn-primary" id="startAddZoneBtn">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Zone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-none" id="addZoneForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<!-- Map for zone placement -->
|
||||||
|
<div id="zoneMap" style="height: 400px; width: 100%;"></div>
|
||||||
|
<small class="text-muted">Click on the map to place a privacy zone</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<form id="zoneDetailsForm">
|
||||||
|
<input type="hidden" id="zoneId">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="zoneName" class="form-label">Name *</label>
|
||||||
|
<input type="text" class="form-control" id="zoneName" required maxlength="100" placeholder="e.g., Home, Office">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="zoneDescription" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="zoneDescription" rows="2" maxlength="500" placeholder="Optional description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="zoneLatitude" class="form-label">Latitude *</label>
|
||||||
|
<input type="number" class="form-control" id="zoneLatitude" step="0.000001" readonly required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="zoneLongitude" class="form-label">Longitude *</label>
|
||||||
|
<input type="number" class="form-control" id="zoneLongitude" step="0.000001" readonly required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="zoneRadius" class="form-label">Radius (meters) *</label>
|
||||||
|
<input type="range" class="form-range" id="zoneRadius" min="50" max="10000" value="200" step="50">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<small class="text-muted">50m</small>
|
||||||
|
<small><strong id="radiusValue">200</strong> meters</small>
|
||||||
|
<small class="text-muted">10km</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary flex-grow-1" id="saveZoneBtn">
|
||||||
|
<i class="bi bi-check-circle"></i> Save Zone
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancelZoneBtn">
|
||||||
|
<i class="bi bi-x-circle"></i> Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Zones List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Your Privacy Zones</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="zonesListLoading" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading zones...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="zonesListEmpty" class="text-center py-4 text-muted d-none">
|
||||||
|
<i class="bi bi-shield-lock" style="font-size: 3rem;"></i>
|
||||||
|
<p class="mt-3">No privacy zones defined yet.</p>
|
||||||
|
<p class="small">Click "Add Zone" above to create your first privacy zone.</p>
|
||||||
|
</div>
|
||||||
|
<div id="zonesList" class="list-group list-group-flush"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Danger Zone: Delete Account -->
|
<!-- Danger Zone: Delete Account -->
|
||||||
<div class="mt-5">
|
<div class="mt-5" id="dangerZone">
|
||||||
<h5 class="text-danger">
|
<h5 class="text-danger">
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i> Danger Zone
|
<i class="bi bi-exclamation-triangle-fill"></i> Danger Zone
|
||||||
</h5>
|
</h5>
|
||||||
|
|
@ -153,6 +249,7 @@
|
||||||
|
|
||||||
<!-- Custom Scripts -->
|
<!-- Custom Scripts -->
|
||||||
<th:block layout:fragment="scripts">
|
<th:block layout:fragment="scripts">
|
||||||
|
<script src="/js/privacy-zones.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
|
|
@ -161,6 +258,31 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Privacy Zones navigation
|
||||||
|
const settingsContent = document.querySelector('.list-group');
|
||||||
|
const privacyZonesSection = document.getElementById('privacyZonesSection');
|
||||||
|
const dangerZone = document.getElementById('dangerZone');
|
||||||
|
const privacyZonesLink = document.getElementById('privacyZonesLink');
|
||||||
|
const backToSettings = document.getElementById('backToSettings');
|
||||||
|
|
||||||
|
privacyZonesLink.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
settingsContent.classList.add('d-none');
|
||||||
|
dangerZone.classList.add('d-none');
|
||||||
|
privacyZonesSection.classList.remove('d-none');
|
||||||
|
|
||||||
|
// Initialize privacy zones module
|
||||||
|
if (typeof PrivacyZones !== 'undefined') {
|
||||||
|
PrivacyZones.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
backToSettings.addEventListener('click', () => {
|
||||||
|
privacyZonesSection.classList.add('d-none');
|
||||||
|
settingsContent.classList.remove('d-none');
|
||||||
|
dangerZone.classList.remove('d-none');
|
||||||
|
});
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('deleteAccountModal'));
|
const modal = new bootstrap.Modal(document.getElementById('deleteAccountModal'));
|
||||||
const deletePasswordInput = document.getElementById('deletePasswordInput');
|
const deletePasswordInput = document.getElementById('deletePasswordInput');
|
||||||
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue