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"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -125,6 +125,9 @@ public class SecurityConfig {
|
|||
// Protected endpoints - Batch Import API
|
||||
.requestMatchers("/api/batch-import/**").authenticated()
|
||||
|
||||
// Protected endpoints - Privacy Zones API
|
||||
.requestMatchers("/api/privacy-zones/**").authenticated()
|
||||
|
||||
// Protected endpoints - Activities API (upload, edit, delete)
|
||||
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
|
||||
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import org.operaton.fitpub.model.dto.ActivityDTO;
|
|||
import org.operaton.fitpub.model.dto.ActivityUpdateRequest;
|
||||
import org.operaton.fitpub.model.dto.ActivityUploadRequest;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.PrivacyZone;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.ActivityFileService;
|
||||
|
|
@ -14,6 +15,8 @@ import org.operaton.fitpub.service.ActivityImageService;
|
|||
import org.operaton.fitpub.service.ActivityPostProcessingService;
|
||||
import org.operaton.fitpub.service.FederationService;
|
||||
import org.operaton.fitpub.service.FitFileService;
|
||||
import org.operaton.fitpub.service.PrivacyZoneService;
|
||||
import org.operaton.fitpub.service.TrackPrivacyFilter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
|
@ -44,6 +47,8 @@ public class ActivityController {
|
|||
private final FederationService federationService;
|
||||
private final ActivityImageService activityImageService;
|
||||
private final org.operaton.fitpub.service.WeatherService weatherService;
|
||||
private final PrivacyZoneService privacyZoneService;
|
||||
private final TrackPrivacyFilter trackPrivacyFilter;
|
||||
|
||||
@Value("${fitpub.base-url}")
|
||||
private String baseUrl;
|
||||
|
|
@ -61,6 +66,23 @@ public class ActivityController {
|
|||
return user.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get user ID from authenticated UserDetails, or null if not authenticated.
|
||||
*
|
||||
* @param userDetails the authenticated user details (may be null)
|
||||
* @return the user's UUID, or null if not authenticated
|
||||
*/
|
||||
private UUID getUserIdOrNull(UserDetails userDetails) {
|
||||
if (userDetails == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return getUserId(userDetails);
|
||||
} catch (UsernameNotFoundException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an activity file (FIT or GPX) and creates a new activity.
|
||||
*
|
||||
|
|
@ -140,10 +162,21 @@ public class ActivityController {
|
|||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Get requesting user ID (or null for anonymous)
|
||||
UUID requestingUserId = getUserIdOrNull(userDetails);
|
||||
|
||||
// Get activity owner's privacy zones
|
||||
java.util.List<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
|
||||
|
||||
log.debug("Activity {} - Requesting user: {}, Owner: {}, Privacy zones: {}",
|
||||
id, requestingUserId, activity.getUserId(), privacyZones.size());
|
||||
|
||||
// Check visibility
|
||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
||||
// Public activities are always accessible
|
||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
||||
// Public activities are always accessible, but apply privacy filtering
|
||||
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter);
|
||||
log.debug("Activity {} - DTO privacy zones: {}", id,
|
||||
dto.getPrivacyZones() != null ? dto.getPrivacyZones().size() : 0);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +193,8 @@ public class ActivityController {
|
|||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
ActivityDTO dto = ActivityDTO.fromEntity(checkedActivity);
|
||||
// Apply privacy filtering (owner sees full track, others see filtered)
|
||||
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(checkedActivity, requestingUserId, privacyZones, trackPrivacyFilter);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
|
|
@ -277,13 +311,15 @@ public class ActivityController {
|
|||
* @param username the username
|
||||
* @param page page number (default: 0)
|
||||
* @param size page size (default: 10)
|
||||
* @param userDetails the authenticated user (optional)
|
||||
* @return page of public activities
|
||||
*/
|
||||
@GetMapping("/user/{username}")
|
||||
public ResponseEntity<?> getUserPublicActivities(
|
||||
@PathVariable String username,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@AuthenticationPrincipal UserDetails userDetails
|
||||
) {
|
||||
log.debug("Retrieving public activities for user: {}", username);
|
||||
|
||||
|
|
@ -291,6 +327,12 @@ public class ActivityController {
|
|||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
||||
// Get requesting user ID (or null for anonymous)
|
||||
UUID requestingUserId = getUserIdOrNull(userDetails);
|
||||
|
||||
// Get activity owner's privacy zones
|
||||
java.util.List<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(user.getId());
|
||||
|
||||
// Get public activities only
|
||||
org.springframework.data.domain.Pageable pageable =
|
||||
org.springframework.data.domain.PageRequest.of(page, size,
|
||||
|
|
@ -299,8 +341,10 @@ public class ActivityController {
|
|||
org.springframework.data.domain.Page<Activity> activityPage =
|
||||
fitFileService.getPublicActivitiesByUserId(user.getId(), pageable);
|
||||
|
||||
// Convert to DTOs
|
||||
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity);
|
||||
// Convert to DTOs with privacy filtering
|
||||
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(activity ->
|
||||
ActivityDTO.fromEntityWithFiltering(activity, requestingUserId, privacyZones, trackPrivacyFilter)
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(dtoPage);
|
||||
}
|
||||
|
|
@ -343,8 +387,14 @@ public class ActivityController {
|
|||
}
|
||||
}
|
||||
|
||||
// Build GeoJSON FeatureCollection
|
||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
||||
// Get requesting user ID (or null for anonymous)
|
||||
UUID requestingUserId = getUserIdOrNull(userDetails);
|
||||
|
||||
// Get activity owner's privacy zones
|
||||
java.util.List<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
|
||||
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.LineString;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.PrivacyZone;
|
||||
import org.operaton.fitpub.service.TrackPrivacyFilter;
|
||||
import org.operaton.fitpub.util.ActivityFormatter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
|
@ -57,6 +59,9 @@ public class ActivityDTO {
|
|||
private Long commentsCount;
|
||||
private Boolean likedByCurrentUser; // True if current user has liked this activity
|
||||
|
||||
// Privacy zones (only for activity owner, to show what's hidden for others)
|
||||
private List<PrivacyZonePreview> privacyZones;
|
||||
|
||||
// Convenience getters for flattened metrics (for frontend compatibility)
|
||||
public Integer getAverageHeartRate() {
|
||||
return metrics != null ? metrics.getAverageHeartRate() : null;
|
||||
|
|
@ -139,6 +144,124 @@ public class ActivityDTO {
|
|||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DTO from an Activity entity with privacy zone filtering applied.
|
||||
* Filters GPS coordinates that fall within the activity owner's privacy zones.
|
||||
*
|
||||
* @param activity the activity entity
|
||||
* @param requestingUserId the ID of the user requesting the activity (null for anonymous)
|
||||
* @param privacyZones the activity owner's active privacy zones
|
||||
* @param filter the privacy filter service
|
||||
* @return activity DTO with filtered GPS data, or null if entire track was filtered
|
||||
*/
|
||||
public static ActivityDTO fromEntityWithFiltering(
|
||||
Activity activity,
|
||||
UUID requestingUserId,
|
||||
List<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.
|
||||
*/
|
||||
|
|
@ -187,4 +310,19 @@ public class ActivityDTO {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple preview of a privacy zone for map rendering.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class PrivacyZonePreview {
|
||||
private UUID id;
|
||||
private String name;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
private Integer radiusMeters;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.LineString;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.PrivacyZone;
|
||||
import org.operaton.fitpub.util.ActivityFormatter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
|
@ -26,6 +28,8 @@ import java.util.UUID;
|
|||
public class ActivityImageService {
|
||||
|
||||
private final OsmTileRenderer osmTileRenderer;
|
||||
private final PrivacyZoneService privacyZoneService;
|
||||
private final TrackPrivacyFilter trackPrivacyFilter;
|
||||
|
||||
@Value("${fitpub.storage.images.path:${java.io.tmpdir}/fitpub/images}")
|
||||
private String imagesPath;
|
||||
|
|
@ -38,12 +42,16 @@ public class ActivityImageService {
|
|||
|
||||
/**
|
||||
* Generate a preview image for an activity showing the track outline and metadata.
|
||||
* Applies privacy zone filtering to ensure GPS coordinates within zones are not rendered.
|
||||
*
|
||||
* @param activity the activity to generate an image for
|
||||
* @return the URL of the generated image
|
||||
*/
|
||||
public String generateActivityImage(Activity activity) {
|
||||
try {
|
||||
// Apply privacy zone filtering before rendering
|
||||
Activity filteredActivity = applyPrivacyFiltering(activity);
|
||||
|
||||
// Image dimensions
|
||||
int width = 1200;
|
||||
int height = 630; // Open Graph standard size
|
||||
|
|
@ -59,11 +67,11 @@ public class ActivityImageService {
|
|||
|
||||
// Calculate bounds once for both map tiles and track rendering
|
||||
TrackBounds trackBounds = null;
|
||||
boolean isIndoorActivity = activity.getSimplifiedTrack() == null;
|
||||
boolean isIndoorActivity = filteredActivity.getSimplifiedTrack() == null;
|
||||
|
||||
// Render background - either OSM tiles or gradient background
|
||||
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
||||
trackBounds = calculateTrackBounds(activity);
|
||||
if (filteredActivity.getTrackPointsJson() != null && !filteredActivity.getTrackPointsJson().isEmpty()) {
|
||||
trackBounds = calculateTrackBounds(filteredActivity);
|
||||
}
|
||||
|
||||
if (osmTilesEnabled && trackBounds != null && !isIndoorActivity) {
|
||||
|
|
@ -107,20 +115,20 @@ public class ActivityImageService {
|
|||
|
||||
// For indoor activities, draw a large emoji in the center-left area
|
||||
if (isIndoorActivity) {
|
||||
drawIndoorActivityEmoji(g2d, activity, width, height);
|
||||
drawIndoorActivityEmoji(g2d, filteredActivity, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw track if available (not for indoor activities)
|
||||
if (!isIndoorActivity) {
|
||||
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
|
||||
drawTrack(g2d, activity, width, height);
|
||||
} else if (activity.getSimplifiedTrack() != null) {
|
||||
drawSimplifiedTrack(g2d, activity, width, height);
|
||||
if (filteredActivity.getTrackPointsJson() != null && !filteredActivity.getTrackPointsJson().isEmpty()) {
|
||||
drawTrack(g2d, filteredActivity, width, height);
|
||||
} else if (filteredActivity.getSimplifiedTrack() != null) {
|
||||
drawSimplifiedTrack(g2d, filteredActivity, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw metadata overlay
|
||||
// Draw metadata overlay (use original activity for metadata, not filtered)
|
||||
drawMetadata(g2d, activity, width, height, isIndoorActivity);
|
||||
|
||||
g2d.dispose();
|
||||
|
|
@ -741,6 +749,63 @@ public class ActivityImageService {
|
|||
g2d.drawString(emoji, emojiX - emojiWidth / 2, emojiY + emojiHeight / 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply privacy zone filtering to an activity's GPS data.
|
||||
* Filters both simplified track and full track points JSON.
|
||||
*
|
||||
* @param activity the original activity
|
||||
* @return a copy of the activity with filtered GPS data
|
||||
*/
|
||||
private Activity applyPrivacyFiltering(Activity activity) {
|
||||
// Get user's active privacy zones
|
||||
List<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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
time-zone: UTC
|
||||
|
||||
# Cache configuration for privacy zone filtering
|
||||
cache:
|
||||
type: caffeine
|
||||
cache-names:
|
||||
- filteredTracks
|
||||
- filteredTrackPoints
|
||||
caffeine:
|
||||
spec: maximumSize=1000,expireAfterWrite=7d
|
||||
|
||||
# FitPub specific configuration
|
||||
fitpub:
|
||||
# Domain and URL configuration
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
document.getElementById('mapSection').style.display = 'block';
|
||||
document.getElementById('indoorPlaceholder').style.display = 'none';
|
||||
renderMap(activity.simplifiedTrack);
|
||||
renderMap(activity.simplifiedTrack, activity);
|
||||
} else {
|
||||
// Show indoor activity placeholder
|
||||
document.getElementById('mapSection').style.display = 'none';
|
||||
|
|
@ -710,7 +710,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function renderMap(simplifiedTrack) {
|
||||
function renderMap(simplifiedTrack, activity) {
|
||||
// Parse GeoJSON from simplifiedTrack
|
||||
const geoJson = {
|
||||
type: 'LineString',
|
||||
|
|
@ -765,6 +765,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Render privacy zones if present (owner view only)
|
||||
console.log('Privacy zones in response:', activity.privacyZones);
|
||||
if (activity.privacyZones && activity.privacyZones.length > 0) {
|
||||
console.log('Rendering', activity.privacyZones.length, 'privacy zones');
|
||||
renderPrivacyZones(activityMap, activity.privacyZones);
|
||||
} else {
|
||||
console.log('No privacy zones to render');
|
||||
}
|
||||
|
||||
// Force fit bounds again after map is fully rendered
|
||||
if (activityMap && activityMap.trackLayer) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -781,6 +790,60 @@
|
|||
}, 50);
|
||||
}
|
||||
|
||||
function renderPrivacyZones(map, zones) {
|
||||
// Helper to escape HTML
|
||||
const escapeHtml = (text) => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
// Render each privacy zone as a translucent red circle
|
||||
zones.forEach(zone => {
|
||||
const circle = L.circle([zone.latitude, zone.longitude], {
|
||||
color: '#dc3545',
|
||||
fillColor: '#dc3545',
|
||||
fillOpacity: 0.1,
|
||||
opacity: 0.4,
|
||||
weight: 2,
|
||||
dashArray: '5, 10',
|
||||
radius: zone.radiusMeters
|
||||
}).addTo(map);
|
||||
|
||||
// Add tooltip
|
||||
const zoneName = escapeHtml(zone.name || 'Privacy Zone');
|
||||
circle.bindTooltip(
|
||||
`<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) {
|
||||
// Calculate cumulative distance and prepare elevation data
|
||||
let cumulativeDistance = 0;
|
||||
|
|
|
|||
|
|
@ -32,15 +32,15 @@
|
|||
<p class="mb-1">Update your display name, bio, and avatar</p>
|
||||
</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">
|
||||
<h5 class="mb-1">
|
||||
<i class="bi bi-shield-lock"></i> Privacy Settings
|
||||
<i class="bi bi-shield-lock"></i> Privacy Zones
|
||||
</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>
|
||||
<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="d-flex w-100 justify-content-between">
|
||||
|
|
@ -73,8 +73,104 @@
|
|||
</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 -->
|
||||
<div class="mt-5">
|
||||
<div class="mt-5" id="dangerZone">
|
||||
<h5 class="text-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Danger Zone
|
||||
</h5>
|
||||
|
|
@ -153,6 +249,7 @@
|
|||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script src="/js/privacy-zones.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect to login if not authenticated
|
||||
|
|
@ -161,6 +258,31 @@
|
|||
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 deletePasswordInput = document.getElementById('deletePasswordInput');
|
||||
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue