refactor: backup service. (#687)

* refactor: backup service.

* fix: test case error.
pull/689/head
Ryan Wang 2020-03-17 20:22:51 +08:00 committed by GitHub
parent 03a43fb245
commit a77ebed299
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 181 additions and 50 deletions

View File

@ -14,6 +14,8 @@ import static run.halo.app.utils.HaloUtils.ensureSuffix;
* Halo configuration properties. * Halo configuration properties.
* *
* @author johnniang * @author johnniang
* @author ryanwang
* @date 2019-03-15
*/ */
@Data @Data
@ConfigurationProperties("halo") @ConfigurationProperties("halo")
@ -54,6 +56,11 @@ public class HaloProperties {
*/ */
private String backupDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-backup" + FILE_SEPARATOR; private String backupDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-backup" + FILE_SEPARATOR;
/**
* Halo data export directory.
*/
private String dataExportDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-data-export" + FILE_SEPARATOR;
/** /**
* Upload prefix. * Upload prefix.
*/ */

View File

@ -1,6 +1,5 @@
package run.halo.app.controller.admin.api; package run.halo.app.controller.admin.api;
import cn.hutool.core.date.DateUtil;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@ -9,6 +8,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import run.halo.app.config.properties.HaloProperties;
import run.halo.app.model.annotation.DisableOnCondition; import run.halo.app.model.annotation.DisableOnCondition;
import run.halo.app.model.dto.BackupDTO; import run.halo.app.model.dto.BackupDTO;
import run.halo.app.model.dto.post.BasePostDetailDTO; import run.halo.app.model.dto.post.BasePostDetailDTO;
@ -16,13 +16,13 @@ import run.halo.app.service.BackupService;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.io.IOException; import java.io.IOException;
import java.util.Date;
import java.util.List; import java.util.List;
/** /**
* Backup controller * Backup controller
* *
* @author johnniang * @author johnniang
* @author ryanwang
* @date 2019-04-26 * @date 2019-04-26
*/ */
@RestController @RestController
@ -32,31 +32,35 @@ public class BackupController {
private final BackupService backupService; private final BackupService backupService;
public BackupController(BackupService backupService) { private final HaloProperties haloProperties;
public BackupController(BackupService backupService,
HaloProperties haloProperties) {
this.backupService = backupService; this.backupService = backupService;
this.haloProperties = haloProperties;
} }
@PostMapping("halo") @PostMapping("work-dir")
@ApiOperation("Backups halo") @ApiOperation("Backups work directory")
@DisableOnCondition @DisableOnCondition
public BackupDTO backupHalo() { public BackupDTO backupHalo() {
return backupService.zipWorkDirectory(); return backupService.backupWorkDirectory();
} }
@GetMapping("halo") @GetMapping("work-dir")
@ApiOperation("Gets all backups") @ApiOperation("Gets all work directory backups")
public List<BackupDTO> listBackups() { public List<BackupDTO> listBackups() {
return backupService.listHaloBackups(); return backupService.listWorkDirBackups();
} }
@GetMapping("halo/{fileName:.+}") @GetMapping("work-dir/{fileName:.+}")
@ApiOperation("Downloads backup file") @ApiOperation("Downloads a work directory backup file")
@DisableOnCondition @DisableOnCondition
public ResponseEntity<Resource> downloadBackup(@PathVariable("fileName") String fileName, HttpServletRequest request) { public ResponseEntity<Resource> downloadBackup(@PathVariable("fileName") String fileName, HttpServletRequest request) {
log.info("Try to download backup file: [{}]", fileName); log.info("Try to download backup file: [{}]", fileName);
// Load file as resource // Load file as resource
Resource backupResource = backupService.loadFileAsResource(fileName); Resource backupResource = backupService.loadFileAsResource(haloProperties.getBackupDir(), fileName);
String contentType = "application/octet-stream"; String contentType = "application/octet-stream";
// Try to determine file's content type // Try to determine file's content type
@ -73,30 +77,59 @@ public class BackupController {
.body(backupResource); .body(backupResource);
} }
@DeleteMapping("halo") @DeleteMapping("work-dir")
@ApiOperation("Deletes a backup") @ApiOperation("Deletes a work directory backup")
@DisableOnCondition @DisableOnCondition
public void deleteBackup(@RequestParam("filename") String filename) { public void deleteBackup(@RequestParam("filename") String filename) {
backupService.deleteHaloBackup(filename); backupService.deleteWorkDirBackup(filename);
} }
@PostMapping("import/markdown") @PostMapping("markdown")
@ApiOperation("Import markdown") @ApiOperation("Imports markdown")
public BasePostDetailDTO backupMarkdowns(@RequestPart("file") MultipartFile file) throws IOException { public BasePostDetailDTO backupMarkdowns(@RequestPart("file") MultipartFile file) throws IOException {
return backupService.importMarkdown(file); return backupService.importMarkdown(file);
} }
@GetMapping("export/data") @PostMapping("data")
@ApiOperation("Exports all data")
@DisableOnCondition @DisableOnCondition
public ResponseEntity<String> exportData() { public BackupDTO exportData() {
return backupService.exportData();
}
String contentType = "application/octet-stream;charset=UTF-8"; @GetMapping("data")
@ApiOperation("Lists all exported data")
public List<BackupDTO> listExportedData() {
return backupService.listExportedData();
}
String filename = "halo-data-" + DateUtil.format(new Date(), "yyyy-MM-dd-HH-mm-ss.json"); @DeleteMapping("data")
@ApiOperation("Deletes a exported data")
@DisableOnCondition
public void deleteExportedData(@RequestParam("filename") String filename) {
backupService.deleteExportedData(filename);
}
@GetMapping("data/{fileName:.+}")
@ApiOperation("Downloads a exported data")
@DisableOnCondition
public ResponseEntity<Resource> downloadExportedData(@PathVariable("fileName") String fileName, HttpServletRequest request) {
log.info("Try to download exported data file: [{}]", fileName);
// Load exported data as resource
Resource exportDataResource = backupService.loadFileAsResource(haloProperties.getDataExportDir(), fileName);
String contentType = "application/octet-stream";
// Try to determine file's content type
try {
contentType = request.getServletContext().getMimeType(exportDataResource.getFile().getAbsolutePath());
} catch (IOException e) {
log.warn("Could not determine file type", e);
}
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType)) .contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + exportDataResource.getFilename() + "\"")
.body(backupService.exportData().toJSONString()); .body(exportDataResource);
} }
} }

View File

@ -1,7 +1,9 @@
package run.halo.app.controller.admin.api; package run.halo.app.controller.admin.api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import run.halo.app.service.DataProcessService; import run.halo.app.service.DataProcessService;
import run.halo.app.service.ThemeSettingService; import run.halo.app.service.ThemeSettingService;

View File

@ -148,6 +148,7 @@ public class StartedListener implements ApplicationListener<ApplicationStartedEv
private void initDirectory() { private void initDirectory() {
Path workPath = Paths.get(haloProperties.getWorkDir()); Path workPath = Paths.get(haloProperties.getWorkDir());
Path backupPath = Paths.get(haloProperties.getBackupDir()); Path backupPath = Paths.get(haloProperties.getBackupDir());
Path dataExportPath = Paths.get(haloProperties.getDataExportDir());
try { try {
if (Files.notExists(workPath)) { if (Files.notExists(workPath)) {
@ -160,6 +161,11 @@ public class StartedListener implements ApplicationListener<ApplicationStartedEv
log.info("Created backup directory: [{}]", backupPath); log.info("Created backup directory: [{}]", backupPath);
} }
if (Files.notExists(dataExportPath)) {
Files.createDirectories(dataExportPath);
log.info("Created data export directory: [{}]", dataExportPath);
}
} catch (IOException ie) { } catch (IOException ie) {
throw new RuntimeException("Failed to initialize directories", ie); throw new RuntimeException("Failed to initialize directories", ie);
} }

View File

@ -9,9 +9,6 @@ import lombok.Data;
@Data @Data
public class BackupDTO { public class BackupDTO {
@Deprecated
private String downloadUrl;
private String downloadLink; private String downloadLink;
private String filename; private String filename;

View File

@ -15,7 +15,7 @@ import java.util.Objects;
@Entity @Entity
@Table(name = "post_categories", @Table(name = "post_categories",
indexes = {@Index(name = "post_categories_post_id", columnList = "post_id"), indexes = {@Index(name = "post_categories_post_id", columnList = "post_id"),
@Index(name = "post_categories_category_id", columnList = "category_id")}) @Index(name = "post_categories_category_id", columnList = "category_id")})
@Data @Data
@ToString(callSuper = true) @ToString(callSuper = true)
public class PostCategory extends BaseEntity { public class PostCategory extends BaseEntity {

View File

@ -29,6 +29,11 @@ public class HaloConst {
*/ */
public final static String HALO_BACKUP_PREFIX = "halo-backup-"; public final static String HALO_BACKUP_PREFIX = "halo-backup-";
/**
* Halo data export prefix.
*/
public final static String HALO_DATA_EXPORT_PREFIX = "halo-data-export-";
/** /**
* Static pages pack prefix. * Static pages pack prefix.
*/ */

View File

@ -1,6 +1,5 @@
package run.halo.app.service; package run.halo.app.service;
import com.alibaba.fastjson.JSONObject;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -14,6 +13,7 @@ import java.util.List;
* Backup service interface. * Backup service interface.
* *
* @author johnniang * @author johnniang
* @author ryanwang
* @date 2019-04-26 * @date 2019-04-26
*/ */
public interface BackupService { public interface BackupService {
@ -33,7 +33,7 @@ public interface BackupService {
* @return backup dto. * @return backup dto.
*/ */
@NonNull @NonNull
BackupDTO zipWorkDirectory(); BackupDTO backupWorkDirectory();
/** /**
@ -42,23 +42,24 @@ public interface BackupService {
* @return backup list * @return backup list
*/ */
@NonNull @NonNull
List<BackupDTO> listHaloBackups(); List<BackupDTO> listWorkDirBackups();
/** /**
* Deletes backup. * Deletes backup.
* *
* @param fileName filename must not be blank * @param fileName filename must not be blank
*/ */
void deleteHaloBackup(@NonNull String fileName); void deleteWorkDirBackup(@NonNull String fileName);
/** /**
* Loads file as resource. * Loads file as resource.
* *
* @param fileName backup file name must not be blank. * @param fileName backup file name must not be blank.
* @param basePath base path
* @return resource of the given file * @return resource of the given file
*/ */
@NonNull @NonNull
Resource loadFileAsResource(@NonNull String fileName); Resource loadFileAsResource(@NonNull String basePath, @NonNull String fileName);
/** /**
@ -67,7 +68,21 @@ public interface BackupService {
* @return data * @return data
*/ */
@NonNull @NonNull
JSONObject exportData(); BackupDTO exportData();
/**
* List all exported data.
*
* @return list of backup dto
*/
List<BackupDTO> listExportedData();
/**
* Deletes exported data.
*
* @param fileName fileName
*/
void deleteExportedData(@NonNull String fileName);
/** /**
* Import data * Import data

View File

@ -2,6 +2,8 @@ package run.halo.app.service.impl;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.file.FileWriter;
import cn.hutool.core.util.CharsetUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -51,7 +53,9 @@ import java.util.stream.Stream;
@Slf4j @Slf4j
public class BackupServiceImpl implements BackupService { public class BackupServiceImpl implements BackupService {
private static final String BACKUP_RESOURCE_BASE_URI = "/api/admin/backups/halo"; private static final String BACKUP_RESOURCE_BASE_URI = "/api/admin/backups/work-dir";
private static final String DATA_EXPORT_BASE_URI = "/api/admin/backups/data";
private static final String LINE_SEPARATOR = System.getProperty("line.separator"); private static final String LINE_SEPARATOR = System.getProperty("line.separator");
@ -155,7 +159,7 @@ public class BackupServiceImpl implements BackupService {
} }
@Override @Override
public BackupDTO zipWorkDirectory() { public BackupDTO backupWorkDirectory() {
// Zip work directory to temporary file // Zip work directory to temporary file
try { try {
// Create zip path for halo zip // Create zip path for halo zip
@ -169,14 +173,14 @@ public class BackupServiceImpl implements BackupService {
run.halo.app.utils.FileUtils.zip(Paths.get(this.haloProperties.getWorkDir()), haloZipPath); run.halo.app.utils.FileUtils.zip(Paths.get(this.haloProperties.getWorkDir()), haloZipPath);
// Build backup dto // Build backup dto
return buildBackupDto(haloZipPath); return buildBackupDto(BACKUP_RESOURCE_BASE_URI, haloZipPath);
} catch (IOException e) { } catch (IOException e) {
throw new ServiceException("Failed to backup halo", e); throw new ServiceException("Failed to backup halo", e);
} }
} }
@Override @Override
public List<BackupDTO> listHaloBackups() { public List<BackupDTO> listWorkDirBackups() {
// Ensure the parent folder exist // Ensure the parent folder exist
Path backupParentPath = Paths.get(haloProperties.getBackupDir()); Path backupParentPath = Paths.get(haloProperties.getBackupDir());
if (Files.notExists(backupParentPath)) { if (Files.notExists(backupParentPath)) {
@ -187,7 +191,7 @@ public class BackupServiceImpl implements BackupService {
try (Stream<Path> subPathStream = Files.list(backupParentPath)) { try (Stream<Path> subPathStream = Files.list(backupParentPath)) {
return subPathStream return subPathStream
.filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_BACKUP_PREFIX)) .filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_BACKUP_PREFIX))
.map(this::buildBackupDto) .map(backupPath -> buildBackupDto(BACKUP_RESOURCE_BASE_URI, backupPath))
.sorted((leftBackup, rightBackup) -> { .sorted((leftBackup, rightBackup) -> {
// Sort the result // Sort the result
if (leftBackup.getUpdateTime() < rightBackup.getUpdateTime()) { if (leftBackup.getUpdateTime() < rightBackup.getUpdateTime()) {
@ -203,7 +207,7 @@ public class BackupServiceImpl implements BackupService {
} }
@Override @Override
public void deleteHaloBackup(String fileName) { public void deleteWorkDirBackup(String fileName) {
Assert.hasText(fileName, "File name must not be blank"); Assert.hasText(fileName, "File name must not be blank");
Path backupRootPath = Paths.get(haloProperties.getBackupDir()); Path backupRootPath = Paths.get(haloProperties.getBackupDir());
@ -225,10 +229,11 @@ public class BackupServiceImpl implements BackupService {
} }
@Override @Override
public Resource loadFileAsResource(String fileName) { public Resource loadFileAsResource(String basePath, String fileName) {
Assert.hasText(basePath, "Base path must not be blank");
Assert.hasText(fileName, "Backup file name must not be blank"); Assert.hasText(fileName, "Backup file name must not be blank");
Path backupParentPath = Paths.get(haloProperties.getBackupDir()); Path backupParentPath = Paths.get(basePath);
try { try {
if (Files.notExists(backupParentPath)) { if (Files.notExists(backupParentPath)) {
@ -237,7 +242,7 @@ public class BackupServiceImpl implements BackupService {
} }
// Get backup file path // Get backup file path
Path backupFilePath = Paths.get(haloProperties.getBackupDir(), fileName).normalize(); Path backupFilePath = Paths.get(basePath, fileName).normalize();
// Check directory traversal // Check directory traversal
run.halo.app.utils.FileUtils.checkDirectoryTraversal(backupParentPath, backupFilePath); run.halo.app.utils.FileUtils.checkDirectoryTraversal(backupParentPath, backupFilePath);
@ -258,7 +263,7 @@ public class BackupServiceImpl implements BackupService {
} }
@Override @Override
public JSONObject exportData() { public BackupDTO exportData() {
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();
data.put("version", HaloConst.HALO_VERSION); data.put("version", HaloConst.HALO_VERSION);
data.put("export_date", DateUtil.now()); data.put("export_date", DateUtil.now());
@ -283,7 +288,67 @@ public class BackupServiceImpl implements BackupService {
data.put("tags", tagService.listAll()); data.put("tags", tagService.listAll());
data.put("theme_settings", themeSettingService.listAll()); data.put("theme_settings", themeSettingService.listAll());
data.put("user", userService.listAll()); data.put("user", userService.listAll());
return data;
try {
String haloDataFileName = HaloConst.HALO_DATA_EXPORT_PREFIX +
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss-")) +
IdUtil.simpleUUID().hashCode() + ".json";
Path haloDataPath = Files.createFile(Paths.get(haloProperties.getDataExportDir(), haloDataFileName));
FileWriter fileWriter = new FileWriter(haloDataPath.toFile(), CharsetUtil.UTF_8);
fileWriter.write(data.toJSONString());
return buildBackupDto(DATA_EXPORT_BASE_URI, haloDataPath);
} catch (IOException e) {
throw new ServiceException("导出数据失败", e);
}
}
@Override
public List<BackupDTO> listExportedData() {
Path exportedDataParentPath = Paths.get(haloProperties.getDataExportDir());
if (Files.notExists(exportedDataParentPath)) {
return Collections.emptyList();
}
try (Stream<Path> subPathStream = Files.list(exportedDataParentPath)) {
return subPathStream
.filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_DATA_EXPORT_PREFIX))
.map(backupPath -> buildBackupDto(DATA_EXPORT_BASE_URI, backupPath))
.sorted((leftBackup, rightBackup) -> {
// Sort the result
if (leftBackup.getUpdateTime() < rightBackup.getUpdateTime()) {
return 1;
} else if (leftBackup.getUpdateTime() > rightBackup.getUpdateTime()) {
return -1;
}
return 0;
}).collect(Collectors.toList());
} catch (IOException e) {
throw new ServiceException("Failed to fetch exported data", e);
}
}
@Override
public void deleteExportedData(String fileName) {
Assert.hasText(fileName, "File name must not be blank");
Path dataExportRootPath = Paths.get(haloProperties.getDataExportDir());
Path backupPath = dataExportRootPath.resolve(fileName);
run.halo.app.utils.FileUtils.checkDirectoryTraversal(dataExportRootPath, backupPath);
try {
// Delete backup file
Files.delete(backupPath);
} catch (NoSuchFileException e) {
throw new NotFoundException("The file " + fileName + " was not found", e);
} catch (IOException e) {
throw new ServiceException("Failed to delete backup", e);
}
} }
@Override @Override
@ -363,14 +428,14 @@ public class BackupServiceImpl implements BackupService {
* @param backupPath backup path must not be null * @param backupPath backup path must not be null
* @return backup dto * @return backup dto
*/ */
private BackupDTO buildBackupDto(@NonNull Path backupPath) { private BackupDTO buildBackupDto(@NonNull String basePath, @NonNull Path backupPath) {
Assert.notNull(basePath, "Base path must not be null");
Assert.notNull(backupPath, "Backup path must not be null"); Assert.notNull(backupPath, "Backup path must not be null");
String backupFileName = backupPath.getFileName().toString(); String backupFileName = backupPath.getFileName().toString();
BackupDTO backup = new BackupDTO(); BackupDTO backup = new BackupDTO();
try { try {
backup.setDownloadUrl(buildDownloadUrl(backupFileName)); backup.setDownloadLink(buildDownloadUrl(basePath, backupFileName));
backup.setDownloadLink(backup.getDownloadUrl());
backup.setFilename(backupFileName); backup.setFilename(backupFileName);
backup.setUpdateTime(Files.getLastModifiedTime(backupPath).toMillis()); backup.setUpdateTime(Files.getLastModifiedTime(backupPath).toMillis());
backup.setFileSize(Files.size(backupPath)); backup.setFileSize(Files.size(backupPath));
@ -388,11 +453,12 @@ public class BackupServiceImpl implements BackupService {
* @return download url * @return download url
*/ */
@NonNull @NonNull
private String buildDownloadUrl(@NonNull String filename) { private String buildDownloadUrl(@NonNull String basePath, @NonNull String filename) {
Assert.notNull(basePath, "Base path must not be null");
Assert.hasText(filename, "File name must not be blank"); Assert.hasText(filename, "File name must not be blank");
// Composite http url // Composite http url
String backupUri = BACKUP_RESOURCE_BASE_URI + HaloUtils.URL_SEPARATOR + filename; String backupUri = basePath + HaloUtils.URL_SEPARATOR + filename;
// Get a one-time token // Get a one-time token
String oneTimeToken = oneTimeTokenService.create(backupUri); String oneTimeToken = oneTimeTokenService.create(backupUri);