More ActivityPub

This commit is contained in:
Tim Zöller 2025-11-28 09:00:07 +01:00
parent fe5bc54e92
commit 1901daf5ce
17 changed files with 593 additions and 78 deletions

View file

@ -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

View file

@ -64,11 +64,6 @@
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-63</artifactId>

View file

@ -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<Activity> activities = fitFileService.getUserActivities(userId);
List<ActivityDTO> 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) {

View file

@ -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) {}
}

View file

@ -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) {

View file

@ -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();

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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<Activity> 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);
}
}

View 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-----";
}
}

View file

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

View 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

View 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

View file

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