This commit is contained in:
Marcus Fihlon 2026-05-05 11:27:17 +02:00 committed by GitHub
commit 834026020a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 374 additions and 10 deletions

View file

@ -166,7 +166,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/*").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/search").permitAll() // User search
.requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users

View file

@ -14,9 +14,11 @@ import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.ProfileAccessService;
import net.javahippie.fitpub.service.WebFingerClient;
import net.javahippie.fitpub.service.UserService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
@ -25,6 +27,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
@ -47,6 +50,7 @@ public class UserController {
private final WebFingerClient webFingerClient;
private final FederationService federationService;
private final UserService userService;
private final ProfileAccessService profileAccessService;
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
@Value("${fitpub.base-url}")
@ -68,6 +72,14 @@ public class UserController {
dto.setFollowingCount((long) followingCount);
}
private User getCurrentUserOrNull(UserDetails userDetails) {
if (userDetails == null) {
return null;
}
return userRepository.findByUsername(userDetails.getUsername()).orElse(null);
}
/**
* Get current user's profile.
*
@ -111,6 +123,9 @@ public class UserController {
if (request.getBio() != null) {
user.setBio(request.getBio().trim());
}
if (request.getProfileVisibility() != null) {
user.setProfileVisibility(request.getProfileVisibility());
}
if (request.getAvatarUrl() != null) {
user.setAvatarUrl(request.getAvatarUrl().trim());
}
@ -177,13 +192,21 @@ public class UserController {
* @return user profile
*/
@GetMapping("/{username}")
public ResponseEntity<UserDTO> getUserByUsername(@PathVariable String username) {
public ResponseEntity<UserDTO> getUserByUsername(
@PathVariable String username,
@AuthenticationPrincipal UserDetails userDetails
) {
log.debug("Retrieving profile for username: {}", username);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
UserDTO dto = UserDTO.fromEntity(user);
User viewer = getCurrentUserOrNull(userDetails);
profileAccessService.requireProfileAccess(user, viewer);
UserDTO dto = viewer != null && viewer.getId().equals(user.getId())
? UserDTO.fromEntity(user)
: UserDTO.fromEntityPublic(user);
populateSocialCounts(dto, user);
return ResponseEntity.ok(dto);
@ -196,13 +219,21 @@ public class UserController {
* @return user profile
*/
@GetMapping("/id/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID id) {
public ResponseEntity<UserDTO> getUserById(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails
) {
log.debug("Retrieving profile for user ID: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserDTO dto = UserDTO.fromEntity(user);
User viewer = getCurrentUserOrNull(userDetails);
profileAccessService.requireProfileAccess(user, viewer);
UserDTO dto = viewer != null && viewer.getId().equals(user.getId())
? UserDTO.fromEntity(user)
: UserDTO.fromEntityPublic(user);
populateSocialCounts(dto, user);
return ResponseEntity.ok(dto);
@ -623,13 +654,17 @@ public class UserController {
*/
@GetMapping("/{username}/peaks")
public ResponseEntity<java.util.List<Map<String, Object>>> getUserPeaks(
@PathVariable String username
@PathVariable String username,
@AuthenticationPrincipal UserDetails userDetails
) {
User user = userRepository.findByUsername(username).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
User viewer = getCurrentUserOrNull(userDetails);
profileAccessService.requireProfileAccess(user, viewer);
var projections = activityPeakRepository.findPeaksVisitedByUser(user.getId());
var result = projections.stream()
.map(p -> {
@ -645,4 +680,13 @@ public class UserController {
return ResponseEntity.ok(result);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(ResponseStatusException e) {
HttpStatus status = HttpStatus.valueOf(e.getStatusCode().value());
String message = e.getReason() != null ? e.getReason() : status.getReasonPhrase();
return ResponseEntity.status(status).body(new ErrorResponse(status.name(), message));
}
record ErrorResponse(String error, String message) {}
}

View file

@ -24,6 +24,7 @@ public class UserDTO {
private String email; // Only shown to the user themselves
private String displayName;
private String bio;
private User.ProfileVisibility profileVisibility;
private String avatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@ -52,6 +53,7 @@ public class UserDTO {
.email(user.getEmail())
.displayName(user.getDisplayName())
.bio(user.getBio())
.profileVisibility(user.getProfileVisibility())
.avatarUrl(user.getAvatarUrl())
.homeLatitude(user.getHomeLatitude())
.homeLongitude(user.getHomeLongitude())
@ -72,6 +74,7 @@ public class UserDTO {
.username(user.getUsername())
.displayName(user.getDisplayName())
.bio(user.getBio())
.profileVisibility(user.getProfileVisibility())
.avatarUrl(user.getAvatarUrl())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())

View file

@ -7,6 +7,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import net.javahippie.fitpub.model.entity.User;
import org.hibernate.validator.constraints.URL;
/**
@ -24,6 +25,8 @@ public class UserUpdateRequest {
@Size(max = 500, message = "Bio must not exceed 500 characters")
private String bio;
private User.ProfileVisibility profileVisibility;
@URL(message = "Avatar URL must be a valid URL")
private String avatarUrl;

View file

@ -43,6 +43,11 @@ public class User {
@Column(columnDefinition = "TEXT")
private String bio;
@Enumerated(EnumType.STRING)
@Column(name = "profile_visibility", nullable = false, length = 20)
@Builder.Default
private ProfileVisibility profileVisibility = ProfileVisibility.FOLLOWERS;
@Column(name = "avatar_url")
private String avatarUrl;
@ -112,4 +117,10 @@ public class User {
public String getWebFingerAccount(String domain) {
return String.format("acct:%s@%s", username, domain);
}
public enum ProfileVisibility {
PUBLIC,
FOLLOWERS,
PRIVATE
}
}

View file

@ -0,0 +1,59 @@
package net.javahippie.fitpub.service;
import lombok.RequiredArgsConstructor;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.FORBIDDEN;
/**
* Central access policy for profile visibility checks.
*/
@Service
@RequiredArgsConstructor
public class ProfileAccessService {
private final FollowRepository followRepository;
@Value("${fitpub.base-url}")
private String baseUrl;
public boolean canViewProfile(User profileOwner, User viewer) {
if (viewer != null && viewer.getId().equals(profileOwner.getId())) {
return true;
}
User.ProfileVisibility visibility = profileOwner.getProfileVisibility() != null
? profileOwner.getProfileVisibility()
: User.ProfileVisibility.PUBLIC;
if (visibility == User.ProfileVisibility.PUBLIC) {
return true;
}
if (visibility == User.ProfileVisibility.PRIVATE || viewer == null) {
return false;
}
String actorUri = profileOwner.getActorUri(baseUrl);
return followRepository.findByFollowerIdAndFollowingActorUri(viewer.getId(), actorUri)
.filter(follow -> follow.getStatus() == Follow.FollowStatus.ACCEPTED)
.isPresent();
}
public String getAccessDeniedMessage(User profileOwner) {
return profileOwner.getProfileVisibility() == User.ProfileVisibility.FOLLOWERS
? "This profile is only visible to followers."
: "This profile is private.";
}
public void requireProfileAccess(User profileOwner, User viewer) {
if (!canViewProfile(profileOwner, viewer)) {
throw new ResponseStatusException(FORBIDDEN, getAccessDeniedMessage(profileOwner));
}
}
}

View file

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN profile_visibility VARCHAR(20) NOT NULL DEFAULT 'FOLLOWERS';

View file

@ -323,6 +323,12 @@ const FitPubAuth = {
return;
}
// Public profile pages are public (visibility is enforced by the profile API)
// Pattern: /users/{username}
if (currentPath.startsWith('/users/') && currentPath.split('/').length === 3) {
return;
}
if (currentPath.startsWith('/terms')) {
return;
}

View file

@ -60,6 +60,17 @@
</div>
</div>
<!-- Profile visibility -->
<div class="mb-4">
<label for="profileVisibility" class="form-label">Profile Visibility</label>
<select class="form-select" id="profileVisibility" name="profileVisibility">
<option value="PUBLIC">Public - Anyone can see</option>
<option value="FOLLOWERS">Followers Only - Only your followers can see</option>
<option value="PRIVATE">Private - Only you can see</option>
</select>
<div class="form-text">Controls who can view your profile page.</div>
</div>
<!-- Home Location Section -->
<div class="mb-4">
<h5 class="mb-3">
@ -254,6 +265,7 @@
const formData = {
displayName: document.getElementById('displayName').value.trim(),
bio: document.getElementById('bio').value.trim(),
profileVisibility: document.getElementById('profileVisibility').value,
avatarUrl: document.getElementById('avatarUrl').value.trim(),
homeLatitude: document.getElementById('homeLatitude').value ? parseFloat(document.getElementById('homeLatitude').value) : null,
homeLongitude: document.getElementById('homeLongitude').value ? parseFloat(document.getElementById('homeLongitude').value) : null,
@ -320,6 +332,7 @@
function populateForm(user) {
document.getElementById('displayName').value = user.displayName || '';
document.getElementById('bio').value = user.bio || '';
document.getElementById('profileVisibility').value = user.profileVisibility || 'FOLLOWERS';
document.getElementById('avatarUrl').value = user.avatarUrl || '';
document.getElementById('email').value = user.email || '';
document.getElementById('username').value = user.username || '';

View file

@ -24,6 +24,11 @@
<span id="errorMessage"></span>
</div>
<div id="accessNotice" class="alert alert-info d-none" role="alert">
<i class="bi bi-shield-lock"></i>
<span id="accessNoticeMessage"></span>
</div>
<!-- Profile Content -->
<div id="profileContent" class="d-none">
<!-- Profile Header -->
@ -156,10 +161,20 @@
function loadProfile() {
// For now, we'll fetch from the user API endpoint
// In the future, this should use /api/users/{username}
fetch(`/api/users/${targetUsername}`)
fetch(`/api/users/${targetUsername}`, {
headers: {
'Accept': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('User not found');
return response.json()
.catch(() => ({}))
.then(errorData => {
const error = new Error(errorData.message || 'User not found');
error.status = response.status;
throw error;
});
}
return response.json();
})
@ -171,8 +186,13 @@
.catch(error => {
console.error('Error loading profile:', error);
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.';
document.getElementById('errorAlert').classList.remove('d-none');
if (error.status === 403) {
document.getElementById('accessNoticeMessage').textContent = error.message;
document.getElementById('accessNotice').classList.remove('d-none');
} else {
document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.';
document.getElementById('errorAlert').classList.remove('d-none');
}
});
}

View file

@ -0,0 +1,203 @@
package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.lang.reflect.Proxy;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.HttpStatus.FORBIDDEN;
@DisplayName("ProfileAccessService Tests")
class ProfileAccessServiceTest {
@Test
@DisplayName("PUBLIC profile should be visible anonymously")
void publicProfileShouldBeVisibleAnonymously() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-public", User.ProfileVisibility.PUBLIC);
assertTrue(service.canViewProfile(owner, null));
assertEquals(0, lookupCount.get());
}
@Test
@DisplayName("PUBLIC profile should be visible to another authenticated user")
void publicProfileShouldBeVisibleToAnotherAuthenticatedUser() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-public-auth", User.ProfileVisibility.PUBLIC);
User viewer = user("viewer-public-auth", User.ProfileVisibility.PUBLIC);
assertTrue(service.canViewProfile(owner, viewer));
assertEquals(0, lookupCount.get());
}
@Test
@DisplayName("FOLLOWERS profile should be forbidden anonymously")
void followersProfileShouldBeForbiddenAnonymously() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-followers-anon", User.ProfileVisibility.FOLLOWERS);
assertFalse(service.canViewProfile(owner, null));
}
@Test
@DisplayName("FOLLOWERS profile should be forbidden to non followers")
void followersProfileShouldBeForbiddenToNonFollowers() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-followers-nonf", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-nonf", User.ProfileVisibility.PUBLIC);
assertFalse(service.canViewProfile(owner, viewer));
assertEquals(1, lookupCount.get());
}
@Test
@DisplayName("FOLLOWERS profile should be visible to accepted followers")
void followersProfileShouldBeVisibleToAcceptedFollowers() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(
Optional.of(Follow.builder().status(Follow.FollowStatus.ACCEPTED).build()),
lookupCount
);
User owner = user("owner-followers-accepted", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-accepted", User.ProfileVisibility.PUBLIC);
assertTrue(service.canViewProfile(owner, viewer));
assertEquals(1, lookupCount.get());
}
@Test
@DisplayName("FOLLOWERS profile should be forbidden to pending followers")
void followersProfileShouldBeForbiddenToPendingFollowers() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(
Optional.of(Follow.builder().status(Follow.FollowStatus.PENDING).build()),
lookupCount
);
User owner = user("owner-followers-pending", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-pending", User.ProfileVisibility.PUBLIC);
assertFalse(service.canViewProfile(owner, viewer));
assertEquals(1, lookupCount.get());
}
@Test
@DisplayName("PRIVATE profile should be forbidden anonymously")
void privateProfileShouldBeForbiddenAnonymously() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-private-anon", User.ProfileVisibility.PRIVATE);
assertFalse(service.canViewProfile(owner, null));
}
@Test
@DisplayName("PRIVATE profile should be forbidden to another authenticated user")
void privateProfileShouldBeForbiddenToAnotherAuthenticatedUser() {
AtomicInteger lookupCount = new AtomicInteger();
ProfileAccessService service = createService(Optional.empty(), lookupCount);
User owner = user("owner-private-other", User.ProfileVisibility.PRIVATE);
User viewer = user("viewer-private-other", User.ProfileVisibility.PUBLIC);
assertFalse(service.canViewProfile(owner, viewer));
assertEquals(0, lookupCount.get());
}
@Test
@DisplayName("Owner should always be able to view own profile")
void ownerShouldAlwaysBeAbleToViewOwnProfile() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
for (User.ProfileVisibility visibility : User.ProfileVisibility.values()) {
User owner = user("self-" + visibility.name().toLowerCase(), visibility);
assertTrue(service.canViewProfile(owner, owner));
}
}
@Test
@DisplayName("Require profile access should throw forbidden with followers message")
void requireProfileAccessShouldThrowForbiddenWithFollowersMessage() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-followers-msg", User.ProfileVisibility.FOLLOWERS);
User viewer = user("viewer-followers-msg", User.ProfileVisibility.PUBLIC);
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.requireProfileAccess(owner, viewer)
);
assertEquals(FORBIDDEN, exception.getStatusCode());
assertEquals("This profile is only visible to followers.", exception.getReason());
}
@Test
@DisplayName("Require profile access should throw forbidden with private message")
void requireProfileAccessShouldThrowForbiddenWithPrivateMessage() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-private-msg", User.ProfileVisibility.PRIVATE);
User viewer = user("viewer-private-msg", User.ProfileVisibility.PUBLIC);
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.requireProfileAccess(owner, viewer)
);
assertEquals(FORBIDDEN, exception.getStatusCode());
assertEquals("This profile is private.", exception.getReason());
}
@Test
@DisplayName("Require profile access should allow visible profiles")
void requireProfileAccessShouldAllowVisibleProfiles() {
ProfileAccessService service = createService(Optional.empty(), new AtomicInteger());
User owner = user("owner-public-visible", User.ProfileVisibility.PUBLIC);
assertDoesNotThrow(() -> service.requireProfileAccess(owner, null));
}
private ProfileAccessService createService(Optional<Follow> followLookupResult, AtomicInteger lookupCount) {
FollowRepository repository = (FollowRepository) Proxy.newProxyInstance(
FollowRepository.class.getClassLoader(),
new Class[]{FollowRepository.class},
(proxy, method, args) -> {
if ("findByFollowerIdAndFollowingActorUri".equals(method.getName())) {
lookupCount.incrementAndGet();
return followLookupResult;
}
throw new UnsupportedOperationException("Unexpected repository method: " + method.getName());
}
);
ProfileAccessService service = new ProfileAccessService(repository);
ReflectionTestUtils.setField(service, "baseUrl", "http://localhost:8080");
return service;
}
private User user(String username, User.ProfileVisibility visibility) {
return User.builder()
.id(UUID.randomUUID())
.username(username)
.email(username + "@example.com")
.passwordHash("hash")
.publicKey("pub")
.privateKey("priv")
.profileVisibility(visibility)
.build();
}
}