Compare commits

...
Author SHA1 Message Date
Anthony Stirling 7a414a37f0 fix(profile-pictures): declare schema ownership and serve avatars as png with nosniff 2026-08-30 10:18:38 +01:00
Anthony Stirling 25e8fbba2f Merge branch 'main' into per-user-profile-pictures 2026-08-30 10:09:12 +01:00
Anthony Stirling 297ab6c423 Merge remote-tracking branch 'origin/main' into sweep/pr7396 2026-08-26 08:10:43 +01:00
Anthony Stirling e0e371f8e1 Merge remote-tracking branch 'origin/main' into sweep/pr7396 2026-08-26 07:10:47 +01:00
Anthony Stirling bc517356c3 Merge branch 'main' into per-user-profile-pictures 2026-08-20 09:45:32 +01:00
Anthony Stirling 048f7fd769 Drop the inert SaaS half of profile pictures and add real test coverage 2026-08-13 10:18:16 +01:00
Anthony Stirling a34df20be2 Merge remote-tracking branch 'origin/main' into per-user-profile-pictures 2026-08-13 09:47:06 +01:00
Anthony Stirling 83636c2ab3 Merge remote-tracking branch 'origin/main' into per-user-profile-pictures
# Conflicts:
#	frontend/editor/src/core/utils/toolIOCompat.test.ts
#	frontend/editor/src/core/utils/toolIOLabels.test.ts
2026-08-12 11:24:27 +01:00
Anthony Stirling 24edcc81ba Inline walkthrough report images and flag fixed findings 2026-08-10 10:34:13 +01:00
Anthony Stirling 1c64cea499 Add profile picture UI walkthrough capture spec 2026-08-10 10:33:35 +01:00
Anthony Stirling e2a56ca9e7 Round the avatar crop and confirm profile picture removal 2026-08-10 10:32:54 +01:00
Anthony Stirling a54f97a900 Bound avatar decoding and chunk roster avatar requests 2026-08-09 21:46:24 +01:00
Anthony Stirling 1ae323f7ee Add per-user profile pictures 2026-08-09 13:57:51 +01:00
Anthony Stirling 6b322cf2a6 Fix toolIO tests failing to locate repo files on Windows 2026-08-09 13:57:28 +01:00
35 changed files with 2093 additions and 28 deletions
@@ -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()));
@@ -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());
}
}
@@ -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);
}
@@ -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);
@@ -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;
}
@@ -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;
@@ -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
*
@@ -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();
}
}
@@ -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);
@@ -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() {
@@ -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) {
@@ -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
@@ -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});
}
}
@@ -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());
}
}
@@ -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"
@@ -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}
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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)}
/>
@@ -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
+51 -1
View File
@@ -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>
);
}
@@ -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}>
@@ -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
@@ -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}