LocalMediaStorage.java
5,566 bytes
| 1 | package com.tasteprint.media; |
|---|---|
| 2 | |
| 3 | import java.io.IOException; |
| 4 | import java.nio.file.Files; |
| 5 | import java.nio.file.Path; |
| 6 | import java.time.Clock; |
| 7 | import java.util.Map; |
| 8 | import java.util.UUID; |
| 9 | |
| 10 | import org.springframework.beans.factory.annotation.Value; |
| 11 | import org.springframework.stereotype.Service; |
| 12 | import org.springframework.transaction.annotation.Transactional; |
| 13 | import org.springframework.web.multipart.MultipartFile; |
| 14 | |
| 15 | import com.tasteprint.shared.ForbiddenException; |
| 16 | |
| 17 | @Service |
| 18 | class LocalMediaStorage implements MediaStorage { |
| 19 | |
| 20 | private static final long MAX_BYTES = 6L * 1024 * 1024; |
| 21 | private static final Map<String, String> EXTENSIONS = Map.of( |
| 22 | "image/jpeg", ".jpg", |
| 23 | "image/png", ".png", |
| 24 | "image/webp", ".webp" |
| 25 | ); |
| 26 | |
| 27 | private final Path mediaDirectory; |
| 28 | private final MediaAssetRepository assets; |
| 29 | private final Clock clock; |
| 30 | |
| 31 | LocalMediaStorage(@Value("${app.media-directory}") String mediaDirectory, |
| 32 | MediaAssetRepository assets, Clock clock) { |
| 33 | this.mediaDirectory = Path.of(mediaDirectory).toAbsolutePath().normalize(); |
| 34 | this.assets = assets; |
| 35 | this.clock = clock; |
| 36 | } |
| 37 | |
| 38 | @Override |
| 39 | @Transactional |
| 40 | public MediaUpload store(UUID ownerId, MultipartFile file) { |
| 41 | if (file == null || file.isEmpty()) { |
| 42 | throw new IllegalArgumentException("Choose a photo to upload."); |
| 43 | } |
| 44 | if (file.getSize() > MAX_BYTES) { |
| 45 | throw new IllegalArgumentException("Photo must be smaller than 6 MB."); |
| 46 | } |
| 47 | String contentType = file.getContentType(); |
| 48 | String extension = EXTENSIONS.get(contentType); |
| 49 | if (extension == null) { |
| 50 | throw new IllegalArgumentException("Photo must be JPEG, PNG, or WebP."); |
| 51 | } |
| 52 | |
| 53 | try { |
| 54 | byte[] bytes = file.getBytes(); |
| 55 | if (!matchesSignature(contentType, bytes)) { |
| 56 | throw new IllegalArgumentException("The uploaded file is not a valid image."); |
| 57 | } |
| 58 | Files.createDirectories(mediaDirectory); |
| 59 | UUID assetId = UUID.randomUUID(); |
| 60 | String filename = assetId + extension; |
| 61 | Path target = mediaDirectory.resolve(filename).normalize(); |
| 62 | if (!target.getParent().equals(mediaDirectory)) { |
| 63 | throw new IllegalStateException("Invalid media path."); |
| 64 | } |
| 65 | Files.write(target, bytes); |
| 66 | try { |
| 67 | assets.save(new MediaAsset(assetId, ownerId, filename, contentType, bytes.length, clock.instant())); |
| 68 | } catch (RuntimeException exception) { |
| 69 | Files.deleteIfExists(target); |
| 70 | throw exception; |
| 71 | } |
| 72 | return new MediaUpload("/uploads/" + filename, contentType, bytes.length); |
| 73 | } catch (IOException exception) { |
| 74 | throw new IllegalStateException("Photo could not be stored.", exception); |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | @Override |
| 79 | @Transactional(readOnly = true) |
| 80 | public void requireUsableBy(UUID ownerId, String url) { |
| 81 | String filename = localFilename(url); |
| 82 | if (filename != null && !assets.existsByFilenameAndOwnerId(filename, ownerId)) { |
| 83 | throw new ForbiddenException("That uploaded photo does not belong to your account."); |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | @Override |
| 88 | @Transactional |
| 89 | public void deleteOwned(UUID ownerId, String url) { |
| 90 | String filename = localFilename(url); |
| 91 | if (filename == null) { |
| 92 | return; |
| 93 | } |
| 94 | assets.findByFilename(filename) |
| 95 | .filter(asset -> asset.ownerId().equals(ownerId)) |
| 96 | .ifPresent(asset -> { |
| 97 | deleteFile(asset.filename()); |
| 98 | assets.delete(asset); |
| 99 | }); |
| 100 | } |
| 101 | |
| 102 | @Override |
| 103 | @Transactional |
| 104 | public void deleteAllOwned(UUID ownerId) { |
| 105 | for (MediaAsset asset : assets.findByOwnerId(ownerId)) { |
| 106 | deleteFile(asset.filename()); |
| 107 | assets.delete(asset); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | private String localFilename(String url) { |
| 112 | if (url == null || url.isBlank() || !url.startsWith("/uploads/")) { |
| 113 | return null; |
| 114 | } |
| 115 | return url.substring("/uploads/".length()); |
| 116 | } |
| 117 | |
| 118 | private void deleteFile(String filename) { |
| 119 | try { |
| 120 | Path target = mediaDirectory.resolve(filename).normalize(); |
| 121 | if (!target.getParent().equals(mediaDirectory)) { |
| 122 | throw new IllegalStateException("Invalid media path."); |
| 123 | } |
| 124 | Files.deleteIfExists(target); |
| 125 | } catch (IOException exception) { |
| 126 | throw new IllegalStateException("Photo could not be deleted.", exception); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | private boolean matchesSignature(String contentType, byte[] bytes) { |
| 131 | return switch (contentType) { |
| 132 | case "image/jpeg" -> bytes.length >= 3 |
| 133 | && unsigned(bytes[0]) == 0xFF && unsigned(bytes[1]) == 0xD8 && unsigned(bytes[2]) == 0xFF; |
| 134 | case "image/png" -> bytes.length >= 8 |
| 135 | && unsigned(bytes[0]) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 |
| 136 | && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; |
| 137 | case "image/webp" -> bytes.length >= 12 |
| 138 | && bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F' |
| 139 | && bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P'; |
| 140 | default -> false; |
| 141 | }; |
| 142 | } |
| 143 | |
| 144 | private int unsigned(byte value) { |
| 145 | return value & 0xFF; |
| 146 | } |
| 147 | } |
| 148 | |