diff --git a/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java b/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java index 1f2294f3a..08b76bfb7 100644 --- a/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java +++ b/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java @@ -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/"); diff --git a/src/main/java/run/halo/app/config/properties/HaloProperties.java b/src/main/java/run/halo/app/config/properties/HaloProperties.java index a5960a515..b982f7eab 100644 --- a/src/main/java/run/halo/app/config/properties/HaloProperties.java +++ b/src/main/java/run/halo/app/config/properties/HaloProperties.java @@ -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. diff --git a/src/main/java/run/halo/app/controller/admin/api/BackupController.java b/src/main/java/run/halo/app/controller/admin/api/BackupController.java index 2fa3c3f88..84496c383 100644 --- a/src/main/java/run/halo/app/controller/admin/api/BackupController.java +++ b/src/main/java/run/halo/app/controller/admin/api/BackupController.java @@ -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 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) { diff --git a/src/main/java/run/halo/app/core/ControllerLogAop.java b/src/main/java/run/halo/app/core/ControllerLogAop.java index a2a7c1ad1..db64b8534 100644 --- a/src/main/java/run/halo/app/core/ControllerLogAop.java +++ b/src/main/java/run/halo/app/core/ControllerLogAop.java @@ -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 { + returnData = toString(responseEntity.getBody()); + } } else { - returningData = JsonUtils.objectToJson(returnObj); + 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; + } } diff --git a/src/main/java/run/halo/app/service/BackupService.java b/src/main/java/run/halo/app/service/BackupService.java index 6a4061b9a..cd12d1a05 100644 --- a/src/main/java/run/halo/app/service/BackupService.java +++ b/src/main/java/run/halo/app/service/BackupService.java @@ -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); } diff --git a/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java b/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java index 81b7d1e75..628fb4364 100644 --- a/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java @@ -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); } } diff --git a/src/main/java/run/halo/app/utils/HaloUtils.java b/src/main/java/run/halo/app/utils/HaloUtils.java index ab58e4ad4..afee27c25 100755 --- a/src/main/java/run/halo/app/utils/HaloUtils.java +++ b/src/main/java/run/halo/app/utils/HaloUtils.java @@ -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. * diff --git a/src/test/java/run/halo/app/utils/HaloUtilsTest.java b/src/test/java/run/halo/app/utils/HaloUtilsTest.java index 64fe55bee..84dd52de5 100644 --- a/src/test/java/run/halo/app/utils/HaloUtilsTest.java +++ b/src/test/java/run/halo/app/utils/HaloUtilsTest.java @@ -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); + } }