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