Spring Boot Setup, Start of ActivitiPub

This commit is contained in:
Tim Zöller 2025-11-27 23:31:03 +01:00
parent 0bc4fb3118
commit fe5bc54e92
21 changed files with 1695 additions and 9 deletions

104
CLAUDE.md
View file

@ -507,14 +507,102 @@ For ActivityPub federated posts and thumbnails:
## Development Roadmap
### Phase 1: MVP (Minimum Viable Product)
- [ ] FIT file upload and parsing
- [ ] Basic activity storage and display
- [ ] Interactive map rendering with Leaflet
- [ ] User registration and authentication
- [ ] ActivityPub actor profile implementation
- [ ] WebFinger support
- [ ] Basic federation (Create, Follow, Accept activities)
- [ ] Public timeline view
**System Component 1: FIT File Processing Module** ✅
- [x] FIT file upload and parsing (FitParser)
- [x] FIT file validation (FitFileValidator)
- [x] Activity entity with JSONB track points and simplified LineString
- [x] Activity metrics extraction and storage
- [x] Track simplification using Douglas-Peucker algorithm
- [x] FIT file service with comprehensive tests
- [x] Integration test with real FIT file
**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
**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)
**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
**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)
**WebFinger Support**
- [ ] WebFinger controller
- [ ] GET /.well-known/webfinger - User discovery
- [ ] WebFinger response DTO
- [ ] Account identifier parsing (acct:user@domain)
**ActivityPub Collections**
- [ ] GET /users/{username}/inbox - Inbox endpoint
- [ ] GET /users/{username}/outbox - Outbox endpoint
- [ ] GET /users/{username}/followers - Followers collection
- [ ] GET /users/{username}/following - Following collection
- [ ] OrderedCollection model classes
- [ ] Collection pagination support
**Basic Federation**
- [ ] Federation service for outbound activities
- [ ] HTTP signature signing for outbound requests
- [ ] HTTP signature verification for inbound requests
- [ ] Create activity - Post new workout
- [ ] Follow activity - Remote user follows local user
- [ ] Accept activity - Accept follow requests
- [ ] Follow entity and repository
- [ ] Remote actor entity and repository
- [ ] Inbox processor for incoming activities
**Public Timeline**
- [ ] Timeline service
- [ ] GET /api/timeline - Federated timeline
- [ ] Merge local and remote activities
- [ ] Timeline filtering and pagination
- [ ] Activity visibility enforcement
**Database Migrations**
- [ ] Flyway or Liquibase setup
- [ ] Initial schema migration (users table)
- [ ] Activities table migration
- [ ] Activity metrics table migration
- [ ] Follows table migration
- [ ] Remote actors table migration
- [ ] Indexes for performance (user lookups, activity queries)
- [ ] PostGIS spatial indexes
**Testing & Documentation**
- [ ] Integration tests for REST endpoints
- [ ] Integration tests for ActivityPub federation
- [ ] Integration tests for WebFinger
- [ ] README with setup instructions
- [ ] API documentation (Swagger/OpenAPI)
- [ ] Database setup guide
- [ ] Deployment instructions
### Phase 2: Social Features
- [ ] Likes and comments

15
pom.xml
View file

@ -54,6 +54,21 @@
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-spatial</artifactId>
</dependency>
<!-- Testcontainers for Dev Services -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
</dependency>
<dependency>
<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

@ -0,0 +1,36 @@
package org.operaton.fitpub;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.config.TestcontainersConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.client.RestTemplate;
/**
* Main Spring Boot application class for FitPub.
* FitPub is a federated fitness tracking platform that integrates with the Fediverse
* through the ActivityPub protocol.
*/
@SpringBootApplication
@EnableAsync
@Slf4j
@Import(TestcontainersConfiguration.class)
public class FitPubApplication {
public static void main(String[] args) {
SpringApplication.run(FitPubApplication.class, args);
log.info("FitPub application started successfully!");
log.info("Upload your FIT files and share your activities with the Fediverse!");
}
/**
* REST template for making HTTP requests to remote ActivityPub servers.
*/
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View file

@ -0,0 +1,117 @@
package org.operaton.fitpub.config;
import lombok.RequiredArgsConstructor;
import org.operaton.fitpub.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
* Security configuration for the application.
* Configures JWT-based authentication for REST API and public access for ActivityPub endpoints.
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
/**
* Configures the security filter chain.
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Disable CSRF for REST API
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()
.requestMatchers(HttpMethod.GET, "/users/**").permitAll()
.requestMatchers(HttpMethod.POST, "/users/*/inbox").permitAll()
// Public endpoints - Authentication
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/users/register").permitAll()
// Protected endpoints - Activities
.requestMatchers("/api/activities/**").authenticated()
// All other requests require authentication
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* Configures the password encoder.
* Uses BCrypt with default strength (10 rounds).
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Configures the authentication provider.
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* Exposes the authentication manager bean.
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
/**
* Configures CORS for frontend access.
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View file

@ -0,0 +1,36 @@
package org.operaton.fitpub.config;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
/**
* Testcontainers configuration for development using Spring Boot Dev Services.
* Automatically starts a PostgreSQL container with PostGIS extension when running in dev mode.
*
* This ensures development environment matches production (PostgreSQL + PostGIS).
*/
@Configuration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
/**
* PostgreSQL container with PostGIS extension.
* Uses postgis/postgis image which includes both PostgreSQL and PostGIS.
*
* @ServiceConnection automatically configures spring.datasource.* properties
*/
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(
DockerImageName.parse("postgis/postgis:16-3.4")
.asCompatibleSubstituteFor("postgres")
)
.withDatabaseName("fitpub")
.withUsername("fitpub")
.withPassword("fitpub")
.withReuse(true); // Reuse container across runs for faster startup
}
}

View file

@ -0,0 +1,172 @@
package org.operaton.fitpub.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.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.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* REST controller for activity management.
* Handles FIT file uploads, activity retrieval, updates, and deletion.
*/
@RestController
@RequestMapping("/api/activities")
@RequiredArgsConstructor
@Slf4j
public class ActivityController {
private final FitFileService fitFileService;
/**
* Uploads a FIT file and creates a new activity.
*
* @param file the FIT file
* @param request the upload request with metadata
* @param userDetails the authenticated user
* @return the created activity
*/
@PostMapping("/upload")
public ResponseEntity<ActivityDTO> uploadActivity(
@RequestParam("file") MultipartFile file,
@Valid @ModelAttribute ActivityUploadRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
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
Activity activity = fitFileService.processFitFile(
file,
userId,
request.getTitle(),
request.getDescription(),
request.getVisibility()
);
ActivityDTO dto = ActivityDTO.fromEntity(activity);
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
}
/**
* Retrieves an activity by ID.
*
* @param id the activity ID
* @param userDetails the authenticated user
* @return the activity
*/
@GetMapping("/{id}")
public ResponseEntity<ActivityDTO> getActivity(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails
) {
// TODO: Get actual user ID from UserDetails
UUID userId = UUID.randomUUID(); // Temporary
Activity activity = fitFileService.getActivity(id, userId);
if (activity == null) {
return ResponseEntity.notFound().build();
}
ActivityDTO dto = ActivityDTO.fromEntity(activity);
return ResponseEntity.ok(dto);
}
/**
* Lists all activities for the authenticated user.
*
* @param userDetails the authenticated user
* @return list of activities
*/
@GetMapping
public ResponseEntity<List<ActivityDTO>> getUserActivities(
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} retrieving activities", userDetails.getUsername());
// TODO: Get actual user ID from UserDetails
UUID userId = UUID.randomUUID(); // Temporary
List<Activity> activities = fitFileService.getUserActivities(userId);
List<ActivityDTO> dtos = activities.stream()
.map(ActivityDTO::fromEntity)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
/**
* Updates activity metadata.
*
* @param id the activity ID
* @param request the update request
* @param userDetails the authenticated user
* @return the updated activity
*/
@PutMapping("/{id}")
public ResponseEntity<ActivityDTO> updateActivity(
@PathVariable UUID id,
@Valid @RequestBody ActivityUpdateRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} updating activity {}", userDetails.getUsername(), id);
// TODO: Get actual user ID from UserDetails
UUID userId = UUID.randomUUID(); // Temporary
Activity activity = fitFileService.getActivity(id, userId);
if (activity == null) {
return ResponseEntity.notFound().build();
}
// Update fields
activity.setTitle(request.getTitle());
activity.setDescription(request.getDescription());
activity.setVisibility(request.getVisibility());
// TODO: Add update method to FitFileService
// Activity updated = fitFileService.updateActivity(activity);
ActivityDTO dto = ActivityDTO.fromEntity(activity);
return ResponseEntity.ok(dto);
}
/**
* Deletes an activity.
*
* @param id the activity ID
* @param userDetails the authenticated user
* @return no content
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteActivity(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} deleting activity {}", userDetails.getUsername(), id);
// TODO: Get actual user ID from UserDetails
UUID userId = UUID.randomUUID(); // Temporary
boolean deleted = fitFileService.deleteActivity(id, userId);
if (!deleted) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
}

View file

@ -0,0 +1,169 @@
package org.operaton.fitpub.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.activitypub.Actor;
import org.operaton.fitpub.model.activitypub.OrderedCollection;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
/**
* ActivityPub protocol controller.
* Implements ActivityPub server-to-server (S2S) protocol endpoints.
*
* Spec: https://www.w3.org/TR/activitypub/
*/
@RestController
@RequiredArgsConstructor
@Slf4j
public class ActivityPubController {
private final UserRepository userRepository;
@Value("${fitpub.base-url}")
private String baseUrl;
private static final String ACTIVITY_JSON = "application/activity+json";
private static final String LD_JSON = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
/**
* Actor profile endpoint.
* Returns the ActivityPub Actor object for a user.
*
* @param username the username
* @return Actor object in JSON-LD format
*/
@GetMapping(
value = "/users/{username}",
produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<Actor> getActor(@PathVariable String username) {
log.debug("ActivityPub actor request for user: {}", username);
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
log.warn("User not found for ActivityPub request: {}", username);
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
Actor actor = Actor.fromUser(user, baseUrl);
return ResponseEntity.ok(actor);
}
/**
* Inbox endpoint for receiving ActivityPub activities.
* POST /users/{username}/inbox
*
* @param username the username
* @param activity the incoming activity
* @return accepted response
*/
@PostMapping(
value = "/users/{username}/inbox",
consumes = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<Void> inbox(
@PathVariable String username,
@RequestBody Map<String, Object> activity,
@RequestHeader(value = "Signature", required = false) String signature
) {
log.info("Received ActivityPub activity for user {}: {}", username, activity.get("type"));
// TODO: Validate HTTP signature
// TODO: Process activity based on type (Follow, Like, Create, etc.)
// For MVP, just accept all activities
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
}
/**
* Outbox endpoint for user's activities.
* GET /users/{username}/outbox
*
* @param username the username
* @return OrderedCollection of activities
*/
@GetMapping(
value = "/users/{username}/outbox",
produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<OrderedCollection> outbox(@PathVariable String username) {
log.debug("Outbox request for user: {}", username);
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
String outboxUrl = baseUrl + "/users/" + username + "/outbox";
// TODO: Fetch actual activities from database
OrderedCollection collection = OrderedCollection.empty(outboxUrl);
return ResponseEntity.ok(collection);
}
/**
* Followers collection endpoint.
* GET /users/{username}/followers
*
* @param username the username
* @return OrderedCollection of followers
*/
@GetMapping(
value = "/users/{username}/followers",
produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<OrderedCollection> followers(@PathVariable String username) {
log.debug("Followers request for user: {}", username);
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
String followersUrl = baseUrl + "/users/" + username + "/followers";
// TODO: Fetch actual followers from database
OrderedCollection collection = OrderedCollection.empty(followersUrl);
return ResponseEntity.ok(collection);
}
/**
* Following collection endpoint.
* GET /users/{username}/following
*
* @param username the username
* @return OrderedCollection of following
*/
@GetMapping(
value = "/users/{username}/following",
produces = {ACTIVITY_JSON, LD_JSON, MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<OrderedCollection> following(@PathVariable String username) {
log.debug("Following request for user: {}", username);
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
String followingUrl = baseUrl + "/users/" + username + "/following";
// TODO: Fetch actual following from database
OrderedCollection collection = OrderedCollection.empty(followingUrl);
return ResponseEntity.ok(collection);
}
}

View file

@ -0,0 +1,98 @@
package org.operaton.fitpub.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.activitypub.WebFingerResponse;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
/**
* WebFinger controller for user discovery.
* Implements RFC 7033 WebFinger protocol.
*
* Example: /.well-known/webfinger?resource=acct:username@domain.com
*/
@RestController
@RequiredArgsConstructor
@Slf4j
public class WebFingerController {
private final UserRepository userRepository;
@Value("${fitpub.domain}")
private String domain;
@Value("${fitpub.base-url}")
private String baseUrl;
/**
* WebFinger endpoint for user discovery.
*
* @param resource the resource identifier (acct:username@domain)
* @return WebFinger response
*/
@GetMapping(value = "/.well-known/webfinger", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<WebFingerResponse> webfinger(@RequestParam("resource") String resource) {
log.debug("WebFinger request for resource: {}", resource);
// Parse resource identifier
String username = parseUsername(resource);
if (username == null) {
log.warn("Invalid WebFinger resource format: {}", resource);
return ResponseEntity.badRequest().build();
}
// Look up user
Optional<User> userOpt = userRepository.findByUsername(username);
if (userOpt.isEmpty()) {
log.warn("User not found for WebFinger request: {}", username);
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
String actorUrl = user.getActorUri(baseUrl);
WebFingerResponse response = WebFingerResponse.forUser(username, domain, actorUrl);
return ResponseEntity.ok(response);
}
/**
* Parses the username from an acct: URI.
*
* @param resource the resource URI (acct:username@domain)
* @return the username or null if invalid
*/
private String parseUsername(String resource) {
if (!resource.startsWith("acct:")) {
return null;
}
// Remove "acct:" prefix
String acct = resource.substring(5);
// Split on @
String[] parts = acct.split("@");
if (parts.length != 2) {
return null;
}
String username = parts[0];
String requestedDomain = parts[1];
// Verify domain matches
if (!requestedDomain.equalsIgnoreCase(domain)) {
log.warn("WebFinger request for different domain: {} (ours: {})", requestedDomain, domain);
return null;
}
return username;
}
}

View file

@ -0,0 +1,102 @@
package org.operaton.fitpub.model.activitypub;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* ActivityPub Actor object.
* Represents a user's ActivityPub profile.
*
* Spec: https://www.w3.org/TR/activitypub/#actors
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Actor {
@JsonProperty("@context")
private Object context;
private String type;
private String id;
private String preferredUsername;
private String name;
private String summary;
private String inbox;
private String outbox;
private String followers;
private String following;
private PublicKey publicKey;
private Image icon;
private String url;
/**
* Creates an Actor from a User entity.
*/
public static Actor fromUser(org.operaton.fitpub.model.entity.User user, String baseUrl) {
String actorUri = user.getActorUri(baseUrl);
return Actor.builder()
.context(List.of(
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
))
.type("Person")
.id(actorUri)
.preferredUsername(user.getUsername())
.name(user.getDisplayName() != null ? user.getDisplayName() : user.getUsername())
.summary(user.getBio())
.inbox(actorUri + "/inbox")
.outbox(actorUri + "/outbox")
.followers(actorUri + "/followers")
.following(actorUri + "/following")
.publicKey(PublicKey.builder()
.id(actorUri + "#main-key")
.owner(actorUri)
.publicKeyPem(user.getPublicKey())
.build())
.icon(user.getAvatarUrl() != null ? Image.builder()
.type("Image")
.mediaType("image/jpeg")
.url(user.getAvatarUrl())
.build() : null)
.url(baseUrl + "/@" + user.getUsername())
.build();
}
/**
* Public key object for HTTP signature verification.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class PublicKey {
private String id;
private String owner;
private String publicKeyPem;
}
/**
* Image object for avatars.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Image {
private String type;
private String mediaType;
private String url;
}
}

View file

@ -0,0 +1,60 @@
package org.operaton.fitpub.model.activitypub;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* ActivityPub OrderedCollection.
* Used for inbox, outbox, followers, and following collections.
*
* Spec: https://www.w3.org/TR/activitystreams-core/#collections
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OrderedCollection {
@JsonProperty("@context")
private String context;
private String type;
private String id;
private Integer totalItems;
private String first;
private String last;
private List<Object> orderedItems;
/**
* Creates an empty OrderedCollection.
*/
public static OrderedCollection empty(String id) {
return OrderedCollection.builder()
.context("https://www.w3.org/ns/activitystreams")
.type("OrderedCollection")
.id(id)
.totalItems(0)
.orderedItems(List.of())
.build();
}
/**
* Creates an OrderedCollection with items.
*/
public static OrderedCollection of(String id, List<Object> items) {
return OrderedCollection.builder()
.context("https://www.w3.org/ns/activitystreams")
.type("OrderedCollection")
.id(id)
.totalItems(items.size())
.orderedItems(items)
.build();
}
}

View file

@ -0,0 +1,65 @@
package org.operaton.fitpub.model.activitypub;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* WebFinger response format (RFC 7033).
* Used for user discovery in ActivityPub.
*
* Spec: https://datatracker.ietf.org/doc/html/rfc7033
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class WebFingerResponse {
private String subject;
private List<String> aliases;
private List<Link> links;
/**
* Creates a WebFinger response for a user.
*/
public static WebFingerResponse forUser(String username, String domain, String actorUrl) {
String acctUri = String.format("acct:%s@%s", username, domain);
return WebFingerResponse.builder()
.subject(acctUri)
.aliases(List.of(actorUrl))
.links(List.of(
Link.builder()
.rel("self")
.type("application/activity+json")
.href(actorUrl)
.build(),
Link.builder()
.rel("http://webfinger.net/rel/profile-page")
.type("text/html")
.href(actorUrl)
.build()
))
.build();
}
/**
* WebFinger link object.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class Link {
private String rel;
private String type;
private String href;
}
}

View file

@ -0,0 +1,29 @@
package org.operaton.fitpub.model.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.Activity;
/**
* Request DTO for updating activity metadata.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActivityUpdateRequest {
@NotNull(message = "Title is required")
@Size(min = 1, max = 200, message = "Title must be between 1 and 200 characters")
private String title;
@Size(max = 5000, message = "Description must not exceed 5000 characters")
private String description;
@NotNull(message = "Visibility is required")
private Activity.Visibility visibility;
}

View file

@ -0,0 +1,29 @@
package org.operaton.fitpub.model.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.Activity;
/**
* Request DTO for uploading a new activity.
* Used with multipart file upload.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ActivityUploadRequest {
@Size(max = 200, message = "Title must not exceed 200 characters")
private String title;
@Size(max = 5000, message = "Description must not exceed 5000 characters")
private String description;
@NotNull(message = "Visibility is required")
private Activity.Visibility visibility;
}

View file

@ -90,8 +90,8 @@ public class Activity {
* Original FIT file for re-processing if needed.
* Allows us to re-parse with updated algorithms.
*/
@Column(name = "raw_fit_file")
@Lob
@Column(name = "raw_fit_file")
private byte[] rawFitFile;
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)

View file

@ -0,0 +1,94 @@
package org.operaton.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* User entity representing a local user account.
* Each user has an ActivityPub Actor profile for federation.
*/
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_user_username", columnList = "username", unique = true),
@Index(name = "idx_user_email", columnList = "email", unique = true)
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(name = "display_name", length = 100)
private String displayName;
@Column(columnDefinition = "TEXT")
private String bio;
@Column(name = "avatar_url")
private String avatarUrl;
/**
* RSA public key for ActivityPub HTTP Signatures.
* Used by remote servers to verify signed requests from this user.
*/
@Column(name = "public_key", columnDefinition = "TEXT", nullable = false)
private String publicKey;
/**
* RSA private key for signing ActivityPub requests.
* Encrypted at rest (handled by application layer).
*/
@Column(name = "private_key", columnDefinition = "TEXT", nullable = false)
private String privateKey;
@Column(nullable = false)
@Builder.Default
private boolean enabled = true;
@Column(nullable = false)
@Builder.Default
private boolean locked = false;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* Gets the ActivityPub actor URI for this user.
* Format: https://domain/users/{username}
*/
public String getActorUri(String baseUrl) {
return String.format("%s/users/%s", baseUrl, username);
}
/**
* Gets the WebFinger account identifier.
* Format: acct:username@domain
*/
public String getWebFingerAccount(String domain) {
return String.format("acct:%s@%s", username, domain);
}
}

View file

@ -0,0 +1,60 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Repository for User entity.
* Provides methods for user authentication and discovery.
*/
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
/**
* Finds a user by username.
* Used for login and WebFinger discovery.
*
* @param username the username
* @return optional user
*/
Optional<User> findByUsername(String username);
/**
* Finds a user by email.
* Used for login and duplicate email checking.
*
* @param email the email address
* @return optional user
*/
Optional<User> findByEmail(String email);
/**
* Checks if a username already exists.
* Used during registration.
*
* @param username the username to check
* @return true if username exists
*/
boolean existsByUsername(String username);
/**
* Checks if an email already exists.
* Used during registration.
*
* @param email the email to check
* @return true if email exists
*/
boolean existsByEmail(String email);
/**
* Finds all enabled users.
* Used for administrative purposes.
*
* @return list of enabled users
*/
Optional<User> findByUsernameAndEnabledTrue(String username);
}

View file

@ -0,0 +1,199 @@
package org.operaton.fitpub.security;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Validates HTTP Signatures for ActivityPub federation.
* Implements the HTTP Signatures specification used by ActivityPub.
*
* Reference: https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class HttpSignatureValidator {
private static final Pattern SIGNATURE_PATTERN = Pattern.compile(
"keyId=\"([^\"]+)\",algorithm=\"([^\"]+)\",headers=\"([^\"]+)\",signature=\"([^\"]+)\""
);
/**
* Validates an HTTP signature.
*
* @param signatureHeader the Signature header value
* @param headers the HTTP headers map
* @param publicKeyPem the actor's public key in PEM format
* @return true if signature is valid
*/
public boolean validate(String signatureHeader, Map<String, String> headers, String publicKeyPem) {
try {
// Parse signature header
Matcher matcher = SIGNATURE_PATTERN.matcher(signatureHeader);
if (!matcher.find()) {
log.warn("Invalid signature header format");
return false;
}
String keyId = matcher.group(1);
String algorithm = matcher.group(2);
String headersString = matcher.group(3);
String signatureBase64 = matcher.group(4);
// Build signing string from specified headers
String signingString = buildSigningString(headersString, headers);
// Decode signature
byte[] signature = Base64.getDecoder().decode(signatureBase64);
// Parse public key
PublicKey publicKey = parsePublicKey(publicKeyPem);
// Verify signature
return verifySignature(signingString, signature, publicKey, algorithm);
} catch (Exception e) {
log.error("Error validating HTTP signature", e);
return false;
}
}
/**
* Builds the signing string from the specified headers.
*
* @param headersString space-separated list of header names
* @param headers the actual header values
* @return the signing string
*/
private String buildSigningString(String headersString, Map<String, String> headers) {
String[] headerNames = headersString.split(" ");
StringBuilder signingString = new StringBuilder();
for (int i = 0; i < headerNames.length; i++) {
String headerName = headerNames[i].toLowerCase();
String headerValue = headers.get(headerName);
if (headerValue == null) {
log.warn("Header {} specified in signature but not found in request", headerName);
headerValue = "";
}
// Special handling for (request-target) pseudo-header
if (headerName.equals("(request-target)")) {
signingString.append(headerName).append(": ").append(headerValue);
} else {
signingString.append(headerName).append(": ").append(headerValue);
}
if (i < headerNames.length - 1) {
signingString.append("\n");
}
}
return signingString.toString();
}
/**
* Parses a PEM-encoded public key.
*
* @param publicKeyPem the public key in PEM format
* @return the PublicKey object
*/
private PublicKey parsePublicKey(String publicKeyPem) throws Exception {
// Remove PEM headers and whitespace
String publicKeyContent = publicKeyPem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
// Decode Base64
byte[] keyBytes = Base64.getDecoder().decode(publicKeyContent);
// Create PublicKey
X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(spec);
}
/**
* Verifies the signature using the public key.
*
* @param signingString the string that was signed
* @param signature the signature bytes
* @param publicKey the public key
* @param algorithm the signature algorithm
* @return true if signature is valid
*/
private boolean verifySignature(String signingString, byte[] signature, PublicKey publicKey, String algorithm)
throws Exception {
// Map ActivityPub algorithm names to Java algorithm names
String javaAlgorithm = mapAlgorithm(algorithm);
Signature sig = Signature.getInstance(javaAlgorithm);
sig.initVerify(publicKey);
sig.update(signingString.getBytes(StandardCharsets.UTF_8));
return sig.verify(signature);
}
/**
* Maps ActivityPub algorithm names to Java Signature algorithm names.
*
* @param activityPubAlgorithm the ActivityPub algorithm name
* @return the Java algorithm name
*/
private String mapAlgorithm(String activityPubAlgorithm) {
switch (activityPubAlgorithm.toLowerCase()) {
case "rsa-sha256":
return "SHA256withRSA";
case "rsa-sha512":
return "SHA512withRSA";
default:
log.warn("Unknown signature algorithm: {}, defaulting to SHA256withRSA", activityPubAlgorithm);
return "SHA256withRSA";
}
}
/**
* Signs a message using a private key.
* Used for outbound federation requests.
*
* @param signingString the string to sign
* @param privateKeyPem the private key in PEM format
* @return Base64-encoded signature
*/
public String sign(String signingString, String privateKeyPem) throws Exception {
// Parse private key
String privateKeyContent = privateKeyPem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
.replace("-----END RSA PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent);
java.security.spec.PKCS8EncodedKeySpec spec = new java.security.spec.PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
java.security.PrivateKey privateKey = keyFactory.generatePrivate(spec);
// Sign
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(signingString.getBytes(StandardCharsets.UTF_8));
byte[] signature = sig.sign();
return Base64.getEncoder().encodeToString(signature);
}
}

View file

@ -0,0 +1,73 @@
package org.operaton.fitpub.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT authentication filter that validates JWT tokens on each request.
* Extracts the token from the Authorization header and authenticates the user.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (jwt != null && tokenProvider.validateToken(jwt)) {
String username = tokenProvider.getUsername(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Set authentication for user: {}", username);
}
} catch (Exception e) {
log.error("Could not set user authentication in security context", e);
}
filterChain.doFilter(request, response);
}
/**
* Extracts the JWT token from the Authorization header.
*
* @param request the HTTP request
* @return the JWT token or null if not found
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
return tokenProvider.resolveToken(bearerToken);
}
}

View file

@ -0,0 +1,113 @@
package org.operaton.fitpub.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT token provider for creating and validating authentication tokens.
* Tokens are used for session management in the REST API.
*/
@Component
@Slf4j
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long validityInMilliseconds;
public JwtTokenProvider(
@Value("${fitpub.security.jwt.secret}") String secret,
@Value("${fitpub.security.jwt.expiration:86400000}") long validityInMilliseconds
) {
// Ensure secret is long enough for HS256 (at least 256 bits / 32 bytes)
if (secret.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalArgumentException("JWT secret must be at least 32 characters long");
}
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds;
}
/**
* Creates a JWT token for an authenticated user.
*
* @param authentication the authentication object
* @return JWT token string
*/
public String createToken(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return createToken(userDetails.getUsername());
}
/**
* Creates a JWT token for a username.
*
* @param username the username
* @return JWT token string
*/
public String createToken(String username) {
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
/**
* Extracts the username from a JWT token.
*
* @param token the JWT token
* @return username
*/
public String getUsername(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
/**
* Validates a JWT token.
*
* @param token the JWT token
* @return true if token is valid
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.warn("Invalid JWT token: {}", e.getMessage());
return false;
}
}
/**
* Extracts the token from the Authorization header.
*
* @param bearerToken the Authorization header value
* @return the token or null if not found
*/
public String resolveToken(String bearerToken) {
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

View file

@ -0,0 +1,57 @@
package org.operaton.fitpub.security;
import lombok.RequiredArgsConstructor;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.Collections;
/**
* Custom UserDetailsService implementation for Spring Security.
* Loads user-specific data from the database for authentication.
*/
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPasswordHash(),
user.isEnabled(),
true, // accountNonExpired
true, // credentialsNonExpired
!user.isLocked(), // accountNonLocked
getAuthorities(user)
);
}
/**
* Gets the authorities for a user.
* Currently, all users have the same ROLE_USER authority.
* This can be extended to support roles and permissions in the future.
*
* @param user the user
* @return collection of granted authorities
*/
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
// For MVP, all users have the same role
// Future: Add roles/permissions to User entity
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
}
}

View file

@ -0,0 +1,74 @@
spring:
application:
name: fitpub
# Datasource configuration is handled by Testcontainers in dev mode
# For production, set these via environment variables:
# - SPRING_DATASOURCE_URL
# - SPRING_DATASOURCE_USERNAME
# - SPRING_DATASOURCE_PASSWORD
datasource:
url: ${SPRING_DATASOURCE_URL:}
username: ${SPRING_DATASOURCE_USERNAME:}
password: ${SPRING_DATASOURCE_PASSWORD:}
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
use_sql_comments: true
show-sql: false
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
enabled: true
jackson:
serialization:
write-dates-as-timestamps: false
time-zone: UTC
# FitPub specific configuration
fitpub:
# Domain and URL configuration
domain: ${FITPUB_DOMAIN:localhost:8080}
base-url: ${FITPUB_BASE_URL:http://localhost:8080}
# ActivityPub federation settings
activitypub:
enabled: true
max-federation-retries: 3
request-timeout-seconds: 30
# Security settings
security:
jwt:
secret: ${JWT_SECRET:change-this-secret-key-in-production-must-be-at-least-32-characters-long}
expiration: 86400000 # 24 hours in milliseconds
# Storage settings
storage:
fit-files:
enabled: true
retention-days: 365
# Logging configuration
logging:
level:
root: INFO
org.operaton.fitpub: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.springframework.security: DEBUG
# Server configuration
server:
port: ${PORT:8080}
error:
include-message: always
include-binding-errors: always