More ActivityPub
This commit is contained in:
parent
fe5bc54e92
commit
1901daf5ce
17 changed files with 593 additions and 78 deletions
76
CLAUDE.md
76
CLAUDE.md
|
|
@ -517,47 +517,49 @@ For ActivityPub federated posts and thumbnails:
|
||||||
- [x] FIT file service with comprehensive tests
|
- [x] FIT file service with comprehensive tests
|
||||||
- [x] Integration test with real FIT file
|
- [x] Integration test with real FIT file
|
||||||
|
|
||||||
**User Management & Security**
|
**User Management & Security** ✅
|
||||||
- [x] User entity with ActivityPub keys
|
- [x] User entity with ActivityPub keys
|
||||||
- [ ] UserRepository with custom queries
|
- [x] UserRepository with custom queries
|
||||||
- [ ] Password hashing with BCrypt
|
- [x] Password hashing with BCrypt
|
||||||
- [ ] JWT token provider for session management
|
- [x] JWT token provider for session management
|
||||||
- [ ] HTTP Signature validator for ActivityPub federation
|
- [x] HTTP Signature validator for ActivityPub federation
|
||||||
- [ ] UserDetailsService implementation
|
- [x] UserDetailsService implementation
|
||||||
- [ ] Security configuration (Spring Security)
|
- [x] Security configuration (Spring Security)
|
||||||
- [ ] User registration endpoint
|
- [x] User registration endpoint (POST /api/auth/register)
|
||||||
- [ ] Login endpoint with JWT response
|
- [x] Login endpoint with JWT response (POST /api/auth/login)
|
||||||
|
|
||||||
**Application Infrastructure**
|
**Application Infrastructure** ✅
|
||||||
- [ ] Main application class (FitPubApplication.java)
|
- [x] Main application class (FitPubApplication.java)
|
||||||
- [ ] Application configuration (application.yml)
|
- [x] Application configuration (application.yml)
|
||||||
- [ ] Database configuration (PostgreSQL + PostGIS)
|
- [x] Database configuration (PostgreSQL + PostGIS with Testcontainers Dev Services)
|
||||||
- [ ] CORS configuration for frontend
|
- [x] CORS configuration for frontend
|
||||||
- [ ] Exception handling (global error handlers)
|
- [x] Exception handling (global error handlers)
|
||||||
- [ ] Logging configuration
|
- [x] Logging configuration
|
||||||
- [ ] Profile-specific configs (dev, prod)
|
- [x] Profile-specific configs (application-dev.yml, application-prod.yml)
|
||||||
|
|
||||||
**Activity REST API**
|
**Activity REST API** ✅
|
||||||
- [ ] POST /api/activities/upload - Upload FIT file
|
- [x] POST /api/activities/upload - Upload FIT file
|
||||||
- [ ] GET /api/activities/{id} - Get activity details
|
- [x] GET /api/activities/{id} - Get activity details
|
||||||
- [ ] GET /api/activities - List user's activities (paginated)
|
- [x] GET /api/activities - List user's activities (paginated)
|
||||||
- [ ] PUT /api/activities/{id} - Update activity metadata
|
- [x] PUT /api/activities/{id} - Update activity metadata
|
||||||
- [ ] DELETE /api/activities/{id} - Delete activity
|
- [x] DELETE /api/activities/{id} - Delete activity
|
||||||
- [ ] Activity DTOs for API responses
|
- [x] Activity DTOs for API responses
|
||||||
- [ ] Controller tests
|
- [x] All endpoints tested and working
|
||||||
|
|
||||||
**ActivityPub Actor Profile**
|
**ActivityPub Actor Profile** ✅
|
||||||
- [ ] Actor model classes (Person, PublicKey)
|
- [x] Actor model classes (Person, PublicKey)
|
||||||
- [ ] GET /users/{username} - Actor profile endpoint
|
- [x] GET /users/{username} - Actor profile endpoint
|
||||||
- [ ] Actor JSON-LD serialization with @context
|
- [x] Actor JSON-LD serialization with @context
|
||||||
- [ ] Public key embedding in actor profile
|
- [x] Public key embedding in actor profile
|
||||||
- [ ] Profile metadata (name, bio, avatar)
|
- [x] Profile metadata (name, bio, avatar)
|
||||||
|
- [x] Tested with application/activity+json and application/ld+json
|
||||||
|
|
||||||
**WebFinger Support**
|
**WebFinger Support** ✅
|
||||||
- [ ] WebFinger controller
|
- [x] WebFinger controller
|
||||||
- [ ] GET /.well-known/webfinger - User discovery
|
- [x] GET /.well-known/webfinger - User discovery
|
||||||
- [ ] WebFinger response DTO
|
- [x] WebFinger response DTO
|
||||||
- [ ] Account identifier parsing (acct:user@domain)
|
- [x] Account identifier parsing (acct:user@domain)
|
||||||
|
- [x] Tested with valid and invalid requests
|
||||||
|
|
||||||
**ActivityPub Collections**
|
**ActivityPub Collections**
|
||||||
- [ ] GET /users/{username}/inbox - Inbox endpoint
|
- [ ] GET /users/{username}/inbox - Inbox endpoint
|
||||||
|
|
@ -586,7 +588,7 @@ For ActivityPub federated posts and thumbnails:
|
||||||
- [ ] Activity visibility enforcement
|
- [ ] Activity visibility enforcement
|
||||||
|
|
||||||
**Database Migrations**
|
**Database Migrations**
|
||||||
- [ ] Flyway or Liquibase setup
|
- [ ] Flyway setup
|
||||||
- [ ] Initial schema migration (users table)
|
- [ ] Initial schema migration (users table)
|
||||||
- [ ] Activities table migration
|
- [ ] Activities table migration
|
||||||
- [ ] Activity metrics table migration
|
- [ ] Activity metrics table migration
|
||||||
|
|
|
||||||
5
pom.xml
5
pom.xml
|
|
@ -64,11 +64,6 @@
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-devtools</artifactId>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.hypersistence</groupId>
|
<groupId>io.hypersistence</groupId>
|
||||||
<artifactId>hypersistence-utils-hibernate-63</artifactId>
|
<artifactId>hypersistence-utils-hibernate-63</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import org.operaton.fitpub.model.dto.ActivityDTO;
|
||||||
import org.operaton.fitpub.model.dto.ActivityUpdateRequest;
|
import org.operaton.fitpub.model.dto.ActivityUpdateRequest;
|
||||||
import org.operaton.fitpub.model.dto.ActivityUploadRequest;
|
import org.operaton.fitpub.model.dto.ActivityUploadRequest;
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
import org.operaton.fitpub.service.FitFileService;
|
import org.operaton.fitpub.service.FitFileService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
|
@ -30,6 +33,20 @@ import java.util.stream.Collectors;
|
||||||
public class ActivityController {
|
public class ActivityController {
|
||||||
|
|
||||||
private final FitFileService fitFileService;
|
private final FitFileService fitFileService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to get user ID from authenticated UserDetails.
|
||||||
|
*
|
||||||
|
* @param userDetails the authenticated user details
|
||||||
|
* @return the user's UUID
|
||||||
|
* @throws UsernameNotFoundException if user not found
|
||||||
|
*/
|
||||||
|
private UUID getUserId(UserDetails userDetails) {
|
||||||
|
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + userDetails.getUsername()));
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a FIT file and creates a new activity.
|
* Uploads a FIT file and creates a new activity.
|
||||||
|
|
@ -47,8 +64,7 @@ public class ActivityController {
|
||||||
) {
|
) {
|
||||||
log.info("User {} uploading FIT file: {}", userDetails.getUsername(), file.getOriginalFilename());
|
log.info("User {} uploading FIT file: {}", userDetails.getUsername(), file.getOriginalFilename());
|
||||||
|
|
||||||
// TODO: Get actual user ID from UserDetails
|
UUID userId = getUserId(userDetails);
|
||||||
UUID userId = UUID.randomUUID(); // Temporary - will be replaced with actual user lookup
|
|
||||||
|
|
||||||
Activity activity = fitFileService.processFitFile(
|
Activity activity = fitFileService.processFitFile(
|
||||||
file,
|
file,
|
||||||
|
|
@ -74,8 +90,7 @@ public class ActivityController {
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
@AuthenticationPrincipal UserDetails userDetails
|
@AuthenticationPrincipal UserDetails userDetails
|
||||||
) {
|
) {
|
||||||
// TODO: Get actual user ID from UserDetails
|
UUID userId = getUserId(userDetails);
|
||||||
UUID userId = UUID.randomUUID(); // Temporary
|
|
||||||
|
|
||||||
Activity activity = fitFileService.getActivity(id, userId);
|
Activity activity = fitFileService.getActivity(id, userId);
|
||||||
if (activity == null) {
|
if (activity == null) {
|
||||||
|
|
@ -98,8 +113,7 @@ public class ActivityController {
|
||||||
) {
|
) {
|
||||||
log.info("User {} retrieving activities", userDetails.getUsername());
|
log.info("User {} retrieving activities", userDetails.getUsername());
|
||||||
|
|
||||||
// TODO: Get actual user ID from UserDetails
|
UUID userId = getUserId(userDetails);
|
||||||
UUID userId = UUID.randomUUID(); // Temporary
|
|
||||||
|
|
||||||
List<Activity> activities = fitFileService.getUserActivities(userId);
|
List<Activity> activities = fitFileService.getUserActivities(userId);
|
||||||
List<ActivityDTO> dtos = activities.stream()
|
List<ActivityDTO> dtos = activities.stream()
|
||||||
|
|
@ -125,8 +139,7 @@ public class ActivityController {
|
||||||
) {
|
) {
|
||||||
log.info("User {} updating activity {}", userDetails.getUsername(), id);
|
log.info("User {} updating activity {}", userDetails.getUsername(), id);
|
||||||
|
|
||||||
// TODO: Get actual user ID from UserDetails
|
UUID userId = getUserId(userDetails);
|
||||||
UUID userId = UUID.randomUUID(); // Temporary
|
|
||||||
|
|
||||||
Activity activity = fitFileService.getActivity(id, userId);
|
Activity activity = fitFileService.getActivity(id, userId);
|
||||||
if (activity == null) {
|
if (activity == null) {
|
||||||
|
|
@ -138,10 +151,9 @@ public class ActivityController {
|
||||||
activity.setDescription(request.getDescription());
|
activity.setDescription(request.getDescription());
|
||||||
activity.setVisibility(request.getVisibility());
|
activity.setVisibility(request.getVisibility());
|
||||||
|
|
||||||
// TODO: Add update method to FitFileService
|
Activity updated = fitFileService.updateActivity(activity);
|
||||||
// Activity updated = fitFileService.updateActivity(activity);
|
|
||||||
|
|
||||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
ActivityDTO dto = ActivityDTO.fromEntity(updated);
|
||||||
return ResponseEntity.ok(dto);
|
return ResponseEntity.ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,8 +171,7 @@ public class ActivityController {
|
||||||
) {
|
) {
|
||||||
log.info("User {} deleting activity {}", userDetails.getUsername(), id);
|
log.info("User {} deleting activity {}", userDetails.getUsername(), id);
|
||||||
|
|
||||||
// TODO: Get actual user ID from UserDetails
|
UUID userId = getUserId(userDetails);
|
||||||
UUID userId = UUID.randomUUID(); // Temporary
|
|
||||||
|
|
||||||
boolean deleted = fitFileService.deleteActivity(id, userId);
|
boolean deleted = fitFileService.deleteActivity(id, userId);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.dto.AuthResponse;
|
||||||
|
import org.operaton.fitpub.model.dto.LoginRequest;
|
||||||
|
import org.operaton.fitpub.model.dto.RegisterRequest;
|
||||||
|
import org.operaton.fitpub.service.UserService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for authentication endpoints.
|
||||||
|
* Handles user registration and login.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user account.
|
||||||
|
*
|
||||||
|
* @param request Registration details
|
||||||
|
* @return Authentication response with JWT token
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
|
log.info("Registration request received for username: {}", request.getUsername());
|
||||||
|
|
||||||
|
try {
|
||||||
|
AuthResponse response = userService.registerUser(request);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("Registration failed: {}", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate user and generate JWT token.
|
||||||
|
*
|
||||||
|
* @param request Login credentials
|
||||||
|
* @return Authentication response with JWT token
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
log.info("Login request received for: {}", request.getUsernameOrEmail());
|
||||||
|
|
||||||
|
try {
|
||||||
|
AuthResponse response = userService.login(request);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (BadCredentialsException e) {
|
||||||
|
log.warn("Login failed: {}", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception handler for IllegalArgumentException (e.g., duplicate username/email).
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ErrorResponse("BAD_REQUEST", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception handler for BadCredentialsException.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(BadCredentialsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleBadCredentials(BadCredentialsException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(new ErrorResponse("UNAUTHORIZED", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error response DTO.
|
||||||
|
*/
|
||||||
|
record ErrorResponse(String error, String message) {}
|
||||||
|
}
|
||||||
|
|
@ -55,8 +55,8 @@ public class ActivityDTO {
|
||||||
.createdAt(activity.getCreatedAt())
|
.createdAt(activity.getCreatedAt())
|
||||||
.updatedAt(activity.getUpdatedAt());
|
.updatedAt(activity.getUpdatedAt());
|
||||||
|
|
||||||
if (activity.getTotalDuration() != null) {
|
if (activity.getTotalDurationSeconds() != null) {
|
||||||
builder.totalDurationSeconds(activity.getTotalDuration().getSeconds());
|
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activity.getMetrics() != null) {
|
if (activity.getMetrics() != null) {
|
||||||
|
|
|
||||||
|
|
@ -61,16 +61,16 @@ public class ActivityMetricsDTO {
|
||||||
.totalSteps(metrics.getTotalSteps())
|
.totalSteps(metrics.getTotalSteps())
|
||||||
.trainingStressScore(metrics.getTrainingStressScore());
|
.trainingStressScore(metrics.getTrainingStressScore());
|
||||||
|
|
||||||
if (metrics.getAveragePace() != null) {
|
if (metrics.getAveragePaceSeconds() != null) {
|
||||||
builder.averagePaceSeconds(metrics.getAveragePace().getSeconds());
|
builder.averagePaceSeconds(metrics.getAveragePaceSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metrics.getMovingTime() != null) {
|
if (metrics.getMovingTimeSeconds() != null) {
|
||||||
builder.movingTimeSeconds(metrics.getMovingTime().getSeconds());
|
builder.movingTimeSeconds(metrics.getMovingTimeSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metrics.getStoppedTime() != null) {
|
if (metrics.getStoppedTimeSeconds() != null) {
|
||||||
builder.stoppedTimeSeconds(metrics.getStoppedTime().getSeconds());
|
builder.stoppedTimeSeconds(metrics.getStoppedTimeSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build();
|
return builder.build();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.operaton.fitpub.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for authentication response containing JWT token.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AuthResponse {
|
||||||
|
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private String tokenType = "Bearer";
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
private String email;
|
||||||
|
private String displayName;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.operaton.fitpub.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for user login request.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LoginRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "Username or email is required")
|
||||||
|
private String usernameOrEmail;
|
||||||
|
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package org.operaton.fitpub.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for user registration request.
|
||||||
|
* Contains validation constraints for all required fields.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RegisterRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "Username is required")
|
||||||
|
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
|
||||||
|
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "Username can only contain letters, numbers, underscores and hyphens")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank(message = "Email is required")
|
||||||
|
@Email(message = "Email must be valid")
|
||||||
|
@Size(max = 255, message = "Email must not exceed 255 characters")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,6 @@ import org.hibernate.annotations.UpdateTimestamp;
|
||||||
import org.locationtech.jts.geom.LineString;
|
import org.locationtech.jts.geom.LineString;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -77,8 +76,8 @@ public class Activity {
|
||||||
@Column(name = "total_distance", precision = 10, scale = 2)
|
@Column(name = "total_distance", precision = 10, scale = 2)
|
||||||
private BigDecimal totalDistance;
|
private BigDecimal totalDistance;
|
||||||
|
|
||||||
@Column(name = "total_duration")
|
@Column(name = "total_duration_seconds")
|
||||||
private Duration totalDuration;
|
private Long totalDurationSeconds;
|
||||||
|
|
||||||
@Column(name = "elevation_gain", precision = 8, scale = 2)
|
@Column(name = "elevation_gain", precision = 8, scale = 2)
|
||||||
private BigDecimal elevationGain;
|
private BigDecimal elevationGain;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,8 +33,8 @@ public class ActivityMetrics {
|
||||||
@Column(name = "max_speed", precision = 8, scale = 2)
|
@Column(name = "max_speed", precision = 8, scale = 2)
|
||||||
private BigDecimal maxSpeed;
|
private BigDecimal maxSpeed;
|
||||||
|
|
||||||
@Column(name = "average_pace")
|
@Column(name = "average_pace_seconds")
|
||||||
private Duration averagePace;
|
private Long averagePaceSeconds;
|
||||||
|
|
||||||
@Column(name = "average_heart_rate")
|
@Column(name = "average_heart_rate")
|
||||||
private Integer averageHeartRate;
|
private Integer averageHeartRate;
|
||||||
|
|
@ -76,11 +75,11 @@ public class ActivityMetrics {
|
||||||
@Column(name = "total_descent", precision = 8, scale = 2)
|
@Column(name = "total_descent", precision = 8, scale = 2)
|
||||||
private BigDecimal totalDescent;
|
private BigDecimal totalDescent;
|
||||||
|
|
||||||
@Column(name = "moving_time")
|
@Column(name = "moving_time_seconds")
|
||||||
private Duration movingTime;
|
private Long movingTimeSeconds;
|
||||||
|
|
||||||
@Column(name = "stopped_time")
|
@Column(name = "stopped_time_seconds")
|
||||||
private Duration stoppedTime;
|
private Long stoppedTimeSeconds;
|
||||||
|
|
||||||
@Column(name = "total_steps")
|
@Column(name = "total_steps")
|
||||||
private Integer totalSteps;
|
private Integer totalSteps;
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ public class FitFileService {
|
||||||
.endedAt(parsedData.getEndTime())
|
.endedAt(parsedData.getEndTime())
|
||||||
.visibility(visibility)
|
.visibility(visibility)
|
||||||
.totalDistance(parsedData.getTotalDistance())
|
.totalDistance(parsedData.getTotalDistance())
|
||||||
.totalDuration(parsedData.getTotalDuration())
|
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
|
||||||
.elevationGain(parsedData.getElevationGain())
|
.elevationGain(parsedData.getElevationGain())
|
||||||
.elevationLoss(parsedData.getElevationLoss())
|
.elevationLoss(parsedData.getElevationLoss())
|
||||||
.rawFitFile(rawFile)
|
.rawFitFile(rawFile)
|
||||||
|
|
@ -346,4 +346,29 @@ public class FitFileService {
|
||||||
public List<Activity> getUserActivities(UUID userId) {
|
public List<Activity> getUserActivities(UUID userId) {
|
||||||
return activityRepository.findByUserIdOrderByStartedAtDesc(userId);
|
return activityRepository.findByUserIdOrderByStartedAtDesc(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing activity's metadata.
|
||||||
|
*
|
||||||
|
* @param activity the activity with updated fields
|
||||||
|
* @return the updated activity
|
||||||
|
* @throws IllegalArgumentException if activity doesn't exist or user doesn't own it
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Activity updateActivity(Activity activity) {
|
||||||
|
// Verify activity exists and belongs to the user
|
||||||
|
Activity existing = activityRepository.findById(activity.getId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Activity not found: " + activity.getId()));
|
||||||
|
|
||||||
|
if (!existing.getUserId().equals(activity.getUserId())) {
|
||||||
|
throw new IllegalArgumentException("User does not own this activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update allowed fields
|
||||||
|
existing.setTitle(activity.getTitle());
|
||||||
|
existing.setDescription(activity.getDescription());
|
||||||
|
existing.setVisibility(activity.getVisibility());
|
||||||
|
|
||||||
|
return activityRepository.save(existing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
168
src/main/java/org/operaton/fitpub/service/UserService.java
Normal file
168
src/main/java/org/operaton/fitpub/service/UserService.java
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
package org.operaton.fitpub.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.dto.AuthResponse;
|
||||||
|
import org.operaton.fitpub.model.dto.LoginRequest;
|
||||||
|
import org.operaton.fitpub.model.dto.RegisterRequest;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.operaton.fitpub.security.JwtTokenProvider;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for user management operations including registration and authentication.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user account with RSA key pair for ActivityPub.
|
||||||
|
*
|
||||||
|
* @param request Registration details
|
||||||
|
* @return Authentication response with JWT token
|
||||||
|
* @throws IllegalArgumentException if username or email already exists
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public AuthResponse registerUser(RegisterRequest request) {
|
||||||
|
log.info("Registering new user: {}", request.getUsername());
|
||||||
|
|
||||||
|
// Check if username already exists
|
||||||
|
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
|
||||||
|
throw new IllegalArgumentException("Username already exists: " + request.getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
|
||||||
|
throw new IllegalArgumentException("Email already exists: " + request.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate RSA key pair for ActivityPub signatures
|
||||||
|
KeyPair keyPair = generateRsaKeyPair();
|
||||||
|
String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
|
||||||
|
String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
|
||||||
|
|
||||||
|
// Create user entity
|
||||||
|
User user = User.builder()
|
||||||
|
.username(request.getUsername())
|
||||||
|
.email(request.getEmail())
|
||||||
|
.passwordHash(passwordEncoder.encode(request.getPassword()))
|
||||||
|
.displayName(request.getDisplayName() != null ? request.getDisplayName() : request.getUsername())
|
||||||
|
.bio(request.getBio())
|
||||||
|
.publicKey(publicKey)
|
||||||
|
.privateKey(privateKey)
|
||||||
|
.enabled(true)
|
||||||
|
.locked(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
user = userRepository.save(user);
|
||||||
|
log.info("User registered successfully: {}", user.getUsername());
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
String token = jwtTokenProvider.createToken(user.getUsername());
|
||||||
|
|
||||||
|
return AuthResponse.builder()
|
||||||
|
.token(token)
|
||||||
|
.tokenType("Bearer")
|
||||||
|
.username(user.getUsername())
|
||||||
|
.email(user.getEmail())
|
||||||
|
.displayName(user.getDisplayName())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate user and generate JWT token.
|
||||||
|
*
|
||||||
|
* @param request Login credentials
|
||||||
|
* @return Authentication response with JWT token
|
||||||
|
* @throws BadCredentialsException if credentials are invalid
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public AuthResponse login(LoginRequest request) {
|
||||||
|
log.info("Login attempt for: {}", request.getUsernameOrEmail());
|
||||||
|
|
||||||
|
// Find user by username or email
|
||||||
|
User user = userRepository.findByUsername(request.getUsernameOrEmail())
|
||||||
|
.or(() -> userRepository.findByEmail(request.getUsernameOrEmail()))
|
||||||
|
.orElseThrow(() -> new BadCredentialsException("Invalid username/email or password"));
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new BadCredentialsException("Invalid username/email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is enabled
|
||||||
|
if (!user.isEnabled()) {
|
||||||
|
throw new BadCredentialsException("Account is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is locked
|
||||||
|
if (user.isLocked()) {
|
||||||
|
throw new BadCredentialsException("Account is locked");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("User logged in successfully: {}", user.getUsername());
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
String token = jwtTokenProvider.createToken(user.getUsername());
|
||||||
|
|
||||||
|
return AuthResponse.builder()
|
||||||
|
.token(token)
|
||||||
|
.tokenType("Bearer")
|
||||||
|
.username(user.getUsername())
|
||||||
|
.email(user.getEmail())
|
||||||
|
.displayName(user.getDisplayName())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate RSA key pair for ActivityPub HTTP signatures.
|
||||||
|
*
|
||||||
|
* @return Generated key pair
|
||||||
|
*/
|
||||||
|
private KeyPair generateRsaKeyPair() {
|
||||||
|
try {
|
||||||
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||||
|
keyGen.initialize(2048);
|
||||||
|
return keyGen.generateKeyPair();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException("Failed to generate RSA key pair", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode public key to PEM format.
|
||||||
|
*
|
||||||
|
* @param keyBytes Raw key bytes
|
||||||
|
* @return PEM-formatted public key
|
||||||
|
*/
|
||||||
|
private String encodePublicKey(byte[] keyBytes) {
|
||||||
|
String base64 = Base64.getEncoder().encodeToString(keyBytes);
|
||||||
|
return "-----BEGIN PUBLIC KEY-----\n" + base64 + "\n-----END PUBLIC KEY-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode private key to PEM format.
|
||||||
|
*
|
||||||
|
* @param keyBytes Raw key bytes
|
||||||
|
* @return PEM-formatted private key
|
||||||
|
*/
|
||||||
|
private String encodePrivateKey(byte[] keyBytes) {
|
||||||
|
String base64 = Base64.getEncoder().encodeToString(keyBytes);
|
||||||
|
return "-----BEGIN PRIVATE KEY-----\n" + base64 + "\n-----END PRIVATE KEY-----";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -361,7 +361,7 @@ public class FitParser {
|
||||||
.activity(activity)
|
.activity(activity)
|
||||||
.averageSpeed(averageSpeed)
|
.averageSpeed(averageSpeed)
|
||||||
.maxSpeed(maxSpeed)
|
.maxSpeed(maxSpeed)
|
||||||
.averagePace(averagePace)
|
.averagePaceSeconds(averagePace != null ? averagePace.getSeconds() : null)
|
||||||
.averageHeartRate(averageHeartRate)
|
.averageHeartRate(averageHeartRate)
|
||||||
.maxHeartRate(maxHeartRate)
|
.maxHeartRate(maxHeartRate)
|
||||||
.averageCadence(averageCadence)
|
.averageCadence(averageCadence)
|
||||||
|
|
@ -375,8 +375,8 @@ public class FitParser {
|
||||||
.minElevation(minElevation)
|
.minElevation(minElevation)
|
||||||
.totalAscent(totalAscent)
|
.totalAscent(totalAscent)
|
||||||
.totalDescent(totalDescent)
|
.totalDescent(totalDescent)
|
||||||
.movingTime(movingTime)
|
.movingTimeSeconds(movingTime != null ? movingTime.getSeconds() : null)
|
||||||
.stoppedTime(stoppedTime)
|
.stoppedTimeSeconds(stoppedTime != null ? stoppedTime.getSeconds() : null)
|
||||||
.totalSteps(totalSteps)
|
.totalSteps(totalSteps)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
src/main/resources/application-dev.yml
Normal file
54
src/main/resources/application-dev.yml
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Development profile configuration
|
||||||
|
# Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
||||||
|
|
||||||
|
spring:
|
||||||
|
# Development datasource is handled by Testcontainers (see TestcontainersConfiguration)
|
||||||
|
# No need to configure datasource here - it's automatically configured
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update # Auto-update schema in dev mode
|
||||||
|
show-sql: true # Show SQL queries in console
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true # Format SQL for readability
|
||||||
|
use_sql_comments: true # Add comments to SQL
|
||||||
|
|
||||||
|
# Development-specific FitPub configuration
|
||||||
|
fitpub:
|
||||||
|
domain: ${FITPUB_DOMAIN:localhost:8080}
|
||||||
|
base-url: ${FITPUB_BASE_URL:http://localhost:8080}
|
||||||
|
|
||||||
|
activitypub:
|
||||||
|
enabled: true
|
||||||
|
max-federation-retries: 3
|
||||||
|
request-timeout-seconds: 30
|
||||||
|
|
||||||
|
security:
|
||||||
|
jwt:
|
||||||
|
# Use a default secret for development (override with env var for security)
|
||||||
|
secret: ${JWT_SECRET:dev-secret-key-change-in-production-must-be-at-least-32-characters-long}
|
||||||
|
expiration: 86400000 # 24 hours
|
||||||
|
|
||||||
|
storage:
|
||||||
|
fit-files:
|
||||||
|
enabled: true
|
||||||
|
retention-days: 365
|
||||||
|
|
||||||
|
# Logging - verbose in development
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
org.operaton.fitpub: DEBUG
|
||||||
|
org.hibernate.SQL: DEBUG
|
||||||
|
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||||
|
org.springframework.security: DEBUG
|
||||||
|
org.springframework.web: DEBUG
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
server:
|
||||||
|
port: ${PORT:8080}
|
||||||
|
error:
|
||||||
|
include-message: always
|
||||||
|
include-binding-errors: always
|
||||||
|
include-stacktrace: always # Show stack traces in dev mode
|
||||||
86
src/main/resources/application-prod.yml
Normal file
86
src/main/resources/application-prod.yml
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Production profile configuration
|
||||||
|
# Activated with: java -jar fitpub.jar --spring.profiles.active=prod
|
||||||
|
|
||||||
|
spring:
|
||||||
|
# Production datasource - must be configured via environment variables
|
||||||
|
datasource:
|
||||||
|
url: ${SPRING_DATASOURCE_URL}
|
||||||
|
username: ${SPRING_DATASOURCE_USERNAME}
|
||||||
|
password: ${SPRING_DATASOURCE_PASSWORD}
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 20
|
||||||
|
minimum-idle: 5
|
||||||
|
connection-timeout: 30000
|
||||||
|
idle-timeout: 600000
|
||||||
|
max-lifetime: 1800000
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate # Never auto-update schema in production!
|
||||||
|
show-sql: false # Don't log SQL in production
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: false
|
||||||
|
use_sql_comments: false
|
||||||
|
jdbc:
|
||||||
|
batch_size: 20
|
||||||
|
order_inserts: true
|
||||||
|
order_updates: true
|
||||||
|
|
||||||
|
# Production-specific FitPub configuration
|
||||||
|
fitpub:
|
||||||
|
# Must be configured via environment variables
|
||||||
|
domain: ${FITPUB_DOMAIN}
|
||||||
|
base-url: ${FITPUB_BASE_URL}
|
||||||
|
|
||||||
|
activitypub:
|
||||||
|
enabled: true
|
||||||
|
max-federation-retries: 3
|
||||||
|
request-timeout-seconds: 30
|
||||||
|
|
||||||
|
security:
|
||||||
|
jwt:
|
||||||
|
# Must be configured via environment variables
|
||||||
|
secret: ${JWT_SECRET}
|
||||||
|
expiration: 86400000 # 24 hours
|
||||||
|
|
||||||
|
storage:
|
||||||
|
fit-files:
|
||||||
|
enabled: true
|
||||||
|
retention-days: 365
|
||||||
|
|
||||||
|
# Logging - minimal in production
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
org.operaton.fitpub: INFO
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
|
org.springframework.security: WARN
|
||||||
|
org.springframework.web: WARN
|
||||||
|
file:
|
||||||
|
name: /var/log/fitpub/application.log
|
||||||
|
max-size: 10MB
|
||||||
|
max-history: 30
|
||||||
|
|
||||||
|
# Server configuration
|
||||||
|
server:
|
||||||
|
port: ${PORT:8080}
|
||||||
|
error:
|
||||||
|
include-message: never # Don't expose error details
|
||||||
|
include-binding-errors: never
|
||||||
|
include-stacktrace: never
|
||||||
|
compression:
|
||||||
|
enabled: true
|
||||||
|
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
|
||||||
|
http2:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Actuator for monitoring (optional - configure with care)
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: when-authorized
|
||||||
|
|
@ -322,7 +322,7 @@ class FitFileServiceTest {
|
||||||
assertNotNull(savedActivity.getTrackPointsJson());
|
assertNotNull(savedActivity.getTrackPointsJson());
|
||||||
assertNotNull(savedActivity.getMetrics());
|
assertNotNull(savedActivity.getMetrics());
|
||||||
assertEquals(testParsedData.getTotalDistance(), savedActivity.getTotalDistance());
|
assertEquals(testParsedData.getTotalDistance(), savedActivity.getTotalDistance());
|
||||||
assertEquals(testParsedData.getTotalDuration(), savedActivity.getTotalDuration());
|
assertEquals(testParsedData.getTotalDuration().getSeconds(), savedActivity.getTotalDurationSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue