Skip to content

Commit 527805a

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

File tree

7 files changed

+287
-4
lines changed

7 files changed

+287
-4
lines changed

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

Lines changed: 5 additions & 0 deletions
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
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
* Copyright (C) 2009-2016 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 = {}", request.getParameter("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 "image" field.
90+
// Doing this our validation will be able to check downloaded file later.
91+
92+
LOG.debug("User provided link, downloading");
93+
// TODO: where/how to show possible errors during downloading?
94+
byte[] data;
95+
try {
96+
URL url = new URL(imageUrl);
97+
LOG.debug("URL.getPath(): {}", url.getPath());
98+
LOG.debug("URL.getFile(): {}", url.getFile());
99+
100+
// TODO: add title to field that only HTTP protocol is supported and no redirects are allowed
101+
if (!"http".equals(url.getProtocol())) {
102+
// TODO(security): fix possible log injection
103+
LOG.info("Invalid link '{}': only HTTP protocol is supported", imageUrl);
104+
return true;
105+
}
106+
107+
try {
108+
URLConnection connection = url.openConnection();
109+
if (!(connection instanceof HttpURLConnection)) {
110+
LOG.warn(
111+
"Unknown type of connection class ({}). "
112+
+ "Downloading images from external servers won't work!",
113+
connection
114+
);
115+
return true;
116+
}
117+
HttpURLConnection conn = (HttpURLConnection)connection;
118+
119+
conn.setRequestProperty(
120+
"User-Agent",
121+
"Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:46.0) Gecko/20100101 Firefox/46.0"
122+
);
123+
124+
long timeout = TimeUnit.SECONDS.toMillis(1);
125+
conn.setReadTimeout(Math.toIntExact(timeout));
126+
LOG.debug("getReadTimeout(): {}", conn.getReadTimeout());
127+
128+
// TODO: how bad is it?
129+
conn.setInstanceFollowRedirects(false);
130+
131+
try {
132+
conn.connect();
133+
} catch (IOException ex) {
134+
// TODO(security): fix possible log injection
135+
LOG.error("Couldn't connect to '{}': {}", imageUrl, ex.getMessage());
136+
return true;
137+
}
138+
139+
try (InputStream stream = new BufferedInputStream(conn.getInputStream())) {
140+
int status = conn.getResponseCode();
141+
if (status != HttpURLConnection.HTTP_OK) {
142+
// TODO(security): fix possible log injection
143+
LOG.error("Couldn't download file '{}': bad response status {}", imageUrl, status);
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("Couldn't download file '{}': it has {} bytes length", imageUrl, contentLength);
153+
return true;
154+
}
155+
156+
String contentType = conn.getContentType();
157+
LOG.debug("Content-Type: {}", contentType);
158+
if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType)) {
159+
// TODO(security): fix possible log injection
160+
LOG.error("Couldn't download file '{}': unsupported image type '{}'", imageUrl, contentType);
161+
return true;
162+
}
163+
164+
data = StreamUtils.copyToByteArray(stream);
165+
166+
} catch (FileNotFoundException ignored) {
167+
// TODO: show error to user
168+
// TODO(security): fix possible log injection
169+
LOG.error("Couldn't download file '{}': not found", imageUrl);
170+
return true;
171+
172+
} catch (IOException ex) {
173+
// TODO(security): fix possible log injection
174+
LOG.error("Couldn't download file from URL '{}': {}", imageUrl, ex.getMessage());
175+
return true;
176+
}
177+
178+
} catch (IOException ex) {
179+
LOG.error("Couldn't open connection: {}", ex.getMessage());
180+
return true;
181+
}
182+
183+
} catch (MalformedURLException ex) {
184+
// TODO(security): fix possible log injection
185+
// TODO: show error to user
186+
LOG.error("Invalid image URL '{}': {}", imageUrl, ex.getMessage());
187+
return true;
188+
}
189+
LOG.debug("Downloaded!");
190+
191+
// TODO: use URL.getFile() instead of full link?
192+
multipartRequest.getMultiFileMap().set("image", new MyMultipartFile(data, imageUrl));
193+
LOG.debug("Request updated");
194+
195+
// TODO: how we can validate url?
196+
197+
return true;
198+
}
199+
200+
@RequiredArgsConstructor
201+
private class MyMultipartFile implements MultipartFile {
202+
private final byte[] content;
203+
private final String link;
204+
205+
@Override
206+
public String getName() {
207+
throw new IllegalStateException("Not implemented");
208+
}
209+
210+
@Override
211+
public String getOriginalFilename() {
212+
return link;
213+
}
214+
215+
// TODO: preserve original content type
216+
@Override
217+
public String getContentType() {
218+
return "image/jpeg";
219+
}
220+
221+
@Override
222+
public boolean isEmpty() {
223+
return getSize() == 0;
224+
}
225+
226+
@Override
227+
public long getSize() {
228+
return content.length;
229+
}
230+
231+
@Override
232+
public byte[] getBytes() throws IOException {
233+
return content;
234+
}
235+
236+
@Override
237+
public InputStream getInputStream() throws IOException {
238+
return new ByteArrayInputStream(content);
239+
}
240+
241+
@Override
242+
public void transferTo(File dest) throws IOException, IllegalStateException {
243+
throw new IllegalStateException("Not implemented");
244+
}
245+
}
246+
247+
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import javax.validation.constraints.Size;
2727

2828
import org.hibernate.validator.constraints.Range;
29+
import org.hibernate.validator.constraints.URL;
2930

3031
import org.springframework.web.multipart.MultipartFile;
3132

@@ -56,6 +57,9 @@
5657

5758
@Getter
5859
@Setter
60+
// TODO: image or imageUrl must be specified (but not both)
61+
// TODO: localize URL
62+
// TODO: disallow urls like http://test
5963
// TODO: combine price with currency to separate class
6064
@SuppressWarnings({"PMD.TooManyFields", "PMD.AvoidDuplicateLiterals"})
6165
@NotNullIfFirstField.List({
@@ -128,13 +132,17 @@ public class AddSeriesForm implements AddSeriesDto {
128132
@Size(max = MAX_SERIES_COMMENT_LENGTH, message = "{value.too-long}")
129133
private String comment;
130134

131-
@NotNull
135+
// @NotNull
132136
@NotEmptyFilename(groups = Image1Checks.class)
133137
@NotEmptyFile(groups = Image2Checks.class)
134138
@MaxFileSize(value = MAX_IMAGE_SIZE, unit = Unit.Kbytes, groups = Image3Checks.class)
135139
@ImageFile(groups = Image3Checks.class)
136140
private MultipartFile image;
137141

142+
// Name of this field must match with the field name that
143+
// is being inspected by DownloadImageInterceptor
144+
@URL(protocol = "http")
145+
private String imageUrl;
138146

139147
@GroupSequence({
140148
ReleaseDate1Checks.class,

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

Lines changed: 5 additions & 1 deletion
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/resources/ru/mystamps/i18n/Messages.properties

Lines changed: 1 addition & 0 deletions
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>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ t_sg = Gibbons
117117
t_add_comment = Добавить комментарий
118118
t_comment = Комментарий
119119
t_image = Изображение
120+
t_image_url = Ссылка на изображение
120121
t_add_more_images_hint = Вы сможете добавить дополнительные изображения позже
121122
t_not_chosen = Не выбрана
122123
t_create_category_hint = Вы также можете <a tabindex="-1" href="{0}">добавить новую категорию</a>

src/main/webapp/WEB-INF/views/series/add.html

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,15 @@ <h3 th:text="${#strings.capitalize(add_series)}">
262262
<span class="field-label" th:text="#{t_image}">
263263
Image
264264
</span>
265-
<span id="image.required" class="required_field">*</span>
265+
<!--/*/
266+
<span id="image.required" class="required_field" togglz:inactive="DOWNLOAD_IMAGE">*</span>
267+
/*/-->
266268
</label>
267269
<div class="col-sm-7">
268-
<input type="file" id="image" style="box-shadow: none; border: 0px;" required="required" accept="image/png,image/jpeg" th:field="*{image}" />
270+
<input type="file" id="image" style="box-shadow: none; border: 0px;" accept="image/png,image/jpeg" th:field="*{image}" togglz:active="DOWNLOAD_IMAGE"/>
271+
<!--/*/
272+
<input type="file" id="image" style="box-shadow: none; border: 0px;" required="required" accept="image/png,image/jpeg" th:field="*{image}" togglz:inactive="DOWNLOAD_IMAGE"/>
273+
/*/-->
269274
<small togglz:active="ADD_ADDITIONAL_IMAGES_TO_SERIES" sec:authorize="hasAuthority('ADD_IMAGES_TO_SERIES')">
270275
<span class="hint-block" th:text="#{t_add_more_images_hint}">
271276
Later you will be able to add additional images
@@ -277,6 +282,18 @@ <h3 th:text="${#strings.capitalize(add_series)}">
277282
</div>
278283
</div>
279284

285+
<div class="form-group form-group-sm" th:classappend="${#fields.hasErrors('imageUrl') ? 'has-error' : ''}" togglz:active="DOWNLOAD_IMAGE">
286+
<label for="image-url" class="control-label col-sm-3">
287+
<span class="field-label" th:text="#{t_image_url}">
288+
Image URL
289+
</span>
290+
</label>
291+
<div class="col-sm-5">
292+
<input type="url" id="image-url" class="form-control" th:field="*{imageUrl}" />
293+
<span id="image-url.errors" class="help-block" th:if="${#fields.hasErrors('imageUrl')}" th:each="error : ${#fields.errors('imageUrl')}" th:text="${error}"></span>
294+
</div>
295+
</div>
296+
280297
<div class="form-group js-collapse-toggle-header">
281298
<div class="col-sm-offset-3 col-sm-5">
282299
<span class="glyphicon glyphicon-chevron-right" th:class="${issueDateHasErrors or issueDateHasValues ? 'glyphicon glyphicon-chevron-down' : 'glyphicon glyphicon-chevron-right'}"></span>&nbsp;<a href="javascript:void(0)" id="specify-issue-date-link" data-toggle="collapse" data-target=".js-issue-date" th:text="#{t_specify_issue_date}">Specify date of release</a>

0 commit comments

Comments
 (0)