mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a414a37f0 | ||
|
|
25e8fbba2f | ||
|
|
297ab6c423 | ||
|
|
e0e371f8e1 | ||
|
|
bc517356c3 | ||
|
|
048f7fd769 | ||
|
|
a34df20be2 | ||
|
|
83636c2ab3 | ||
|
|
24edcc81ba | ||
|
|
1c64cea499 | ||
|
|
e2a56ca9e7 | ||
|
|
a54f97a900 | ||
|
|
1ae323f7ee | ||
|
|
6b322cf2a6 |
+20
-3
@@ -52,6 +52,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -78,6 +79,7 @@ public class ProprietaryUIDataController {
|
||||
private final MfaService mfaService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final ProfilePictureService profilePictureService;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -94,7 +96,8 @@ public class ProprietaryUIDataController {
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
MfaService mfaService,
|
||||
LoginAttemptService loginAttemptService,
|
||||
ResourceAccessService resourceAccessService) {
|
||||
ResourceAccessService resourceAccessService,
|
||||
ProfilePictureService profilePictureService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
@@ -110,6 +113,7 @@ public class ProprietaryUIDataController {
|
||||
this.mfaService = mfaService;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.profilePictureService = profilePictureService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,9 +370,18 @@ public class ProprietaryUIDataController {
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> portalAccessUserIds =
|
||||
resourceAccessService.usersWithPortalAccess(sortedUsers, activeTeamLeaderUserIds);
|
||||
// Which roster rows carry an avatar; no image bytes are loaded.
|
||||
Set<Long> usersWithProfilePicture =
|
||||
profilePictureService.withPicture(sortedUsers.stream().map(User::getId).toList());
|
||||
List<AdminUserSummary> userSummaries =
|
||||
sortedUsers.stream()
|
||||
.map(user -> convertUserToSummary(user, leaderUserIds, portalAccessUserIds))
|
||||
.map(
|
||||
user ->
|
||||
convertUserToSummary(
|
||||
user,
|
||||
leaderUserIds,
|
||||
portalAccessUserIds,
|
||||
usersWithProfilePicture))
|
||||
.toList();
|
||||
|
||||
AdminSettingsData data = new AdminSettingsData();
|
||||
@@ -589,9 +602,13 @@ public class ProprietaryUIDataController {
|
||||
* Convert a User to AdminUserSummary (excludes sensitive fields); portal access is passed in.
|
||||
*/
|
||||
private AdminUserSummary convertUserToSummary(
|
||||
User user, Set<Long> leaderUserIds, Set<Long> portalAccessUserIds) {
|
||||
User user,
|
||||
Set<Long> leaderUserIds,
|
||||
Set<Long> portalAccessUserIds,
|
||||
Set<Long> usersWithProfilePicture) {
|
||||
AdminUserSummary summary = new AdminUserSummary();
|
||||
summary.setId(user.getId());
|
||||
summary.setHasProfilePicture(usersWithProfilePicture.contains(user.getId()));
|
||||
summary.setTeamLead(leaderUserIds.contains(user.getId()));
|
||||
// Portal access (same policy /me uses).
|
||||
summary.setPortalAccess(portalAccessUserIds.contains(user.getId()));
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService.InvalidProfilePictureException;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService.StoredImage;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Per-user avatars, all routes authenticated. The batch endpoint drops ids the caller may not see
|
||||
* ({@link ProfilePictureService} owns that rule), so it can't be used to probe for accounts.
|
||||
*/
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ProfilePictureController {
|
||||
|
||||
/** Guard on the batch endpoint; a roster page never needs more than this. */
|
||||
private static final int MAX_BATCH_IDS = 500;
|
||||
|
||||
private final ProfilePictureService profilePictureService;
|
||||
private final UserService userService;
|
||||
|
||||
@Operation(summary = "Upload the signed-in user's profile picture")
|
||||
@PreAuthorize("isAuthenticated() and !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping(value = "/profile-picture", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<Map<String, Object>> upload(
|
||||
Principal principal, @RequestParam("file") MultipartFile file) {
|
||||
Optional<User> user = currentUser(principal);
|
||||
if (user.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
try {
|
||||
profilePictureService.store(user.get(), file);
|
||||
return ResponseEntity.ok(Map.of("hasProfilePicture", true));
|
||||
} catch (InvalidProfilePictureException e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "invalidImage", "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "Remove the signed-in user's profile picture")
|
||||
@PreAuthorize("isAuthenticated() and !hasAuthority('ROLE_DEMO_USER')")
|
||||
@DeleteMapping("/profile-picture")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<Map<String, Object>> remove(Principal principal) {
|
||||
Optional<User> user = currentUser(principal);
|
||||
if (user.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
profilePictureService.delete(user.get().getId());
|
||||
return ResponseEntity.ok(Map.of("hasProfilePicture", false));
|
||||
}
|
||||
|
||||
@Operation(summary = "Get the signed-in user's profile picture")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@GetMapping("/profile-picture")
|
||||
public ResponseEntity<byte[]> ownPicture(Principal principal) {
|
||||
Optional<User> user = currentUser(principal);
|
||||
if (user.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
return imageResponse(user.get().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Roster thumbnails as data URLs, keyed by user id. Data URLs rather than image URLs because
|
||||
* the app authenticates with a bearer token, which an {@code <img src>} request would not
|
||||
* carry.
|
||||
*/
|
||||
@Operation(summary = "Batch-fetch profile picture thumbnails as data URLs")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@GetMapping("/profile-pictures")
|
||||
public ResponseEntity<Map<String, String>> thumbnails(
|
||||
Principal principal, @RequestParam("userIds") List<Long> userIds) {
|
||||
Optional<User> viewer = currentUser(principal);
|
||||
if (viewer.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return ResponseEntity.ok(Map.of());
|
||||
}
|
||||
List<Long> requested = userIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (requested.size() > MAX_BATCH_IDS) {
|
||||
log.debug(
|
||||
"Profile picture batch asked for {} ids; serving the first {}",
|
||||
requested.size(),
|
||||
MAX_BATCH_IDS);
|
||||
requested = requested.subList(0, MAX_BATCH_IDS);
|
||||
}
|
||||
Set<Long> visible = profilePictureService.visibleUserIds(viewer.get(), requested);
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
profilePictureService
|
||||
.thumbnailDataUrls(visible)
|
||||
.forEach((id, dataUrl) -> body.put(String.valueOf(id), dataUrl));
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> imageResponse(Long userId) {
|
||||
Optional<StoredImage> stored = profilePictureService.findImage(userId);
|
||||
if (stored.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
StoredImage image = stored.get();
|
||||
return ResponseEntity.ok()
|
||||
// store() re-encodes every upload, so the stored type is always PNG; parsing it
|
||||
// back would only add a 500 path for a row written by anything else.
|
||||
.contentType(MediaType.IMAGE_PNG)
|
||||
// Same URI for every user, so a shared browser must not reuse it. The client keeps
|
||||
// the blob for the session anyway, so there is nothing to gain from caching here.
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Content-Disposition", "inline; filename=\"avatar.png\"")
|
||||
.body(image.data());
|
||||
}
|
||||
|
||||
private Optional<User> currentUser(Principal principal) {
|
||||
if (principal == null || principal.getName() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return userService.findByUsernameIgnoreCase(principal.getName());
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package stirling.software.proprietary.security.database.repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
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 stirling.software.proprietary.security.model.UserProfilePicture;
|
||||
|
||||
/**
|
||||
* Reads are column projections, not entity loads: loading the entity would drag the full-size image
|
||||
* bytes along even when only a flag or a thumbnail is wanted.
|
||||
*/
|
||||
@Repository
|
||||
public interface UserProfilePictureRepository extends JpaRepository<UserProfilePicture, Long> {
|
||||
|
||||
/** Which of these users have an avatar; no image bytes are read. */
|
||||
@Query("SELECT p.userId FROM UserProfilePicture p WHERE p.userId IN :ids")
|
||||
List<Long> findUserIdsWithPicture(@Param("ids") Collection<Long> ids);
|
||||
|
||||
/** (userId, thumbnailData) for the roster batch endpoint. */
|
||||
@Query("SELECT p.userId, p.thumbnailData FROM UserProfilePicture p WHERE p.userId IN :ids")
|
||||
List<Object[]> findThumbnailsByUserIds(@Param("ids") Collection<Long> ids);
|
||||
|
||||
/** (imageData, contentType) for a single avatar; empty when the user has none. */
|
||||
@Query("SELECT p.imageData, p.contentType FROM UserProfilePicture p WHERE p.userId = :id")
|
||||
List<Object[]> findImageByUserId(@Param("id") Long id);
|
||||
|
||||
void deleteByUserId(Long userId);
|
||||
}
|
||||
+4
@@ -50,6 +50,10 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
@Query("SELECT DISTINCT u FROM User u")
|
||||
List<User> findAllWithTeamAndAuthorities();
|
||||
|
||||
/** (userId, teamId) for the given users that have a primary team; used by avatar visibility. */
|
||||
@Query("SELECT u.id, u.team.id FROM User u WHERE u.id IN :ids AND u.team IS NOT NULL")
|
||||
List<Object[]> findPrimaryTeamIdsByUserIds(@Param("ids") Collection<Long> ids);
|
||||
|
||||
/** (userId, key, value) settings rows for the given users. */
|
||||
@Query("SELECT u.id, KEY(s), VALUE(s) FROM User u JOIN u.settings s WHERE u.id IN :ids")
|
||||
List<Object[]> findSettingsByUserIds(@Param("ids") Collection<Long> ids);
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
/**
|
||||
* A user's avatar, in its own table so the bytes never ride along on the eagerly-fetched users row.
|
||||
* Two sizes: the full avatar, and a thumbnail the roster endpoints inline as data URLs.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_profile_pictures")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class UserProfilePicture implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Shares the users PK; one avatar per user. */
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
@EqualsAndHashCode.Include
|
||||
@ToString.Include
|
||||
private Long userId;
|
||||
|
||||
@Lob
|
||||
@Column(name = "image_data", nullable = false, columnDefinition = "bytea")
|
||||
@JsonIgnore
|
||||
private byte[] imageData;
|
||||
|
||||
@Lob
|
||||
@Column(name = "thumbnail_data", nullable = false, columnDefinition = "bytea")
|
||||
@JsonIgnore
|
||||
private byte[] thumbnailData;
|
||||
|
||||
/** Always image/png - uploads are re-encoded on the way in. */
|
||||
@Column(name = "content_type", nullable = false, length = 100)
|
||||
private String contentType;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+3
@@ -58,6 +58,9 @@ public class AdminUserSummary {
|
||||
"Whether the user may access the portal, per the server-side access policy")
|
||||
private boolean portalAccess;
|
||||
|
||||
@Schema(description = "Whether the user has uploaded a profile picture")
|
||||
private boolean hasProfilePicture;
|
||||
|
||||
@Schema(description = "User account creation timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
+7
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -62,6 +63,12 @@ public interface TeamMembershipRepository extends JpaRepository<TeamMembership,
|
||||
+ " ORDER BY tm.createdAt ASC")
|
||||
List<TeamMembership> findPrimaryMembership(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* (userId, teamId) pairs for the given users; backs the teammate check on avatar visibility.
|
||||
*/
|
||||
@Query("SELECT tm.user.id, tm.team.id FROM TeamMembership tm WHERE tm.user.id IN :userIds")
|
||||
List<Object[]> findUserTeamPairs(@Param("userIds") Collection<Long> userIds);
|
||||
|
||||
/**
|
||||
* Find all members with a specific role in a team
|
||||
*
|
||||
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReadParam;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.database.repository.UserProfilePictureRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.UserProfilePicture;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* Per-user avatars. Uploads are re-encoded, never stored as sent, so EXIF and polyglot payloads
|
||||
* don't survive. Visible to yourself, to admins, and to people you share a team with; nobody else.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ProfilePictureService {
|
||||
|
||||
/** Stored avatar edge length, used by the account page and the sidebar. */
|
||||
public static final int AVATAR_SIZE = 256;
|
||||
|
||||
/** Roster thumbnail edge length; small enough to inline as a data URL. */
|
||||
public static final int THUMBNAIL_SIZE = 64;
|
||||
|
||||
/** Largest upload accepted before decoding. */
|
||||
public static final long MAX_UPLOAD_BYTES = 5L * 1024 * 1024;
|
||||
|
||||
/** Sanity bound on the header; the region+subsampling read in decode() bounds the memory. */
|
||||
private static final long MAX_SOURCE_PIXELS = 50_000_000L;
|
||||
|
||||
private static final String PNG = "image/png";
|
||||
|
||||
private static final byte[] PNG_SIGNATURE = {
|
||||
(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A
|
||||
};
|
||||
private static final byte[] JPEG_SIGNATURE = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF};
|
||||
private static final byte[] RIFF_SIGNATURE = {'R', 'I', 'F', 'F'};
|
||||
|
||||
private final UserProfilePictureRepository profilePictureRepository;
|
||||
private final TeamMembershipRepository teamMembershipRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/** Thrown when an upload is missing, too large, or not a decodable image. */
|
||||
public static class InvalidProfilePictureException extends RuntimeException {
|
||||
public InvalidProfilePictureException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** A stored avatar ready to serve. */
|
||||
public record StoredImage(byte[] data, String contentType) {}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<StoredImage> findImage(Long userId) {
|
||||
if (userId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Object[]> rows = profilePictureRepository.findImageByUserId(userId);
|
||||
if (rows.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Object[] row = rows.get(0);
|
||||
byte[] data = (byte[]) row[0];
|
||||
if (data == null || data.length == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new StoredImage(data, (String) row[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of {@code userIds} have an avatar. Rosters use this without loading any image bytes.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Set<Long> withPicture(Collection<Long> userIds) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return new HashSet<>(profilePictureRepository.findUserIdsWithPicture(userIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnails as {@code data:image/png;base64,...}, keyed by user id. Missing rows are absent.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<Long, String> thumbnailDataUrls(Collection<Long> userIds) {
|
||||
if (userIds == null || userIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, String> result = new LinkedHashMap<>();
|
||||
for (Object[] row : profilePictureRepository.findThumbnailsByUserIds(userIds)) {
|
||||
byte[] data = (byte[]) row[1];
|
||||
if (data != null && data.length > 0) {
|
||||
result.put(
|
||||
(Long) row[0],
|
||||
"data:" + PNG + ";base64," + Base64.getEncoder().encodeToString(data));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates, re-encodes and stores {@code file} as {@code user}'s avatar, replacing any prior.
|
||||
*/
|
||||
@Transactional
|
||||
public void store(User user, MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new InvalidProfilePictureException("No image was uploaded");
|
||||
}
|
||||
if (file.getSize() > MAX_UPLOAD_BYTES) {
|
||||
throw new InvalidProfilePictureException(
|
||||
"Image is larger than " + (MAX_UPLOAD_BYTES / (1024 * 1024)) + "MB");
|
||||
}
|
||||
|
||||
BufferedImage source = decode(file);
|
||||
byte[] avatar = encodePng(resizeSquare(source, AVATAR_SIZE));
|
||||
byte[] thumbnail = encodePng(resizeSquare(source, THUMBNAIL_SIZE));
|
||||
|
||||
UserProfilePicture picture =
|
||||
profilePictureRepository
|
||||
.findById(user.getId())
|
||||
.orElseGet(
|
||||
() -> {
|
||||
UserProfilePicture fresh = new UserProfilePicture();
|
||||
fresh.setUserId(user.getId());
|
||||
return fresh;
|
||||
});
|
||||
picture.setImageData(avatar);
|
||||
picture.setThumbnailData(thumbnail);
|
||||
picture.setContentType(PNG);
|
||||
profilePictureRepository.save(picture);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long userId) {
|
||||
if (userId != null) {
|
||||
profilePictureRepository.deleteByUserId(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/** The subset of {@code targetUserIds} {@code viewer} may see, applying the same rule. */
|
||||
@Transactional(readOnly = true)
|
||||
public Set<Long> visibleUserIds(User viewer, Collection<Long> targetUserIds) {
|
||||
if (viewer == null || viewer.getId() == null || targetUserIds == null) {
|
||||
return Set.of();
|
||||
}
|
||||
if (isAdmin(viewer)) {
|
||||
return new HashSet<>(targetUserIds);
|
||||
}
|
||||
Set<Long> lookups = new HashSet<>(targetUserIds);
|
||||
lookups.remove(null);
|
||||
lookups.add(viewer.getId());
|
||||
Map<Long, Set<Long>> teamsByUser = teamIds(lookups);
|
||||
Set<Long> viewerTeams = teamsByUser.getOrDefault(viewer.getId(), Set.of());
|
||||
|
||||
Set<Long> visible = new HashSet<>();
|
||||
for (Long targetId : targetUserIds) {
|
||||
if (targetId == null) {
|
||||
continue;
|
||||
}
|
||||
if (viewer.getId().equals(targetId)
|
||||
|| !Collections.disjoint(
|
||||
viewerTeams, teamsByUser.getOrDefault(targetId, Set.of()))) {
|
||||
visible.add(targetId);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private boolean isAdmin(User user) {
|
||||
return user.getAuthorities().stream()
|
||||
.anyMatch(authority -> Role.ADMIN.getRoleId().equals(authority.getAuthority()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Team ids per user, from both membership rows and the primary users.team_id. Both are
|
||||
* consulted because an install can carry a primary team that predates the membership table.
|
||||
*/
|
||||
private Map<Long, Set<Long>> teamIds(Collection<Long> userIds) {
|
||||
Map<Long, Set<Long>> byUser = new HashMap<>();
|
||||
for (Object[] row : teamMembershipRepository.findUserTeamPairs(userIds)) {
|
||||
byUser.computeIfAbsent((Long) row[0], key -> new HashSet<>()).add((Long) row[1]);
|
||||
}
|
||||
for (Object[] row : userRepository.findPrimaryTeamIdsByUserIds(userIds)) {
|
||||
byUser.computeIfAbsent((Long) row[0], key -> new HashSet<>()).add((Long) row[1]);
|
||||
}
|
||||
return byUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an upload, picking the reader by signature rather than letting {@code ImageIO.read}
|
||||
* choose: the app ships TwelveMonkeys' Batik plugin, which would rasterise a scriptable SVG
|
||||
* renamed to .png. Dimensions come from the header, so a bomb is refused before it allocates.
|
||||
*/
|
||||
private BufferedImage decode(MultipartFile file) {
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = file.getBytes();
|
||||
} catch (IOException e) {
|
||||
log.debug("Profile picture upload could not be read", e);
|
||||
throw new InvalidProfilePictureException("The file could not be read");
|
||||
}
|
||||
|
||||
String format = sniffFormat(bytes);
|
||||
if (format == null) {
|
||||
throw new InvalidProfilePictureException(
|
||||
"Unsupported image format - use PNG, JPEG or WebP");
|
||||
}
|
||||
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName(format);
|
||||
if (!readers.hasNext()) {
|
||||
throw new InvalidProfilePictureException(
|
||||
"No decoder available for " + format.toUpperCase(Locale.ROOT) + " images");
|
||||
}
|
||||
ImageReader reader = readers.next();
|
||||
try (ImageInputStream in =
|
||||
ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
|
||||
reader.setInput(in, true, true);
|
||||
int width = reader.getWidth(0);
|
||||
int height = reader.getHeight(0);
|
||||
if (width <= 0 || height <= 0 || (long) width * height > MAX_SOURCE_PIXELS) {
|
||||
throw new InvalidProfilePictureException("Image dimensions are too large");
|
||||
}
|
||||
Rectangle region = centreSquare(width, height);
|
||||
int step = subsamplingStep(region.width);
|
||||
ImageReadParam params = reader.getDefaultReadParam();
|
||||
params.setSourceRegion(region);
|
||||
params.setSourceSubsampling(step, step, 0, 0);
|
||||
return reader.read(0, params);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
if (e instanceof InvalidProfilePictureException invalid) {
|
||||
throw invalid;
|
||||
}
|
||||
log.debug("Profile picture upload could not be decoded", e);
|
||||
throw new InvalidProfilePictureException("The file could not be read as an image");
|
||||
} finally {
|
||||
reader.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The centre square we are going to keep anyway. Read as a region, not just subsampled, because
|
||||
* the step below is bounded by the SHORT edge: a 50000x1000 PNG is a few KB on the wire, clears
|
||||
* the pixel cap, and would still decode to ~200MB if we read the whole frame.
|
||||
*/
|
||||
static Rectangle centreSquare(int width, int height) {
|
||||
int edge = Math.min(width, height);
|
||||
return new Rectangle((width - edge) / 2, (height - edge) / 2, edge, edge);
|
||||
}
|
||||
|
||||
/** Largest decode step that still leaves ~2x the target edge to scale down from. */
|
||||
static int subsamplingStep(int edge) {
|
||||
return Math.max(1, edge / (2 * AVATAR_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* The ImageIO format name for the bytes, by signature - or null when it isn't one of the three
|
||||
* formats we accept. Deliberately ignores the client-supplied filename and content type.
|
||||
*/
|
||||
private static String sniffFormat(byte[] bytes) {
|
||||
if (startsWith(bytes, PNG_SIGNATURE)) {
|
||||
return "png";
|
||||
}
|
||||
if (startsWith(bytes, JPEG_SIGNATURE)) {
|
||||
return "jpeg";
|
||||
}
|
||||
// RIFF....WEBP
|
||||
if (bytes.length >= 12
|
||||
&& startsWith(bytes, RIFF_SIGNATURE)
|
||||
&& bytes[8] == 'W'
|
||||
&& bytes[9] == 'E'
|
||||
&& bytes[10] == 'B'
|
||||
&& bytes[11] == 'P') {
|
||||
return "webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean startsWith(byte[] bytes, byte[] prefix) {
|
||||
if (bytes.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (bytes[i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre-crops to a square then scales to {@code size}, drawn on white so alpha never bleeds.
|
||||
*/
|
||||
private static BufferedImage resizeSquare(BufferedImage source, int size) {
|
||||
int edge = Math.min(source.getWidth(), source.getHeight());
|
||||
int x = (source.getWidth() - edge) / 2;
|
||||
int y = (source.getHeight() - edge) / 2;
|
||||
BufferedImage square = source.getSubimage(x, y, edge, edge);
|
||||
|
||||
BufferedImage scaled = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = scaled.createGraphics();
|
||||
try {
|
||||
g.setRenderingHint(
|
||||
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g.drawImage(square, 0, 0, size, size, null);
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
return scaled;
|
||||
}
|
||||
|
||||
private static byte[] encodePng(BufferedImage image) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try {
|
||||
if (!ImageIO.write(image, "png", out)) {
|
||||
log.warn("No PNG writer available; profile picture could not be encoded");
|
||||
throw new InvalidProfilePictureException("Could not encode the image");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// The input is an image we just built, so this is ours, not the user's.
|
||||
log.warn("Profile picture could not be encoded as PNG", e);
|
||||
throw new InvalidProfilePictureException("Could not encode the image");
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
+5
@@ -47,6 +47,7 @@ import stirling.software.proprietary.integration.repository.IntegrationConfigRep
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserProfilePictureRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
@@ -96,6 +97,7 @@ public class UserService implements UserServiceInterface {
|
||||
private final IntegrationConfigRepository integrationConfigRepository;
|
||||
private final TeamMembershipService teamMembershipService;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final UserProfilePictureRepository userProfilePictureRepository;
|
||||
|
||||
@Transactional
|
||||
public void processSSOPostLogin(
|
||||
@@ -281,6 +283,9 @@ public class UserService implements UserServiceInterface {
|
||||
// Delete server certificate (non-nullable OneToOne → User)
|
||||
userServerCertificateService.deleteUserCertificate(user.getId());
|
||||
|
||||
// Avatar row keys off users.user_id and would dangle once the user row is gone
|
||||
userProfilePictureRepository.deleteByUserId(user.getId());
|
||||
|
||||
// Delete FileShareAccess records where this user is the accessor
|
||||
fileShareAccessRepository.deleteByUser(user);
|
||||
|
||||
|
||||
+3
-1
@@ -44,6 +44,7 @@ import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@@ -222,7 +223,8 @@ class AdminSettingsPerfHarness {
|
||||
mock(PersistentAuditEventRepository.class),
|
||||
mock(MfaService.class),
|
||||
loginAttemptService,
|
||||
resourceAccessService);
|
||||
resourceAccessService,
|
||||
mock(ProfilePictureService.class));
|
||||
}
|
||||
|
||||
Authentication adminAuth() {
|
||||
|
||||
+4
-1
@@ -46,6 +46,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@@ -67,6 +68,7 @@ class ProprietaryUIDataControllerMoreTest {
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private ResourceAccessService resourceAccessService;
|
||||
@Mock private ProfilePictureService profilePictureService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AuditConfigurationProperties auditConfig;
|
||||
@@ -100,7 +102,8 @@ class ProprietaryUIDataControllerMoreTest {
|
||||
auditRepository,
|
||||
mfaService,
|
||||
loginAttemptService,
|
||||
resourceAccessService);
|
||||
resourceAccessService,
|
||||
profilePictureService);
|
||||
}
|
||||
|
||||
private static User normalUser(Long id, String username) {
|
||||
|
||||
+4
-1
@@ -32,6 +32,7 @@ import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@@ -52,6 +53,7 @@ class ProprietaryUIDataControllerTest {
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private ResourceAccessService resourceAccessService;
|
||||
@Mock private ProfilePictureService profilePictureService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AuditConfigurationProperties auditConfig;
|
||||
@@ -87,7 +89,8 @@ class ProprietaryUIDataControllerTest {
|
||||
auditRepository,
|
||||
mfaService,
|
||||
loginAttemptService,
|
||||
resourceAccessService);
|
||||
resourceAccessService,
|
||||
profilePictureService);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService.InvalidProfilePictureException;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService.StoredImage;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* The visibility rule lives in the service, but the controller is what applies it. These pin the
|
||||
* glue: feed the batch the filtered id set, and never confirm an avatar the caller may not see.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ProfilePictureControllerTest {
|
||||
|
||||
@Mock private ProfilePictureService profilePictureService;
|
||||
@Mock private UserService userService;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private static final Principal VIEWER = () -> "viewer";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc =
|
||||
MockMvcBuilders.standaloneSetup(
|
||||
new ProfilePictureController(profilePictureService, userService))
|
||||
.build();
|
||||
User viewer = new User();
|
||||
viewer.setId(1L);
|
||||
viewer.setUsername("viewer");
|
||||
// lenient: the unknown-principal case never resolves this one.
|
||||
lenient()
|
||||
.when(userService.findByUsernameIgnoreCase("viewer"))
|
||||
.thenReturn(Optional.of(viewer));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBatchOnlyLooksUpThumbnailsForIdsTheViewerMaySee() throws Exception {
|
||||
when(profilePictureService.visibleUserIds(any(), anyCollection()))
|
||||
.thenReturn(Set.of(1L, 2L));
|
||||
when(profilePictureService.thumbnailDataUrls(anyCollection()))
|
||||
.thenReturn(
|
||||
Map.of(1L, "data:image/png;base64,AQID", 2L, "data:image/png;base64,BAUG"));
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v1/user/profile-pictures")
|
||||
.param("userIds", "1,2,3")
|
||||
.principal(VIEWER))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.1").exists())
|
||||
.andExpect(jsonPath("$.2").exists())
|
||||
.andExpect(jsonPath("$.3").doesNotExist());
|
||||
|
||||
// The load-bearing assertion: passing the unfiltered ids here would leak every avatar, and
|
||||
// thumbnailDataUrls does no filtering of its own.
|
||||
ArgumentCaptor<Collection<Long>> looked = ArgumentCaptor.forClass(Collection.class);
|
||||
verify(profilePictureService).thumbnailDataUrls(looked.capture());
|
||||
assertThat(looked.getValue()).containsExactlyInAnyOrder(1L, 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBatchIsCappedSoOneRequestCannotSweepAWholeInstall() throws Exception {
|
||||
when(profilePictureService.visibleUserIds(any(), anyCollection())).thenReturn(Set.of());
|
||||
when(profilePictureService.thumbnailDataUrls(anyCollection())).thenReturn(Map.of());
|
||||
// Duplicated ids also exercise the distinct() pass before the cap applies.
|
||||
String ids =
|
||||
LongStream.rangeClosed(1, 600)
|
||||
.boxed()
|
||||
.flatMap(id -> List.of(id, id).stream())
|
||||
.map(String::valueOf)
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v1/user/profile-pictures")
|
||||
.param("userIds", ids)
|
||||
.principal(VIEWER))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
ArgumentCaptor<Collection<Long>> requested = ArgumentCaptor.forClass(Collection.class);
|
||||
verify(profilePictureService).visibleUserIds(any(), requested.capture());
|
||||
assertThat(requested.getValue()).hasSize(500);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyIdListSkipsTheServiceEntirely() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/user/profile-pictures").param("userIds", "").principal(VIEWER))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(profilePictureService, never()).thumbnailDataUrls(anyCollection());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownPictureIsA404WhenThereIsNoneRatherThanAnEmptyBody() throws Exception {
|
||||
when(profilePictureService.findImage(1L)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/profile-picture").principal(VIEWER))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownPictureIsNeverCached() throws Exception {
|
||||
// Every user shares this URI, so a cached copy would follow the browser profile, not the
|
||||
// account.
|
||||
when(profilePictureService.findImage(1L))
|
||||
.thenReturn(Optional.of(new StoredImage(new byte[] {1, 2, 3}, "image/png")));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/profile-picture").principal(VIEWER))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(MediaType.IMAGE_PNG))
|
||||
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
|
||||
.andExpect(
|
||||
result ->
|
||||
assertThat(result.getResponse().getHeader("Cache-Control"))
|
||||
.contains("no-store"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStoredTypeThatIsNotAMediaTypeStillServesAsPngRatherThanA500() throws Exception {
|
||||
// Nothing writes this today, but parsing the column back would turn a bad row into a 500.
|
||||
when(profilePictureService.findImage(1L))
|
||||
.thenReturn(Optional.of(new StoredImage(new byte[] {1, 2, 3}, "not a media type")));
|
||||
|
||||
mockMvc.perform(get("/api/v1/user/profile-picture").principal(VIEWER))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(MediaType.IMAGE_PNG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownPrincipalGetsA401RatherThanAnEmptyRoster() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v1/user/profile-pictures")
|
||||
.param("userIds", "1")
|
||||
.principal(() -> "ghost"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadStoresAgainstTheResolvedUserNotTheRequest() throws Exception {
|
||||
mockMvc.perform(multipart("/api/v1/user/profile-picture").file(png()).principal(VIEWER))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.hasProfilePicture").value(true));
|
||||
|
||||
// The load-bearing assertion: the account written to comes from the principal, so a
|
||||
// request cannot aim the upload at somebody else.
|
||||
ArgumentCaptor<User> stored = ArgumentCaptor.forClass(User.class);
|
||||
verify(profilePictureService).store(stored.capture(), any(MultipartFile.class));
|
||||
assertThat(stored.getValue().getId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRejectedUploadIsA400CarryingTheReasonRatherThanA500() throws Exception {
|
||||
doThrow(new InvalidProfilePictureException("Image is larger than 5MB"))
|
||||
.when(profilePictureService)
|
||||
.store(any(), any(MultipartFile.class));
|
||||
|
||||
mockMvc.perform(multipart("/api/v1/user/profile-picture").file(png()).principal(VIEWER))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("invalidImage"))
|
||||
.andExpect(jsonPath("$.message").value("Image is larger than 5MB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadFromAnUnknownPrincipalStoresNothing() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/api/v1/user/profile-picture")
|
||||
.file(png())
|
||||
.principal(() -> "ghost"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
verifyNoInteractions(profilePictureService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeDeletesTheSignedInUsersOwnPicture() throws Exception {
|
||||
mockMvc.perform(delete("/api/v1/user/profile-picture").principal(VIEWER))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.hasProfilePicture").value(false));
|
||||
|
||||
verify(profilePictureService).delete(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeFromAnUnknownPrincipalDeletesNothing() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(delete("/api/v1/user/profile-picture").principal(() -> "ghost"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
|
||||
verifyNoInteractions(profilePictureService);
|
||||
}
|
||||
|
||||
private static MockMultipartFile png() {
|
||||
return new MockMultipartFile("file", "avatar.png", "image/png", new byte[] {1, 2, 3});
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.database.repository.UserProfilePictureRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.UserProfilePicture;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.proprietary.security.service.ProfilePictureService.InvalidProfilePictureException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ProfilePictureServiceTest {
|
||||
|
||||
@Mock private UserProfilePictureRepository profilePictureRepository;
|
||||
@Mock private TeamMembershipRepository teamMembershipRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
|
||||
private ProfilePictureService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service =
|
||||
new ProfilePictureService(
|
||||
profilePictureRepository, teamMembershipRepository, userRepository);
|
||||
}
|
||||
|
||||
private static User user(Long id, String... authorities) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setUsername("user" + id);
|
||||
for (String authority : authorities) {
|
||||
Authority granted = new Authority();
|
||||
granted.setAuthority(authority);
|
||||
user.addAuthority(granted);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private static byte[] pngBytes(int width, int height) throws IOException {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = image.createGraphics();
|
||||
g.setColor(Color.RED);
|
||||
g.fillRect(0, 0, width, height);
|
||||
g.dispose();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "png", out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeNormalisesToTwoFixedSquareSizes() throws IOException {
|
||||
User owner = user(1L);
|
||||
when(profilePictureRepository.findById(1L)).thenReturn(Optional.empty());
|
||||
when(profilePictureRepository.save(any(UserProfilePicture.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// Deliberately non-square and oversized so both the crop and the downscale are exercised.
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", "me.png", "image/png", pngBytes(900, 400));
|
||||
service.store(owner, file);
|
||||
|
||||
ArgumentCaptor<UserProfilePicture> saved =
|
||||
ArgumentCaptor.forClass(UserProfilePicture.class);
|
||||
org.mockito.Mockito.verify(profilePictureRepository).save(saved.capture());
|
||||
UserProfilePicture picture = saved.getValue();
|
||||
|
||||
assertThat(picture.getUserId()).isEqualTo(1L);
|
||||
assertThat(picture.getContentType()).isEqualTo("image/png");
|
||||
|
||||
BufferedImage avatar = ImageIO.read(new ByteArrayInputStream(picture.getImageData()));
|
||||
assertThat(avatar.getWidth()).isEqualTo(ProfilePictureService.AVATAR_SIZE);
|
||||
assertThat(avatar.getHeight()).isEqualTo(ProfilePictureService.AVATAR_SIZE);
|
||||
|
||||
BufferedImage thumbnail =
|
||||
ImageIO.read(new ByteArrayInputStream(picture.getThumbnailData()));
|
||||
assertThat(thumbnail.getWidth()).isEqualTo(ProfilePictureService.THUMBNAIL_SIZE);
|
||||
assertThat(thumbnail.getHeight()).isEqualTo(ProfilePictureService.THUMBNAIL_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeRejectsAFileThatIsNotAnImage() {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file",
|
||||
"payload.png",
|
||||
"image/png",
|
||||
"<script>alert(1)</script>".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(() -> service.store(user(1L), file))
|
||||
.isInstanceOf(InvalidProfilePictureException.class)
|
||||
.hasMessageContaining("Unsupported image format");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeRejectsAnSvgEvenThoughImageIoCanRasteriseOne() {
|
||||
// The app ships TwelveMonkeys' Batik plugin, so ImageIO.read() would happily render this
|
||||
// scriptable document. Only PNG/JPEG/WebP signatures may reach a reader.
|
||||
String svg =
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\">"
|
||||
+ "<script>alert(1)</script></svg>";
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"file", "avatar.png", "image/png", svg.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(() -> service.store(user(1L), file))
|
||||
.isInstanceOf(InvalidProfilePictureException.class)
|
||||
.hasMessageContaining("Unsupported image format");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExtremeAspectRatioStillDecodesOnlyTheCentreSquare() {
|
||||
// Subsampling alone is bounded by the SHORT edge, so on a 50000x1000 frame the step is 1
|
||||
// and the whole ~200MB raster would be decoded. The region is what keeps it bounded.
|
||||
Rectangle region = ProfilePictureService.centreSquare(50000, 1000);
|
||||
assertThat(region.width).isEqualTo(1000);
|
||||
assertThat(region.height).isEqualTo(1000);
|
||||
assertThat(region.x).isEqualTo(24500);
|
||||
assertThat(region.y).isZero();
|
||||
|
||||
long decodedPixels =
|
||||
(long) region.width
|
||||
* region.height
|
||||
/ (long) Math.pow(ProfilePictureService.subsamplingStep(region.width), 2);
|
||||
assertThat(decodedPixels).isLessThan(2_000_000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSquareBombIsSubsampledDownToRoughlyTwiceTheTargetEdge() {
|
||||
Rectangle region = ProfilePictureService.centreSquare(7000, 7000);
|
||||
int step = ProfilePictureService.subsamplingStep(region.width);
|
||||
|
||||
assertThat(region.width / step)
|
||||
.isBetween(
|
||||
ProfilePictureService.AVATAR_SIZE, 3 * ProfilePictureService.AVATAR_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anImageAlreadySmallerThanTheTargetIsNotSubsampled() {
|
||||
assertThat(ProfilePictureService.subsamplingStep(200)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeRejectsAnEmptyUpload() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "me.png", "image/png", new byte[0]);
|
||||
|
||||
assertThatThrownBy(() -> service.store(user(1L), file))
|
||||
.isInstanceOf(InvalidProfilePictureException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeAcceptsAJpegAndStillWritesPng() throws IOException {
|
||||
User owner = user(1L);
|
||||
when(profilePictureRepository.findById(1L)).thenReturn(Optional.empty());
|
||||
when(profilePictureRepository.save(any(UserProfilePicture.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
BufferedImage source = new BufferedImage(120, 80, BufferedImage.TYPE_INT_RGB);
|
||||
ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
|
||||
ImageIO.write(source, "jpeg", jpeg);
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", "me.jpg", "image/jpeg", jpeg.toByteArray());
|
||||
|
||||
service.store(owner, file);
|
||||
|
||||
ArgumentCaptor<UserProfilePicture> saved =
|
||||
ArgumentCaptor.forClass(UserProfilePicture.class);
|
||||
org.mockito.Mockito.verify(profilePictureRepository).save(saved.capture());
|
||||
assertThat(saved.getValue().getContentType()).isEqualTo("image/png");
|
||||
assertThat(ImageIO.read(new ByteArrayInputStream(saved.getValue().getImageData())))
|
||||
.isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyoneCanSeeTheirOwnPicture() {
|
||||
User viewer = user(7L);
|
||||
stubNoTeams();
|
||||
|
||||
assertThat(service.visibleUserIds(viewer, List.of(7L))).containsExactly(7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminsCanSeeEveryPicture() {
|
||||
User admin = user(1L, Role.ADMIN.getRoleId());
|
||||
|
||||
assertThat(service.visibleUserIds(admin, List.of(99L))).containsExactly(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void teammatesCanSeeEachOther() {
|
||||
User viewer = user(1L, Role.USER.getRoleId());
|
||||
when(teamMembershipRepository.findUserTeamPairs(anyCollection()))
|
||||
.thenReturn(List.of(new Object[] {1L, 50L}, new Object[] {2L, 50L}));
|
||||
when(userRepository.findPrimaryTeamIdsByUserIds(anyCollection())).thenReturn(List.of());
|
||||
|
||||
assertThat(service.visibleUserIds(viewer, List.of(2L))).containsExactly(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aUserOnAnotherTeamIsNotVisible() {
|
||||
User viewer = user(1L, Role.USER.getRoleId());
|
||||
when(teamMembershipRepository.findUserTeamPairs(anyCollection()))
|
||||
.thenReturn(List.of(new Object[] {1L, 50L}, new Object[] {3L, 51L}));
|
||||
when(userRepository.findPrimaryTeamIdsByUserIds(anyCollection())).thenReturn(List.of());
|
||||
|
||||
assertThat(service.visibleUserIds(viewer, List.of(3L))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theLegacyPrimaryTeamAlsoCountsAsSharedMembership() {
|
||||
// An install predating team_memberships still has users.team_id; both are consulted.
|
||||
User viewer = user(1L, Role.USER.getRoleId());
|
||||
when(teamMembershipRepository.findUserTeamPairs(anyCollection())).thenReturn(List.of());
|
||||
when(userRepository.findPrimaryTeamIdsByUserIds(anyCollection()))
|
||||
.thenReturn(List.of(new Object[] {1L, 50L}, new Object[] {4L, 50L}));
|
||||
|
||||
assertThat(service.visibleUserIds(viewer, List.of(4L))).containsExactly(4L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void visibleUserIdsFiltersOutStrangers() {
|
||||
User viewer = user(1L, Role.USER.getRoleId());
|
||||
when(teamMembershipRepository.findUserTeamPairs(anyCollection()))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new Object[] {1L, 50L},
|
||||
new Object[] {2L, 50L},
|
||||
new Object[] {3L, 51L}));
|
||||
when(userRepository.findPrimaryTeamIdsByUserIds(anyCollection())).thenReturn(List.of());
|
||||
|
||||
assertThat(service.visibleUserIds(viewer, List.of(1L, 2L, 3L))).isEqualTo(Set.of(1L, 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAnonymousViewerSeesNothing() {
|
||||
assertThat(service.visibleUserIds(null, List.of(1L, 2L))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void thumbnailsAreReturnedAsPngDataUrls() {
|
||||
byte[] bytes = {1, 2, 3};
|
||||
when(profilePictureRepository.findThumbnailsByUserIds(anyCollection()))
|
||||
.thenReturn(List.<Object[]>of(new Object[] {5L, bytes}));
|
||||
|
||||
assertThat(service.thumbnailDataUrls(List.of(5L)))
|
||||
.containsEntry(5L, "data:image/png;base64,AQID");
|
||||
}
|
||||
|
||||
private void stubNoTeams() {
|
||||
lenient()
|
||||
.when(teamMembershipRepository.findUserTeamPairs(anyCollection()))
|
||||
.thenReturn(List.of());
|
||||
lenient()
|
||||
.when(userRepository.findPrimaryTeamIdsByUserIds(anyCollection()))
|
||||
.thenReturn(List.of());
|
||||
}
|
||||
}
|
||||
+4
@@ -28,6 +28,7 @@ import stirling.software.proprietary.access.repository.ResourceGrantRepository;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserProfilePictureRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
@@ -72,6 +73,7 @@ class UserServiceTest {
|
||||
|
||||
@Mock private TeamMembershipService teamMembershipService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
@Mock private UserProfilePictureRepository userProfilePictureRepository;
|
||||
|
||||
@Spy @InjectMocks private UserService userService;
|
||||
|
||||
@@ -267,6 +269,8 @@ class UserServiceTest {
|
||||
userService.deleteUser("target");
|
||||
|
||||
verify(userServerCertificateService).deleteUserCertificate(1L);
|
||||
// Avatar row keys off users.user_id, so it must go before the user row
|
||||
verify(userProfilePictureRepository).deleteByUserId(1L);
|
||||
verify(fileShareAccessRepository).deleteByUser(user);
|
||||
// Inbound share (file shared with this user by others) cleaned up
|
||||
verify(fileShareAccessRepository).deleteByFileShare(inboundShare);
|
||||
|
||||
@@ -106,6 +106,7 @@ public final class SaasSchemaOwnership {
|
||||
"stored_file_blobs",
|
||||
"stored_files",
|
||||
"user_license_settings",
|
||||
"user_profile_pictures",
|
||||
"user_server_certificates",
|
||||
"workflow_participants",
|
||||
"workflow_sessions");
|
||||
|
||||
@@ -164,6 +164,21 @@ title = "Two-factor authentication"
|
||||
logOut = "Log out"
|
||||
signedInAs = "Signed in as: {{email}}"
|
||||
|
||||
[account.profilePicture]
|
||||
change = "Change picture"
|
||||
current = "Your profile picture"
|
||||
description = "Add a picture so teammates recognise you across Stirling PDF."
|
||||
help = "PNG, JPG or WebP, up to {{megabytes}}MB. Pictures are cropped and resized automatically."
|
||||
remove = "Remove"
|
||||
removeConfirm = "Your picture will be deleted and teammates will see your initials again. You can upload a new one at any time."
|
||||
removeError = "Could not remove your profile picture. Please try again."
|
||||
removeTitle = "Remove profile picture"
|
||||
sizeError = "Please choose an image smaller than {{megabytes}}MB."
|
||||
title = "Profile picture"
|
||||
upload = "Upload picture"
|
||||
uploadError = "Could not upload your profile picture. Please try again."
|
||||
visibility = "Visible to you, your administrators, and people on your teams."
|
||||
|
||||
[add-page-numbers]
|
||||
tags = "paginate,label,organize,index"
|
||||
|
||||
|
||||
+4
@@ -157,6 +157,10 @@ export const ProfilePictureCropper: React.FC<ProfilePictureCropperProps> = ({
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
// Avatars render as circles everywhere, so frame the crop as one; without this a
|
||||
// face framed to the corners silently loses them.
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={onCropChange}
|
||||
onZoomChange={onZoomChange}
|
||||
onCropComplete={onCropCompleteCallback}
|
||||
@@ -46,7 +46,7 @@
|
||||
height: 2.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
/* Account-settings hero disc. */
|
||||
/* Account-settings hero disc: large enough to judge the picture you uploaded. */
|
||||
.sui-avatar--xl {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
|
||||
@@ -64,9 +64,10 @@ export function Avatar({
|
||||
.join(" ");
|
||||
|
||||
const content = showImage ? (
|
||||
// The wrapper already carries the accessible name, so the image is decorative.
|
||||
<img
|
||||
src={src}
|
||||
alt={ariaLabel ?? name}
|
||||
alt=""
|
||||
className="sui-avatar__img"
|
||||
onError={() => setSrcFailed(true)}
|
||||
/>
|
||||
|
||||
+17
-11
@@ -10,6 +10,12 @@ export interface Area {
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest edge of the returned image. react-easy-crop reports the crop in natural pixels, so an
|
||||
* uncapped canvas turns a phone photo into a multi-MB lossless PNG that the upload gates reject.
|
||||
*/
|
||||
const MAX_OUTPUT_EDGE = 512;
|
||||
|
||||
/**
|
||||
* Creates a cropped image blob from the source image and crop area.
|
||||
*
|
||||
@@ -26,7 +32,6 @@ export async function getCroppedImage(
|
||||
|
||||
image.onload = () => {
|
||||
try {
|
||||
// Create canvas with crop dimensions
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
@@ -35,16 +40,17 @@ export async function getCroppedImage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to crop dimensions
|
||||
canvas.width = pixelCrop.width;
|
||||
canvas.height = pixelCrop.height;
|
||||
// Downscale to MAX_OUTPUT_EDGE; also keeps the canvas under Safari's ~16.7M pixel ceiling,
|
||||
// past which toBlob returns null.
|
||||
const scale = Math.min(
|
||||
1,
|
||||
MAX_OUTPUT_EDGE / Math.max(pixelCrop.width, pixelCrop.height),
|
||||
);
|
||||
canvas.width = Math.max(1, Math.round(pixelCrop.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(pixelCrop.height * scale));
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
// Draw the cropped region
|
||||
// drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
|
||||
// sx, sy: source x, y coordinates
|
||||
// sw, sh: source width, height
|
||||
// dx, dy: destination x, y coordinates (0, 0 for top-left)
|
||||
// dw, dh: destination width, height
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
@@ -53,8 +59,8 @@ export async function getCroppedImage(
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
|
||||
// Convert canvas to PNG blob
|
||||
@@ -35,7 +35,10 @@ export interface Member {
|
||||
portalGrantId?: number;
|
||||
/** Relative-time string, e.g. "4m ago". Invited members read "—". */
|
||||
lastActive: string;
|
||||
/** Optional avatar image; falls back to initials when absent. */
|
||||
/**
|
||||
* Optional avatar image; falls back to initials when absent. Self-hosted supplies a data URL (the
|
||||
* bearer-token transport rules out a plain image URL); SaaS has none yet.
|
||||
*/
|
||||
avatarUrl?: string;
|
||||
/** Backend linkage for row actions (absent on pure fixtures). */
|
||||
username?: string;
|
||||
@@ -226,6 +229,7 @@ interface AdminUserSummaryDto {
|
||||
authenticationType?: string;
|
||||
/** Authoritative server-side portal access (honors the configured default policy). */
|
||||
portalAccess?: boolean;
|
||||
hasProfilePicture?: boolean;
|
||||
}
|
||||
|
||||
interface AdminSettingsDto {
|
||||
@@ -275,6 +279,42 @@ function normalizeSeatLimit(max: number | undefined): number | null {
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Ids per avatar batch request; see fetchAvatarThumbnails. */
|
||||
const AVATAR_BATCH_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Roster avatars as data URLs, keyed by user id. Data URLs because the portal authenticates with a
|
||||
* bearer token, which an `<img src>` request would not carry. Avatars are decoration, so a failure
|
||||
* degrades to initials rather than failing the roster. Kept on the portal's own transport rather
|
||||
* than reusing the editor's service, which would pull the editor apiClient into this bundle.
|
||||
*/
|
||||
async function fetchAvatarThumbnails(
|
||||
userIds: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const ids = Array.from(new Set(userIds)).filter(Boolean);
|
||||
if (ids.length === 0) return {};
|
||||
// Chunked: the ids ride in the query string, and a few thousand overflow nginx's default 8KB
|
||||
// request line, which 414s and wipes every avatar. Also stays under the server's 500-id cap.
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += AVATAR_BATCH_SIZE) {
|
||||
chunks.push(ids.slice(i, i + AVATAR_BATCH_SIZE));
|
||||
}
|
||||
const results = await Promise.all(
|
||||
chunks.map(async (chunk) => {
|
||||
try {
|
||||
return (
|
||||
(await apiClient.local.json<Record<string, string>>(
|
||||
`/api/v1/user/profile-pictures?userIds=${encodeURIComponent(chunk.join(","))}`,
|
||||
)) ?? {}
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}),
|
||||
);
|
||||
return Object.assign({}, ...results);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/proprietary/ui-data/admin-settings adapted onto the portal's
|
||||
* UsersResponse. Role = stored authority + team leadership; the role
|
||||
@@ -303,6 +343,16 @@ export async function fetchUsers(tier: Tier): Promise<UsersResponse> {
|
||||
authType: u.authenticationType,
|
||||
authority: u.rolesAsString,
|
||||
}));
|
||||
const avatars = await fetchAvatarThumbnails(
|
||||
(data.users ?? [])
|
||||
.filter((u) => u.hasProfilePicture)
|
||||
.map((u) => String(u.id)),
|
||||
);
|
||||
for (const member of members) {
|
||||
const avatarUrl = avatars[member.id];
|
||||
if (avatarUrl) member.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
const seatLimit = normalizeSeatLimit(data.maxAllowedUsers);
|
||||
const seatsUsed = data.totalUsers ?? members.length;
|
||||
return {
|
||||
|
||||
@@ -36,12 +36,16 @@ const MEMBER: Member = {
|
||||
};
|
||||
const TEAMS: Team[] = [{ id: 1, name: "Acme", userCount: 1, owners: [] }];
|
||||
|
||||
function renderDirectory(caps: typeof saasCaps, teams: Team[] = TEAMS) {
|
||||
function renderDirectory(
|
||||
caps: typeof saasCaps,
|
||||
teams: Team[] = TEAMS,
|
||||
members: Member[] = [MEMBER],
|
||||
) {
|
||||
const onRemove = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<UsersDirectory
|
||||
members={[MEMBER]}
|
||||
members={members}
|
||||
teams={teams}
|
||||
capabilities={caps}
|
||||
processorTeamIds={new Set()}
|
||||
@@ -108,3 +112,28 @@ describe("flavor capabilities — invitations + remove scope", () => {
|
||||
expect(selfHostedCaps.removeScope).toBe("org");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UsersDirectory - member avatars", () => {
|
||||
// The Avatar wrapper always carries role="img"; the picture is the nested <img>.
|
||||
const pictureOf = () =>
|
||||
document.querySelector<HTMLImageElement>("img.sui-avatar__img");
|
||||
|
||||
it("shows the member's picture when the roster carried one", () => {
|
||||
// Self-hosted supplies a data URL; the row only cares that it has one.
|
||||
const withPicture: Member = {
|
||||
...MEMBER,
|
||||
avatarUrl: "data:image/png;base64,AQID",
|
||||
};
|
||||
renderDirectory(selfHostedCaps, TEAMS, [withPicture]);
|
||||
|
||||
expect(pictureOf()).toHaveAttribute("src", "data:image/png;base64,AQID");
|
||||
});
|
||||
|
||||
it("falls back to initials when the member has no picture", () => {
|
||||
renderDirectory(selfHostedCaps);
|
||||
|
||||
expect(pictureOf()).toBeNull();
|
||||
// A one-word name renders a single initial.
|
||||
expect(screen.getByText("P")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +180,12 @@ export function UsersDirectory({
|
||||
key: "person",
|
||||
header: t("users.columns.person", "Person"),
|
||||
icon: (m) => (
|
||||
<Avatar name={m.name} size="sm" tone={avatarToneForMember(m)} />
|
||||
<Avatar
|
||||
src={m.avatarUrl}
|
||||
name={m.name}
|
||||
size="sm"
|
||||
tone={avatarToneForMember(m)}
|
||||
/>
|
||||
),
|
||||
primary: (m) => m.name,
|
||||
suffix: (m) => (m.isSelf ? t("users.you", "(you)") : undefined),
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
upload: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
pictureUrl: null as string | null,
|
||||
/** Set by the cropper stub so a test can drive the post-crop upload. */
|
||||
cropComplete: null as ((blob: Blob) => void) | null,
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/profilePictureService", () => ({
|
||||
MAX_PROFILE_PICTURE_BYTES: h.maxBytes,
|
||||
PROFILE_PICTURE_ACCEPT: "image/png,image/jpeg,image/webp",
|
||||
uploadProfilePicture: h.upload,
|
||||
removeProfilePicture: h.remove,
|
||||
}));
|
||||
vi.mock("@app/hooks/useProfilePictureUrl", () => ({
|
||||
useProfilePictureUrl: () => h.pictureUrl,
|
||||
refreshOwnProfilePicture: h.refresh,
|
||||
}));
|
||||
// The real cropper pulls in react-easy-crop and a canvas; the card only cares that it opened.
|
||||
vi.mock("@app/components/shared/config/ProfilePictureCropper", () => ({
|
||||
ProfilePictureCropper: ({
|
||||
opened,
|
||||
onCropComplete,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onCropComplete: (blob: Blob) => void;
|
||||
}) => {
|
||||
h.cropComplete = onCropComplete;
|
||||
return opened ? <div data-testid="cropper" /> : null;
|
||||
},
|
||||
}));
|
||||
vi.mock("@app/components/shared/LocalIcon", () => ({
|
||||
default: () => <span />,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string, vars?: Record<string, unknown>) =>
|
||||
(fallback ?? _key).replace(/\{\{(\w+)\}\}/g, (_match, name: string) =>
|
||||
String(vars?.[name] ?? ""),
|
||||
),
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
import ProfilePictureCard from "@app/components/shared/config/ProfilePictureCard";
|
||||
|
||||
function renderCard() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ProfilePictureCard displayName="Priya Raman" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Hands the hidden file input a file of the given nominal size, bypassing the OS dialog. */
|
||||
function pickFile(sizeBytes: number) {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
const file = new File(["x"], "avatar.png", { type: "image/png" });
|
||||
Object.defineProperty(file, "size", { value: sizeBytes });
|
||||
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
/** Drives the cropper stub's callback, i.e. everything after the user confirms the crop. */
|
||||
async function finishCrop() {
|
||||
await act(async () => {
|
||||
await h.cropComplete?.(new Blob(["x"]));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.upload.mockReset();
|
||||
h.remove.mockReset();
|
||||
h.refresh.mockReset().mockResolvedValue(undefined);
|
||||
h.pictureUrl = null;
|
||||
h.cropComplete = null;
|
||||
});
|
||||
|
||||
describe("ProfilePictureCard - size gate", () => {
|
||||
it("refuses a file over the limit and names the limit in the error", () => {
|
||||
renderCard();
|
||||
pickFile(h.maxBytes + 1);
|
||||
|
||||
expect(
|
||||
screen.getByText("Please choose an image smaller than 5MB."),
|
||||
).toBeInTheDocument();
|
||||
// The load-bearing assertion: an oversized file must not reach the cropper or the upload.
|
||||
expect(screen.queryByTestId("cropper")).not.toBeInTheDocument();
|
||||
expect(h.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a file at exactly the limit through to the cropper", () => {
|
||||
renderCard();
|
||||
pickFile(h.maxBytes);
|
||||
|
||||
expect(screen.getByTestId("cropper")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Please choose an image smaller than 5MB."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears a previous size error once an acceptable file is picked", () => {
|
||||
renderCard();
|
||||
pickFile(h.maxBytes + 1);
|
||||
expect(
|
||||
screen.getByText("Please choose an image smaller than 5MB."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
pickFile(1024);
|
||||
|
||||
expect(
|
||||
screen.queryByText("Please choose an image smaller than 5MB."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProfilePictureCard - error surface", () => {
|
||||
it("shows the server's reason when the upload is rejected", async () => {
|
||||
h.upload.mockRejectedValue({
|
||||
response: { data: { message: "Unsupported image format" } },
|
||||
});
|
||||
renderCard();
|
||||
pickFile(1024);
|
||||
await finishCrop();
|
||||
|
||||
expect(screen.getByText("Unsupported image format")).toBeInTheDocument();
|
||||
expect(h.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a generic message when the upload failure carries none", async () => {
|
||||
h.upload.mockRejectedValue(new Error("network"));
|
||||
renderCard();
|
||||
pickFile(1024);
|
||||
await finishCrop();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Could not upload your profile picture. Please try again.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a failed removal", async () => {
|
||||
h.pictureUrl = "blob:avatar";
|
||||
h.remove.mockRejectedValue(new Error("boom"));
|
||||
renderCard();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
|
||||
// Removal is confirmed in a modal, so the destructive click is the one inside it.
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await act(async () => {
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
|
||||
});
|
||||
|
||||
expect(h.remove).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Could not remove your profile picture. Please try again.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes every consumer after a successful upload", async () => {
|
||||
h.upload.mockResolvedValue(undefined);
|
||||
renderCard();
|
||||
pickFile(1024);
|
||||
await finishCrop();
|
||||
|
||||
expect(h.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, Group, Modal, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "@app/ui/Avatar";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { FilePicker } from "@app/ui/FilePicker";
|
||||
import { ProfilePictureCropper } from "@app/components/shared/config/ProfilePictureCropper";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import {
|
||||
MAX_PROFILE_PICTURE_BYTES,
|
||||
PROFILE_PICTURE_ACCEPT,
|
||||
removeProfilePicture,
|
||||
uploadProfilePicture,
|
||||
} from "@app/services/profilePictureService";
|
||||
import {
|
||||
refreshOwnProfilePicture,
|
||||
useProfilePictureUrl,
|
||||
} from "@app/hooks/useProfilePictureUrl";
|
||||
|
||||
interface ProfilePictureCardProps {
|
||||
/** Name the initials fall back to when there is no picture. */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
function errorMessageOf(err: unknown, fallback: string): string {
|
||||
const response = (err as { response?: { data?: { message?: string } } })
|
||||
?.response;
|
||||
return response?.data?.message || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload / remove the signed-in user's avatar. The image is cropped to a square client-side, then
|
||||
* re-encoded server-side, so what lands in the database is always a small PNG.
|
||||
*/
|
||||
export default function ProfilePictureCard({
|
||||
displayName,
|
||||
}: ProfilePictureCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const pictureUrl = useProfilePictureUrl();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cropperFile, setCropperFile] = useState<File | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
// Mantine keeps the picked file on the hidden input, so re-picking the same one after Cancel
|
||||
// fires no change event. Reset first, before any guard can return early.
|
||||
const resetPicker = useRef<() => void>(null);
|
||||
|
||||
const handleFilePicked = (file: File | null) => {
|
||||
resetPicker.current?.();
|
||||
if (!file) return;
|
||||
if (file.size > MAX_PROFILE_PICTURE_BYTES) {
|
||||
setError(
|
||||
t(
|
||||
"account.profilePicture.sizeError",
|
||||
"Please choose an image smaller than {{megabytes}}MB.",
|
||||
{
|
||||
megabytes: Math.round(MAX_PROFILE_PICTURE_BYTES / (1024 * 1024)),
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setCropperFile(file);
|
||||
};
|
||||
|
||||
const handleCropComplete = async (croppedBlob: Blob) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadProfilePicture(croppedBlob);
|
||||
await refreshOwnProfilePicture();
|
||||
} catch (err) {
|
||||
setError(
|
||||
errorMessageOf(
|
||||
err,
|
||||
t(
|
||||
"account.profilePicture.uploadError",
|
||||
"Could not upload your profile picture. Please try again.",
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setCropperFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setConfirmRemove(false);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await removeProfilePicture();
|
||||
await refreshOwnProfilePicture();
|
||||
} catch (err) {
|
||||
setError(
|
||||
errorMessageOf(
|
||||
err,
|
||||
t(
|
||||
"account.profilePicture.removeError",
|
||||
"Could not remove your profile picture. Please try again.",
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>
|
||||
{t("account.profilePicture.title", "Profile picture")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"account.profilePicture.description",
|
||||
"Add a picture so teammates recognise you across Stirling PDF.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group align="center" gap="md">
|
||||
<Avatar
|
||||
src={pictureUrl ?? undefined}
|
||||
name={displayName}
|
||||
size="xl"
|
||||
ariaLabel={t(
|
||||
"account.profilePicture.current",
|
||||
"Your profile picture",
|
||||
)}
|
||||
/>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm">
|
||||
<FilePicker
|
||||
onChange={handleFilePicked}
|
||||
resetRef={resetPicker}
|
||||
accept={PROFILE_PICTURE_ACCEPT}
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
>
|
||||
{pictureUrl
|
||||
? t("account.profilePicture.change", "Change picture")
|
||||
: t("account.profilePicture.upload", "Upload picture")}
|
||||
</FilePicker>
|
||||
<Button
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
disabled={busy || !pictureUrl}
|
||||
>
|
||||
{t("account.profilePicture.remove", "Remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"account.profilePicture.help",
|
||||
"PNG, JPG or WebP, up to {{megabytes}}MB. Pictures are cropped and resized automatically.",
|
||||
{
|
||||
megabytes: Math.round(
|
||||
MAX_PROFILE_PICTURE_BYTES / (1024 * 1024),
|
||||
),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Group gap={6} align="center" mt={2}>
|
||||
<LocalIcon
|
||||
icon="visibility-rounded"
|
||||
width="0.875rem"
|
||||
height="0.875rem"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"account.profilePicture.visibility",
|
||||
"Visible to you, your administrators, and people on your teams.",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<ProfilePictureCropper
|
||||
file={cropperFile}
|
||||
opened={cropperFile !== null}
|
||||
onClose={() => setCropperFile(null)}
|
||||
onCropComplete={handleCropComplete}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={confirmRemove}
|
||||
onClose={() => setConfirmRemove(false)}
|
||||
title={t(
|
||||
"account.profilePicture.removeTitle",
|
||||
"Remove profile picture",
|
||||
)}
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"account.profilePicture.removeConfirm",
|
||||
"Your picture will be deleted and teammates will see your initials again. You can upload a new one at any time.",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button accent="danger" onClick={handleRemove} loading={busy}>
|
||||
{t("account.profilePicture.remove", "Remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+3
@@ -21,6 +21,7 @@ import { QRCodeSVG } from "qrcode.react";
|
||||
import { useAccountLogout } from "@app/extensions/accountLogout";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
|
||||
import ProfilePictureCard from "@app/components/shared/config/ProfilePictureCard";
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -412,6 +413,8 @@ const AccountSection: React.FC = () => {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ProfilePictureCard displayName={userIdentifier} />
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>
|
||||
|
||||
+8
@@ -33,6 +33,7 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
|
||||
import { useLicense } from "@app/contexts/LicenseContext";
|
||||
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useProfilePictureThumbnails } from "@app/hooks/useProfilePictureThumbnails";
|
||||
import {
|
||||
useAdminUsers,
|
||||
useTeams,
|
||||
@@ -336,6 +337,12 @@ User: ${user.username}`)
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const avatarIds = useMemo(
|
||||
() => users.filter((user) => user.hasProfilePicture).map((user) => user.id),
|
||||
[users],
|
||||
);
|
||||
const avatars = useProfilePictureThumbnails(avatarIds);
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: "ROLE_ADMIN",
|
||||
@@ -589,6 +596,7 @@ User: ${user.username}`)
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? "blue" : "gray"}
|
||||
src={avatars[String(user.id)]}
|
||||
styles={{
|
||||
root: {
|
||||
border: user.isActive
|
||||
|
||||
+26
-4
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Stack,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@app/services/userManagementService";
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
|
||||
import { useProfilePictureThumbnails } from "@app/hooks/useProfilePictureThumbnails";
|
||||
import {
|
||||
useAdminUsers,
|
||||
useTeamDetails,
|
||||
@@ -51,9 +52,22 @@ export default function TeamDetailsSection({
|
||||
|
||||
const loading = details.isPending || admin.isPending;
|
||||
const team = details.data?.team ?? null;
|
||||
const teamUsers = Array.isArray(details.data?.teamUsers)
|
||||
? details.data.teamUsers
|
||||
: [];
|
||||
// The team endpoint returns raw user rows, which carry no avatar flag; the
|
||||
// admin roster does, so borrow it from there rather than widening the payload.
|
||||
const teamUsers = useMemo<User[]>(() => {
|
||||
const rows = Array.isArray(details.data?.teamUsers)
|
||||
? details.data.teamUsers
|
||||
: [];
|
||||
const withPicture = new Set(
|
||||
(admin.data?.users ?? [])
|
||||
.filter((user) => user.hasProfilePicture)
|
||||
.map((user) => user.id),
|
||||
);
|
||||
return rows.map((user) => ({
|
||||
...user,
|
||||
hasProfilePicture: withPicture.has(user.id),
|
||||
}));
|
||||
}, [details.data, admin.data]);
|
||||
const availableUsers = Array.isArray(details.data?.availableUsers)
|
||||
? details.data.availableUsers
|
||||
: [];
|
||||
@@ -78,6 +92,13 @@ export default function TeamDetailsSection({
|
||||
|
||||
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
|
||||
|
||||
const avatarIds = useMemo(
|
||||
() =>
|
||||
teamUsers.filter((user) => user.hasProfilePicture).map((user) => user.id),
|
||||
[teamUsers],
|
||||
);
|
||||
const avatars = useProfilePictureThumbnails(avatarIds);
|
||||
|
||||
// A failed load leaves nothing to show, so the view hands back to the list.
|
||||
const loadFailed = details.isLoadingError || admin.isLoadingError;
|
||||
const reportedRef = useRef(false);
|
||||
@@ -394,6 +415,7 @@ User: ${user.username}`)
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? "blue" : "gray"}
|
||||
src={avatars[String(user.id)]}
|
||||
styles={{
|
||||
root: {
|
||||
border: isActive
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Roster avatars: one batch request per distinct set of user ids. Ids the signed-in user may not
|
||||
* see come back absent, so those rows keep their initials.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { fetchProfilePictureThumbnails } from "@app/services/profilePictureService";
|
||||
|
||||
export function useProfilePictureThumbnails(
|
||||
userIds: Array<number | string>,
|
||||
): Record<string, string> {
|
||||
// Stable across renders that re-derive the same ids, so the effect doesn't refetch on every pass.
|
||||
const key = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(userIds.map(String)))
|
||||
.sort()
|
||||
.join(","),
|
||||
[userIds],
|
||||
);
|
||||
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) {
|
||||
setThumbnails({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchProfilePictureThumbnails(key.split(",")).then((result) => {
|
||||
if (!cancelled) setThumbnails(result);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return thumbnails;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The signed-in user's avatar. A module store, not a context, so the sidebar and account section
|
||||
* share one object URL without threading a provider through every flavor's app tree.
|
||||
*/
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { fetchOwnProfilePicture } from "@app/services/profilePictureService";
|
||||
|
||||
let currentUrl: string | null = null;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
let loaded = false;
|
||||
/** Bumped by every refresh so an in-flight load can't overwrite a newer result. */
|
||||
let generation = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function snapshot(): string | null {
|
||||
return currentUrl;
|
||||
}
|
||||
|
||||
function setUrl(next: string | null): void {
|
||||
// Object URLs pin the blob in memory until revoked.
|
||||
if (currentUrl && currentUrl !== next) URL.revokeObjectURL(currentUrl);
|
||||
currentUrl = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
function load(): Promise<void> {
|
||||
if (!loadPromise) {
|
||||
const token = generation;
|
||||
loadPromise = fetchOwnProfilePicture()
|
||||
.then((url) => {
|
||||
if (token !== generation) {
|
||||
// A refresh superseded us; drop the stale blob rather than showing it.
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setUrl(url);
|
||||
loaded = true;
|
||||
})
|
||||
.catch(() => {
|
||||
// Transient: leave `loaded` false so the next mount tries again rather than showing
|
||||
// initials for the rest of the session.
|
||||
})
|
||||
.finally(() => {
|
||||
loadPromise = null;
|
||||
});
|
||||
}
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/** Re-read the avatar after an upload or removal, so every consumer updates at once. */
|
||||
export async function refreshOwnProfilePicture(): Promise<void> {
|
||||
generation += 1;
|
||||
loaded = false;
|
||||
loadPromise = null;
|
||||
await load();
|
||||
}
|
||||
|
||||
export function useProfilePictureUrl(): string | null {
|
||||
const { config } = useAppConfig();
|
||||
const loginEnabled = config?.enableLogin === true;
|
||||
|
||||
useEffect(() => {
|
||||
// Without login there is no user to have an avatar, and the endpoint would 401.
|
||||
if (!loginEnabled || loaded) return;
|
||||
void load();
|
||||
}, [loginEnabled]);
|
||||
|
||||
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
const get = vi.fn();
|
||||
const post = vi.fn();
|
||||
const del = vi.fn();
|
||||
|
||||
vi.mock("@app/services/apiClient", () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => get(...args),
|
||||
post: (...args: unknown[]) => post(...args),
|
||||
delete: (...args: unknown[]) => del(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchOwnProfilePicture,
|
||||
fetchProfilePictureThumbnails,
|
||||
uploadProfilePicture,
|
||||
} from "@app/services/profilePictureService";
|
||||
|
||||
describe("profilePictureService", () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
del.mockReset();
|
||||
});
|
||||
|
||||
it("turns the signed-in user's avatar into an object URL", async () => {
|
||||
const createObjectURL = vi.fn(() => "blob:avatar");
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL });
|
||||
get.mockResolvedValue({ data: new Blob(["png"]) });
|
||||
|
||||
await expect(fetchOwnProfilePicture()).resolves.toBe("blob:avatar");
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
"/api/v1/user/profile-picture",
|
||||
expect.objectContaining({ responseType: "blob" }),
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([404, 401, 403])(
|
||||
"reports no avatar for a settled %i response",
|
||||
async (status) => {
|
||||
get.mockRejectedValue({ response: { status } });
|
||||
|
||||
await expect(fetchOwnProfilePicture()).resolves.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("rethrows a transient failure so the caller can retry", async () => {
|
||||
// Swallowing this would cache "no avatar" for the whole browser session.
|
||||
get.mockRejectedValue({ response: { status: 500 } });
|
||||
|
||||
await expect(fetchOwnProfilePicture()).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("dedupes ids and sends one batch request for thumbnails", async () => {
|
||||
get.mockResolvedValue({ data: { "1": "data:image/png;base64,AQID" } });
|
||||
|
||||
const result = await fetchProfilePictureThumbnails([1, 2, 1, "2"]);
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
"/api/v1/user/profile-pictures",
|
||||
expect.objectContaining({ params: { userIds: "1,2" } }),
|
||||
);
|
||||
expect(result).toEqual({ "1": "data:image/png;base64,AQID" });
|
||||
});
|
||||
|
||||
it("chunks a large roster so the query string cannot overflow", async () => {
|
||||
// Sent as one request, ~1400 ids blow past nginx's default 8KB request line and 414, which
|
||||
// would silently drop every avatar on the page.
|
||||
get.mockResolvedValue({ data: {} });
|
||||
|
||||
await fetchProfilePictureThumbnails(
|
||||
Array.from({ length: 1400 }, (_, i) => i + 1),
|
||||
);
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(7);
|
||||
for (const call of get.mock.calls) {
|
||||
const ids = (call[1] as { params: { userIds: string } }).params.userIds;
|
||||
expect(ids.split(",").length).toBeLessThanOrEqual(200);
|
||||
expect(ids.length).toBeLessThan(2000);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the avatars it did get when one chunk fails", async () => {
|
||||
get
|
||||
.mockResolvedValueOnce({ data: { "1": "data:image/png;base64,AQID" } })
|
||||
.mockRejectedValueOnce(new Error("500"));
|
||||
|
||||
const result = await fetchProfilePictureThumbnails(
|
||||
Array.from({ length: 300 }, (_, i) => i + 1),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ "1": "data:image/png;base64,AQID" });
|
||||
});
|
||||
|
||||
it("skips the request entirely when there are no ids", async () => {
|
||||
await expect(fetchProfilePictureThumbnails([])).resolves.toEqual({});
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("degrades to no avatars when the batch request fails", async () => {
|
||||
get.mockRejectedValue(new Error("500"));
|
||||
|
||||
await expect(fetchProfilePictureThumbnails([1])).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it("uploads the picture as multipart form data", async () => {
|
||||
post.mockResolvedValue({ data: { hasProfilePicture: true } });
|
||||
|
||||
await uploadProfilePicture(new Blob(["png"]));
|
||||
|
||||
const [url, body] = post.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/user/profile-picture");
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect((body as FormData).get("file")).toBeInstanceOf(Blob);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Self-hosted profile pictures. The app authenticates with a bearer token, which an `<img src>`
|
||||
* would not carry, so avatars come through the API client as blobs (own) or data URLs (rosters).
|
||||
*/
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
/** Matches ProfilePictureService.MAX_UPLOAD_BYTES on the backend. */
|
||||
export const MAX_PROFILE_PICTURE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export const PROFILE_PICTURE_ACCEPT = "image/png,image/jpeg,image/webp";
|
||||
|
||||
/**
|
||||
* Ids per batch request. The ids travel in the query string, and a few thousand of them overflow the
|
||||
* 8KB request line nginx allows by default - which 414s and wipes every avatar on the page. Also
|
||||
* keeps each request under the server's own 500-id cap.
|
||||
*/
|
||||
const BATCH_SIZE = 200;
|
||||
|
||||
/**
|
||||
* The signed-in user's avatar as an object URL, or null when they have none.
|
||||
*
|
||||
* Rethrows anything that is not a definitive answer, so the caller can retry a transient failure
|
||||
* instead of caching "no avatar" for the rest of the session.
|
||||
*/
|
||||
export async function fetchOwnProfilePicture(): Promise<string | null> {
|
||||
try {
|
||||
const response = await apiClient.get<Blob>("/api/v1/user/profile-picture", {
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
return URL.createObjectURL(response.data);
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
// 404 (no picture) and 401/403 (login disabled or signed out) are settled answers; a 5xx or a
|
||||
// dropped connection is not.
|
||||
if (status === 404 || status === 401 || status === 403) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadProfilePicture(file: Blob): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, "avatar.png");
|
||||
await apiClient.post("/api/v1/user/profile-picture", formData, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeProfilePicture(): Promise<void> {
|
||||
await apiClient.delete("/api/v1/user/profile-picture", {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnails for a roster, keyed by user id. Ids the caller isn't allowed to see are simply absent
|
||||
* from the response, so the row falls back to initials.
|
||||
*/
|
||||
export async function fetchProfilePictureThumbnails(
|
||||
userIds: Array<number | string>,
|
||||
): Promise<Record<string, string>> {
|
||||
const ids = Array.from(new Set(userIds.map(String))).filter(Boolean);
|
||||
if (ids.length === 0) return {};
|
||||
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
|
||||
chunks.push(ids.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
const results = await Promise.all(chunks.map(fetchThumbnailChunk));
|
||||
return Object.assign({}, ...results);
|
||||
}
|
||||
|
||||
async function fetchThumbnailChunk(
|
||||
ids: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
try {
|
||||
const response = await apiClient.get<Record<string, string>>(
|
||||
"/api/v1/user/profile-pictures",
|
||||
{
|
||||
params: { userIds: ids.join(",") },
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
return response.data ?? {};
|
||||
} catch {
|
||||
// Avatars are decoration: a failure here must never break the roster.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export interface User {
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/** Whether the user has an avatar; drives whether the roster asks for a thumbnail. */
|
||||
hasProfilePicture?: boolean;
|
||||
// Enriched client-side fields
|
||||
isActive?: boolean;
|
||||
lastRequest?: number; // timestamp in milliseconds
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Divider,
|
||||
@@ -60,6 +60,8 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const [profileUploading, setProfileUploading] = useState(false);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [cropperFile, setCropperFile] = useState<File | null>(null);
|
||||
// Without this, re-picking the same file after Cancel fires no change event.
|
||||
const resetPicker = useRef<() => void>(null);
|
||||
const [cropperOpen, setCropperOpen] = useState(false);
|
||||
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
@@ -72,6 +74,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
const profilePath = user ? `${user.id}/avatar` : null;
|
||||
|
||||
const handleProfileUpload = async (file: File | null) => {
|
||||
resetPicker.current?.();
|
||||
if (!file || !user || !profilePath) {
|
||||
return;
|
||||
}
|
||||
@@ -462,6 +465,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
<Group gap="sm">
|
||||
<FilePicker
|
||||
onChange={handleProfileUpload}
|
||||
resetRef={resetPicker}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={!user || profileUploading}
|
||||
loading={profileUploading}
|
||||
|
||||
Reference in New Issue
Block a user