Compare commits

..

2 Commits

Author SHA1 Message Date
Jesse Brault
02c7e8887e MME-7 Add resource exists endpoint for images. 2026-02-04 17:58:08 -06:00
Jesse Brault
67a4a1339f Refactor ImageController to use ImageUpdateBodyToSpecConverter. 2026-02-03 12:23:03 -06:00
9 changed files with 78 additions and 27 deletions

View File

@ -188,7 +188,7 @@ public class ImageControllerTests {
} }
@Test @Test
public void putImage() throws Exception { public void uploadImage() throws Exception {
final User owner = this.seedUser(); final User owner = this.seedUser();
final String accessToken = this.getAccessToken(owner); final String accessToken = this.getAccessToken(owner);
try (final InputStream hal9000 = getHal9000InputStream()) { try (final InputStream hal9000 = getHal9000InputStream()) {

View File

@ -2,12 +2,13 @@ package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.image.body.ImageUpdateBody; import app.mealsmadeeasy.api.image.body.ImageUpdateBody;
import app.mealsmadeeasy.api.image.converter.ImageToViewConverter; import app.mealsmadeeasy.api.image.converter.ImageToViewConverter;
import app.mealsmadeeasy.api.image.converter.ImageUpdateBodyToSpecConverter;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec; import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.image.view.ImageView;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.user.UserService;
import app.mealsmadeeasy.api.util.AccessDeniedView; import app.mealsmadeeasy.api.util.AccessDeniedView;
import app.mealsmadeeasy.api.util.ResourceExistsView;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -32,27 +33,7 @@ public class ImageController {
private final ImageService imageService; private final ImageService imageService;
private final UserService userService; private final UserService userService;
private final ImageToViewConverter imageToViewConverter; private final ImageToViewConverter imageToViewConverter;
private final ImageUpdateBodyToSpecConverter imageUpdateBodyToSpecConverter;
private ImageUpdateSpec getImageUpdateSpec(ImageUpdateBody body) {
final var builder = ImageUpdateSpec.builder()
.alt(body.getAlt())
.caption(body.getCaption())
.isPublic(body.getIsPublic())
.clearAllViewers(body.getClearAllViewers());
if (body.getViewersToAdd() != null) {
builder.viewersToAdd(body.getViewersToAdd().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
);
}
if (body.getViewersToRemove() != null) {
builder.viewersToRemove(body.getViewersToRemove().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
);
}
return builder.build();
}
@ExceptionHandler @ExceptionHandler
public ResponseEntity<AccessDeniedView> onAccessDenied(AccessDeniedException e) { public ResponseEntity<AccessDeniedView> onAccessDenied(AccessDeniedException e) {
@ -81,8 +62,8 @@ public class ImageController {
.body(new InputStreamResource(imageInputStream)); .body(new InputStreamResource(imageInputStream));
} }
@PutMapping @PostMapping
public ResponseEntity<ImageView> putImage( public ResponseEntity<ImageView> uploadImage(
@RequestParam MultipartFile image, @RequestParam MultipartFile image,
@RequestParam String filename, @RequestParam String filename,
@RequestParam(required = false) String alt, @RequestParam(required = false) String alt,
@ -109,7 +90,7 @@ public class ImageController {
return ResponseEntity.status(201).body(this.imageToViewConverter.convert(saved, principal, true)); return ResponseEntity.status(201).body(this.imageToViewConverter.convert(saved, principal, true));
} }
@PostMapping("/{username}/{filename}") @PutMapping("/{username}/{filename}")
public ResponseEntity<ImageView> updateInfo( public ResponseEntity<ImageView> updateInfo(
@AuthenticationPrincipal User principal, @AuthenticationPrincipal User principal,
@PathVariable String username, @PathVariable String username,
@ -118,10 +99,24 @@ public class ImageController {
) { ) {
final User owner = this.userService.getUser(username); final User owner = this.userService.getUser(username);
final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal);
final Image updated = this.imageService.update(image, principal, this.getImageUpdateSpec(body)); final Image updated = this.imageService.update(
image,
principal,
this.imageUpdateBodyToSpecConverter.convert(body)
);
return ResponseEntity.ok(this.imageToViewConverter.convert(updated, principal, true)); return ResponseEntity.ok(this.imageToViewConverter.convert(updated, principal, true));
} }
@GetMapping("/{username}/{filename}/exists")
public ResponseEntity<ResourceExistsView> resourceExists(
@AuthenticationPrincipal User principal,
@PathVariable String username,
@PathVariable String filename
) {
final boolean exists = this.imageService.exists(principal, username, filename);
return ResponseEntity.ok(new ResourceExistsView(exists));
}
@DeleteMapping("/{username}/{filename}") @DeleteMapping("/{username}/{filename}")
public ResponseEntity<Object> deleteImage( public ResponseEntity<Object> deleteImage(
@AuthenticationPrincipal User principal, @AuthenticationPrincipal User principal,

View File

@ -12,6 +12,7 @@ public class ImageEndpointAuthConfigurator implements EndpointAuthConfigurator {
@Override @Override
public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) { public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers(HttpMethod.GET, "/images/**").permitAll(); registry.requestMatchers(HttpMethod.GET, "/images/**").permitAll();
registry.requestMatchers(HttpMethod.GET, "/images/*/*/exists").authenticated();
registry.requestMatchers(HttpMethod.POST, "/images/**").authenticated(); registry.requestMatchers(HttpMethod.POST, "/images/**").authenticated();
registry.requestMatchers(HttpMethod.PUT, "/images/**").authenticated(); registry.requestMatchers(HttpMethod.PUT, "/images/**").authenticated();
registry.requestMatchers(HttpMethod.DELETE, "/images/**").authenticated(); registry.requestMatchers(HttpMethod.DELETE, "/images/**").authenticated();

View File

@ -6,4 +6,5 @@ import org.jetbrains.annotations.Nullable;
public interface ImageSecurity { public interface ImageSecurity {
boolean isViewableBy(Image image, @Nullable User viewer); boolean isViewableBy(Image image, @Nullable User viewer);
boolean isOwner(Image image, @Nullable User user); boolean isOwner(Image image, @Nullable User user);
boolean canSeeExists(User viewer, String username, String filename);
} }

View File

@ -45,4 +45,9 @@ public class ImageSecurityImpl implements ImageSecurity {
return image.getOwner() != null && user != null && Objects.equals(image.getOwner().getId(), user.getId()); return image.getOwner() != null && user != null && Objects.equals(image.getOwner().getId(), user.getId());
} }
@Override
public boolean canSeeExists(User viewer, String username, String filename) {
return viewer.getUsername().equals(username);
}
} }

View File

@ -21,6 +21,8 @@ public interface ImageService {
InputStream getImageContent(Image image, @Nullable User viewer) throws IOException; InputStream getImageContent(Image image, @Nullable User viewer) throws IOException;
List<Image> getImagesOwnedBy(User user); List<Image> getImagesOwnedBy(User user);
boolean exists(User viewer, String username, String filename);
Image update(Image image, User modifier, ImageUpdateSpec spec); Image update(Image image, User modifier, ImageUpdateSpec spec);
void deleteImage(Image image, User modifier) throws IOException; void deleteImage(Image image, User modifier) throws IOException;

View File

@ -196,6 +196,12 @@ public class S3ImageService implements ImageService {
return new ArrayList<>(this.imageRepository.findAllByOwner(user)); return new ArrayList<>(this.imageRepository.findAllByOwner(user));
} }
@Override
@PreAuthorize("@imageSecurity.canSeeExists(#viewer, #username, #filename)")
public boolean exists(User viewer, String username, String filename) {
return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).isPresent();
}
@Override @Override
@PreAuthorize("@imageSecurity.isOwner(#image, #modifier)") @PreAuthorize("@imageSecurity.isOwner(#image, #modifier)")
public Image update(final Image image, User modifier, ImageUpdateSpec updateSpec) { public Image update(final Image image, User modifier, ImageUpdateSpec updateSpec) {

View File

@ -0,0 +1,38 @@
package app.mealsmadeeasy.api.image.converter;
import app.mealsmadeeasy.api.image.body.ImageUpdateBody;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class ImageUpdateBodyToSpecConverter {
private final UserService userService;
public ImageUpdateSpec convert(ImageUpdateBody body) {
final var builder = ImageUpdateSpec.builder()
.alt(body.getAlt())
.caption(body.getCaption())
.isPublic(body.getIsPublic())
.clearAllViewers(body.getClearAllViewers());
if (body.getViewersToAdd() != null) {
builder.viewersToAdd(body.getViewersToAdd().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
);
}
if (body.getViewersToRemove() != null) {
builder.viewersToRemove(body.getViewersToRemove().stream()
.map(this.userService::getUser)
.collect(Collectors.toSet())
);
}
return builder.build();
}
}

View File

@ -0,0 +1,3 @@
package app.mealsmadeeasy.api.util;
public record ResourceExistsView(boolean exists) {}