Spring Boot Setup, Start of ActivitiPub
This commit is contained in:
parent
0bc4fb3118
commit
fe5bc54e92
21 changed files with 1695 additions and 9 deletions
104
CLAUDE.md
104
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
|
||||
|
|
|
|||
15
pom.xml
15
pom.xml
|
|
@ -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>
|
||||
|
|
|
|||
36
src/main/java/org/operaton/fitpub/FitPubApplication.java
Normal file
36
src/main/java/org/operaton/fitpub/FitPubApplication.java
Normal 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();
|
||||
}
|
||||
}
|
||||
117
src/main/java/org/operaton/fitpub/config/SecurityConfig.java
Normal file
117
src/main/java/org/operaton/fitpub/config/SecurityConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
102
src/main/java/org/operaton/fitpub/model/activitypub/Actor.java
Normal file
102
src/main/java/org/operaton/fitpub/model/activitypub/Actor.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
94
src/main/java/org/operaton/fitpub/model/entity/User.java
Normal file
94
src/main/java/org/operaton/fitpub/model/entity/User.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
113
src/main/java/org/operaton/fitpub/security/JwtTokenProvider.java
Normal file
113
src/main/java/org/operaton/fitpub/security/JwtTokenProvider.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
74
src/main/resources/application.yml
Normal file
74
src/main/resources/application.yml
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue