Privacy Zones

This commit is contained in:
Tim Zöller 2026-01-09 13:32:45 +01:00
parent 6a8598ef30
commit fcef751483
18 changed files with 1791 additions and 26 deletions

2
.idea/vcs.xml generated
View file

@ -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>

View file

@ -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()

View file

@ -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<>();

View file

@ -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();
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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.
*/

View file

@ -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);
}
}

View file

@ -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()};
}
}

View file

@ -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

View file

@ -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)';

View 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: '&copy; <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)} &middot;
<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
};
})();

View file

@ -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;

View file

@ -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');