Secure backup file downloading

pull/755/head
johnniang 2019-11-30 01:29:01 +08:00
parent 8463a3f31e
commit 2153998a32
8 changed files with 208 additions and 40 deletions

View File

@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.jackson.JsonComponentModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
@ -32,7 +31,9 @@ import java.io.IOException;
import java.util.List;
import java.util.Properties;
import static run.halo.app.model.support.HaloConst.FILE_SEPARATOR;
import static run.halo.app.model.support.HaloConst.HALO_ADMIN_RELATIVE_PATH;
import static run.halo.app.utils.HaloUtils.*;
/**
* Mvc configuration.
@ -81,18 +82,20 @@ public class WebMvcAutoConfiguration implements WebMvcConfigurer {
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String workDir = FILE_PROTOCOL + StringUtils.appendIfMissing(haloProperties.getWorkDir(), "/");
String backupDir = FILE_PROTOCOL + StringUtils.appendIfMissing(haloProperties.getBackupDir(), "/");
String workDir = FILE_PROTOCOL + ensureSuffix(haloProperties.getWorkDir(), FILE_SEPARATOR);
String backupDir = FILE_PROTOCOL + ensureSuffix(haloProperties.getBackupDir(), FILE_SEPARATOR);
registry.addResourceHandler("/**")
.addResourceLocations(workDir + "templates/themes/")
.addResourceLocations(workDir + "templates/admin/")
.addResourceLocations("classpath:/admin/")
.addResourceLocations(workDir + "static/");
registry.addResourceHandler(haloProperties.getUploadUrlPrefix() + "/**")
String uploadUrlPattern = ensureBoth(haloProperties.getUploadUrlPrefix(), URL_SEPARATOR) + "**";
String adminPathPattern = ensureSuffix(haloProperties.getAdminPath(), URL_SEPARATOR) + "**";
registry.addResourceHandler(uploadUrlPattern)
.addResourceLocations(workDir + "upload/");
registry.addResourceHandler(haloProperties.getBackupUrlPrefix() + "/**")
.addResourceLocations(workDir + "backup/", backupDir);
registry.addResourceHandler(haloProperties.getAdminPath() + "/**")
registry.addResourceHandler(adminPathPattern)
.addResourceLocations(workDir + HALO_ADMIN_RELATIVE_PATH)
.addResourceLocations("classpath:/admin/");

View File

@ -2,14 +2,15 @@ package run.halo.app.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import run.halo.app.model.support.HaloConst;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Duration;
import static run.halo.app.model.support.HaloConst.*;
import static run.halo.app.utils.HaloUtils.ensureSuffix;
/**
* Halo configuration properties.
@ -38,27 +39,22 @@ public class HaloProperties {
/**
* Admin path.
*/
private String adminPath = "/admin";
private String adminPath = "admin";
/**
* Halo backup directory.(Not recommended to modify this config);
*/
private String backupDir = HaloConst.TEMP_DIR + "/halo-backup/";
private String backupDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-backup" + FILE_SEPARATOR;
/**
* Work directory.
*/
private String workDir = HaloConst.USER_HOME + "/.halo/";
private String workDir = ensureSuffix(USER_HOME, FILE_SEPARATOR) + ".halo" + FILE_SEPARATOR;
/**
* Upload prefix.
*/
private String uploadUrlPrefix = "/upload";
/**
* backup prefix.
*/
private String backupUrlPrefix = "/backup";
private String uploadUrlPrefix = "upload";
/**
* Download Timeout.

View File

@ -6,6 +6,10 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.json.JSONObject;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import run.halo.app.exception.FileOperationException;
@ -13,6 +17,7 @@ import run.halo.app.model.dto.BackupDTO;
import run.halo.app.model.dto.post.BasePostDetailDTO;
import run.halo.app.service.BackupService;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
@ -49,6 +54,28 @@ public class BackupController {
return backupService.listHaloBackups();
}
@GetMapping("halo/{fileName:.+}")
@ApiOperation("Download backup file")
public ResponseEntity<Resource> downloadBackup(@PathVariable("fileName") String fileName, HttpServletRequest request) {
log.info("Try to download backup file: [{}]", fileName);
// Load file as resource
Resource backupResource = backupService.loadFileAsResource(fileName);
String contentType = "application/octet-stream";
// Try to determine file's content type
try {
contentType = request.getServletContext().getMimeType(backupResource.getFile().getAbsolutePath());
} catch (IOException e) {
log.warn("Could not determine file type", e);
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + backupResource.getFilename() + "\"")
.body(backupResource);
}
@DeleteMapping("halo")
@ApiOperation("Delete a backup")
public void deleteBackup(@RequestParam("filename") String filename) {

View File

@ -7,7 +7,11 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
@ -75,16 +79,36 @@ public class ControllerLogAop {
private void printResponseLog(HttpServletRequest request, String className, String methodName, Object returnObj, long usage) throws JsonProcessingException {
if (log.isDebugEnabled()) {
String returningData = null;
String returnData = "";
if (returnObj != null) {
if (returnObj.getClass().isAssignableFrom(byte[].class)) {
returningData = "Binary data";
if (returnObj instanceof ResponseEntity) {
ResponseEntity responseEntity = (ResponseEntity) returnObj;
if (responseEntity.getBody() instanceof Resource) {
returnData = "[ BINARY DATA ]";
} else {
returningData = JsonUtils.objectToJson(returnObj);
returnData = toString(responseEntity.getBody());
}
} else {
returnData = toString(returnObj);
}
log.debug("{}.{} Response: [{}], usage: [{}]ms", className, methodName, returningData, usage);
}
log.debug("{}.{} Response: [{}], usage: [{}]ms", className, methodName, returnData, usage);
}
}
@NonNull
private String toString(@NonNull Object obj) throws JsonProcessingException {
Assert.notNull(obj, "Return object must not be null");
String toString = "";
if (obj.getClass().isAssignableFrom(byte[].class) && obj instanceof Resource) {
toString = "[ BINARY DATA ]";
} else {
toString = JsonUtils.objectToJson(obj);
}
return toString;
}
}

View File

@ -1,6 +1,7 @@
package run.halo.app.service;
import org.json.JSONObject;
import org.springframework.core.io.Resource;
import org.springframework.lang.NonNull;
import org.springframework.web.multipart.MultipartFile;
import run.halo.app.model.dto.BackupDTO;
@ -61,7 +62,16 @@ public interface BackupService {
/**
* Deletes backup.
*
* @param filename filename must not be blank
* @param fileName filename must not be blank
*/
void deleteHaloBackup(@NonNull String filename);
void deleteHaloBackup(@NonNull String fileName);
/**
* Loads file as resource.
*
* @param fileName backup file name must not be blank.
* @return resource of the given file
*/
@NonNull
Resource loadFileAsResource(@NonNull String fileName);
}

View File

@ -7,6 +7,8 @@ import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.json.JSONObject;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
@ -24,9 +26,11 @@ import run.halo.app.service.BackupService;
import run.halo.app.service.OptionService;
import run.halo.app.service.PostService;
import run.halo.app.service.PostTagService;
import run.halo.app.utils.HaloUtils;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
@ -125,10 +129,8 @@ public class BackupServiceImpl implements BackupService {
if (StringUtils.isNotBlank(post.getPassword())) {
passwords.add(one);
continue;
} else if (post.getDeleted()) {
drafts.add(one);
continue;
} else {
posts.add(one);
}
@ -159,10 +161,10 @@ public class BackupServiceImpl implements BackupService {
// Zip work directory to temporary file
try {
// Create zip path for halo zip
String haloZipFileName = new StringBuilder().append(HaloConst.HALO_BACKUP_PREFIX)
.append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss")))
.append(IdUtil.simpleUUID())
.append(".zip").toString();
String haloZipFileName = HaloConst.HALO_BACKUP_PREFIX +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss")) +
IdUtil.simpleUUID() +
".zip";
// Create halo zip file
Path haloZipPath = Files.createFile(Paths.get(haloProperties.getBackupDir(), haloZipFileName));
@ -198,22 +200,42 @@ public class BackupServiceImpl implements BackupService {
}
@Override
public void deleteHaloBackup(String filename) {
Assert.hasText(filename, "File name must not be blank");
public void deleteHaloBackup(String fileName) {
Assert.hasText(fileName, "File name must not be blank");
// Get backup path
Path backupPath = Paths.get(haloProperties.getBackupDir(), filename);
Path backupPath = Paths.get(haloProperties.getBackupDir(), fileName);
try {
// Delete backup file
Files.delete(backupPath);
} catch (NoSuchFileException e) {
throw new NotFoundException("The file " + filename + " was not found", e);
throw new NotFoundException("The file " + fileName + " was not found", e);
} catch (IOException e) {
throw new ServiceException("Failed to delete backup", e);
}
}
@Override
public Resource loadFileAsResource(String fileName) {
Assert.hasText(fileName, "Backup file name must not be blank");
// Get backup file path
Path backupFilePath = Paths.get(haloProperties.getBackupDir(), fileName).normalize();
try {
// Build url resource
Resource backupResource = new UrlResource(backupFilePath.toUri());
if (!backupResource.exists()) {
// If the backup resouce is not exist
throw new NotFoundException("The file " + fileName + " was not found");
}
// Return the backup resource
return backupResource;
} catch (MalformedURLException e) {
throw new NotFoundException("The file " + fileName + " was not found", e);
}
}
/**
* Builds backup dto.
*
@ -226,7 +248,7 @@ public class BackupServiceImpl implements BackupService {
String backupFileName = backupPath.getFileName().toString();
BackupDTO backup = new BackupDTO();
backup.setDownloadUrl(buildDownloadUrl(backupFileName));
backup.setDownloadLink(backup.getDownloadLink());
backup.setDownloadLink(backup.getDownloadUrl());
backup.setFilename(backupFileName);
try {
backup.setUpdateTime(Files.getLastModifiedTime(backupPath).toMillis());
@ -247,9 +269,6 @@ public class BackupServiceImpl implements BackupService {
private String buildDownloadUrl(@NonNull String filename) {
Assert.hasText(filename, "File name must not be blank");
return StringUtils.joinWith("/",
optionService.getBlogBaseUrl(),
StringUtils.removeEnd(StringUtils.removeStart(haloProperties.getBackupUrlPrefix(), "/"), "/"),
filename);
return HaloUtils.compositeHttpUrl(optionService.getBlogBaseUrl(), "api/admin/backups/halo", filename);
}
}

View File

@ -24,6 +24,75 @@ public class HaloUtils {
private static final String RE_HTML_MARK = "(<[^<]*?>)|(<[\\s]*?/[^<]*?>)|(<[^<]*?/[\\s]*?>)";
public static final String URL_SEPARATOR = "/";
@NonNull
public static String ensureBoth(@NonNull String string, @NonNull String bothfix) {
return ensureBoth(string, bothfix, bothfix);
}
@NonNull
public static String ensureBoth(@NonNull String string, @NonNull String prefix, @NonNull String suffix) {
return ensureSuffix(ensurePrefix(string, prefix), suffix);
}
/**
* Ensures the string contain prefix.
*
* @param string string must not be blank
* @param prefix prefix must not be blank
* @return string contain prefix specified
*/
@NonNull
public static String ensurePrefix(@NonNull String string, @NonNull String prefix) {
Assert.hasText(string, "String must not be blank");
Assert.hasText(prefix, "Prefix must not be blank");
return prefix + StringUtils.removeStart(string, prefix);
}
/**
* Ensures the string contain suffix.
*
* @param string string must not be blank
* @param suffix suffix must not be blank
* @return string contain suffix specified
*/
@NonNull
public static String ensureSuffix(@NonNull String string, @NonNull String suffix) {
Assert.hasText(string, "String must not be blank");
Assert.hasText(suffix, "Suffix must not be blank");
return StringUtils.removeEnd(string, suffix) + suffix;
}
/**
* Composites partial url to full http url.
*
* @param partUrls partial urls must not be empty
* @return full url
*/
public static String compositeHttpUrl(@NonNull String... partUrls) {
Assert.notEmpty(partUrls, "Partial url must not be blank");
StringBuilder builder = new StringBuilder();
for (int i = 0; i < partUrls.length; i++) {
String partUrl = partUrls[i];
if (StringUtils.isBlank(partUrl)) {
continue;
}
partUrl = StringUtils.removeStart(partUrl, URL_SEPARATOR);
partUrl = StringUtils.removeEnd(partUrl, URL_SEPARATOR);
if (i != 0) {
builder.append(URL_SEPARATOR);
}
builder.append(partUrl);
}
return builder.toString();
}
/**
* Desensitizes the plain text.
*

View File

@ -2,11 +2,13 @@ package run.halo.app.utils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomUtils;
import org.junit.Assert;
import org.junit.Test;
import java.util.stream.IntStream;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
/**
@ -123,4 +125,22 @@ public class HaloUtilsTest {
String plainText = " ";
HaloUtils.desensitize(plainText, 1, 1);
}
@Test
public void compositeHttpUrl() {
String url = HaloUtils.compositeHttpUrl("https://halo.run", "path1", "path2");
assertEquals("https://halo.run/path1/path2", url);
url = HaloUtils.compositeHttpUrl("https://halo.run/", "path1", "path2");
assertEquals("https://halo.run/path1/path2", url);
url = HaloUtils.compositeHttpUrl("https://halo.run/", "/path1", "path2");
assertEquals("https://halo.run/path1/path2", url);
url = HaloUtils.compositeHttpUrl("https://halo.run/", "/path1/", "path2");
assertEquals("https://halo.run/path1/path2", url);
url = HaloUtils.compositeHttpUrl("https://halo.run/", "/path1/", "/path2/");
assertEquals("https://halo.run/path1/path2", url);
}
}