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 cdc80690d6..ea2aafd414 100644 --- a/src/main/java/run/halo/app/config/properties/HaloProperties.java +++ b/src/main/java/run/halo/app/config/properties/HaloProperties.java @@ -58,6 +58,11 @@ public class HaloProperties { */ private String backupDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-backup" + FILE_SEPARATOR; + /** + * Halo backup markdown directory.(Not recommended to modify this config); + */ + private String backupMarkdownDir = ensureSuffix(TEMP_DIR, FILE_SEPARATOR) + "halo-backup-markdown" + FILE_SEPARATOR; + /** * Halo data export directory. */ 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 8687319300..87b15492f3 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 @@ -12,6 +12,7 @@ import run.halo.app.config.properties.HaloProperties; import run.halo.app.model.dto.BackupDTO; import run.halo.app.model.dto.post.BasePostDetailDTO; +import run.halo.app.model.params.PostMarkdownParam; import run.halo.app.service.BackupService; import javax.servlet.http.HttpServletRequest; @@ -23,6 +24,7 @@ * * @author johnniang * @author ryanwang + * @author Raremaa * @date 2019-04-26 */ @RestController @@ -34,8 +36,7 @@ public class BackupController { private final HaloProperties haloProperties; - public BackupController(BackupService backupService, - HaloProperties haloProperties) { + public BackupController(BackupService backupService, HaloProperties haloProperties) { this.backupService = backupService; this.haloProperties = haloProperties; } @@ -84,7 +85,7 @@ public void deleteBackup(@RequestParam("filename") String filename) { backupService.deleteWorkDirBackup(filename); } - @PostMapping("markdown") + @PostMapping("markdown/import") @ApiOperation("Imports markdown") public BasePostDetailDTO backupMarkdowns(@RequestPart("file") MultipartFile file) throws IOException { return backupService.importMarkdown(file); @@ -132,4 +133,50 @@ public ResponseEntity downloadExportedData(@PathVariable("fileName") S .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + exportDataResource.getFilename() + "\"") .body(exportDataResource); } + + @PostMapping("markdown/export") + @ApiOperation("Exports markdowns") + @DisableOnCondition + public BackupDTO exportMarkdowns(@RequestBody PostMarkdownParam postMarkdownParam) throws IOException { + return backupService.exportMarkdowns(postMarkdownParam); + } + + @GetMapping("markdown/export") + @ApiOperation("Gets all markdown backups") + public List listMarkdowns() { + return backupService.listMarkdowns(); + } + + @DeleteMapping("markdown/export") + @ApiOperation("Deletes a markdown backup") + @DisableOnCondition + public void deleteMarkdown(@RequestParam("filename") String filename) { + backupService.deleteMarkdown(filename); + } + + @GetMapping("markdown/export/{fileName:.+}") + @ApiOperation("Downloads a work markdown backup file") + @DisableOnCondition + public ResponseEntity downloadMarkdown(@PathVariable("fileName") String fileName, HttpServletRequest request) { + log.info("Try to download markdown backup file: [{}]", fileName); + + // Load file as resource + Resource backupResource = backupService.loadFileAsResource(haloProperties.getBackupMarkdownDir(), fileName); + + String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE; + // 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); + // Ignore this error + } + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + backupResource.getFilename() + "\"") + .body(backupResource); + } + + } diff --git a/src/main/java/run/halo/app/model/params/PostMarkdownParam.java b/src/main/java/run/halo/app/model/params/PostMarkdownParam.java new file mode 100644 index 0000000000..56f5200829 --- /dev/null +++ b/src/main/java/run/halo/app/model/params/PostMarkdownParam.java @@ -0,0 +1,17 @@ +package run.halo.app.model.params; + +import lombok.Data; + +/** + * @author Raremaa + * @date 2020/12/25 11:22 上午 + */ +@Data +public class PostMarkdownParam { + + /** + * true if need frontMatter + * default false + */ + private Boolean needFrontMatter; +} diff --git a/src/main/java/run/halo/app/model/support/HaloConst.java b/src/main/java/run/halo/app/model/support/HaloConst.java index 08244433df..1424dc5fc2 100644 --- a/src/main/java/run/halo/app/model/support/HaloConst.java +++ b/src/main/java/run/halo/app/model/support/HaloConst.java @@ -36,6 +36,11 @@ public class HaloConst { */ public final static String HALO_BACKUP_PREFIX = "halo-backup-"; + /** + * Halo backup markdown prefix. + */ + public final static String HALO_BACKUP_MARKDOWN_PREFIX = "halo-backup-markdown-"; + /** * Halo data export prefix. */ diff --git a/src/main/java/run/halo/app/model/vo/PostMarkdownVO.java b/src/main/java/run/halo/app/model/vo/PostMarkdownVO.java new file mode 100644 index 0000000000..7e314364bb --- /dev/null +++ b/src/main/java/run/halo/app/model/vo/PostMarkdownVO.java @@ -0,0 +1,23 @@ +package run.halo.app.model.vo; + +import lombok.Data; +import lombok.ToString; + +/** + * Markdown export VO + * + * @author Raremaa + * @date 2020/12/25 9:14 上午 + */ +@Data +@ToString +public class PostMarkdownVO { + + private String title; + + private String slug; + + private String originalContent; + + private String frontMatter; +} diff --git a/src/main/java/run/halo/app/service/BackupService.java b/src/main/java/run/halo/app/service/BackupService.java index 5cf0b3b367..e1d262973b 100644 --- a/src/main/java/run/halo/app/service/BackupService.java +++ b/src/main/java/run/halo/app/service/BackupService.java @@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile; import run.halo.app.model.dto.BackupDTO; import run.halo.app.model.dto.post.BasePostDetailDTO; +import run.halo.app.model.params.PostMarkdownParam; import java.io.IOException; import java.util.List; @@ -91,4 +92,29 @@ public interface BackupService { * @throws IOException throws IOException */ void importData(MultipartFile file) throws IOException; + + /** + * Export Markdown content + * + * @param postMarkdownParam param + * @return backup dto. + * @throws IOException throws IOException + */ + @NonNull + BackupDTO exportMarkdowns(PostMarkdownParam postMarkdownParam) throws IOException; + + /** + * list Markdown backups + * + * @return backup list + */ + @NonNull + List listMarkdowns(); + + /** + * delete a markdown backup + * + * @param fileName + */ + void deleteMarkdown(@NonNull String fileName); } diff --git a/src/main/java/run/halo/app/service/PostService.java b/src/main/java/run/halo/app/service/PostService.java index 6472d12fdd..fc2481eac9 100755 --- a/src/main/java/run/halo/app/service/PostService.java +++ b/src/main/java/run/halo/app/service/PostService.java @@ -8,10 +8,7 @@ import run.halo.app.model.entity.PostMeta; import run.halo.app.model.enums.PostStatus; import run.halo.app.model.params.PostQuery; -import run.halo.app.model.vo.ArchiveMonthVO; -import run.halo.app.model.vo.ArchiveYearVO; -import run.halo.app.model.vo.PostDetailVO; -import run.halo.app.model.vo.PostListVO; +import run.halo.app.model.vo.*; import run.halo.app.service.base.BasePostService; import javax.validation.constraints.NotNull; @@ -112,8 +109,8 @@ public interface PostService extends BasePostService { /** * Gets post by post year and slug. * - * @param year post create year. - * @param slug post slug. + * @param year post create year. + * @param slug post slug. * @return post info */ @NonNull @@ -276,4 +273,12 @@ public interface PostService extends BasePostService { @NotNull Sort getPostDefaultSort(); + + /** + * Lists PostMarkdown vo + * + * @return a list of PostMarkdown vo + */ + @NonNull + List listPostMarkdowns(); } 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 34ead25a86..71ef7741af 100644 --- a/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/BackupServiceImpl.java @@ -23,10 +23,13 @@ import run.halo.app.event.theme.ThemeUpdatedEvent; import run.halo.app.exception.NotFoundException; import run.halo.app.exception.ServiceException; +import run.halo.app.handler.file.FileHandler; import run.halo.app.model.dto.BackupDTO; import run.halo.app.model.dto.post.BasePostDetailDTO; import run.halo.app.model.entity.*; +import run.halo.app.model.params.PostMarkdownParam; import run.halo.app.model.support.HaloConst; +import run.halo.app.model.vo.PostMarkdownVO; import run.halo.app.security.service.OneTimeTokenService; import run.halo.app.service.*; import run.halo.app.utils.DateTimeUtils; @@ -45,12 +48,14 @@ import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.zip.ZipOutputStream; /** * Backup service implementation. * * @author johnniang * @author ryanwang + * @author Raremaa * @date 2019-04-26 */ @Service @@ -59,10 +64,14 @@ public class BackupServiceImpl implements BackupService { private static final String BACKUP_RESOURCE_BASE_URI = "/api/admin/backups/work-dir"; + private static final String DATA_EXPORT_MARKDOWN_BASE_URI = "/api/admin/backups/markdown/export"; + private static final String DATA_EXPORT_BASE_URI = "/api/admin/backups/data"; private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + private final static String UPLOAD_SUB_DIR = "upload/"; + private static final Type MAP_TYPE = new TypeToken>() { }.getType(); @@ -429,6 +438,121 @@ public void importData(MultipartFile file) throws IOException { eventPublisher.publishEvent(new ThemeUpdatedEvent(this)); } + @Override + public BackupDTO exportMarkdowns(PostMarkdownParam postMarkdownParam) throws IOException { + // Query all Post data + List postMarkdownList = postService.listPostMarkdowns(); + Assert.notEmpty(postMarkdownList, "当前无文章可以导出"); + + // Write files to the temporary directory + String markdownFileTempPathName = haloProperties.getBackupMarkdownDir() + IdUtil.simpleUUID().hashCode(); + for (int i = 0; i < postMarkdownList.size(); i++) { + PostMarkdownVO postMarkdownVO = postMarkdownList.get(i); + StringBuilder content = new StringBuilder(); + Boolean needFrontMatter = Optional.ofNullable(postMarkdownParam.getNeedFrontMatter()).orElse(false); + if (needFrontMatter) { + // Add front-matter + content.append(postMarkdownVO.getFrontMatter()).append("\n"); + } + content.append(postMarkdownVO.getOriginalContent()); + try { + String markdownFileName = postMarkdownVO.getTitle() + "-" + postMarkdownVO.getSlug() + ".md"; + Path markdownFilePath = Paths.get(markdownFileTempPathName, markdownFileName); + if (!Files.exists(markdownFilePath.getParent())) { + Files.createDirectories(markdownFilePath.getParent()); + } + Path markdownDataPath = Files.createFile(markdownFilePath); + FileWriter fileWriter = new FileWriter(markdownDataPath.toFile(), CharsetUtil.UTF_8); + fileWriter.write(content.toString()); + } catch (IOException e) { + throw new ServiceException("导出数据失败", e); + } + } + + ZipOutputStream markdownZipOut = null; + // Zip file + try { + // Create zip path + String markdownZipFileName = HaloConst.HALO_BACKUP_MARKDOWN_PREFIX + + DateTimeUtils.format(LocalDateTime.now(), DateTimeUtils.HORIZONTAL_LINE_DATETIME_FORMATTER) + + IdUtil.simpleUUID().hashCode() + ".zip"; + + // Create zip file + Path markdownZipFilePath = Paths.get(haloProperties.getBackupMarkdownDir(), markdownZipFileName); + if (!Files.exists(markdownZipFilePath.getParent())) { + Files.createDirectories(markdownZipFilePath.getParent()); + } + Path markdownZipPath = Files.createFile(markdownZipFilePath); + + markdownZipOut = new ZipOutputStream(Files.newOutputStream(markdownZipPath)); + + // Zip temporary directory + Path markdownFileTempPath = Paths.get(markdownFileTempPathName); + run.halo.app.utils.FileUtils.zip(markdownFileTempPath, markdownZipOut); + + // Zip upload sub-directory + String uploadPathName = FileHandler.normalizeDirectory(haloProperties.getWorkDir()) + UPLOAD_SUB_DIR; + Path uploadPath = Paths.get(uploadPathName); + if (Files.exists(uploadPath)) { + run.halo.app.utils.FileUtils.zip(uploadPath, markdownZipOut); + } + + // Remove files in the temporary directory + run.halo.app.utils.FileUtils.deleteFolder(markdownFileTempPath); + + // Build backup dto + return buildBackupDto(DATA_EXPORT_MARKDOWN_BASE_URI, markdownZipPath); + } catch (IOException e) { + throw new ServiceException("Failed to export markdowns", e); + } finally { + if (markdownZipOut != null) { + markdownZipOut.close(); + } + } + } + + @Override + public List listMarkdowns() { + // Ensure the parent folder exist + Path backupParentPath = Paths.get(haloProperties.getBackupMarkdownDir()); + if (Files.notExists(backupParentPath)) { + return Collections.emptyList(); + } + + // Build backup dto + try (Stream subPathStream = Files.list(backupParentPath)) { + return subPathStream + .filter(backupPath -> StringUtils.startsWithIgnoreCase(backupPath.getFileName().toString(), HaloConst.HALO_BACKUP_MARKDOWN_PREFIX)) + .map(backupPath -> buildBackupDto(DATA_EXPORT_MARKDOWN_BASE_URI, backupPath)) + .sorted(Comparator.comparingLong(BackupDTO::getUpdateTime).reversed()) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new ServiceException("Failed to fetch backups", e); + } + } + + @Override + public void deleteMarkdown(String fileName) { + Assert.hasText(fileName, "File name must not be blank"); + + Path backupRootPath = Paths.get(haloProperties.getBackupMarkdownDir()); + + // Get backup path + Path backupPath = backupRootPath.resolve(fileName); + + // Check directory traversal + run.halo.app.utils.FileUtils.checkDirectoryTraversal(backupRootPath, 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); + } + } + /** * Builds backup dto. * diff --git a/src/main/java/run/halo/app/service/impl/PostServiceImpl.java b/src/main/java/run/halo/app/service/impl/PostServiceImpl.java index 68f9350f96..adda03a16f 100644 --- a/src/main/java/run/halo/app/service/impl/PostServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/PostServiceImpl.java @@ -27,10 +27,7 @@ import run.halo.app.model.params.PostParam; import run.halo.app.model.params.PostQuery; import run.halo.app.model.properties.PostProperties; -import run.halo.app.model.vo.ArchiveMonthVO; -import run.halo.app.model.vo.ArchiveYearVO; -import run.halo.app.model.vo.PostDetailVO; -import run.halo.app.model.vo.PostListVO; +import run.halo.app.model.vo.*; import run.halo.app.repository.PostRepository; import run.halo.app.repository.base.BasePostRepository; import run.halo.app.service.*; @@ -57,6 +54,7 @@ * @author guqing * @author evanwang * @author coor.top + * @author Raremaa * @date 2019-03-14 */ @Slf4j @@ -819,6 +817,64 @@ public void publishVisitEvent(Integer postId) { return Sort.by(DESC, "topPriority").and(Sort.by(DESC, indexSort).and(Sort.by(DESC, "id"))); } + @Override + public List listPostMarkdowns() { + List allPostList = listAll(); + List result = new ArrayList(allPostList.size()); + for (int i = 0; i < allPostList.size(); i++) { + Post post = allPostList.get(i); + result.add(convertToPostMarkdownVo(post)); + } + return result; + } + + private PostMarkdownVO convertToPostMarkdownVo(Post post) { + PostMarkdownVO postMarkdownVO = new PostMarkdownVO(); + + StringBuilder frontMatter = new StringBuilder("---\n"); + frontMatter.append("title: ").append(post.getTitle()).append("\n"); + frontMatter.append("date: ").append(post.getCreateTime()).append("\n"); + frontMatter.append("updated: ").append(post.getUpdateTime()).append("\n"); + + //set fullPath + frontMatter.append("url: ").append(buildFullPath(post)).append("\n"); + + //set category + List categories = postCategoryService.listCategoriesBy(post.getId()); + StringBuilder categoryContent = new StringBuilder(); + for (int i = 0; i < categories.size(); i++) { + Category category = categories.get(i); + String categoryName = category.getName(); + if (i == 0) { + categoryContent.append(categoryName); + } else { + categoryContent.append(" | ").append(categoryName); + } + } + frontMatter.append("categories: ").append(categoryContent.toString()).append("\n"); + + //set tags + List tags = postTagService.listTagsBy(post.getId()); + StringBuilder tagContent = new StringBuilder(); + for (int i = 0; i < tags.size(); i++) { + Tag tag = tags.get(i); + String tagName = tag.getName(); + if (i == 0) { + tagContent.append(tagName); + } else { + tagContent.append(" | ").append(tagName); + } + } + frontMatter.append("tags: ").append(tagContent.toString()).append("\n"); + + frontMatter.append("---\n"); + postMarkdownVO.setFrontMatter(frontMatter.toString()); + postMarkdownVO.setOriginalContent(post.getOriginalContent()); + postMarkdownVO.setTitle(post.getTitle()); + postMarkdownVO.setSlug(post.getSlug()); + return postMarkdownVO; + } + private String buildFullPath(Post post) { PostPermalinkType permalinkType = optionService.getPostPermalinkType(); diff --git a/src/test/java/run/halo/app/service/impl/PostServiceImplTest.java b/src/test/java/run/halo/app/service/impl/PostServiceImplTest.java index afcb1af7f2..6a4eb6f7da 100644 --- a/src/test/java/run/halo/app/service/impl/PostServiceImplTest.java +++ b/src/test/java/run/halo/app/service/impl/PostServiceImplTest.java @@ -36,7 +36,7 @@ class PostServiceImplTest { @Test void getContent() { - String exportMarkdown = postService.exportMarkdown(18); + String exportMarkdown = postService.exportMarkdown(1); log.debug(exportMarkdown); }