This commit is contained in:
Tim Zöller 2025-11-29 09:56:55 +01:00
parent c1729a629d
commit ac53f04e0a
27 changed files with 3019 additions and 88 deletions

View file

@ -0,0 +1,81 @@
package org.operaton.fitpub.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* Development data initializer that creates a demo user for testing.
* Only active when the 'dev' profile is enabled.
*/
@Configuration
@Profile("dev")
@RequiredArgsConstructor
@Slf4j
public class DevDataInitializer {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Bean
public CommandLineRunner initDemoUser() {
return args -> {
// Check if demo user already exists
if (userRepository.findByUsername("demo").isPresent()) {
log.info("Demo user already exists, skipping initialization");
return;
}
log.info("Creating demo user for development...");
try {
// Generate RSA key pair for ActivityPub
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
String publicKey = "-----BEGIN PUBLIC KEY-----\n" +
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()) +
"\n-----END PUBLIC KEY-----";
String privateKey = "-----BEGIN PRIVATE KEY-----\n" +
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()) +
"\n-----END PRIVATE KEY-----";
// Create demo user
User demoUser = User.builder()
.username("demo")
.email("demo@fitpub.local")
.passwordHash(passwordEncoder.encode("demo"))
.displayName("Demo User")
.bio("This is a demo account for testing FitPub features. Upload your FIT files and explore the federated fitness tracking platform!")
.publicKey(publicKey)
.privateKey(privateKey)
.build();
userRepository.save(demoUser);
log.info("=".repeat(80));
log.info("Demo user created successfully!");
log.info("Username: demo");
log.info("Password: demo");
log.info("Email: demo@fitpub.local");
log.info("=".repeat(80));
} catch (NoSuchAlgorithmException e) {
log.error("Failed to generate RSA key pair for demo user", e);
}
};
}
}

View file

@ -56,7 +56,8 @@ public class SecurityConfig {
.requestMatchers("/error").permitAll()
// Public endpoints - Web UI pages
.requestMatchers("/", "/login", "/register", "/timeline", "/activities", "/activities/**").permitAll()
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()
@ -70,14 +71,23 @@ public class SecurityConfig {
// Public endpoints - Timeline API (read-only)
.requestMatchers(HttpMethod.GET, "/api/timeline/public").permitAll()
// Public endpoints - Activity track data (for public activities)
.requestMatchers(HttpMethod.GET, "/api/activities/*/track").permitAll()
// Public endpoints - User's public activities
.requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll()
// Protected endpoints - Activities API
.requestMatchers("/api/activities/**").authenticated()
// Protected endpoints - Timeline API (user-specific)
.requestMatchers("/api/timeline/**").authenticated()
// Protected web pages
.requestMatchers("/profile", "/settings").authenticated()
// User API endpoints
.requestMatchers(HttpMethod.GET, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
// All other requests require authentication
.anyRequest().authenticated()

View file

@ -102,25 +102,31 @@ public class ActivityController {
}
/**
* Lists all activities for the authenticated user.
* Lists all activities for the authenticated user with pagination.
*
* @param userDetails the authenticated user
* @return list of activities
* @param page page number (default: 0)
* @param size page size (default: 10)
* @return page of activities
*/
@GetMapping
public ResponseEntity<List<ActivityDTO>> getUserActivities(
@AuthenticationPrincipal UserDetails userDetails
public ResponseEntity<?> getUserActivities(
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
log.info("User {} retrieving activities", userDetails.getUsername());
log.info("User {} retrieving activities (page: {}, size: {})", userDetails.getUsername(), page, size);
UUID userId = getUserId(userDetails);
List<Activity> activities = fitFileService.getUserActivities(userId);
List<ActivityDTO> dtos = activities.stream()
.map(ActivityDTO::fromEntity)
.collect(Collectors.toList());
org.springframework.data.domain.Page<Activity> activityPage =
fitFileService.getUserActivitiesPaginated(userId, page, size);
return ResponseEntity.ok(dtos);
// Convert to DTOs
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity);
// Return Spring Page object with all pagination metadata
return ResponseEntity.ok(dtoPage);
}
/**
@ -180,4 +186,108 @@ public class ActivityController {
return ResponseEntity.noContent().build();
}
/**
* Lists public activities for a specific user by username.
*
* @param username the username
* @param page page number (default: 0)
* @param size page size (default: 10)
* @return page of public activities
*/
@GetMapping("/user/{username}")
public ResponseEntity<?> getUserPublicActivities(
@PathVariable String username,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
log.debug("Retrieving public activities for user: {}", username);
// Get user by username
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
// Get public activities only
org.springframework.data.domain.Pageable pageable =
org.springframework.data.domain.PageRequest.of(page, size,
org.springframework.data.domain.Sort.by("startedAt").descending());
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);
return ResponseEntity.ok(dtoPage);
}
/**
* Gets the GPS track data for an activity in GeoJSON format.
* Public activities can be accessed without authentication.
* Private/followers activities require authentication and proper access.
*
* @param id the activity ID
* @param userDetails the authenticated user (optional for public activities)
* @return GeoJSON FeatureCollection with track data
*/
@GetMapping("/{id}/track")
public ResponseEntity<?> getActivityTrack(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails
) {
log.debug("Retrieving track data for activity {}", id);
// First try to get the activity regardless of user
Activity activity = fitFileService.getActivityById(id);
if (activity == null) {
return ResponseEntity.notFound().build();
}
// Check visibility and access permissions
if (activity.getVisibility() != Activity.Visibility.PUBLIC) {
// Non-public activities require authentication
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
UUID userId = getUserId(userDetails);
// Check if user owns the activity
if (!activity.getUserId().equals(userId)) {
// TODO: Check if user is following the activity owner (for FOLLOWERS visibility)
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
// Build GeoJSON FeatureCollection
ActivityDTO dto = ActivityDTO.fromEntity(activity);
if (dto.getSimplifiedTrack() == null) {
// Return empty FeatureCollection if no track data
return ResponseEntity.ok(java.util.Map.of(
"type", "FeatureCollection",
"features", java.util.List.of()
));
}
// Create GeoJSON Feature with the track
java.util.Map<String, Object> feature = new java.util.LinkedHashMap<>();
feature.put("type", "Feature");
feature.put("geometry", dto.getSimplifiedTrack());
// Add properties
java.util.Map<String, Object> properties = new java.util.LinkedHashMap<>();
properties.put("title", activity.getTitle());
properties.put("activityType", activity.getActivityType().name());
properties.put("distance", activity.getTotalDistance());
properties.put("duration", activity.getTotalDurationSeconds());
feature.put("properties", properties);
// Create FeatureCollection
java.util.Map<String, Object> geoJson = new java.util.LinkedHashMap<>();
geoJson.put("type", "FeatureCollection");
geoJson.put("features", java.util.List.of(feature));
return ResponseEntity.ok(geoJson);
}
}

View file

@ -0,0 +1,67 @@
package org.operaton.fitpub.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
/**
* Controller for user profile view pages.
*/
@Controller
public class ProfileViewController {
/**
* Current user's profile page.
* Shows own profile with edit capabilities.
*
* @param model the model
* @return profile template
*/
@GetMapping("/profile")
public String myProfile(Model model) {
model.addAttribute("pageTitle", "My Profile");
return "profile/view";
}
/**
* Profile edit page.
* Allows user to edit their profile information.
*
* @param model the model
* @return profile edit template
*/
@GetMapping("/profile/edit")
public String editProfile(Model model) {
model.addAttribute("pageTitle", "Edit Profile");
return "profile/edit";
}
/**
* Settings page.
* Allows user to access various settings.
*
* @param model the model
* @return settings template
*/
@GetMapping("/settings")
public String settings(Model model) {
model.addAttribute("pageTitle", "Settings");
return "settings";
}
/**
* Public user profile page by username.
* Shows public profile of any user.
*
* @param username the username
* @param model the model
* @return profile template
*/
@GetMapping("/users/{username}")
public String userProfile(@PathVariable String username, Model model) {
model.addAttribute("pageTitle", "Profile - @" + username);
model.addAttribute("username", username);
return "profile/public";
}
}

View file

@ -0,0 +1,50 @@
package org.operaton.fitpub.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Controller for timeline view pages.
*/
@Controller
public class TimelineViewController {
/**
* Public timeline page - shows all public activities.
*
* @param model the model
* @return timeline template
*/
@GetMapping("/timeline")
public String publicTimeline(Model model) {
model.addAttribute("pageTitle", "Public Timeline");
return "timeline/public";
}
/**
* Federated timeline page - shows activities from followed users.
* Requires authentication.
*
* @param model the model
* @return timeline template
*/
@GetMapping("/timeline/federated")
public String federatedTimeline(Model model) {
model.addAttribute("pageTitle", "Federated Timeline");
return "timeline/federated";
}
/**
* User timeline page - shows current user's own activities.
* Requires authentication.
*
* @param model the model
* @return timeline template
*/
@GetMapping("/timeline/user")
public String userTimeline(Model model) {
model.addAttribute("pageTitle", "My Timeline");
return "timeline/user";
}
}

View file

@ -0,0 +1,109 @@
package org.operaton.fitpub.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.dto.UserDTO;
import org.operaton.fitpub.model.dto.UserUpdateRequest;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
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.UUID;
/**
* REST controller for user profile operations.
*/
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserRepository userRepository;
/**
* Get current user's profile.
*
* @param userDetails the authenticated user
* @return user profile
*/
@GetMapping("/me")
public ResponseEntity<UserDTO> getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) {
log.debug("User {} retrieving own profile", userDetails.getUsername());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return ResponseEntity.ok(UserDTO.fromEntity(user));
}
/**
* Update current user's profile.
*
* @param request the update request
* @param userDetails the authenticated user
* @return updated user profile
*/
@PutMapping("/me")
public ResponseEntity<UserDTO> updateCurrentUser(
@Valid @RequestBody UserUpdateRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} updating profile", userDetails.getUsername());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
// Update allowed fields
if (request.getDisplayName() != null) {
user.setDisplayName(request.getDisplayName().trim());
}
if (request.getBio() != null) {
user.setBio(request.getBio().trim());
}
if (request.getAvatarUrl() != null) {
user.setAvatarUrl(request.getAvatarUrl().trim());
}
User updated = userRepository.save(user);
return ResponseEntity.ok(UserDTO.fromEntity(updated));
}
/**
* Get user profile by username.
*
* @param username the username
* @return user profile
*/
@GetMapping("/{username}")
public ResponseEntity<UserDTO> getUserByUsername(@PathVariable String username) {
log.debug("Retrieving profile for username: {}", username);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return ResponseEntity.ok(UserDTO.fromEntity(user));
}
/**
* Get user profile by ID.
*
* @param id the user ID
* @return user profile
*/
@GetMapping("/id/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID id) {
log.debug("Retrieving profile for user ID: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return ResponseEntity.ok(UserDTO.fromEntity(user));
}
}

View file

@ -1,15 +1,24 @@
package org.operaton.fitpub.model.dto;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.LineString;
import org.operaton.fitpub.model.entity.Activity;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* DTO for Activity data transfer.
@ -36,6 +45,40 @@ public class ActivityDTO {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Map rendering data
private Map<String, Object> simplifiedTrack; // GeoJSON LineString
private List<Map<String, Object>> trackPoints; // Full track points from JSONB
// Convenience getters for flattened metrics (for frontend compatibility)
public Integer getAverageHeartRate() {
return metrics != null ? metrics.getAverageHeartRate() : null;
}
public Integer getMaxHeartRate() {
return metrics != null ? metrics.getMaxHeartRate() : null;
}
public Integer getAverageCadence() {
return metrics != null ? metrics.getAverageCadence() : null;
}
public BigDecimal getAverageSpeed() {
return metrics != null ? metrics.getAverageSpeed() : null;
}
public BigDecimal getMaxSpeed() {
return metrics != null ? metrics.getMaxSpeed() : null;
}
public Integer getCalories() {
return metrics != null ? metrics.getCalories() : null;
}
// Alias for frontend compatibility
public Long getTotalDuration() {
return totalDurationSeconds;
}
/**
* Creates a DTO from an Activity entity.
*/
@ -63,6 +106,65 @@ public class ActivityDTO {
builder.metrics(ActivityMetricsDTO.fromEntity(activity.getMetrics()));
}
// Convert simplified track to GeoJSON
if (activity.getSimplifiedTrack() != null) {
builder.simplifiedTrack(lineStringToGeoJson(activity.getSimplifiedTrack()));
}
// Parse track points from JSONB
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
builder.trackPoints(parseTrackPoints(activity.getTrackPointsJson()));
}
return builder.build();
}
/**
* Converts a JTS LineString to GeoJSON format.
*/
private static Map<String, Object> lineStringToGeoJson(LineString lineString) {
Map<String, Object> geoJson = new LinkedHashMap<>();
geoJson.put("type", "LineString");
List<List<Double>> coordinates = Stream.of(lineString.getCoordinates())
.map(coord -> List.of(coord.getX(), coord.getY()))
.collect(Collectors.toList());
geoJson.put("coordinates", coordinates);
return geoJson;
}
/**
* Parses track points from JSONB string.
*/
private static List<Map<String, Object>> parseTrackPoints(String trackPointsJson) {
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(trackPointsJson);
if (root.isArray()) {
List<Map<String, Object>> trackPoints = new java.util.ArrayList<>();
for (JsonNode node : root) {
Map<String, Object> point = new LinkedHashMap<>();
if (node.has("timestamp")) point.put("timestamp", node.get("timestamp").asText());
if (node.has("latitude")) point.put("latitude", node.get("latitude").asDouble());
if (node.has("longitude")) point.put("longitude", node.get("longitude").asDouble());
if (node.has("elevation")) point.put("elevation", node.get("elevation").asDouble());
if (node.has("heartRate")) point.put("heartRate", node.get("heartRate").asInt());
if (node.has("cadence")) point.put("cadence", node.get("cadence").asInt());
if (node.has("speed")) point.put("speed", node.get("speed").asDouble());
if (node.has("power")) point.put("power", node.get("power").asInt());
if (node.has("temperature")) point.put("temperature", node.get("temperature").asDouble());
trackPoints.add(point);
}
return trackPoints;
}
} catch (Exception e) {
// Log error but don't fail the entire DTO creation
System.err.println("Error parsing track points JSON: " + e.getMessage());
}
return null;
}
}

View file

@ -0,0 +1,63 @@
package org.operaton.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.User;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO for User data transfer.
* Used for public user profiles (excludes sensitive data like password hash and private keys).
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private UUID id;
private String username;
private String email; // Only shown to the user themselves
private String displayName;
private String bio;
private String avatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* Creates a DTO from a User entity.
* Note: email should only be included when user is viewing their own profile.
*/
public static UserDTO fromEntity(User user) {
return UserDTO.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.displayName(user.getDisplayName())
.bio(user.getBio())
.avatarUrl(user.getAvatarUrl())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())
.build();
}
/**
* Creates a public DTO from a User entity (excludes email).
* Use this when returning user data to other users.
*/
public static UserDTO fromEntityPublic(User user) {
return UserDTO.builder()
.id(user.getId())
.username(user.getUsername())
.displayName(user.getDisplayName())
.bio(user.getBio())
.avatarUrl(user.getAvatarUrl())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())
.build();
}
}

View file

@ -0,0 +1,27 @@
package org.operaton.fitpub.model.dto;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.URL;
/**
* DTO for user profile update requests.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserUpdateRequest {
@Size(max = 100, message = "Display name must not exceed 100 characters")
private String displayName;
@Size(max = 500, message = "Bio must not exceed 500 characters")
private String bio;
@URL(message = "Avatar URL must be a valid URL")
private String avatarUrl;
}

View file

@ -45,6 +45,7 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
* Find all public activities for a user.
*
* @param userId the user ID
* @param visibility the visibility level
* @return list of activities
*/
List<Activity> findByUserIdAndVisibilityOrderByStartedAtDesc(
@ -52,6 +53,20 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
Activity.Visibility visibility
);
/**
* Find activities for a user by visibility with pagination.
*
* @param userId the user ID
* @param visibility the visibility level
* @param pageable pagination parameters
* @return page of activities
*/
Page<Activity> findByUserIdAndVisibilityOrderByStartedAtDesc(
UUID userId,
Activity.Visibility visibility,
Pageable pageable
);
/**
* Find activities by type for a user.
*

View file

@ -339,6 +339,19 @@ public class FitFileService {
return activityRepository.findByIdAndUserId(activityId, userId).orElse(null);
}
/**
* Retrieves an activity by ID without user authorization check.
* This is used for public activity access (e.g., viewing public tracks).
* Caller is responsible for checking visibility and access permissions.
*
* @param activityId the activity ID
* @return the activity or null if not found
*/
@Transactional(readOnly = true)
public Activity getActivityById(UUID activityId) {
return activityRepository.findById(activityId).orElse(null);
}
/**
* Retrieves all activities for a user.
*
@ -350,6 +363,33 @@ public class FitFileService {
return activityRepository.findByUserIdOrderByStartedAtDesc(userId);
}
/**
* Retrieves activities for a user with pagination.
*
* @param userId the user ID
* @param page page number (0-indexed)
* @param size page size
* @return page of activities
*/
@Transactional(readOnly = true)
public org.springframework.data.domain.Page<Activity> getUserActivitiesPaginated(UUID userId, int page, int size) {
org.springframework.data.domain.Pageable pageable =
org.springframework.data.domain.PageRequest.of(page, size, org.springframework.data.domain.Sort.by("startedAt").descending());
return activityRepository.findByUserIdOrderByStartedAtDesc(userId, pageable);
}
/**
* Retrieves public activities for a user with pagination.
*
* @param userId the user ID
* @param pageable pagination parameters
* @return page of public activities
*/
@Transactional(readOnly = true)
public org.springframework.data.domain.Page<Activity> getPublicActivitiesByUserId(UUID userId, org.springframework.data.domain.Pageable pageable) {
return activityRepository.findByUserIdAndVisibilityOrderByStartedAtDesc(userId, Activity.Visibility.PUBLIC, pageable);
}
/**
* Update an existing activity's metadata.
*