More vibin
This commit is contained in:
parent
1901daf5ce
commit
c1729a629d
47 changed files with 5754 additions and 41 deletions
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:/";
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/main/java/org/operaton/fitpub/model/entity/Follow.java
Normal file
75
src/main/java/org/operaton/fitpub/model/entity/Follow.java
Normal 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
|
||||
}
|
||||
}
|
||||
126
src/main/java/org/operaton/fitpub/model/entity/RemoteActor.java
Normal file
126
src/main/java/org/operaton/fitpub/model/entity/RemoteActor.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
259
src/main/java/org/operaton/fitpub/service/FederationService.java
Normal file
259
src/main/java/org/operaton/fitpub/service/FederationService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
178
src/main/java/org/operaton/fitpub/service/InboxProcessor.java
Normal file
178
src/main/java/org/operaton/fitpub/service/InboxProcessor.java
Normal 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);
|
||||
}
|
||||
}
|
||||
176
src/main/java/org/operaton/fitpub/service/TimelineService.java
Normal file
176
src/main/java/org/operaton/fitpub/service/TimelineService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue