More vibin

This commit is contained in:
Tim Zöller 2025-11-28 21:04:38 +01:00
parent 1901daf5ce
commit c1729a629d
47 changed files with 5754 additions and 41 deletions

View file

@ -49,18 +49,36 @@ public class SecurityConfig {
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
// Public endpoints - Static resources
.requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico").permitAll()
// Public endpoints - Error pages
.requestMatchers("/error").permitAll()
// Public endpoints - Web UI pages
.requestMatchers("/", "/login", "/register", "/timeline", "/activities", "/activities/**").permitAll()
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()
.requestMatchers(HttpMethod.GET, "/users/**").permitAll()
.requestMatchers(HttpMethod.POST, "/users/*/inbox").permitAll()
// Public endpoints - Authentication
// Public endpoints - Authentication API
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/users/register").permitAll()
// Protected endpoints - Activities
// Public endpoints - Timeline API (read-only)
.requestMatchers(HttpMethod.GET, "/api/timeline/public").permitAll()
// Protected endpoints - Activities API
.requestMatchers("/api/activities/**").authenticated()
// Protected endpoints - Timeline API (user-specific)
.requestMatchers("/api/timeline/**").authenticated()
// Protected web pages
.requestMatchers("/profile", "/settings").authenticated()
// All other requests require authentication
.anyRequest().authenticated()
)

View file

@ -3,6 +3,7 @@ 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.springframework.context.annotation.Profile;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
@ -11,8 +12,11 @@ import org.testcontainers.utility.DockerImageName;
* Automatically starts a PostgreSQL container with PostGIS extension when running in dev mode.
*
* This ensures development environment matches production (PostgreSQL + PostGIS).
*
* Only active when NOT running in production profile.
*/
@Configuration(proxyBeanMethods = false)
@Profile("!prod")
public class TestcontainersConfiguration {
/**

View file

@ -0,0 +1,20 @@
package org.operaton.fitpub.config;
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Thymeleaf configuration for Layout Dialect support
*/
@Configuration
public class ThymeleafConfig {
/**
* Configure Thymeleaf Layout Dialect for template inheritance
*/
@Bean
public LayoutDialect layoutDialect() {
return new LayoutDialect();
}
}

View file

@ -0,0 +1,48 @@
package org.operaton.fitpub.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* Controller for serving activity-related HTML pages
*/
@Controller
@RequestMapping("/activities")
public class ActivitiesViewController {
/**
* Show activities list page
*/
@GetMapping
public String listActivities() {
return "activities/list";
}
/**
* Show activity upload page
*/
@GetMapping("/upload")
public String uploadActivity() {
return "activities/upload";
}
/**
* Show activity detail page
*/
@GetMapping("/{id}")
public String viewActivity(@PathVariable String id) {
// The activity data will be loaded via JavaScript API calls
return "activities/detail";
}
/**
* Show activity edit page
*/
@GetMapping("/{id}/edit")
public String editActivity(@PathVariable String id) {
// The activity data will be loaded via JavaScript API calls
return "activities/edit";
}
}

View file

@ -27,6 +27,7 @@ import java.util.Optional;
public class ActivityPubController {
private final UserRepository userRepository;
private final org.operaton.fitpub.service.InboxProcessor inboxProcessor;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -79,10 +80,16 @@ public class ActivityPubController {
) {
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.)
// TODO: Validate HTTP signature (signature validation can be added later)
// For MVP, just accept all activities
// Process activity asynchronously to avoid blocking the sender
try {
inboxProcessor.processActivity(username, activity);
} catch (Exception e) {
log.error("Error processing inbox activity", e);
}
// Always return 202 Accepted per ActivityPub spec
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
}

View file

@ -0,0 +1,29 @@
package org.operaton.fitpub.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
/**
* Controller for authentication-related web pages
*/
@Controller
public class AuthViewController {
@GetMapping("/login")
public String login() {
return "auth/login";
}
@GetMapping("/register")
public String register() {
return "auth/register";
}
@PostMapping("/logout")
public String logout() {
// Logout is handled client-side (removing JWT token)
// Redirect to home page
return "redirect:/";
}
}

View file

@ -0,0 +1,16 @@
package org.operaton.fitpub.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Controller for home page and general public pages
*/
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "index";
}
}

View file

@ -0,0 +1,126 @@
package org.operaton.fitpub.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.dto.TimelineActivityDTO;
import org.operaton.fitpub.service.TimelineService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* REST controller for timeline endpoints.
* Provides access to federated, public, and user timelines.
*/
@RestController
@RequestMapping("/api/timeline")
@RequiredArgsConstructor
@Slf4j
public class TimelineController {
private final TimelineService timelineService;
/**
* Get the federated timeline for the authenticated user.
* Shows activities from users they follow.
*
* GET /api/timeline/federated?page=0&size=20
*
* @param authentication the authenticated user
* @param page page number (default: 0)
* @param size page size (default: 20)
* @return page of timeline activities
*/
@GetMapping("/federated")
public ResponseEntity<Page<TimelineActivityDTO>> getFederatedTimeline(
Authentication authentication,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
UUID userId = UUID.fromString(authentication.getName());
log.debug("Federated timeline request from user: {}", userId);
Pageable pageable = PageRequest.of(page, size);
Page<TimelineActivityDTO> timeline = timelineService.getFederatedTimeline(userId, pageable);
return ResponseEntity.ok(timeline);
}
/**
* Get the public timeline.
* Shows all public activities from all users.
*
* GET /api/timeline/public?page=0&size=20
*
* @param page page number (default: 0)
* @param size page size (default: 20)
* @return page of timeline activities
*/
@GetMapping("/public")
public ResponseEntity<Page<TimelineActivityDTO>> getPublicTimeline(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
log.debug("Public timeline request");
Pageable pageable = PageRequest.of(page, size);
Page<TimelineActivityDTO> timeline = timelineService.getPublicTimeline(pageable);
return ResponseEntity.ok(timeline);
}
/**
* Get the user's own timeline.
* Shows only activities by the authenticated user.
*
* GET /api/timeline/user?page=0&size=20
*
* @param authentication the authenticated user
* @param page page number (default: 0)
* @param size page size (default: 20)
* @return page of timeline activities
*/
@GetMapping("/user")
public ResponseEntity<Page<TimelineActivityDTO>> getUserTimeline(
Authentication authentication,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
UUID userId = UUID.fromString(authentication.getName());
log.debug("User timeline request from user: {}", userId);
Pageable pageable = PageRequest.of(page, size);
Page<TimelineActivityDTO> timeline = timelineService.getUserTimeline(userId, pageable);
return ResponseEntity.ok(timeline);
}
/**
* Get another user's public timeline by username.
* Shows public activities by a specific user.
*
* GET /api/timeline/user/{username}?page=0&size=20
*
* @param username the username
* @param page page number (default: 0)
* @param size page size (default: 20)
* @return page of timeline activities
*/
@GetMapping("/user/{username}")
public ResponseEntity<Page<TimelineActivityDTO>> getUserTimelineByUsername(
@PathVariable String username,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
log.debug("User timeline request for username: {}", username);
// TODO: Implement getUserTimelineByUsername in TimelineService
// For now, return not implemented
return ResponseEntity.status(501).build();
}
}

View file

@ -24,6 +24,5 @@ public class ActivityUploadRequest {
@Size(max = 5000, message = "Description must not exceed 5000 characters")
private String description;
@NotNull(message = "Visibility is required")
private Activity.Visibility visibility;
}

View file

@ -0,0 +1,97 @@
package org.operaton.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.Activity;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO for timeline activity items.
* Represents an activity in the federated timeline.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TimelineActivityDTO {
private UUID id;
private String activityType;
private String title;
private String description;
private LocalDateTime startedAt;
private LocalDateTime endedAt;
private Double totalDistance;
private Long totalDurationSeconds;
private Double elevationGain;
private Double elevationLoss;
private String visibility;
private LocalDateTime createdAt;
// User information
private String username;
private String displayName;
private String avatarUrl;
private boolean isLocal;
// Metrics summary
private ActivityMetricsSummary metrics;
/**
* Convert Activity entity to timeline DTO.
*/
public static TimelineActivityDTO fromActivity(Activity activity, String username, String displayName, String avatarUrl) {
return TimelineActivityDTO.builder()
.id(activity.getId())
.activityType(activity.getActivityType().name())
.title(activity.getTitle())
.description(activity.getDescription())
.startedAt(activity.getStartedAt())
.endedAt(activity.getEndedAt())
.totalDistance(activity.getTotalDistance() != null ? activity.getTotalDistance().doubleValue() : null)
.totalDurationSeconds(activity.getTotalDurationSeconds())
.elevationGain(activity.getElevationGain() != null ? activity.getElevationGain().doubleValue() : null)
.elevationLoss(activity.getElevationLoss() != null ? activity.getElevationLoss().doubleValue() : null)
.visibility(activity.getVisibility().name())
.createdAt(activity.getCreatedAt())
.username(username)
.displayName(displayName)
.avatarUrl(avatarUrl)
.isLocal(true)
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
.build();
}
/**
* Summary of activity metrics for timeline display.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ActivityMetricsSummary {
private Integer averageHeartRate;
private Integer maxHeartRate;
private Double averageSpeed;
private Double maxSpeed;
private Long averagePaceSeconds;
private Integer averagePower;
private Integer calories;
public static ActivityMetricsSummary fromMetrics(org.operaton.fitpub.model.entity.ActivityMetrics metrics) {
return ActivityMetricsSummary.builder()
.averageHeartRate(metrics.getAverageHeartRate())
.maxHeartRate(metrics.getMaxHeartRate())
.averageSpeed(metrics.getAverageSpeed() != null ? metrics.getAverageSpeed().doubleValue() : null)
.maxSpeed(metrics.getMaxSpeed() != null ? metrics.getMaxSpeed().doubleValue() : null)
.averagePaceSeconds(metrics.getAveragePaceSeconds())
.averagePower(metrics.getAveragePower())
.calories(metrics.getCalories())
.build();
}
}
}

View file

@ -0,0 +1,75 @@
package org.operaton.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import java.time.Instant;
import java.util.UUID;
/**
* Represents a follow relationship between users (local or remote).
* Supports both local-to-local and local-to-remote follow relationships.
*/
@Entity
@Table(name = "follows", indexes = {
@Index(name = "idx_follower_id", columnList = "follower_id"),
@Index(name = "idx_following_actor_uri", columnList = "following_actor_uri")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Follow {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
/**
* The local user who is following.
*/
@Column(name = "follower_id", nullable = false)
private UUID followerId;
/**
* The ActivityPub actor URI being followed (local or remote).
* Example: https://mastodon.social/users/alice
*/
@Column(name = "following_actor_uri", nullable = false, length = 512)
private String followingActorUri;
/**
* Status of the follow relationship.
*/
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@Builder.Default
private FollowStatus status = FollowStatus.PENDING;
/**
* The ActivityPub Follow activity ID.
* Used to reference the original Follow activity.
*/
@Column(name = "activity_id", length = 512)
private String activityId;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
/**
* Status of a follow relationship.
*/
public enum FollowStatus {
/** Follow request sent, awaiting acceptance */
PENDING,
/** Follow request accepted, relationship active */
ACCEPTED,
/** Follow request rejected */
REJECTED
}
}

View file

@ -0,0 +1,126 @@
package org.operaton.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.Instant;
import java.util.UUID;
/**
* Represents a remote ActivityPub actor (user from another server).
* Cached information about remote actors for federation.
*/
@Entity
@Table(name = "remote_actors", indexes = {
@Index(name = "idx_actor_uri", columnList = "actor_uri", unique = true),
@Index(name = "idx_domain", columnList = "domain")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RemoteActor {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
/**
* The full ActivityPub actor URI.
* Example: https://mastodon.social/users/alice
*/
@Column(name = "actor_uri", nullable = false, unique = true, length = 512)
private String actorUri;
/**
* The username part of the actor.
* Example: alice
*/
@Column(nullable = false, length = 255)
private String username;
/**
* The domain of the remote server.
* Example: mastodon.social
*/
@Column(nullable = false, length = 255)
private String domain;
/**
* The actor's inbox URL for sending activities.
*/
@Column(name = "inbox_url", nullable = false, length = 512)
private String inboxUrl;
/**
* The actor's outbox URL for fetching activities.
*/
@Column(name = "outbox_url", length = 512)
private String outboxUrl;
/**
* The actor's shared inbox URL (if available).
* More efficient for server-to-server communication.
*/
@Column(name = "shared_inbox_url", length = 512)
private String sharedInboxUrl;
/**
* The actor's public key in PEM format.
* Used for verifying HTTP signatures.
*/
@Column(name = "public_key", columnDefinition = "TEXT", nullable = false)
private String publicKey;
/**
* The actor's public key ID.
* Example: https://mastodon.social/users/alice#main-key
*/
@Column(name = "public_key_id", length = 512)
private String publicKeyId;
/**
* The actor's display name.
*/
@Column(name = "display_name", length = 255)
private String displayName;
/**
* The actor's avatar URL.
*/
@Column(name = "avatar_url", length = 512)
private String avatarUrl;
/**
* The actor's bio/summary.
*/
@Column(columnDefinition = "TEXT")
private String summary;
/**
* When the actor information was last fetched/updated.
*/
@Column(name = "last_fetched_at")
private Instant lastFetchedAt;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
/**
* Extract username and domain from actor URI.
* Example: https://mastodon.social/users/alice -> alice@mastodon.social
*/
public String getHandle() {
return username + "@" + domain;
}
}

View file

@ -1,6 +1,8 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.Activity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@ -85,4 +87,41 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
* @param userId the user ID
*/
void deleteByUserId(UUID userId);
/**
* Find activities for a user with pagination.
*
* @param userId the user ID
* @param pageable pagination parameters
* @return page of activities
*/
Page<Activity> findByUserIdOrderByStartedAtDesc(UUID userId, Pageable pageable);
/**
* Find activities by user IDs and visibility with pagination.
* Used for federated timeline.
*
* @param userIds list of user IDs
* @param visibilities list of visibility values
* @param pageable pagination parameters
* @return page of activities
*/
Page<Activity> findByUserIdInAndVisibilityInOrderByStartedAtDesc(
List<UUID> userIds,
List<Activity.Visibility> visibilities,
Pageable pageable
);
/**
* Find all public activities with pagination.
* Used for public timeline.
*
* @param visibility the visibility level
* @param pageable pagination parameters
* @return page of activities
*/
Page<Activity> findByVisibilityOrderByStartedAtDesc(
Activity.Visibility visibility,
Pageable pageable
);
}

View file

@ -0,0 +1,70 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.Follow;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Repository for Follow entity operations.
*/
@Repository
public interface FollowRepository extends JpaRepository<Follow, UUID> {
/**
* Find a follow relationship by follower and following actor URI.
*
* @param followerId the follower's user ID
* @param followingActorUri the actor URI being followed
* @return the follow relationship if it exists
*/
Optional<Follow> findByFollowerIdAndFollowingActorUri(UUID followerId, String followingActorUri);
/**
* Find all follow relationships for a follower.
*
* @param followerId the follower's user ID
* @return list of follow relationships
*/
List<Follow> findByFollowerId(UUID followerId);
/**
* Find all accepted followers of a user by their actor URI.
*
* @param actorUri the actor URI being followed
* @return list of accepted follow relationships
*/
@Query("SELECT f FROM Follow f WHERE f.followingActorUri = :actorUri AND f.status = 'ACCEPTED'")
List<Follow> findAcceptedFollowersByActorUri(@Param("actorUri") String actorUri);
/**
* Count accepted followers for an actor URI.
*
* @param actorUri the actor URI
* @return count of accepted followers
*/
@Query("SELECT COUNT(f) FROM Follow f WHERE f.followingActorUri = :actorUri AND f.status = 'ACCEPTED'")
long countAcceptedFollowersByActorUri(@Param("actorUri") String actorUri);
/**
* Find all accepted following relationships for a user.
*
* @param followerId the follower's user ID
* @return list of accepted follow relationships
*/
@Query("SELECT f FROM Follow f WHERE f.followerId = :followerId AND f.status = 'ACCEPTED'")
List<Follow> findAcceptedFollowingByUserId(@Param("followerId") UUID followerId);
/**
* Find a follow by its Activity ID.
*
* @param activityId the ActivityPub Follow activity ID
* @return the follow relationship if it exists
*/
Optional<Follow> findByActivityId(String activityId);
}

View file

@ -0,0 +1,31 @@
package org.operaton.fitpub.repository;
import org.operaton.fitpub.model.entity.RemoteActor;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Repository for RemoteActor entity operations.
*/
@Repository
public interface RemoteActorRepository extends JpaRepository<RemoteActor, UUID> {
/**
* Find a remote actor by their ActivityPub URI.
*
* @param actorUri the actor URI
* @return the remote actor if found
*/
Optional<RemoteActor> findByActorUri(String actorUri);
/**
* Check if a remote actor exists by their actor URI.
*
* @param actorUri the actor URI
* @return true if the actor exists
*/
boolean existsByActorUri(String actorUri);
}

View file

@ -196,4 +196,57 @@ public class HttpSignatureValidator {
return Base64.getEncoder().encodeToString(signature);
}
/**
* Signs an outbound HTTP request for ActivityPub federation.
*
* @param method the HTTP method (e.g., "POST")
* @param targetUrl the target URL
* @param body the request body
* @param privateKeyPem the sender's private key
* @param keyId the public key ID
* @return the Signature header value
*/
public String signRequest(String method, String targetUrl, String body, String privateKeyPem, String keyId) {
try {
java.net.URI uri = new java.net.URI(targetUrl);
String host = uri.getHost();
String path = uri.getPath();
if (uri.getQuery() != null) {
path += "?" + uri.getQuery();
}
// Build request-target
String requestTarget = method.toLowerCase() + " " + path;
// Calculate digest
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(body.getBytes(StandardCharsets.UTF_8));
String digestValue = "SHA-256=" + Base64.getEncoder().encodeToString(hash);
// Get current date in RFC 1123 format
java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC);
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
String date = now.format(formatter);
// Build signing string
String signingString = String.format(
"(request-target): %s\nhost: %s\ndate: %s\ndigest: %s",
requestTarget, host, date, digestValue
);
// Sign
String signatureBase64 = sign(signingString, privateKeyPem);
// Build signature header
return String.format(
"keyId=\"%s\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"%s\"",
keyId, signatureBase64
);
} catch (Exception e) {
log.error("Failed to sign request", e);
throw new RuntimeException("Failed to sign request", e);
}
}
}

View file

@ -0,0 +1,259 @@
package org.operaton.fitpub.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.Follow;
import org.operaton.fitpub.model.entity.RemoteActor;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.FollowRepository;
import org.operaton.fitpub.repository.RemoteActorRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.security.HttpSignatureValidator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Service for ActivityPub federation operations.
* Handles outbound activities and remote actor management.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FederationService {
private final RemoteActorRepository remoteActorRepository;
private final FollowRepository followRepository;
private final UserRepository userRepository;
private final HttpSignatureValidator signatureValidator;
private final ObjectMapper objectMapper;
private final RestTemplate restTemplate = new RestTemplate();
@Value("${fitpub.base-url}")
private String baseUrl;
/**
* Fetch and cache a remote actor's information.
*
* @param actorUri the actor's URI
* @return the cached remote actor
*/
@Transactional
public RemoteActor fetchRemoteActor(String actorUri) {
log.info("Fetching remote actor: {}", actorUri);
// Check if we have a cached version
RemoteActor cached = remoteActorRepository.findByActorUri(actorUri).orElse(null);
if (cached != null && cached.getLastFetchedAt() != null &&
cached.getLastFetchedAt().isAfter(Instant.now().minusSeconds(3600))) {
log.debug("Using cached actor info for: {}", actorUri);
return cached;
}
try {
// Fetch actor information
HttpHeaders headers = new HttpHeaders();
headers.set("Accept", "application/activity+json");
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(
actorUri,
HttpMethod.GET,
entity,
Map.class
);
Map<String, Object> actorData = response.getBody();
if (actorData == null) {
throw new RuntimeException("Empty actor response from: " + actorUri);
}
// Parse actor data
String username = extractUsername(actorUri, actorData);
String domain = URI.create(actorUri).getHost();
String inboxUrl = (String) actorData.get("inbox");
String outboxUrl = (String) actorData.get("outbox");
String sharedInboxUrl = extractSharedInbox(actorData);
String publicKey = extractPublicKey(actorData);
String publicKeyId = extractPublicKeyId(actorData);
// Update or create remote actor
RemoteActor actor;
if (cached != null) {
actor = cached;
} else {
actor = new RemoteActor();
actor.setActorUri(actorUri);
}
actor.setUsername(username);
actor.setDomain(domain);
actor.setInboxUrl(inboxUrl);
actor.setOutboxUrl(outboxUrl);
actor.setSharedInboxUrl(sharedInboxUrl);
actor.setPublicKey(publicKey);
actor.setPublicKeyId(publicKeyId);
actor.setDisplayName((String) actorData.get("name"));
actor.setAvatarUrl(extractAvatarUrl(actorData));
actor.setSummary((String) actorData.get("summary"));
actor.setLastFetchedAt(Instant.now());
return remoteActorRepository.save(actor);
} catch (Exception e) {
log.error("Failed to fetch remote actor: {}", actorUri, e);
throw new RuntimeException("Failed to fetch remote actor: " + actorUri, e);
}
}
/**
* Send an Accept activity in response to a Follow.
*
* @param follow the follow relationship
* @param localUser the local user being followed
*/
@Transactional
public void sendAcceptActivity(Follow follow, User localUser) {
try {
RemoteActor remoteActor = fetchRemoteActor(follow.getFollowingActorUri());
String acceptId = baseUrl + "/activities/" + UUID.randomUUID();
String actorUri = baseUrl + "/users/" + localUser.getUsername();
Map<String, Object> acceptActivity = new HashMap<>();
acceptActivity.put("@context", "https://www.w3.org/ns/activitystreams");
acceptActivity.put("type", "Accept");
acceptActivity.put("id", acceptId);
acceptActivity.put("actor", actorUri);
acceptActivity.put("object", follow.getActivityId());
sendActivity(remoteActor.getInboxUrl(), acceptActivity, localUser);
log.info("Sent Accept activity to: {}", remoteActor.getActorUri());
} catch (Exception e) {
log.error("Failed to send Accept activity for follow: {}", follow.getId(), e);
}
}
/**
* Send an activity to a remote inbox.
*
* @param inboxUrl the remote inbox URL
* @param activity the activity to send
* @param sender the local user sending the activity
*/
public void sendActivity(String inboxUrl, Map<String, Object> activity, User sender) {
try {
String activityJson = objectMapper.writeValueAsString(activity);
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/activity+json");
headers.set("Accept", "application/activity+json");
// Add HTTP signature
String signature = signatureValidator.signRequest(
HttpMethod.POST.name(),
inboxUrl,
activityJson,
sender.getPrivateKey(),
baseUrl + "/users/" + sender.getUsername() + "#main-key"
);
headers.set("Signature", signature);
HttpEntity<String> entity = new HttpEntity<>(activityJson, headers);
ResponseEntity<String> response = restTemplate.postForEntity(inboxUrl, entity, String.class);
log.info("Sent activity to: {} - Status: {}", inboxUrl, response.getStatusCode());
} catch (Exception e) {
log.error("Failed to send activity to: {}", inboxUrl, e);
throw new RuntimeException("Failed to send activity", e);
}
}
/**
* Get all follower inbox URLs for a local user.
*
* @param userId the local user's ID
* @return list of inbox URLs
*/
@Transactional(readOnly = true)
public List<String> getFollowerInboxes(UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
String actorUri = baseUrl + "/users/" + user.getUsername();
List<Follow> followers = followRepository.findAcceptedFollowersByActorUri(actorUri);
return followers.stream()
.map(follow -> {
try {
RemoteActor actor = remoteActorRepository.findByActorUri(follow.getFollowingActorUri())
.orElseGet(() -> fetchRemoteActor(follow.getFollowingActorUri()));
return actor.getSharedInboxUrl() != null ? actor.getSharedInboxUrl() : actor.getInboxUrl();
} catch (Exception e) {
log.error("Failed to get inbox for follower: {}", follow.getFollowingActorUri(), e);
return null;
}
})
.filter(inbox -> inbox != null)
.distinct()
.toList();
}
// Helper methods
private String extractUsername(String actorUri, Map<String, Object> actorData) {
String preferredUsername = (String) actorData.get("preferredUsername");
if (preferredUsername != null) {
return preferredUsername;
}
// Fallback: extract from URI
return actorUri.substring(actorUri.lastIndexOf("/") + 1);
}
private String extractSharedInbox(Map<String, Object> actorData) {
Object endpoints = actorData.get("endpoints");
if (endpoints instanceof Map) {
return (String) ((Map<?, ?>) endpoints).get("sharedInbox");
}
return null;
}
private String extractPublicKey(Map<String, Object> actorData) {
Object publicKey = actorData.get("publicKey");
if (publicKey instanceof Map) {
return (String) ((Map<?, ?>) publicKey).get("publicKeyPem");
}
throw new RuntimeException("No public key found in actor data");
}
private String extractPublicKeyId(Map<String, Object> actorData) {
Object publicKey = actorData.get("publicKey");
if (publicKey instanceof Map) {
return (String) ((Map<?, ?>) publicKey).get("id");
}
return null;
}
private String extractAvatarUrl(Map<String, Object> actorData) {
Object icon = actorData.get("icon");
if (icon instanceof Map) {
return (String) ((Map<?, ?>) icon).get("url");
}
return null;
}
}

View file

@ -139,6 +139,9 @@ public class FitFileService {
? title
: generateTitle(parsedData);
// Default to PUBLIC if visibility not specified
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PUBLIC;
return Activity.builder()
.userId(userId)
.activityType(parsedData.getActivityType())
@ -146,7 +149,7 @@ public class FitFileService {
.description(description)
.startedAt(parsedData.getStartTime())
.endedAt(parsedData.getEndTime())
.visibility(visibility)
.visibility(activityVisibility)
.totalDistance(parsedData.getTotalDistance())
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
.elevationGain(parsedData.getElevationGain())

View file

@ -0,0 +1,178 @@
package org.operaton.fitpub.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.Follow;
import org.operaton.fitpub.model.entity.RemoteActor;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.FollowRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
/**
* Processes incoming ActivityPub activities in the inbox.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class InboxProcessor {
private final UserRepository userRepository;
private final FollowRepository followRepository;
private final FederationService federationService;
@Value("${fitpub.base-url}")
private String baseUrl;
/**
* Process an incoming activity.
*
* @param username the local username
* @param activity the activity to process
*/
@Transactional
public void processActivity(String username, Map<String, Object> activity) {
String type = (String) activity.get("type");
log.info("Processing {} activity for user {}", type, username);
switch (type) {
case "Follow":
processFollow(username, activity);
break;
case "Undo":
processUndo(username, activity);
break;
case "Accept":
processAccept(username, activity);
break;
case "Create":
processCreate(username, activity);
break;
case "Like":
processLike(username, activity);
break;
default:
log.warn("Unhandled activity type: {}", type);
}
}
/**
* Process a Follow activity.
* Remote user wants to follow local user.
*/
private void processFollow(String username, Map<String, Object> activity) {
try {
String activityId = (String) activity.get("id");
String actor = (String) activity.get("actor");
String object = (String) activity.get("object");
// Verify the follow is for the correct local user
User localUser = userRepository.findByUsername(username)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + username));
String expectedObjectUri = baseUrl + "/users/" + username;
if (!object.equals(expectedObjectUri)) {
log.warn("Follow object mismatch. Expected: {}, Got: {}", expectedObjectUri, object);
return;
}
// Fetch remote actor information
RemoteActor remoteActor = federationService.fetchRemoteActor(actor);
// Check if follow already exists
Follow existing = followRepository.findByActivityId(activityId).orElse(null);
if (existing != null) {
log.debug("Follow already processed: {}", activityId);
return;
}
// Create follow relationship (as the object of the follow, from remote actor's perspective)
// Here we store that the remote actor is following our local user
// Note: We're storing it from the perspective of "who is following whom"
Follow follow = Follow.builder()
.followerId(null) // Remote actor, so no local user ID
.followingActorUri(expectedObjectUri) // The local user being followed
.status(Follow.FollowStatus.ACCEPTED) // Auto-accept for now
.activityId(activityId)
.build();
followRepository.save(follow);
// Send Accept activity
federationService.sendAcceptActivity(follow, localUser);
log.info("Processed Follow from {} for user {}", actor, username);
} catch (Exception e) {
log.error("Error processing Follow activity", e);
}
}
/**
* Process an Undo activity (e.g., unfollow).
*/
private void processUndo(String username, Map<String, Object> activity) {
try {
Object object = activity.get("object");
if (object instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> undoObject = (Map<String, Object>) object;
String type = (String) undoObject.get("type");
if ("Follow".equals(type)) {
String activityId = (String) undoObject.get("id");
Follow follow = followRepository.findByActivityId(activityId).orElse(null);
if (follow != null) {
followRepository.delete(follow);
log.info("Processed Undo Follow: {}", activityId);
}
}
}
} catch (Exception e) {
log.error("Error processing Undo activity", e);
}
}
/**
* Process an Accept activity (e.g., follow request accepted).
*/
private void processAccept(String username, Map<String, Object> activity) {
try {
Object object = activity.get("object");
if (object instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> acceptObject = (Map<String, Object>) object;
String activityId = (String) acceptObject.get("id");
Follow follow = followRepository.findByActivityId(activityId).orElse(null);
if (follow != null && follow.getStatus() == Follow.FollowStatus.PENDING) {
follow.setStatus(Follow.FollowStatus.ACCEPTED);
followRepository.save(follow);
log.info("Follow request accepted: {}", activityId);
}
}
} catch (Exception e) {
log.error("Error processing Accept activity", e);
}
}
/**
* Process a Create activity (e.g., new post).
*/
private void processCreate(String username, Map<String, Object> activity) {
// TODO: Implement Create activity processing
log.debug("Received Create activity for user {}", username);
}
/**
* Process a Like activity.
*/
private void processLike(String username, Map<String, Object> activity) {
// TODO: Implement Like activity processing
log.debug("Received Like activity for user {}", username);
}
}

View file

@ -0,0 +1,176 @@
package org.operaton.fitpub.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.dto.TimelineActivityDTO;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.Follow;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.repository.FollowRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service for managing timelines.
* Provides federated timeline of activities from followed users.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class TimelineService {
private final ActivityRepository activityRepository;
private final FollowRepository followRepository;
private final UserRepository userRepository;
@Value("${fitpub.base-url}")
private String baseUrl;
/**
* Get the federated timeline for a user.
* Includes public activities from:
* - The user's own activities
* - Activities from users they follow (local users only for now)
*
* @param userId the authenticated user's ID
* @param pageable pagination parameters
* @return page of timeline activities
*/
@Transactional(readOnly = true)
public Page<TimelineActivityDTO> getFederatedTimeline(UUID userId, Pageable pageable) {
log.debug("Fetching federated timeline for user: {}", userId);
User currentUser = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
// Get list of user IDs that the current user follows
List<UUID> followedUserIds = getFollowedLocalUserIds(userId);
// Include the current user's own activities
followedUserIds.add(userId);
// Fetch public and followers-only activities from followed users
Page<Activity> activities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
followedUserIds,
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
pageable
);
// Convert to DTOs
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
.map(activity -> {
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
if (activityUser == null) {
return null;
}
return TimelineActivityDTO.fromActivity(
activity,
activityUser.getUsername(),
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
activityUser.getAvatarUrl()
);
})
.filter(dto -> dto != null)
.collect(Collectors.toList());
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
}
/**
* Get the public timeline.
* Shows all public activities from all users.
*
* @param pageable pagination parameters
* @return page of timeline activities
*/
@Transactional(readOnly = true)
public Page<TimelineActivityDTO> getPublicTimeline(Pageable pageable) {
log.debug("Fetching public timeline");
// Fetch all public activities
Page<Activity> activities = activityRepository.findByVisibilityOrderByStartedAtDesc(
Activity.Visibility.PUBLIC,
pageable
);
// Convert to DTOs
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
.map(activity -> {
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
if (activityUser == null) {
return null;
}
return TimelineActivityDTO.fromActivity(
activity,
activityUser.getUsername(),
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
activityUser.getAvatarUrl()
);
})
.filter(dto -> dto != null)
.collect(Collectors.toList());
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
}
/**
* Get user's own timeline (their activities only).
*
* @param userId the user's ID
* @param pageable pagination parameters
* @return page of timeline activities
*/
@Transactional(readOnly = true)
public Page<TimelineActivityDTO> getUserTimeline(UUID userId, Pageable pageable) {
log.debug("Fetching user timeline for: {}", userId);
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
Page<Activity> activities = activityRepository.findByUserIdOrderByStartedAtDesc(userId, pageable);
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
.map(activity -> TimelineActivityDTO.fromActivity(
activity,
user.getUsername(),
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(),
user.getAvatarUrl()
))
.collect(Collectors.toList());
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
}
/**
* Get IDs of local users that the given user follows.
*
* @param userId the user's ID
* @return list of followed local user IDs
*/
private List<UUID> getFollowedLocalUserIds(UUID userId) {
List<Follow> follows = followRepository.findAcceptedFollowingByUserId(userId);
List<UUID> followedUserIds = new ArrayList<>();
for (Follow follow : follows) {
// Check if the followed actor is a local user
String actorUri = follow.getFollowingActorUri();
if (actorUri.startsWith(baseUrl + "/users/")) {
String username = actorUri.substring((baseUrl + "/users/").length());
userRepository.findByUsername(username).ifPresent(user -> followedUserIds.add(user.getId()));
}
}
return followedUserIds;
}
}