Skip to content

Commit 5aae4ba

Browse files
committed
/series/add: allow to specify image URL.
1 parent 6073693 commit 5aae4ba

File tree

10 files changed

+330
-6
lines changed

10 files changed

+330
-6
lines changed

src/main/java/ru/mystamps/web/config/MvcConfig.java

+5
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646

4747
import ru.mystamps.web.Url;
4848
import ru.mystamps.web.controller.converter.LinkEntityDtoGenericConverter;
49+
import ru.mystamps.web.controller.interceptor.DownloadImageInterceptor;
4950
import ru.mystamps.web.support.spring.security.CurrentUserArgumentResolver;
5051

5152
@Configuration
@@ -128,6 +129,10 @@ public void addInterceptors(InterceptorRegistry registry) {
128129
interceptor.setParamName("lang");
129130

130131
registry.addInterceptor(interceptor);
132+
133+
// TODO: check add series with category/country
134+
registry.addInterceptor(new DownloadImageInterceptor())
135+
.addPathPatterns(Url.ADD_SERIES_PAGE);
131136
}
132137

133138
@Override
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/*
2+
* Copyright (C) 2009-2017 Slava Semushin <[email protected]>
3+
*
4+
* This program is free software; you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation; either version 2 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program; if not, write to the Free Software
16+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17+
*/
18+
package ru.mystamps.web.controller.interceptor;
19+
20+
import java.io.BufferedInputStream;
21+
import java.io.ByteArrayInputStream;
22+
import java.io.File;
23+
import java.io.FileNotFoundException;
24+
import java.io.IOException;
25+
import java.io.InputStream;
26+
import java.net.HttpURLConnection;
27+
import java.net.MalformedURLException;
28+
import java.net.URL;
29+
import java.net.URLConnection;
30+
import java.util.concurrent.TimeUnit;
31+
32+
import javax.servlet.http.HttpServletRequest;
33+
import javax.servlet.http.HttpServletResponse;
34+
35+
import org.apache.commons.lang3.StringUtils;
36+
37+
import org.slf4j.Logger;
38+
import org.slf4j.LoggerFactory;
39+
40+
import org.springframework.util.StreamUtils;
41+
import org.springframework.web.multipart.MultipartFile;
42+
import org.springframework.web.multipart.support.StandardMultipartHttpServletRequest;
43+
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
44+
45+
import lombok.RequiredArgsConstructor;
46+
47+
// TODO: javadoc
48+
public class DownloadImageInterceptor extends HandlerInterceptorAdapter {
49+
private static final Logger LOG = LoggerFactory.getLogger(DownloadImageInterceptor.class);
50+
51+
@Override
52+
@SuppressWarnings("PMD.SignatureDeclareThrowsException")
53+
public boolean preHandle(
54+
HttpServletRequest request,
55+
HttpServletResponse response,
56+
Object handler) throws Exception {
57+
58+
if (!"POST".equals(request.getMethod())) {
59+
return true;
60+
}
61+
62+
// Inspecting AddSeriesForm.imageUrl field.
63+
// If it doesn't have a value, then nothing to do here.
64+
String imageUrl = request.getParameter("imageUrl");
65+
if (StringUtils.isEmpty(imageUrl)) {
66+
return true;
67+
}
68+
69+
if (!(request instanceof StandardMultipartHttpServletRequest)) {
70+
LOG.warn(
71+
"Unknown type of request ({}). "
72+
+ "Downloading images from external servers won't work!",
73+
request
74+
);
75+
return true;
76+
}
77+
78+
LOG.debug("preHandle imageUrl = {}", imageUrl);
79+
80+
StandardMultipartHttpServletRequest multipartRequest =
81+
(StandardMultipartHttpServletRequest)request;
82+
MultipartFile image = multipartRequest.getFile("image");
83+
if (image != null && StringUtils.isNotEmpty(image.getOriginalFilename())) {
84+
LOG.debug("User provided image, exited");
85+
// user specified both image and image URL, we'll handle it later, during validation
86+
return true;
87+
}
88+
89+
// user specified image URL: we should download file and represent it as "downloadedImage" field.
90+
// Doing this our validation will be able to check downloaded file later.
91+
92+
byte[] data;
93+
try {
94+
URL url = new URL(imageUrl);
95+
LOG.debug("URL.getPath(): {} / URL.getFile(): {}", url.getPath(), url.getFile());
96+
97+
if (!"http".equals(url.getProtocol())) {
98+
// TODO(security): fix possible log injection
99+
LOG.info("Invalid link '{}': only HTTP protocol is supported", imageUrl);
100+
return true;
101+
}
102+
103+
try {
104+
URLConnection connection = url.openConnection();
105+
if (!(connection instanceof HttpURLConnection)) {
106+
LOG.warn(
107+
"Unknown type of connection class ({}). "
108+
+ "Downloading images from external servers won't work!",
109+
connection
110+
);
111+
return true;
112+
}
113+
HttpURLConnection conn = (HttpURLConnection)connection;
114+
115+
conn.setRequestProperty(
116+
"User-Agent",
117+
"Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:46.0) Gecko/20100101 Firefox/46.0"
118+
);
119+
120+
long timeout = TimeUnit.SECONDS.toMillis(1);
121+
conn.setReadTimeout(Math.toIntExact(timeout));
122+
LOG.debug("getReadTimeout(): {}", conn.getReadTimeout());
123+
124+
// TODO: how bad is it?
125+
conn.setInstanceFollowRedirects(false);
126+
127+
try {
128+
conn.connect();
129+
} catch (IOException ex) {
130+
// TODO(security): fix possible log injection
131+
LOG.error("Couldn't connect to '{}': {}", imageUrl, ex.getMessage());
132+
return true;
133+
}
134+
135+
try (InputStream stream = new BufferedInputStream(conn.getInputStream())) {
136+
int status = conn.getResponseCode();
137+
if (status != HttpURLConnection.HTTP_OK) {
138+
// TODO(security): fix possible log injection
139+
LOG.error(
140+
"Couldn't download file '{}': bad response status {}",
141+
imageUrl,
142+
status
143+
);
144+
return true;
145+
}
146+
147+
// TODO: add protection against huge files
148+
int contentLength = conn.getContentLength();
149+
LOG.debug("Content-Length: {}", contentLength);
150+
if (contentLength <= 0) {
151+
// TODO(security): fix possible log injection
152+
LOG.error(
153+
"Couldn't download file '{}': it has {} bytes length",
154+
imageUrl,
155+
contentLength
156+
);
157+
return true;
158+
}
159+
160+
String contentType = conn.getContentType();
161+
LOG.debug("Content-Type: {}", contentType);
162+
if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType)) {
163+
// TODO(security): fix possible log injection
164+
LOG.error(
165+
"Couldn't download file '{}': unsupported image type '{}'",
166+
imageUrl,
167+
contentType
168+
);
169+
return true;
170+
}
171+
172+
data = StreamUtils.copyToByteArray(stream);
173+
174+
} catch (FileNotFoundException ignored) {
175+
// TODO: show error to user
176+
// TODO(security): fix possible log injection
177+
LOG.error("Couldn't download file '{}': not found", imageUrl);
178+
return true;
179+
180+
} catch (IOException ex) {
181+
// TODO(security): fix possible log injection
182+
LOG.error(
183+
"Couldn't download file from URL '{}': {}",
184+
imageUrl,
185+
ex.getMessage()
186+
);
187+
return true;
188+
}
189+
190+
} catch (IOException ex) {
191+
LOG.error("Couldn't open connection: {}", ex.getMessage());
192+
return true;
193+
}
194+
195+
} catch (MalformedURLException ex) {
196+
// TODO(security): fix possible log injection
197+
// TODO: show error to user
198+
LOG.error("Invalid image URL '{}': {}", imageUrl, ex.getMessage());
199+
return true;
200+
}
201+
202+
// TODO: use URL.getFile() instead of full link?
203+
multipartRequest.getMultiFileMap().set("downloadedImage", new MyMultipartFile(data, imageUrl));
204+
205+
// TODO: how we can validate url?
206+
207+
return true;
208+
}
209+
210+
@RequiredArgsConstructor
211+
private static class MyMultipartFile implements MultipartFile {
212+
private final byte[] content;
213+
private final String link;
214+
215+
@Override
216+
public String getName() {
217+
throw new IllegalStateException("Not implemented");
218+
}
219+
220+
@Override
221+
public String getOriginalFilename() {
222+
return link;
223+
}
224+
225+
@Override
226+
public String getContentType() {
227+
return "image/jpeg";
228+
}
229+
230+
@Override
231+
public boolean isEmpty() {
232+
return getSize() == 0;
233+
}
234+
235+
@Override
236+
public long getSize() {
237+
return content.length;
238+
}
239+
240+
@Override
241+
public byte[] getBytes() throws IOException {
242+
return content;
243+
}
244+
245+
@Override
246+
public InputStream getInputStream() throws IOException {
247+
return new ByteArrayInputStream(content);
248+
}
249+
250+
@Override
251+
public void transferTo(File dest) throws IOException, IllegalStateException {
252+
throw new IllegalStateException("Not implemented");
253+
}
254+
}
255+
256+
}

src/main/java/ru/mystamps/web/model/AddSeriesForm.java

+27-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
import javax.validation.constraints.NotNull;
2626
import javax.validation.constraints.Size;
2727

28+
import org.apache.commons.lang3.StringUtils;
29+
2830
import org.hibernate.validator.constraints.Range;
31+
import org.hibernate.validator.constraints.URL;
2932

3033
import org.springframework.web.multipart.MultipartFile;
3134

@@ -56,6 +59,9 @@
5659

5760
@Getter
5861
@Setter
62+
// TODO: image and downloadedImage should be filled together
63+
// TODO: localize URL
64+
// TODO: disallow urls like http://test
5965
// TODO: combine price with currency to separate class
6066
@SuppressWarnings({"PMD.TooManyFields", "PMD.AvoidDuplicateLiterals"})
6167
@NotNullIfFirstField.List({
@@ -128,13 +134,32 @@ public class AddSeriesForm implements AddSeriesDto {
128134
@Size(max = MAX_SERIES_COMMENT_LENGTH, message = "{value.too-long}")
129135
private String comment;
130136

131-
@NotNull
132-
@NotEmptyFilename(groups = Image1Checks.class)
137+
//@NotEmptyFilename(groups = Image1Checks.class)
133138
@NotEmptyFile(groups = Image2Checks.class)
134139
@MaxFileSize(value = MAX_IMAGE_SIZE, unit = Unit.Kbytes, groups = Image3Checks.class)
135140
@ImageFile(groups = Image3Checks.class)
136141
private MultipartFile image;
137142

143+
// Name of this field must match with the field name that
144+
// is being inspected by DownloadImageInterceptor
145+
@URL(protocol = "http")
146+
private String imageUrl;
147+
148+
// This field holds a file that was downloaded from imageUrl
149+
//@NotEmptyFilename(groups = Image1Checks.class)
150+
@NotEmptyFile(groups = Image2Checks.class)
151+
@MaxFileSize(value = MAX_IMAGE_SIZE, unit = Unit.Kbytes, groups = Image3Checks.class)
152+
@ImageFile(groups = Image3Checks.class)
153+
private MultipartFile downloadedImage;
154+
155+
@Override
156+
public MultipartFile getImage() {
157+
if (image != null && StringUtils.isNotEmpty(image.getOriginalFilename())) {
158+
return image;
159+
}
160+
161+
return downloadedImage;
162+
}
138163

139164
@GroupSequence({
140165
ReleaseDate1Checks.class,

src/main/java/ru/mystamps/web/support/togglz/Features.java

+5-1
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ public enum Features implements Feature {
8282

8383
@Label("/series/add: show link with auto-suggestions")
8484
@EnabledByDefault
85-
SHOW_SUGGESTION_LINK;
85+
SHOW_SUGGESTION_LINK,
86+
87+
@Label("/series/add: possibility to download image from external server")
88+
@EnabledByDefault
89+
DOWNLOAD_IMAGE;
8690

8791
public boolean isActive() {
8892
return FeatureContext.getFeatureManager().isActive(this);

src/main/java/ru/mystamps/web/validation/jsr303/ImageFileValidator.java

+6
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import javax.validation.ConstraintValidator;
2525
import javax.validation.ConstraintValidatorContext;
2626

27+
import org.apache.commons.lang3.StringUtils;
28+
2729
import org.slf4j.Logger;
2830
import org.slf4j.LoggerFactory;
2931

@@ -107,6 +109,10 @@ public boolean isValid(MultipartFile file, ConstraintValidatorContext ctx) {
107109
return true;
108110
}
109111

112+
if (StringUtils.isEmpty(file.getOriginalFilename())) {
113+
return true;
114+
}
115+
110116
if (file.isEmpty()) {
111117
return false;
112118
}

src/main/java/ru/mystamps/web/validation/jsr303/NotEmptyFileValidator.java

+6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import javax.validation.ConstraintValidator;
2121
import javax.validation.ConstraintValidatorContext;
2222

23+
import org.apache.commons.lang3.StringUtils;
24+
2325
import org.springframework.web.multipart.MultipartFile;
2426

2527
public class NotEmptyFileValidator implements ConstraintValidator<NotEmptyFile, MultipartFile> {
@@ -36,6 +38,10 @@ public boolean isValid(MultipartFile file, ConstraintValidatorContext ctx) {
3638
return true;
3739
}
3840

41+
if (StringUtils.isEmpty(file.getOriginalFilename())) {
42+
return true;
43+
}
44+
3945
return !file.isEmpty();
4046
}
4147

src/main/resources/ru/mystamps/i18n/Messages.properties

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ t_sg = Gibbons
117117
t_add_comment = Add comment
118118
t_comment = Comment
119119
t_image = Image
120+
t_image_url = Image URL
120121
t_add_more_images_hint = Later you will be able to add additional images
121122
t_not_chosen = Not chosen
122123
t_create_category_hint = You can also <a tabindex="-1" href="{0}">add a new category</a>

0 commit comments

Comments
 (0)