Added Heatmaps

This commit is contained in:
Tim Zöller 2026-01-01 23:48:05 +01:00
parent c8b37f4720
commit f391028061
22 changed files with 1696 additions and 9 deletions

View file

@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.client.RestTemplate;
/**
@ -19,6 +20,7 @@ import org.springframework.web.client.RestTemplate;
*/
@SpringBootApplication
@EnableAsync
@EnableScheduling
@Slf4j
public class FitPubApplication {

View file

@ -61,6 +61,7 @@ public class SecurityConfig {
.requestMatchers("/discover").permitAll() // User discovery page
.requestMatchers("/notifications").permitAll() // Auth checked client-side
.requestMatchers("/analytics", "/analytics/**").permitAll() // Auth checked client-side
.requestMatchers("/heatmap").permitAll() // Auth checked client-side
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()
@ -105,6 +106,10 @@ public class SecurityConfig {
// Protected endpoints - Analytics API
.requestMatchers("/api/analytics/**").authenticated()
// Protected endpoints - Heatmap API
.requestMatchers("/api/heatmap/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/heatmap/user/*").permitAll()
// Protected endpoints - Activities API (upload, edit, delete)
.requestMatchers(HttpMethod.POST, "/api/activities/upload").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/activities/*").authenticated()

View file

@ -0,0 +1,101 @@
package org.operaton.fitpub.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.dto.HeatmapDataDTO;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.service.HeatmapGridService;
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;
/**
* REST controller for user activity heatmap data.
*/
@RestController
@RequestMapping("/api/heatmap")
@RequiredArgsConstructor
@Slf4j
public class HeatmapController {
private final HeatmapGridService heatmapGridService;
private final UserRepository userRepository;
/**
* Get heatmap data for the authenticated user.
* Optionally filtered by bounding box (viewport).
*
* @param userDetails authenticated user
* @param minLon minimum longitude (optional)
* @param minLat minimum latitude (optional)
* @param maxLon maximum longitude (optional)
* @param maxLat maximum latitude (optional)
* @return heatmap data in GeoJSON format
*/
@GetMapping("/me")
public ResponseEntity<HeatmapDataDTO> getMyHeatmap(
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(required = false) Double minLon,
@RequestParam(required = false) Double minLat,
@RequestParam(required = false) Double maxLon,
@RequestParam(required = false) Double maxLat) {
log.debug("User {} requesting heatmap data", userDetails.getUsername());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
List<UserHeatmapGrid> gridCells = heatmapGridService.getUserHeatmapData(
user.getId(), minLon, minLat, maxLon, maxLat);
Integer maxIntensity = heatmapGridService.getMaxPointCount(user.getId());
HeatmapDataDTO heatmapData = HeatmapDataDTO.fromGridCells(gridCells, maxIntensity);
log.debug("Returning {} grid cells for user {}", gridCells.size(), userDetails.getUsername());
return ResponseEntity.ok(heatmapData);
}
/**
* Get heatmap data for a specific user by username.
* Only returns data for public activities.
*
* @param username the username
* @param minLon minimum longitude (optional)
* @param minLat minimum latitude (optional)
* @param maxLon maximum longitude (optional)
* @param maxLat maximum latitude (optional)
* @return heatmap data in GeoJSON format
*/
@GetMapping("/user/{username}")
public ResponseEntity<HeatmapDataDTO> getUserHeatmap(
@PathVariable String username,
@RequestParam(required = false) Double minLon,
@RequestParam(required = false) Double minLat,
@RequestParam(required = false) Double maxLon,
@RequestParam(required = false) Double maxLat) {
log.debug("Requesting heatmap data for user {}", username);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
List<UserHeatmapGrid> gridCells = heatmapGridService.getUserHeatmapData(
user.getId(), minLon, minLat, maxLon, maxLat);
Integer maxIntensity = heatmapGridService.getMaxPointCount(user.getId());
HeatmapDataDTO heatmapData = HeatmapDataDTO.fromGridCells(gridCells, maxIntensity);
log.debug("Returning {} grid cells for user {}", gridCells.size(), username);
return ResponseEntity.ok(heatmapData);
}
}

View file

@ -13,4 +13,9 @@ public class HomeController {
public String home() {
return "redirect:/timeline";
}
@GetMapping("/heatmap")
public String heatmap() {
return "heatmap";
}
}

View file

@ -0,0 +1,99 @@
package org.operaton.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
import java.util.ArrayList;
import java.util.List;
/**
* DTO for heatmap data in GeoJSON-compatible format.
* Used by frontend to render heatmap with Leaflet.heat.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HeatmapDataDTO {
@Builder.Default
private String type = "FeatureCollection";
private List<Feature> features;
private Integer maxIntensity;
/**
* GeoJSON Feature representing a heatmap grid cell.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Feature {
@Builder.Default
private String type = "Feature";
private Geometry geometry;
private Properties properties;
}
/**
* GeoJSON Point geometry.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Geometry {
@Builder.Default
private String type = "Point";
private double[] coordinates; // [lon, lat]
}
/**
* Feature properties containing intensity.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Properties {
private Integer intensity;
}
/**
* Convert list of UserHeatmapGrid entities to HeatmapDataDTO.
*
* @param gridCells list of grid cells
* @param maxIntensity maximum intensity for normalization
* @return HeatmapDataDTO
*/
public static HeatmapDataDTO fromGridCells(List<UserHeatmapGrid> gridCells, Integer maxIntensity) {
List<Feature> features = new ArrayList<>();
for (UserHeatmapGrid cell : gridCells) {
double lon = cell.getGridCell().getX();
double lat = cell.getGridCell().getY();
Feature feature = Feature.builder()
.type("Feature")
.geometry(Geometry.builder()
.type("Point")
.coordinates(new double[]{lon, lat})
.build())
.properties(Properties.builder()
.intensity(cell.getPointCount())
.build())
.build();
features.add(feature);
}
return HeatmapDataDTO.builder()
.type("FeatureCollection")
.features(features)
.maxIntensity(maxIntensity)
.build();
}
}

View file

@ -0,0 +1,59 @@
package org.operaton.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.UpdateTimestamp;
import org.locationtech.jts.geom.Point;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* User heatmap grid entity representing aggregated track point density.
* Each row represents a grid cell with the count of track points that fall within it.
* Used to efficiently render user activity heatmaps without processing all activities.
*/
@Entity
@Table(name = "user_heatmap_grid",
uniqueConstraints = @UniqueConstraint(name = "unique_user_grid_cell", columnNames = {"user_id", "grid_cell"}),
indexes = {
@Index(name = "idx_user_heatmap_grid_user", columnList = "user_id"),
@Index(name = "idx_user_heatmap_grid_updated", columnList = "last_updated")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserHeatmapGrid {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private UUID userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
/**
* Center point of the grid cell.
* Grid cells are ~100m x 100m (0.001 degrees).
*/
@Column(name = "grid_cell", nullable = false, columnDefinition = "geometry(Point,4326)")
private Point gridCell;
/**
* Number of track points that fall within this grid cell.
* Higher counts indicate more frequently visited areas.
*/
@Column(name = "point_count", nullable = false)
@Builder.Default
private Integer pointCount = 0;
@Column(name = "last_updated", nullable = false)
@UpdateTimestamp
private LocalDateTime lastUpdated;
}

View file

@ -0,0 +1,88 @@
package org.operaton.fitpub.repository;
import org.locationtech.jts.geom.Point;
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
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.Optional;
import java.util.UUID;
/**
* Repository for UserHeatmapGrid entities.
*/
@Repository
public interface UserHeatmapGridRepository extends JpaRepository<UserHeatmapGrid, Long> {
/**
* Find all grid cells for a user.
*
* @param userId the user ID
* @return list of grid cells
*/
List<UserHeatmapGrid> findByUserId(UUID userId);
/**
* Find grid cells for a user within a bounding box.
* Uses PostGIS ST_MakeEnvelope to create a bounding box and ST_Intersects for spatial filtering.
*
* @param userId the user ID
* @param minLon minimum longitude
* @param minLat minimum latitude
* @param maxLon maximum longitude
* @param maxLat maximum latitude
* @return list of grid cells within the bounding box
*/
@Query(value = "SELECT * FROM user_heatmap_grid " +
"WHERE user_id = :userId " +
"AND ST_Intersects(grid_cell, ST_MakeEnvelope(:minLon, :minLat, :maxLon, :maxLat, 4326))",
nativeQuery = true)
List<UserHeatmapGrid> findByUserIdWithinBoundingBox(
@Param("userId") UUID userId,
@Param("minLon") double minLon,
@Param("minLat") double minLat,
@Param("maxLon") double maxLon,
@Param("maxLat") double maxLat
);
/**
* Find a grid cell for a user by exact coordinates.
*
* @param userId the user ID
* @param gridCell the grid cell point
* @return optional grid cell
*/
Optional<UserHeatmapGrid> findByUserIdAndGridCell(UUID userId, Point gridCell);
/**
* Delete all grid cells for a user.
* Used when recalculating the entire heatmap.
*
* @param userId the user ID
*/
@Modifying
@Query("DELETE FROM UserHeatmapGrid g WHERE g.userId = :userId")
void deleteByUserId(@Param("userId") UUID userId);
/**
* Count grid cells for a user.
*
* @param userId the user ID
* @return count of grid cells
*/
long countByUserId(UUID userId);
/**
* Find maximum point count for a user.
* Used for normalizing intensity values.
*
* @param userId the user ID
* @return maximum point count
*/
@Query("SELECT MAX(g.pointCount) FROM UserHeatmapGrid g WHERE g.userId = :userId")
Integer findMaxPointCountByUserId(@Param("userId") UUID userId);
}

View file

@ -0,0 +1,54 @@
package org.operaton.fitpub.scheduler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.service.HeatmapGridService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Scheduled task to recalculate user heatmaps nightly.
* Ensures heatmap data stays in sync with activities even if incremental updates fail.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class HeatmapRecalculationScheduler {
private final HeatmapGridService heatmapGridService;
private final UserRepository userRepository;
/**
* Recalculate heatmaps for all users.
* Runs daily at 3 AM server time.
*/
@Scheduled(cron = "0 0 3 * * *")
public void recalculateAllUserHeatmaps() {
log.info("Starting nightly heatmap recalculation for all users");
long startTime = System.currentTimeMillis();
List<User> users = userRepository.findAll();
log.info("Found {} users to process", users.size());
int successCount = 0;
int errorCount = 0;
for (User user : users) {
try {
heatmapGridService.recalculateUserHeatmap(user);
successCount++;
} catch (Exception e) {
log.error("Failed to recalculate heatmap for user {}", user.getUsername(), e);
errorCount++;
}
}
long duration = System.currentTimeMillis() - startTime;
log.info("Heatmap recalculation completed in {}ms. Success: {}, Errors: {}",
duration, successCount, errorCount);
}
}

View file

@ -50,6 +50,7 @@ public class FitFileService {
private final TrainingLoadService trainingLoadService;
private final ActivitySummaryService activitySummaryService;
private final WeatherService weatherService;
private final HeatmapGridService heatmapGridService;
/**
* Processes an uploaded FIT file and creates an activity.
@ -113,6 +114,9 @@ public class FitFileService {
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
achievementService.checkAndAwardAchievements(savedActivity);
// Update heatmap grid
heatmapGridService.updateHeatmapForActivity(savedActivity);
// Update training load and summaries (async)
trainingLoadService.updateTrainingLoad(savedActivity);
activitySummaryService.updateSummariesForActivity(savedActivity);
@ -215,6 +219,9 @@ public class FitFileService {
trainingLoadService.updateTrainingLoad(savedActivity);
activitySummaryService.updateSummariesForActivity(savedActivity);
// Update heatmap grid
heatmapGridService.updateHeatmapForActivity(savedActivity);
return savedActivity;
}

View file

@ -0,0 +1,277 @@
package org.operaton.fitpub.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.Activity;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.model.entity.UserHeatmapGrid;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.repository.UserHeatmapGridRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* Service for managing user activity heatmap grids.
* Aggregates GPS track points into spatial grid cells for efficient heatmap rendering.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class HeatmapGridService {
private final UserHeatmapGridRepository heatmapGridRepository;
private final ActivityRepository activityRepository;
private final ObjectMapper objectMapper;
/**
* Grid resolution in degrees (~100m at equator).
* 0.001 degrees = ~111 meters
*/
private static final double GRID_SIZE = 0.001;
/**
* SRID for WGS84 coordinate system.
*/
private static final int SRID = 4326;
/**
* Geometry factory for creating PostGIS points.
*/
private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), SRID);
/**
* Sampling rate for large activities.
* Process every Nth point to avoid overwhelming the grid.
*/
private static final int SAMPLING_RATE = 10;
/**
* Update heatmap grid for a single activity.
* Called when a new activity is uploaded.
*
* @param activity the activity to process
*/
@Transactional
public void updateHeatmapForActivity(Activity activity) {
log.info("Updating heatmap grid for activity {} (user {})", activity.getId(), activity.getUserId());
List<Point> gridCells = extractGridCellsFromActivity(activity);
if (gridCells.isEmpty()) {
log.warn("No grid cells extracted from activity {}", activity.getId());
return;
}
// Count frequency of each grid cell
Map<String, Integer> cellCounts = new HashMap<>();
for (Point cell : gridCells) {
String key = cellKey(cell);
cellCounts.put(key, cellCounts.getOrDefault(key, 0) + 1);
}
// Upsert grid cells
for (Map.Entry<String, Integer> entry : cellCounts.entrySet()) {
Point cell = parseCell(entry.getKey());
int count = entry.getValue();
upsertGridCell(activity.getUserId(), cell, count);
}
log.info("Updated {} unique grid cells for activity {}", cellCounts.size(), activity.getId());
}
/**
* Recalculate entire heatmap for a user.
* Called by scheduled job or when user requests full recalculation.
*
* @param user the user to recalculate
*/
@Transactional
public void recalculateUserHeatmap(User user) {
log.info("Recalculating heatmap for user {}", user.getUsername());
// Delete existing grid
heatmapGridRepository.deleteByUserId(user.getId());
// Get all activities for user
List<Activity> activities = activityRepository.findByUserIdOrderByStartedAtDesc(user.getId());
if (activities.isEmpty()) {
log.info("No activities found for user {}", user.getUsername());
return;
}
// Aggregate all grid cells across all activities
Map<String, Integer> allCellCounts = new HashMap<>();
for (Activity activity : activities) {
List<Point> gridCells = extractGridCellsFromActivity(activity);
for (Point cell : gridCells) {
String key = cellKey(cell);
allCellCounts.put(key, allCellCounts.getOrDefault(key, 0) + 1);
}
}
// Bulk insert grid cells
List<UserHeatmapGrid> gridEntities = new ArrayList<>();
for (Map.Entry<String, Integer> entry : allCellCounts.entrySet()) {
Point cell = parseCell(entry.getKey());
UserHeatmapGrid grid = UserHeatmapGrid.builder()
.userId(user.getId())
.gridCell(cell)
.pointCount(entry.getValue())
.build();
gridEntities.add(grid);
}
heatmapGridRepository.saveAll(gridEntities);
log.info("Recalculated {} grid cells for user {} from {} activities",
gridEntities.size(), user.getUsername(), activities.size());
}
/**
* Get heatmap data for a user, optionally filtered by bounding box.
*
* @param userId the user ID
* @param minLon minimum longitude (optional)
* @param minLat minimum latitude (optional)
* @param maxLon maximum longitude (optional)
* @param maxLat maximum latitude (optional)
* @return list of grid cells with intensities
*/
@Transactional(readOnly = true)
public List<UserHeatmapGrid> getUserHeatmapData(UUID userId, Double minLon, Double minLat, Double maxLon, Double maxLat) {
if (minLon != null && minLat != null && maxLon != null && maxLat != null) {
log.debug("Fetching heatmap for user {} with bounding box", userId);
return heatmapGridRepository.findByUserIdWithinBoundingBox(userId, minLon, minLat, maxLon, maxLat);
} else {
log.debug("Fetching full heatmap for user {}", userId);
return heatmapGridRepository.findByUserId(userId);
}
}
/**
* Get maximum point count for a user (for normalization).
*
* @param userId the user ID
* @return maximum point count
*/
@Transactional(readOnly = true)
public Integer getMaxPointCount(UUID userId) {
Integer max = heatmapGridRepository.findMaxPointCountByUserId(userId);
return max != null ? max : 1; // Avoid division by zero
}
/**
* Extract grid cells from an activity's track points.
*
* @param activity the activity
* @return list of grid cell points
*/
private List<Point> extractGridCellsFromActivity(Activity activity) {
String trackPointsJson = activity.getTrackPointsJson();
if (trackPointsJson == null || trackPointsJson.isEmpty()) {
return Collections.emptyList();
}
try {
JsonNode root = objectMapper.readTree(trackPointsJson);
if (!root.isArray()) {
log.warn("Track points JSON is not an array for activity {}", activity.getId());
return Collections.emptyList();
}
List<Point> gridCells = new ArrayList<>();
int pointIndex = 0;
for (JsonNode pointNode : root) {
// Sample every Nth point for large activities
if (pointIndex % SAMPLING_RATE != 0) {
pointIndex++;
continue;
}
JsonNode latNode = pointNode.get("latitude");
JsonNode lonNode = pointNode.get("longitude");
if (latNode != null && lonNode != null) {
double lat = latNode.asDouble();
double lon = lonNode.asDouble();
Point gridCell = snapToGrid(lat, lon);
gridCells.add(gridCell);
}
pointIndex++;
}
return gridCells;
} catch (Exception e) {
log.error("Failed to parse track points for activity {}", activity.getId(), e);
return Collections.emptyList();
}
}
/**
* Snap a coordinate to the nearest grid cell center.
*
* @param lat latitude
* @param lon longitude
* @return grid cell point
*/
private Point snapToGrid(double lat, double lon) {
double gridLat = Math.floor(lat / GRID_SIZE) * GRID_SIZE + (GRID_SIZE / 2);
double gridLon = Math.floor(lon / GRID_SIZE) * GRID_SIZE + (GRID_SIZE / 2);
return geometryFactory.createPoint(new Coordinate(gridLon, gridLat));
}
/**
* Upsert a grid cell (insert or increment count).
*
* @param userId the user ID
* @param gridCell the grid cell point
* @param increment the count to add
*/
private void upsertGridCell(UUID userId, Point gridCell, int increment) {
Optional<UserHeatmapGrid> existing = heatmapGridRepository.findByUserIdAndGridCell(userId, gridCell);
if (existing.isPresent()) {
UserHeatmapGrid grid = existing.get();
grid.setPointCount(grid.getPointCount() + increment);
heatmapGridRepository.save(grid);
} else {
UserHeatmapGrid grid = UserHeatmapGrid.builder()
.userId(userId)
.gridCell(gridCell)
.pointCount(increment)
.build();
heatmapGridRepository.save(grid);
}
}
/**
* Generate a unique key for a grid cell.
*
* @param cell the grid cell point
* @return cell key string
*/
private String cellKey(Point cell) {
return String.format("%.6f,%.6f", cell.getY(), cell.getX());
}
/**
* Parse a cell from a key string.
*
* @param key the cell key
* @return grid cell point
*/
private Point parseCell(String key) {
String[] parts = key.split(",");
double lat = Double.parseDouble(parts[0]);
double lon = Double.parseDouble(parts[1]);
return geometryFactory.createPoint(new Coordinate(lon, lat));
}
}

View file

@ -31,6 +31,12 @@ public class WebFingerClient {
@Value("${fitpub.domain}")
private String localDomain;
@Value("${fitpub.activitypub.allow-private-ips:false}")
private boolean allowPrivateIps;
@Value("${fitpub.activitypub.federation-protocol:https}")
private String federationProtocol;
private static final int TIMEOUT_SECONDS = 5;
private static final String WEBFINGER_PATH = "/.well-known/webfinger";
private static final String ACTIVITYPUB_CONTENT_TYPE = "application/activity+json";
@ -101,13 +107,13 @@ public class WebFingerClient {
}
// Validate domain format (basic check - allow domains and IP addresses)
// Domain: must have at least one dot and end with 2+ letters
// IP: must be 4 numbers separated by dots
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$");
// Domain: must have at least one dot and end with 2+ letters, optional port
// IP: must be 4 numbers separated by dots, optional port
boolean isValidDomain = domain.matches("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}(:[0-9]+)?$");
boolean isValidIP = domain.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:[0-9]+)?$");
if (!isValidDomain && !isValidIP) {
throw new IllegalArgumentException("Invalid domain format");
throw new IllegalArgumentException("Invalid domain format. Domain must be a valid hostname or IP address, optionally with port");
}
return new ParsedHandle(username, domain);
@ -124,7 +130,7 @@ public class WebFingerClient {
private Map<String, Object> fetchWebFingerResource(String domain, String username) throws IOException {
// Construct WebFinger URL
String resource = "acct:" + username + "@" + domain;
String webFingerUrl = "https://" + domain + WEBFINGER_PATH + "?resource=" + resource;
String webFingerUrl = federationProtocol + "://" + domain + WEBFINGER_PATH + "?resource=" + resource;
log.debug("Fetching WebFinger resource: {}", webFingerUrl);
@ -173,6 +179,12 @@ public class WebFingerClient {
throw new IllegalArgumentException("Cannot discover local users via WebFinger. Use local API instead.");
}
// If private IPs are allowed (local testing mode), skip SSRF protection
if (allowPrivateIps) {
log.debug("Private IPs allowed - skipping SSRF validation for domain: {}", domain);
return;
}
try {
InetAddress address = InetAddress.getByName(domain);

View file

@ -55,6 +55,10 @@ fitpub:
enabled: true
max-federation-retries: 3
request-timeout-seconds: 30
# Allow connections to private IPs (for local testing only - disable in production!)
allow-private-ips: ${FITPUB_ALLOW_PRIVATE_IPS:false}
# Federation protocol (http or https) - use http for local testing, https for production
federation-protocol: ${FITPUB_FEDERATION_PROTOCOL:https}
# Security settings
security:

View file

@ -0,0 +1,18 @@
-- Create user_heatmap_grid table for aggregated track density
CREATE TABLE user_heatmap_grid (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
grid_cell GEOMETRY(Point, 4326) NOT NULL,
point_count INTEGER NOT NULL DEFAULT 0,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_user_grid_cell UNIQUE(user_id, grid_cell)
);
-- Index for user lookups
CREATE INDEX idx_user_heatmap_grid_user ON user_heatmap_grid(user_id);
-- Spatial index for grid cell lookups
CREATE INDEX idx_user_heatmap_grid_spatial ON user_heatmap_grid USING GIST(grid_cell);
-- Index for time-based queries
CREATE INDEX idx_user_heatmap_grid_updated ON user_heatmap_grid(last_updated);

View file

@ -217,6 +217,7 @@ const FitPubAuth = {
const myActivitiesLink = document.getElementById('myActivitiesLink');
const uploadLink = document.getElementById('uploadLink');
const analyticsLink = document.getElementById('analyticsLink');
const heatmapLink = document.getElementById('heatmapLink');
const notificationsBell = document.getElementById('notificationsBell');
if (this.isAuthenticated()) {
@ -241,6 +242,10 @@ const FitPubAuth = {
analyticsLink.style.display = '';
analyticsLink.parentElement.style.display = '';
}
if (heatmapLink) {
heatmapLink.style.display = '';
heatmapLink.parentElement.style.display = '';
}
// Show notifications bell
if (notificationsBell) {
@ -280,6 +285,10 @@ const FitPubAuth = {
analyticsLink.style.display = 'none';
analyticsLink.parentElement.style.display = 'none';
}
if (heatmapLink) {
heatmapLink.style.display = 'none';
heatmapLink.parentElement.style.display = 'none';
}
}
},

View file

@ -0,0 +1,177 @@
/**
* Heatmap visualization module
* Renders user activity heatmap using Leaflet.heat
*/
let heatmapMap = null;
let heatLayer = null;
/**
* Initialize the heatmap on page load
*/
document.addEventListener('DOMContentLoaded', async function() {
// Check authentication
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
await loadHeatmap();
});
/**
* Load and render the heatmap
*/
async function loadHeatmap() {
const loadingIndicator = document.getElementById('loadingIndicator');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const emptyState = document.getElementById('emptyState');
const heatmapContainer = document.getElementById('heatmapContainer');
const statsCard = document.getElementById('statsCard');
const legend = document.getElementById('legend');
// Show loading
loadingIndicator.style.display = 'block';
errorAlert.classList.add('d-none');
emptyState.classList.add('d-none');
heatmapContainer.style.display = 'none';
statsCard.style.display = 'none';
legend.style.display = 'none';
try {
// Fetch heatmap data
const response = await FitPubAuth.authenticatedFetch('/api/heatmap/me');
if (!response.ok) {
throw new Error('Failed to load heatmap data');
}
const data = await response.json();
// Hide loading
loadingIndicator.style.display = 'none';
// Check if user has any data
if (!data.features || data.features.length === 0) {
emptyState.classList.remove('d-none');
return;
}
// Show map and stats
heatmapContainer.style.display = 'block';
statsCard.style.display = 'block';
legend.style.display = 'block';
// Update stats
document.getElementById('cellCount').textContent = data.features.length.toLocaleString();
document.getElementById('maxIntensity').textContent = data.maxIntensity.toLocaleString();
// Initialize map
initializeMap();
// Render heatmap
renderHeatmap(data);
} catch (error) {
console.error('Error loading heatmap:', error);
loadingIndicator.style.display = 'none';
errorAlert.classList.remove('d-none');
errorMessage.textContent = 'Failed to load heatmap. Please try again later.';
}
}
/**
* Initialize the Leaflet map
*/
function initializeMap() {
if (heatmapMap) {
return; // Already initialized
}
// Create map centered on world
heatmapMap = L.map('heatmapContainer').setView([20, 0], 2);
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(heatmapMap);
}
/**
* Render heatmap layer from GeoJSON data
*/
function renderHeatmap(data) {
// Convert GeoJSON features to Leaflet.heat format: [lat, lon, intensity]
const heatData = data.features.map(feature => {
const lon = feature.geometry.coordinates[0];
const lat = feature.geometry.coordinates[1];
const intensity = feature.properties.intensity;
// Normalize intensity to 0-1 range
const normalizedIntensity = Math.min(intensity / data.maxIntensity, 1.0);
return [lat, lon, normalizedIntensity];
});
// Remove existing heat layer if present
if (heatLayer) {
heatmapMap.removeLayer(heatLayer);
}
// Create heat layer
heatLayer = L.heatLayer(heatData, {
radius: 25,
blur: 15,
maxZoom: 17,
max: 1.0,
gradient: {
0.0: 'blue',
0.4: 'cyan',
0.6: 'lime',
0.7: 'yellow',
0.9: 'orange',
1.0: 'red'
}
}).addTo(heatmapMap);
// Fit map bounds to heatmap data
fitMapToBounds(data.features);
}
/**
* Fit map to show all heatmap data
*/
function fitMapToBounds(features) {
if (features.length === 0) {
return;
}
// Calculate bounds
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
features.forEach(feature => {
const lon = feature.geometry.coordinates[0];
const lat = feature.geometry.coordinates[1];
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
});
// Add padding
const latPadding = (maxLat - minLat) * 0.1;
const lonPadding = (maxLon - minLon) * 0.1;
const bounds = [
[minLat - latPadding, minLon - lonPadding],
[maxLat + latPadding, maxLon + lonPadding]
];
heatmapMap.fitBounds(bounds);
}

View file

@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>My Heatmap - FitPub</title>
<style>
#heatmapContainer {
height: 80vh;
min-height: 500px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.heatmap-stats {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.stat-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stat-item i {
font-size: 1.5rem;
color: var(--bs-primary);
}
</style>
</head>
<body>
<div layout:fragment="content">
<div class="row">
<div class="col-12">
<div class="mb-4">
<h2 class="mb-3">
<i class="bi bi-map text-primary"></i>
My Activity Heatmap
</h2>
<p class="text-muted">
Visualize all your activities on a single map. Hotter colors show areas you visit more frequently.
</p>
</div>
<!-- Stats Card -->
<div class="heatmap-stats mb-4" id="statsCard" style="display: none;">
<div class="row">
<div class="col-md-4 mb-3 mb-md-0">
<div class="stat-item">
<i class="bi bi-grid-3x3"></i>
<div>
<small class="text-muted d-block">Grid Cells</small>
<strong id="cellCount">0</strong>
</div>
</div>
</div>
<div class="col-md-4 mb-3 mb-md-0">
<div class="stat-item">
<i class="bi bi-fire"></i>
<div>
<small class="text-muted d-block">Max Intensity</small>
<strong id="maxIntensity">0</strong>
</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-item">
<i class="bi bi-clock-history"></i>
<div>
<small class="text-muted d-block">Last Updated</small>
<strong id="lastUpdated">Just now</strong>
</div>
</div>
</div>
</div>
</div>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading your heatmap...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Empty State -->
<div id="emptyState" class="empty-state empty-state-activities d-none">
<div class="empty-state-icon">
<i class="bi bi-map"></i>
</div>
<h3>No Activities Yet</h3>
<p>Upload your first activity to see your heatmap!</p>
<a th:href="@{/upload}" class="btn btn-primary">
<i class="bi bi-upload"></i> Upload Activity
</a>
</div>
<!-- Map Container -->
<div id="heatmapContainer" style="display: none;"></div>
<!-- Legend -->
<div class="mt-3 text-center text-muted" id="legend" style="display: none;">
<small>
<span style="color: blue;"></span> Low Activity
<span class="ms-2" style="color: cyan;"></span> Moderate
<span class="ms-2" style="color: yellow;"></span> High
<span class="ms-2" style="color: red;"></span> Very High
</small>
</div>
</div>
</div>
</div>
<th:block layout:fragment="scripts">
<script th:src="@{/js/heatmap.js}"></script>
</th:block>
</body>
</html>

View file

@ -75,6 +75,11 @@
<i class="bi bi-graph-up"></i> Analytics
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/heatmap}" id="heatmapLink" style="display: none;">
<i class="bi bi-map"></i> Heatmap
</a>
</li>
</ul>
<!-- Right side navigation -->
@ -218,6 +223,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin="anonymous"></script>
<!-- Leaflet.heat Plugin -->
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>

View file

@ -38,8 +38,8 @@
<input type="text"
class="form-control"
id="remoteUserHandle"
placeholder="username@domain.com"
pattern="[a-zA-Z0-9_]+@[a-zA-Z0-9.-]+"
placeholder="username@domain.com or username@instance.local:8080"
pattern="[a-zA-Z0-9_-]+@[a-zA-Z0-9.-]+(:[0-9]+)?"
autocomplete="off"
required>
<button type="submit" class="btn btn-primary">