diff --git a/CLAUDE.md b/CLAUDE.md index f056272..b8db20d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -517,47 +517,49 @@ For ActivityPub federated posts and thumbnails: - [x] FIT file service with comprehensive tests - [x] Integration test with real FIT file -**User Management & Security** +**User Management & Security** ✅ - [x] User entity with ActivityPub keys -- [ ] UserRepository with custom queries -- [ ] Password hashing with BCrypt -- [ ] JWT token provider for session management -- [ ] HTTP Signature validator for ActivityPub federation -- [ ] UserDetailsService implementation -- [ ] Security configuration (Spring Security) -- [ ] User registration endpoint -- [ ] Login endpoint with JWT response +- [x] UserRepository with custom queries +- [x] Password hashing with BCrypt +- [x] JWT token provider for session management +- [x] HTTP Signature validator for ActivityPub federation +- [x] UserDetailsService implementation +- [x] Security configuration (Spring Security) +- [x] User registration endpoint (POST /api/auth/register) +- [x] Login endpoint with JWT response (POST /api/auth/login) -**Application Infrastructure** -- [ ] Main application class (FitPubApplication.java) -- [ ] Application configuration (application.yml) -- [ ] Database configuration (PostgreSQL + PostGIS) -- [ ] CORS configuration for frontend -- [ ] Exception handling (global error handlers) -- [ ] Logging configuration -- [ ] Profile-specific configs (dev, prod) +**Application Infrastructure** ✅ +- [x] Main application class (FitPubApplication.java) +- [x] Application configuration (application.yml) +- [x] Database configuration (PostgreSQL + PostGIS with Testcontainers Dev Services) +- [x] CORS configuration for frontend +- [x] Exception handling (global error handlers) +- [x] Logging configuration +- [x] Profile-specific configs (application-dev.yml, application-prod.yml) -**Activity REST API** -- [ ] POST /api/activities/upload - Upload FIT file -- [ ] GET /api/activities/{id} - Get activity details -- [ ] GET /api/activities - List user's activities (paginated) -- [ ] PUT /api/activities/{id} - Update activity metadata -- [ ] DELETE /api/activities/{id} - Delete activity -- [ ] Activity DTOs for API responses -- [ ] Controller tests +**Activity REST API** ✅ +- [x] POST /api/activities/upload - Upload FIT file +- [x] GET /api/activities/{id} - Get activity details +- [x] GET /api/activities - List user's activities (paginated) +- [x] PUT /api/activities/{id} - Update activity metadata +- [x] DELETE /api/activities/{id} - Delete activity +- [x] Activity DTOs for API responses +- [x] All endpoints tested and working -**ActivityPub Actor Profile** -- [ ] Actor model classes (Person, PublicKey) -- [ ] GET /users/{username} - Actor profile endpoint -- [ ] Actor JSON-LD serialization with @context -- [ ] Public key embedding in actor profile -- [ ] Profile metadata (name, bio, avatar) +**ActivityPub Actor Profile** ✅ +- [x] Actor model classes (Person, PublicKey) +- [x] GET /users/{username} - Actor profile endpoint +- [x] Actor JSON-LD serialization with @context +- [x] Public key embedding in actor profile +- [x] Profile metadata (name, bio, avatar) +- [x] Tested with application/activity+json and application/ld+json -**WebFinger Support** -- [ ] WebFinger controller -- [ ] GET /.well-known/webfinger - User discovery -- [ ] WebFinger response DTO -- [ ] Account identifier parsing (acct:user@domain) +**WebFinger Support** ✅ +- [x] WebFinger controller +- [x] GET /.well-known/webfinger - User discovery +- [x] WebFinger response DTO +- [x] Account identifier parsing (acct:user@domain) +- [x] Tested with valid and invalid requests **ActivityPub Collections** - [ ] GET /users/{username}/inbox - Inbox endpoint @@ -586,7 +588,7 @@ For ActivityPub federated posts and thumbnails: - [ ] Activity visibility enforcement **Database Migrations** -- [ ] Flyway or Liquibase setup +- [ ] Flyway setup - [ ] Initial schema migration (users table) - [ ] Activities table migration - [ ] Activity metrics table migration diff --git a/pom.xml b/pom.xml index d77914a..a1117f0 100644 --- a/pom.xml +++ b/pom.xml @@ -64,11 +64,6 @@ org.testcontainers postgresql - - org.springframework.boot - spring-boot-devtools - true - io.hypersistence hypersistence-utils-hibernate-63 diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java index 0cfc79b..04b9790 100644 --- a/src/main/java/org/operaton/fitpub/controller/ActivityController.java +++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java @@ -7,11 +7,14 @@ import org.operaton.fitpub.model.dto.ActivityDTO; import org.operaton.fitpub.model.dto.ActivityUpdateRequest; import org.operaton.fitpub.model.dto.ActivityUploadRequest; import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.UserRepository; import org.operaton.fitpub.service.FitFileService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -30,6 +33,20 @@ import java.util.stream.Collectors; public class ActivityController { 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. @@ -47,8 +64,7 @@ public class ActivityController { ) { log.info("User {} uploading FIT file: {}", userDetails.getUsername(), file.getOriginalFilename()); - // TODO: Get actual user ID from UserDetails - UUID userId = UUID.randomUUID(); // Temporary - will be replaced with actual user lookup + UUID userId = getUserId(userDetails); Activity activity = fitFileService.processFitFile( file, @@ -74,8 +90,7 @@ public class ActivityController { @PathVariable UUID id, @AuthenticationPrincipal UserDetails userDetails ) { - // TODO: Get actual user ID from UserDetails - UUID userId = UUID.randomUUID(); // Temporary + UUID userId = getUserId(userDetails); Activity activity = fitFileService.getActivity(id, userId); if (activity == null) { @@ -98,8 +113,7 @@ public class ActivityController { ) { log.info("User {} retrieving activities", userDetails.getUsername()); - // TODO: Get actual user ID from UserDetails - UUID userId = UUID.randomUUID(); // Temporary + UUID userId = getUserId(userDetails); List activities = fitFileService.getUserActivities(userId); List dtos = activities.stream() @@ -125,8 +139,7 @@ public class ActivityController { ) { log.info("User {} updating activity {}", userDetails.getUsername(), id); - // TODO: Get actual user ID from UserDetails - UUID userId = UUID.randomUUID(); // Temporary + UUID userId = getUserId(userDetails); Activity activity = fitFileService.getActivity(id, userId); if (activity == null) { @@ -138,10 +151,9 @@ public class ActivityController { activity.setDescription(request.getDescription()); 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); } @@ -159,8 +171,7 @@ public class ActivityController { ) { log.info("User {} deleting activity {}", userDetails.getUsername(), id); - // TODO: Get actual user ID from UserDetails - UUID userId = UUID.randomUUID(); // Temporary + UUID userId = getUserId(userDetails); boolean deleted = fitFileService.deleteActivity(id, userId); if (!deleted) { diff --git a/src/main/java/org/operaton/fitpub/controller/AuthController.java b/src/main/java/org/operaton/fitpub/controller/AuthController.java new file mode 100644 index 0000000..f06c1e0 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/controller/AuthController.java @@ -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 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 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 handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("BAD_REQUEST", e.getMessage())); + } + + /** + * Exception handler for BadCredentialsException. + */ + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials(BadCredentialsException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("UNAUTHORIZED", e.getMessage())); + } + + /** + * Error response DTO. + */ + record ErrorResponse(String error, String message) {} +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java index e50a67a..658045a 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java @@ -55,8 +55,8 @@ public class ActivityDTO { .createdAt(activity.getCreatedAt()) .updatedAt(activity.getUpdatedAt()); - if (activity.getTotalDuration() != null) { - builder.totalDurationSeconds(activity.getTotalDuration().getSeconds()); + if (activity.getTotalDurationSeconds() != null) { + builder.totalDurationSeconds(activity.getTotalDurationSeconds()); } if (activity.getMetrics() != null) { diff --git a/src/main/java/org/operaton/fitpub/model/dto/ActivityMetricsDTO.java b/src/main/java/org/operaton/fitpub/model/dto/ActivityMetricsDTO.java index 35b01e7..f3549ac 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/ActivityMetricsDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/ActivityMetricsDTO.java @@ -61,16 +61,16 @@ public class ActivityMetricsDTO { .totalSteps(metrics.getTotalSteps()) .trainingStressScore(metrics.getTrainingStressScore()); - if (metrics.getAveragePace() != null) { - builder.averagePaceSeconds(metrics.getAveragePace().getSeconds()); + if (metrics.getAveragePaceSeconds() != null) { + builder.averagePaceSeconds(metrics.getAveragePaceSeconds()); } - if (metrics.getMovingTime() != null) { - builder.movingTimeSeconds(metrics.getMovingTime().getSeconds()); + if (metrics.getMovingTimeSeconds() != null) { + builder.movingTimeSeconds(metrics.getMovingTimeSeconds()); } - if (metrics.getStoppedTime() != null) { - builder.stoppedTimeSeconds(metrics.getStoppedTime().getSeconds()); + if (metrics.getStoppedTimeSeconds() != null) { + builder.stoppedTimeSeconds(metrics.getStoppedTimeSeconds()); } return builder.build(); diff --git a/src/main/java/org/operaton/fitpub/model/dto/AuthResponse.java b/src/main/java/org/operaton/fitpub/model/dto/AuthResponse.java new file mode 100644 index 0000000..94486bb --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/AuthResponse.java @@ -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; +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/LoginRequest.java b/src/main/java/org/operaton/fitpub/model/dto/LoginRequest.java new file mode 100644 index 0000000..f7ebb3c --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/LoginRequest.java @@ -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; +} diff --git a/src/main/java/org/operaton/fitpub/model/dto/RegisterRequest.java b/src/main/java/org/operaton/fitpub/model/dto/RegisterRequest.java new file mode 100644 index 0000000..d2930f3 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/model/dto/RegisterRequest.java @@ -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; +} diff --git a/src/main/java/org/operaton/fitpub/model/entity/Activity.java b/src/main/java/org/operaton/fitpub/model/entity/Activity.java index de8097d..2f1d5af 100644 --- a/src/main/java/org/operaton/fitpub/model/entity/Activity.java +++ b/src/main/java/org/operaton/fitpub/model/entity/Activity.java @@ -9,7 +9,6 @@ import org.hibernate.annotations.UpdateTimestamp; import org.locationtech.jts.geom.LineString; import java.math.BigDecimal; -import java.time.Duration; import java.time.LocalDateTime; import java.util.UUID; @@ -77,8 +76,8 @@ public class Activity { @Column(name = "total_distance", precision = 10, scale = 2) private BigDecimal totalDistance; - @Column(name = "total_duration") - private Duration totalDuration; + @Column(name = "total_duration_seconds") + private Long totalDurationSeconds; @Column(name = "elevation_gain", precision = 8, scale = 2) private BigDecimal elevationGain; diff --git a/src/main/java/org/operaton/fitpub/model/entity/ActivityMetrics.java b/src/main/java/org/operaton/fitpub/model/entity/ActivityMetrics.java index 74e72b8..6d90219 100644 --- a/src/main/java/org/operaton/fitpub/model/entity/ActivityMetrics.java +++ b/src/main/java/org/operaton/fitpub/model/entity/ActivityMetrics.java @@ -4,7 +4,6 @@ import jakarta.persistence.*; import lombok.*; import java.math.BigDecimal; -import java.time.Duration; import java.util.UUID; /** @@ -34,8 +33,8 @@ public class ActivityMetrics { @Column(name = "max_speed", precision = 8, scale = 2) private BigDecimal maxSpeed; - @Column(name = "average_pace") - private Duration averagePace; + @Column(name = "average_pace_seconds") + private Long averagePaceSeconds; @Column(name = "average_heart_rate") private Integer averageHeartRate; @@ -76,11 +75,11 @@ public class ActivityMetrics { @Column(name = "total_descent", precision = 8, scale = 2) private BigDecimal totalDescent; - @Column(name = "moving_time") - private Duration movingTime; + @Column(name = "moving_time_seconds") + private Long movingTimeSeconds; - @Column(name = "stopped_time") - private Duration stoppedTime; + @Column(name = "stopped_time_seconds") + private Long stoppedTimeSeconds; @Column(name = "total_steps") private Integer totalSteps; diff --git a/src/main/java/org/operaton/fitpub/service/FitFileService.java b/src/main/java/org/operaton/fitpub/service/FitFileService.java index 1c4a97f..e3f5228 100644 --- a/src/main/java/org/operaton/fitpub/service/FitFileService.java +++ b/src/main/java/org/operaton/fitpub/service/FitFileService.java @@ -148,7 +148,7 @@ public class FitFileService { .endedAt(parsedData.getEndTime()) .visibility(visibility) .totalDistance(parsedData.getTotalDistance()) - .totalDuration(parsedData.getTotalDuration()) + .totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null) .elevationGain(parsedData.getElevationGain()) .elevationLoss(parsedData.getElevationLoss()) .rawFitFile(rawFile) @@ -346,4 +346,29 @@ public class FitFileService { public List getUserActivities(UUID 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); + } } diff --git a/src/main/java/org/operaton/fitpub/service/UserService.java b/src/main/java/org/operaton/fitpub/service/UserService.java new file mode 100644 index 0000000..ee3e652 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/UserService.java @@ -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-----"; + } +} diff --git a/src/main/java/org/operaton/fitpub/util/FitParser.java b/src/main/java/org/operaton/fitpub/util/FitParser.java index 583f132..453cb87 100644 --- a/src/main/java/org/operaton/fitpub/util/FitParser.java +++ b/src/main/java/org/operaton/fitpub/util/FitParser.java @@ -361,7 +361,7 @@ public class FitParser { .activity(activity) .averageSpeed(averageSpeed) .maxSpeed(maxSpeed) - .averagePace(averagePace) + .averagePaceSeconds(averagePace != null ? averagePace.getSeconds() : null) .averageHeartRate(averageHeartRate) .maxHeartRate(maxHeartRate) .averageCadence(averageCadence) @@ -375,8 +375,8 @@ public class FitParser { .minElevation(minElevation) .totalAscent(totalAscent) .totalDescent(totalDescent) - .movingTime(movingTime) - .stoppedTime(stoppedTime) + .movingTimeSeconds(movingTime != null ? movingTime.getSeconds() : null) + .stoppedTimeSeconds(stoppedTime != null ? stoppedTime.getSeconds() : null) .totalSteps(totalSteps) .build(); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..4551165 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -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 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..ca9fdbb --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -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 diff --git a/src/test/java/org/operaton/fitpub/service/FitFileServiceTest.java b/src/test/java/org/operaton/fitpub/service/FitFileServiceTest.java index 31670b0..85c5efe 100644 --- a/src/test/java/org/operaton/fitpub/service/FitFileServiceTest.java +++ b/src/test/java/org/operaton/fitpub/service/FitFileServiceTest.java @@ -322,7 +322,7 @@ class FitFileServiceTest { assertNotNull(savedActivity.getTrackPointsJson()); assertNotNull(savedActivity.getMetrics()); assertEquals(testParsedData.getTotalDistance(), savedActivity.getTotalDistance()); - assertEquals(testParsedData.getTotalDuration(), savedActivity.getTotalDuration()); + assertEquals(testParsedData.getTotalDuration().getSeconds(), savedActivity.getTotalDurationSeconds()); } /**