profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
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