Commit dbb24f83 authored by Matija Obreza's avatar Matija Obreza
Browse files

S3 bytes storage service improvements

- Fixed query string encoding for canonical request
- Removed Content-Length header as it is not used by AWS
- Deserialization of XML fixed
- Logging
parent d8209ada
......@@ -192,5 +192,17 @@
<artifactId>commons-lang3</artifactId>
<version>3.5</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-jaxb-annotations</artifactId>
<version>2.8.8</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.8.8</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
......@@ -19,12 +19,14 @@ package org.genesys.filerepository.service.impl;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
......@@ -36,6 +38,8 @@ import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule;
import org.apache.commons.lang3.StringUtils;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.service.BytesStorageService;
......@@ -45,9 +49,12 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
......@@ -118,7 +125,7 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
public void upsert(final String path, final String filename, final byte[] data) throws FileNotFoundException, IOException {
if (!PathValidator.isValidPath(path)) {
throw new IOException("Path is not valid");
throw new IOException("Path is not valid. Path was " + path);
}
if (filename == null) {
......@@ -157,12 +164,18 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
throw new IOException("File name is null");
}
final String url = getUrl(path, filename);
if (LOG.isDebugEnabled()) {
LOG.debug("Deleting from path={} filename={}", path, filename);
LOG.debug("Deleting from path={} filename={} url={}", path, filename, url);
}
final String url = getUrl(path, filename);
restTemplate.delete(url);
try {
restTemplate.delete(url);
} catch (final HttpClientErrorException e) {
LOG.error("Deleting file failed with error\n{}", e.getResponseBodyAsString());
throw e;
}
}
/*
......@@ -184,7 +197,13 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
LOG.debug("Getting bytes path={} filename={}", path, filename);
}
final String url = getUrl(path, filename);
return restTemplate.getForObject(url, byte[].class);
try {
return restTemplate.getForObject(url, byte[].class);
} catch (final HttpClientErrorException e) {
LOG.error("Getting bytes failed with error\n{}", e.getResponseBodyAsString());
throw e;
}
}
/**
......@@ -274,7 +293,7 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
sb.append(request.getURI().getPath()).append("\n");
// CanonicalQueryString
sb.append(StringUtils.defaultIfBlank(request.getURI().getQuery(), "")).append("\n");
sb.append(buildQueryString(StringUtils.defaultIfBlank(request.getURI().getQuery(), ""))).append("\n");
// sorted headers, lowercase
request.getHeaders().keySet().stream().map(headerName -> headerName.toLowerCase()).sorted()
......@@ -298,12 +317,48 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
return sb.toString();
}
private static byte[] hashSha256(final byte[] bytes) throws NoSuchAlgorithmException {
/**
* Sorted by query parameter name
*
* @param query
* @return
* @throws UnsupportedEncodingException
*/
public static String buildQueryString(String query) throws UnsupportedEncodingException {
LOG.trace("Encoding query string: {}", query);
String result = Arrays.stream(query.split("&"))
// split
.map(part -> part.split("=", 2))
// encode parts
.map(part -> {
try {
return URLEncoder.encode(part[0], "US-ASCII") + (part.length == 1 ? "" : "=" + URLEncoder.encode(part[1], "US-ASCII"));
} catch (UnsupportedEncodingException e) {
return "";
}
})
// must be sorted
.sorted()
// debug
.peek(part -> LOG.trace("Querystring part: {}", part))
// merge
.reduce("", (res, part) -> {
if (res.length() == 0) {
return part;
} else {
// Do not &amp; the ampersands!
return res + "&" + part;
}
});
return result;
}
public static byte[] hashSha256(final byte[] bytes) throws NoSuchAlgorithmException {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
return digest.digest(bytes);
}
private static String printHex(final byte[] bytes) {
public static String printHex(final byte[] bytes) {
return DatatypeConverter.printHexBinary(bytes).toLowerCase();
}
......@@ -396,6 +451,13 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
private RestTemplate initializeRestTemplate() {
final RestTemplate restTemplate = new RestTemplate();
// create module
JaxbAnnotationModule jaxbAnnotationModule = new JaxbAnnotationModule();
restTemplate.getMessageConverters().stream().filter(converter -> {
return converter instanceof MappingJackson2XmlHttpMessageConverter;
}).forEach(converter -> ((MappingJackson2XmlHttpMessageConverter) converter).getObjectMapper().registerModule(jaxbAnnotationModule));
final List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>();
interceptors.add((request, body, execution) -> {
......@@ -403,6 +465,10 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
request.getHeaders().set("Host", getHost());
// This avoids date formatting problems
request.getHeaders().add(AMZ_DATE, HEADER_DATE_FORMAT.format(date));
// DELETE has no Content-length
if (request.getMethod() != HttpMethod.POST && request.getMethod() != HttpMethod.PUT) {
request.getHeaders().remove(HttpHeaders.CONTENT_LENGTH);
}
try {
final String canonicalRequest = buildCanonicalRequest(request, body);
......@@ -417,6 +483,10 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
final ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode() != HttpStatus.OK) {
LOG.trace("S3 HTTP {} {} status={} {}", request.getMethod(), request.getURI(), response.getRawStatusCode(), response.getStatusText());
}
return response;
});
restTemplate.setInterceptors(interceptors);
......@@ -451,11 +521,14 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
});
}
return true;
} catch (final HttpClientErrorException e4XX) {
if (LOG.isDebugEnabled()) {
LOG.debug("4XX error returned: {}", e4XX.getMessage());
} catch (final HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
// valid response
return false;
}
return false;
LOG.error("Testing for file failed with error\n{}", e.getResponseBodyAsString());
throw e;
} catch (final Throwable e) {
LOG.warn("Catch this thing!", e);
throw e;
......@@ -472,35 +545,40 @@ public class S3StorageServiceImpl implements BytesStorageService, InitializingBe
*/
@Override
public List<String> listFiles(final String path) throws InvalidRepositoryPathException {
LOG.debug("Listing S3 bucket for host={} path={}", getHost(), path);
PathValidator.checkValidPath(path);
final String s3prefix = getPath(path).substring(1);
LOG.debug("Listing S3 bucket for host={} path={} prefix={}", getHost(), path, s3prefix);
final ListBucketResult listBucketResult = restTemplate.getForObject("https://" + getHost() + "/?delimiter=/&prefix={path}", ListBucketResult.class, s3prefix);
if (LOG.isDebugEnabled()) {
LOG.debug("Bucket name={} maxKeys={} delimiter={} prefix={}", listBucketResult.getName(), listBucketResult.getMaxKeys(), listBucketResult.getDelimiter(), listBucketResult
.getPrefix());
try {
final ListBucketResult listBucketResult = restTemplate.getForObject("https://" + getHost() + "/?list-type=2&delimiter=/&prefix={path}/", ListBucketResult.class, s3prefix);
if (listBucketResult.getCommonPrefixes() != null) {
listBucketResult.getCommonPrefixes().forEach(commonPrefix -> {
LOG.debug("Subprefix={}", commonPrefix.getPrefix());
});
if (LOG.isDebugEnabled()) {
LOG.debug("Bucket name={} maxKeys={} delimiter={} prefix={}", listBucketResult.getName(), listBucketResult.getMaxKeys(), listBucketResult.getDelimiter(), listBucketResult
.getPrefix());
if (listBucketResult.getCommonPrefixes() != null) {
listBucketResult.getCommonPrefixes().forEach(commonPrefix -> {
LOG.debug("Subprefix={}", commonPrefix.getPrefix());
});
}
if (listBucketResult.getContents() != null) {
listBucketResult.getContents().forEach(content -> {
LOG.debug("Object prefix={} len={} filename={}", content.getKey(), content.getSize(), content.getKey().substring(s3prefix.length()));
});
}
}
if (listBucketResult.getContents() != null) {
listBucketResult.getContents().forEach(content -> {
LOG.debug("Object prefix={} len={} filename={}", content.getKey(), content.getSize(), content.getKey().substring(s3prefix.length()));
});
if (listBucketResult.getContents() == null) {
return Collections.emptyList();
} else {
return listBucketResult.getContents().stream().map(content -> content.getKey().substring(s3prefix.length())).collect(Collectors.toList());
}
}
if (listBucketResult.getContents() == null) {
return Collections.emptyList();
} else {
return listBucketResult.getContents().stream().map(content -> content.getKey().substring(s3prefix.length())).collect(Collectors.toList());
} catch (HttpClientErrorException e) {
LOG.error("Error listing files at path={}\n{}", path, e.getResponseBodyAsString());
throw e;
}
}
......
......@@ -23,6 +23,8 @@ import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
// TODO: Auto-generated Javadoc
/**
* Built from AWS S3 documentation at
......@@ -128,12 +130,14 @@ public class ListBucketResult {
* calculating the number of returns. See MaxKeys.
*/
@XmlElement(name = "CommonPrefixes")
@JacksonXmlElementWrapper(useWrapping = false)
private List<CommonPrefix> commonPrefixes;
/**
* Metadata about each object returned.
*/
@XmlElement(name = "Contents")
@JacksonXmlElementWrapper(useWrapping = false)
private List<Content> contents;
/**
......
......@@ -24,29 +24,35 @@ import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertThat;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.config.DatabaseConfig;
import org.genesys.filerepository.config.ServiceBeanConfig;
import org.junit.Ignore;
import org.genesys.filerepository.service.impl.S3StorageServiceImpl;
import org.genesys.filerepository.service.s3.Content;
import org.genesys.filerepository.service.s3.ListBucketResult;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.web.client.HttpClientErrorException;
// TODO: Auto-generated Javadoc
/**
* The Class S3StorageServiceTest.
*/
@Ignore
// @Ignore
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { ServiceBeanConfig.class, DatabaseConfig.class })
public class S3StorageServiceTest {
......@@ -61,7 +67,7 @@ public class S3StorageServiceTest {
private final long timestamp = System.currentTimeMillis();
/** The Constant PATH. */
private final String PATH = "/temp/" + timestamp + "/";
private final String PATH = "/temp/" + timestamp;
/** The Constant FILENAME. */
private final String FILENAME = "test.txt";
......@@ -89,6 +95,76 @@ public class S3StorageServiceTest {
bytesStorageService.remove(PATH, FILENAME);
}
@Test
public void testQueryStringEncoding() throws UnsupportedEncodingException {
assertThat(S3StorageServiceImpl.buildQueryString("delimiter=/&prefix=mobreza/temp/1508094562430"), is("delimiter=%2F&prefix=mobreza%2Ftemp%2F1508094562430"));
assertThat(S3StorageServiceImpl.buildQueryString("delimiter=/&prefix=mobreza/temp/1508095465187"), is("delimiter=%2F&prefix=mobreza%2Ftemp%2F1508095465187"));
assertThat(S3StorageServiceImpl.buildQueryString("list-type=2&delimiter=/&prefix=prefix"), is("delimiter=%2F&list-type=2&prefix=prefix"));
}
@Test
public void testCanonicalRequestBytes() {
String canonicalRequest = "GET\n" + "/\n" + "delimiter=%2F&prefix=mobreza%2Ftemp%2F1508096203026\n"
+ "accept:application/xml, text/xml, application/json, application/*+xml, application/*+json\n" + "host:genesys-sandbox-repo.s3-eu-central-1.amazonaws.com\n"
+ "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n" + "x-amz-date:20171015T193648Z\n" + "\n"
+ "accept;host;x-amz-content-sha256;x-amz-date\n" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
byte[] a = canonicalRequest.getBytes();
byte[] b = new byte[] { 0x47, 0x45, 0x54, 0x0a, 0x2f, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x65, 0x72, 0x3d, 0x25, 0x32, 0x46, 0x26, 0x70, 0x72, 0x65, 0x66, 0x69,
0x78, 0x3d, 0x6d, 0x6f, 0x62, 0x72, 0x65, 0x7a, 0x61, 0x25, 0x32, 0x46, 0x74, 0x65, 0x6d, 0x70, 0x25, 0x32, 0x46, 0x31, 0x35, 0x30, 0x38, 0x30, 0x39, 0x36, 0x32, 0x30,
0x33, 0x30, 0x32, 0x36, 0x0a, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x3a, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x78, 0x6d, 0x6c, 0x2c,
0x20, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x78, 0x6d, 0x6c, 0x2c, 0x20, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x6a, 0x73, 0x6f, 0x6e, 0x2c,
0x20, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x2a, 0x2b, 0x78, 0x6d, 0x6c, 0x2c, 0x20, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x74,
0x69, 0x6f, 0x6e, 0x2f, 0x2a, 0x2b, 0x6a, 0x73, 0x6f, 0x6e, 0x0a, 0x68, 0x6f, 0x73, 0x74, 0x3a, 0x67, 0x65, 0x6e, 0x65, 0x73, 0x79, 0x73, 0x2d, 0x73, 0x61, 0x6e, 0x64,
0x62, 0x6f, 0x78, 0x2d, 0x72, 0x65, 0x70, 0x6f, 0x2e, 0x73, 0x33, 0x2d, 0x65, 0x75, 0x2d, 0x63, 0x65, 0x6e, 0x74, 0x72, 0x61, 0x6c, 0x2d, 0x31, 0x2e, 0x61, 0x6d, 0x61,
0x7a, 0x6f, 0x6e, 0x61, 0x77, 0x73, 0x2e, 0x63, 0x6f, 0x6d, 0x0a, 0x78, 0x2d, 0x61, 0x6d, 0x7a, 0x2d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, 0x73, 0x68, 0x61,
0x32, 0x35, 0x36, 0x3a, 0x65, 0x33, 0x62, 0x30, 0x63, 0x34, 0x34, 0x32, 0x39, 0x38, 0x66, 0x63, 0x31, 0x63, 0x31, 0x34, 0x39, 0x61, 0x66, 0x62, 0x66, 0x34, 0x63, 0x38,
0x39, 0x39, 0x36, 0x66, 0x62, 0x39, 0x32, 0x34, 0x32, 0x37, 0x61, 0x65, 0x34, 0x31, 0x65, 0x34, 0x36, 0x34, 0x39, 0x62, 0x39, 0x33, 0x34, 0x63, 0x61, 0x34, 0x39, 0x35,
0x39, 0x39, 0x31, 0x62, 0x37, 0x38, 0x35, 0x32, 0x62, 0x38, 0x35, 0x35, 0x0a, 0x78, 0x2d, 0x61, 0x6d, 0x7a, 0x2d, 0x64, 0x61, 0x74, 0x65, 0x3a, 0x32, 0x30, 0x31, 0x37,
0x31, 0x30, 0x31, 0x35, 0x54, 0x31, 0x39, 0x33, 0x36, 0x34, 0x38, 0x5a, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x3b, 0x68, 0x6f, 0x73, 0x74, 0x3b, 0x78, 0x2d,
0x61, 0x6d, 0x7a, 0x2d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x2d, 0x73, 0x68, 0x61, 0x32, 0x35, 0x36, 0x3b, 0x78, 0x2d, 0x61, 0x6d, 0x7a, 0x2d, 0x64, 0x61, 0x74,
0x65, 0x0a, 0x65, 0x33, 0x62, 0x30, 0x63, 0x34, 0x34, 0x32, 0x39, 0x38, 0x66, 0x63, 0x31, 0x63, 0x31, 0x34, 0x39, 0x61, 0x66, 0x62, 0x66, 0x34, 0x63, 0x38, 0x39, 0x39,
0x36, 0x66, 0x62, 0x39, 0x32, 0x34, 0x32, 0x37, 0x61, 0x65, 0x34, 0x31, 0x65, 0x34, 0x36, 0x34, 0x39, 0x62, 0x39, 0x33, 0x34, 0x63, 0x61, 0x34, 0x39, 0x35, 0x39, 0x39,
0x31, 0x62, 0x37, 0x38, 0x35, 0x32, 0x62, 0x38, 0x35, 0x35 };
assertThat(a.length, is(b.length));
assertThat(canonicalRequest, is(new String(b)));
// for (int i = 0; i < a.length && i < b.length; i++) {
// if (a[i] != b[i]) {
// System.err.println(i + "\t" + a[i] + " = " + b[i] + "\t\t" +
// canonicalRequest.charAt(i));
// }
// }
assertThat(a, is(b));
}
@Test
public void testStringToSign() throws NoSuchAlgorithmException {
assertThat(S3StorageServiceImpl.printHex(S3StorageServiceImpl.hashSha256(("GET\n" + "/\n" + "delimiter=%2F&amp;prefix=mobreza%2Ftemp%2F1508095465187\n"
+ "accept:application/xml, text/xml, application/json, application/*+xml, application/*+json\n" + "host:genesys-sandbox-repo.s3-eu-central-1.amazonaws.com\n"
+ "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n" + "x-amz-date:20171015T192428Z\n" + "\n"
+ "accept;host;x-amz-content-sha256;x-amz-date\n" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855").getBytes())), is(
"c748904b8a143d31fd9b9bab6063608691be13cf7f4b5a5eb91878b86b409336"));
}
@Test
public void serializeListBucketResult() throws JsonProcessingException {
ListBucketResult lbr = new ListBucketResult();
lbr.setName("Test");
lbr.setMaxKeys(100);
List<Content> contents = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Content content = new Content();
content.setKey("key" + i);
content.setSize(1l);
contents.add(content);
}
lbr.setContents(contents);
System.err.println(Jackson2ObjectMapperBuilder.xml().build().writeValueAsString(lbr));
}
/**
* Test list.
*
......@@ -119,14 +195,16 @@ public class S3StorageServiceTest {
* @throws IOException Signals that an I/O exception has occurred.
* @throws InvalidKeyException the invalid key exception
* @throws NoSuchAlgorithmException the no such algorithm exception
* @throws InvalidRepositoryPathException
*/
@Test(expected = HttpClientErrorException.class)
public void testRemoveFile() throws IOException, InvalidKeyException, NoSuchAlgorithmException {
@Test
public void testRemoveFile() throws IOException, InvalidKeyException, NoSuchAlgorithmException, InvalidRepositoryPathException {
bytesStorageService.upsert(PATH, FILENAME, SOME_BYTES);
assertThat(bytesStorageService.exists(PATH, FILENAME), is(true));
bytesStorageService.remove(PATH, FILENAME);
bytesStorageService.get(PATH, FILENAME);
assertThat(bytesStorageService.exists(PATH, FILENAME), is(false));
}
/**
......@@ -184,8 +262,8 @@ public class S3StorageServiceTest {
* @throws NoSuchAlgorithmException the no such algorithm exception
*/
@Test(expected = IOException.class)
public void invalidUpsertWithoutLastSlash() throws IOException, InvalidKeyException, NoSuchAlgorithmException {
bytesStorageService.upsert("/test", FILENAME, SOME_BYTES);
public void invalidUpsertWithLastSlash() throws IOException, InvalidKeyException, NoSuchAlgorithmException {
bytesStorageService.upsert("/test/", FILENAME, SOME_BYTES);
}
/**
......@@ -256,8 +334,8 @@ public class S3StorageServiceTest {
* @throws NoSuchAlgorithmException the no such algorithm exception
*/
@Test(expected = IOException.class)
public void invalidRemoveWithoutLastSlash() throws IOException, InvalidKeyException, NoSuchAlgorithmException {
bytesStorageService.remove("/test", FILENAME);
public void invalidRemoveWithLastSlash() throws IOException, InvalidKeyException, NoSuchAlgorithmException {
bytesStorageService.remove("/test/", FILENAME);
}
/**
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment