diff --git a/CLAUDE.md b/CLAUDE.md
index d46d152..f056272 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/pom.xml b/pom.xml
index 6a794bf..d77914a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -54,6 +54,21 @@
org.hibernate.orm
hibernate-spatial
+
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+
+
+ 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/FitPubApplication.java b/src/main/java/org/operaton/fitpub/FitPubApplication.java
new file mode 100644
index 0000000..54bbee4
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/FitPubApplication.java
@@ -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();
+ }
+}
diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java
new file mode 100644
index 0000000..7fe13f7
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java
@@ -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;
+ }
+}
diff --git a/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java b/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java
new file mode 100644
index 0000000..761b4b4
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/config/TestcontainersConfiguration.java
@@ -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
+ }
+}
diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java
new file mode 100644
index 0000000..0cfc79b
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java
@@ -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 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 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> getUserActivities(
+ @AuthenticationPrincipal UserDetails userDetails
+ ) {
+ log.info("User {} retrieving activities", userDetails.getUsername());
+
+ // TODO: Get actual user ID from UserDetails
+ UUID userId = UUID.randomUUID(); // Temporary
+
+ List activities = fitFileService.getUserActivities(userId);
+ List 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 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 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();
+ }
+}
diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java b/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java
new file mode 100644
index 0000000..7568a15
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/controller/ActivityPubController.java
@@ -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 getActor(@PathVariable String username) {
+ log.debug("ActivityPub actor request for user: {}", username);
+
+ Optional 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 inbox(
+ @PathVariable String username,
+ @RequestBody Map 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 outbox(@PathVariable String username) {
+ log.debug("Outbox request for user: {}", username);
+
+ Optional 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 followers(@PathVariable String username) {
+ log.debug("Followers request for user: {}", username);
+
+ Optional 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 following(@PathVariable String username) {
+ log.debug("Following request for user: {}", username);
+
+ Optional 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);
+ }
+}
diff --git a/src/main/java/org/operaton/fitpub/controller/WebFingerController.java b/src/main/java/org/operaton/fitpub/controller/WebFingerController.java
new file mode 100644
index 0000000..4083325
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/controller/WebFingerController.java
@@ -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 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 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;
+ }
+}
diff --git a/src/main/java/org/operaton/fitpub/model/activitypub/Actor.java b/src/main/java/org/operaton/fitpub/model/activitypub/Actor.java
new file mode 100644
index 0000000..d9bcaf3
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/model/activitypub/Actor.java
@@ -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;
+ }
+}
diff --git a/src/main/java/org/operaton/fitpub/model/activitypub/OrderedCollection.java b/src/main/java/org/operaton/fitpub/model/activitypub/OrderedCollection.java
new file mode 100644
index 0000000..f17376e
--- /dev/null
+++ b/src/main/java/org/operaton/fitpub/model/activitypub/OrderedCollection.java
@@ -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