From 30c9baf92ba6555d5399c5e5cf29689072734916 Mon Sep 17 00:00:00 2001 From: John Niang Date: Wed, 27 Jan 2021 00:16:31 +0800 Subject: [PATCH] Fix theme updation error (#1217) * Make rest controller loggable * Refactor pull from git process * Replace Callback interface with Consumer * Tag theme fetch apis and services deprecated * Add getAllBranchesTest * Refactor theme fetcher partially * Refactor theme property scanner * Add ThemeFetcherComposite * Add InputStreamThemeFetcher * Accomplish multipart zip file theme fetcher * Reformat ThemeServiceImpl * Reformat codes * Provide ThemeRepository * Complete MultipartFileThemeUpdater * Make CommonsMultipartResolver support put request method * Replace some methods with ThemeRepository * Add GitThemeUpdater * Add merge two local repo test * Refine merge process with two repos * Add more test entry point in GitTest * Add shutdown hook after creating temporary directory * Add test: find commit by tag * Refactor git clone process in GitThemeFetcher * Refine merge process of two repo * Make sure that RevWalk closed * Fix FileUtils#findRootPath bug * Add clean task before gradle check * Add fallback theme fetcher * Disable logback-test.xml * Set testLogging.showStandardStreams with true * Fix test error while missing halo-test folder * Enhance git theme fetcher * Add copy hidden folder test * Refine GitThemeFetcherTest * Accomplish GitThemeUpdater * Accomplish theme update * Fix checkstyle error * Add more deprecated details --- build.gradle | 1 + .../halo/app/config/HaloMvcConfiguration.java | 15 +- .../controller/admin/api/ThemeController.java | 10 +- .../run/halo/app/core/ControllerLogAop.java | 6 +- .../app/exception/ThemeUpToDateException.java | 13 + .../app/handler/file/LocalFileHandler.java | 15 +- .../theme/config/support/ThemeProperty.java | 35 +- .../halo/app/listener/StartedListener.java | 18 +- .../halo/app/mail/AbstractMailService.java | 29 +- .../run/halo/app/mail/MailServiceImpl.java | 49 +- .../halo/app/repository/ThemeRepository.java | 78 +++ .../app/repository/ThemeRepositoryImpl.java | 159 +++++++ .../run/halo/app/service/ThemeService.java | 7 +- .../run/halo/app/service/TraceService.java | 24 - .../app/service/impl/ThemeServiceImpl.java | 443 ++++-------------- .../run/halo/app/theme/GitThemeFetcher.java | 75 +++ .../run/halo/app/theme/GitThemeUpdater.java | 137 ++++++ .../app/theme/MultipartFileThemeUpdater.java | 73 +++ .../theme/MultipartZipFileThemeFetcher.java | 46 ++ .../java/run/halo/app/theme/ThemeFetcher.java | 28 ++ .../halo/app/theme/ThemeFetcherComposite.java | 64 +++ .../run/halo/app/theme/ThemeMetaLocator.java | 120 +++++ .../halo/app/theme/ThemePropertyScanner.java | 139 ++---- .../java/run/halo/app/theme/ThemeUpdater.java | 67 +++ .../run/halo/app/theme/ZipThemeFetcher.java | 75 +++ .../java/run/halo/app/utils/FileUtils.java | 137 ++++-- .../java/run/halo/app/utils/GitUtils.java | 147 ++++-- .../run/halo/app/utils/RemoteGitHelper.java | 19 + .../halo/app/theme/GitThemeFetcherTest.java | 62 +++ .../halo/app/theme/ZipThemeFetcherTest.java | 22 + .../run/halo/app/utils/FileUtilsTest.java | 99 ++-- src/test/java/run/halo/app/utils/GitTest.java | 183 +++++++- 32 files changed, 1721 insertions(+), 674 deletions(-) create mode 100644 src/main/java/run/halo/app/exception/ThemeUpToDateException.java create mode 100644 src/main/java/run/halo/app/repository/ThemeRepository.java create mode 100644 src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java delete mode 100644 src/main/java/run/halo/app/service/TraceService.java create mode 100644 src/main/java/run/halo/app/theme/GitThemeFetcher.java create mode 100644 src/main/java/run/halo/app/theme/GitThemeUpdater.java create mode 100644 src/main/java/run/halo/app/theme/MultipartFileThemeUpdater.java create mode 100644 src/main/java/run/halo/app/theme/MultipartZipFileThemeFetcher.java create mode 100644 src/main/java/run/halo/app/theme/ThemeFetcher.java create mode 100644 src/main/java/run/halo/app/theme/ThemeFetcherComposite.java create mode 100644 src/main/java/run/halo/app/theme/ThemeMetaLocator.java create mode 100644 src/main/java/run/halo/app/theme/ThemeUpdater.java create mode 100644 src/main/java/run/halo/app/theme/ZipThemeFetcher.java create mode 100644 src/main/java/run/halo/app/utils/RemoteGitHelper.java create mode 100644 src/test/java/run/halo/app/theme/GitThemeFetcherTest.java create mode 100644 src/test/java/run/halo/app/theme/ZipThemeFetcherTest.java diff --git a/build.gradle b/build.gradle index 66794692a4..1c90b28b3d 100644 --- a/build.gradle +++ b/build.gradle @@ -172,4 +172,5 @@ dependencies { test { useJUnitPlatform() + testLogging.showStandardStreams = true } diff --git a/src/main/java/run/halo/app/config/HaloMvcConfiguration.java b/src/main/java/run/halo/app/config/HaloMvcConfiguration.java index 3ec8f239d3..d36d93c12c 100644 --- a/src/main/java/run/halo/app/config/HaloMvcConfiguration.java +++ b/src/main/java/run/halo/app/config/HaloMvcConfiguration.java @@ -14,7 +14,10 @@ import java.util.Properties; import java.util.concurrent.TimeUnit; import javax.servlet.MultipartConfigElement; +import javax.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.fileupload.FileUploadBase; +import org.apache.commons.fileupload.servlet.ServletRequestContext; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration; @@ -32,6 +35,7 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.lang.NonNull; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.multipart.MultipartResolver; import org.springframework.web.multipart.commons.CommonsMultipartResolver; @@ -118,7 +122,16 @@ FreeMarkerConfigurer freemarkerConfig(HaloProperties haloProperties) @Bean(name = "multipartResolver") MultipartResolver multipartResolver(MultipartProperties multipartProperties) { MultipartConfigElement multipartConfigElement = multipartProperties.createMultipartConfig(); - CommonsMultipartResolver resolver = new CommonsMultipartResolver(); + CommonsMultipartResolver resolver = new CommonsMultipartResolver() { + @Override + public boolean isMultipart(@NonNull HttpServletRequest request) { + final var method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) && !"PUT".equalsIgnoreCase(method)) { + return false; + } + return FileUploadBase.isMultipartContent(new ServletRequestContext(request)); + } + }; resolver.setDefaultEncoding("UTF-8"); resolver.setMaxUploadSize(multipartConfigElement.getMaxRequestSize()); resolver.setMaxUploadSizePerFile(multipartConfigElement.getMaxFileSize()); diff --git a/src/main/java/run/halo/app/controller/admin/api/ThemeController.java b/src/main/java/run/halo/app/controller/admin/api/ThemeController.java index e33927af90..e6ff6218d4 100644 --- a/src/main/java/run/halo/app/controller/admin/api/ThemeController.java +++ b/src/main/java/run/halo/app/controller/admin/api/ThemeController.java @@ -177,6 +177,7 @@ public ThemeProperty uploadTheme(@RequestPart("file") MultipartFile file) { return themeService.upload(file); } + @PutMapping("upload/{themeId}") @PostMapping("upload/{themeId}") @ApiOperation("Upgrades theme by file") public ThemeProperty updateThemeByUpload(@PathVariable("themeId") String themeId, @@ -190,20 +191,23 @@ public ThemeProperty fetchTheme(@RequestParam("uri") String uri) { return themeService.fetch(uri); } - @PostMapping("fetchingBranches") + @PostMapping(value = {"fetchingBranches", "/fetching/git/branches"}) @ApiOperation("Fetches all branches") + @Deprecated(since = "1.4.2", forRemoval = true) public List fetchBranches(@RequestParam("uri") String uri) { return themeService.fetchBranches(uri); } @PostMapping("fetchingReleases") @ApiOperation("Fetches all releases") + @Deprecated(since = "1.4.2", forRemoval = true) public List fetchReleases(@RequestParam("uri") String uri) { return themeService.fetchReleases(uri); } @GetMapping("fetchingRelease") @ApiOperation("Fetches a specific release") + @Deprecated(since = "1.4.2", forRemoval = true) public ThemeProperty fetchRelease(@RequestParam("uri") String uri, @RequestParam("tag") String tagName) { return themeService.fetchRelease(uri, tagName); @@ -211,6 +215,7 @@ public ThemeProperty fetchRelease(@RequestParam("uri") String uri, @GetMapping("fetchBranch") @ApiOperation("Fetch specific branch") + @Deprecated(since = "1.4.2", forRemoval = true) public ThemeProperty fetchBranch(@RequestParam("uri") String uri, @RequestParam("branch") String branchName) { return themeService.fetchBranch(uri, branchName); @@ -218,12 +223,13 @@ public ThemeProperty fetchBranch(@RequestParam("uri") String uri, @GetMapping("fetchLatestRelease") @ApiOperation("Fetch latest release") + @Deprecated(since = "1.4.2", forRemoval = true) public ThemeProperty fetchLatestRelease(@RequestParam("uri") String uri) { return themeService.fetchLatestRelease(uri); } @PutMapping("fetching/{themeId}") - @ApiOperation("Upgrades theme by remote") + @ApiOperation("Upgrades theme from remote") public ThemeProperty updateThemeByFetching(@PathVariable("themeId") String themeId) { return themeService.update(themeId); } diff --git a/src/main/java/run/halo/app/core/ControllerLogAop.java b/src/main/java/run/halo/app/core/ControllerLogAop.java index e0c13b7837..69a12092e7 100644 --- a/src/main/java/run/halo/app/core/ControllerLogAop.java +++ b/src/main/java/run/halo/app/core/ControllerLogAop.java @@ -31,11 +31,15 @@ @Slf4j public class ControllerLogAop { + @Pointcut("@within(org.springframework.web.bind.annotation.RestController)") + public void restController() { + } + @Pointcut("@within(org.springframework.stereotype.Controller)") public void controller() { } - @Around("controller()") + @Around("controller() || restController()") public Object controller(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); final Method method = signature.getMethod(); diff --git a/src/main/java/run/halo/app/exception/ThemeUpToDateException.java b/src/main/java/run/halo/app/exception/ThemeUpToDateException.java new file mode 100644 index 0000000000..5b79c4ad7a --- /dev/null +++ b/src/main/java/run/halo/app/exception/ThemeUpToDateException.java @@ -0,0 +1,13 @@ +package run.halo.app.exception; + +/** + * Theme up to date exception. + * + * @author johnniang + */ +public class ThemeUpToDateException extends BadRequestException { + + public ThemeUpToDateException(String message) { + super(message); + } +} diff --git a/src/main/java/run/halo/app/handler/file/LocalFileHandler.java b/src/main/java/run/halo/app/handler/file/LocalFileHandler.java index e2c8ae851c..3d82fd161e 100644 --- a/src/main/java/run/halo/app/handler/file/LocalFileHandler.java +++ b/src/main/java/run/halo/app/handler/file/LocalFileHandler.java @@ -10,7 +10,6 @@ import java.nio.file.Paths; import java.util.Calendar; import java.util.Objects; -import java.util.concurrent.locks.ReentrantLock; import lombok.extern.slf4j.Slf4j; import net.coobird.thumbnailator.Thumbnails; import org.springframework.http.MediaType; @@ -58,8 +57,6 @@ public class LocalFileHandler implements FileHandler { private final String workDir; - private final ReentrantLock lock = new ReentrantLock(); - public LocalFileHandler(OptionService optionService, HaloProperties haloProperties) { this.optionService = optionService; @@ -79,13 +76,11 @@ private void checkWorkDir() { Path workPath = Paths.get(workDir); // Check file type - Assert.isTrue(Files.isDirectory(workPath), workDir + " isn't a directory"); - - // Check readable - Assert.isTrue(Files.isReadable(workPath), workDir + " isn't readable"); - - // Check writable - Assert.isTrue(Files.isWritable(workPath), workDir + " isn't writable"); + if (!Files.isDirectory(workPath) + || !Files.isReadable(workPath) + || !Files.isWritable(workPath)) { + log.warn("Please make sure that {} is a directory, readable and writable!", workDir); + } } @Override diff --git a/src/main/java/run/halo/app/handler/theme/config/support/ThemeProperty.java b/src/main/java/run/halo/app/handler/theme/config/support/ThemeProperty.java index 263e13dc52..f4e1522273 100644 --- a/src/main/java/run/halo/app/handler/theme/config/support/ThemeProperty.java +++ b/src/main/java/run/halo/app/handler/theme/config/support/ThemeProperty.java @@ -8,6 +8,7 @@ * Theme property. * * @author ryanwang + * @author johnniang * @date 2019-03-22 */ @Data @@ -31,13 +32,18 @@ public class ThemeProperty { /** * Theme remote branch.(default is master) */ - private String branch; + private String branch = "master"; /** - * Theme repo url. + * Theme git repo url. */ private String repo; + /** + * Theme update strategy. Default is branch. + */ + private UpdateStrategy updateStrategy = UpdateStrategy.RELEASE; + /** * Theme description. */ @@ -115,8 +121,13 @@ public int hashCode() { return Objects.hash(id); } + /** + * Theme author info. + * + * @author johnniang + */ @Data - private static class Author { + public static class Author { /** * Author name. @@ -133,4 +144,22 @@ private static class Author { */ private String avatar; } + + /** + * Theme update strategy. + * + * @author johnniang + */ + public enum UpdateStrategy { + + /** + * Update from specific branch + */ + BRANCH, + + /** + * Update from latest release, only available if the repo is a github repo + */ + RELEASE; + } } diff --git a/src/main/java/run/halo/app/listener/StartedListener.java b/src/main/java/run/halo/app/listener/StartedListener.java index 49fc9f0609..faebe53369 100644 --- a/src/main/java/run/halo/app/listener/StartedListener.java +++ b/src/main/java/run/halo/app/listener/StartedListener.java @@ -13,6 +13,8 @@ import java.sql.SQLException; import java.util.Collections; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.eclipse.jgit.storage.file.WindowCacheConfig; import org.flywaydb.core.Flyway; import org.flywaydb.core.internal.jdbc.JdbcUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -21,10 +23,10 @@ import org.springframework.boot.ansi.AnsiOutput; import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationListener; -import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.ResourceUtils; import run.halo.app.config.properties.HaloProperties; @@ -42,7 +44,7 @@ * @date 2018-12-05 */ @Slf4j -@Configuration +@Component @Order(Ordered.HIGHEST_PRECEDENCE) public class StartedListener implements ApplicationListener { @@ -71,9 +73,19 @@ public void onApplicationEvent(ApplicationStartedEvent event) { } catch (SQLException e) { log.error("Failed to migrate database!", e); } - this.initThemes(); this.initDirectory(); + this.initThemes(); this.printStartInfo(); + this.configGit(); + } + + private void configGit() { + // Config packed git MMAP + if (SystemUtils.IS_OS_WINDOWS) { + WindowCacheConfig config = new WindowCacheConfig(); + config.setPackedGitMMAP(false); + config.install(); + } } private void printStartInfo() { diff --git a/src/main/java/run/halo/app/mail/AbstractMailService.java b/src/main/java/run/halo/app/mail/AbstractMailService.java index 8431c218b2..4348bea4d9 100644 --- a/src/main/java/run/halo/app/mail/AbstractMailService.java +++ b/src/main/java/run/halo/app/mail/AbstractMailService.java @@ -4,6 +4,7 @@ import java.util.Arrays; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Consumer; import javax.mail.MessagingException; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; @@ -28,10 +29,15 @@ public abstract class AbstractMailService implements MailService { private static final int DEFAULT_POOL_SIZE = 5; + protected final OptionService optionService; + private JavaMailSender cachedMailSender; + private MailProperties cachedMailProperties; + private String cachedFromName; + @Nullable private ExecutorService executorService; @@ -47,7 +53,7 @@ public ExecutorService getExecutorService() { return executorService; } - public void setExecutorService(ExecutorService executorService) { + public void setExecutorService(@Nullable ExecutorService executorService) { this.executorService = executorService; } @@ -72,7 +78,7 @@ public void testConnection() { * * @param callback mime message callback. */ - protected void sendMailTemplate(@Nullable Callback callback) { + protected void sendMailTemplate(@Nullable Consumer callback) { if (callback == null) { log.info("Callback is null, skip to send email"); return; @@ -101,7 +107,7 @@ protected void sendMailTemplate(@Nullable Callback callback) { // set from-name messageHelper.setFrom(getFromAddress(mailSender)); // handle message set separately - callback.handle(messageHelper); + callback.accept(messageHelper); // get mime message MimeMessage mimeMessage = messageHelper.getMimeMessage(); @@ -123,9 +129,10 @@ protected void sendMailTemplate(@Nullable Callback callback) { * @param callback callback message handler * @param tryToAsync if the send procedure should try to asynchronous */ - protected void sendMailTemplate(boolean tryToAsync, @Nullable Callback callback) { + protected void sendMailTemplate(boolean tryToAsync, + @Nullable Consumer callback) { ExecutorService executorService = getExecutorService(); - if (tryToAsync && executorService != null) { + if (tryToAsync) { // send mail asynchronously executorService.execute(() -> sendMailTemplate(callback)); } else { @@ -233,16 +240,4 @@ protected void clearCache() { log.debug("Cleared all mail caches"); } - /** - * Message callback. - */ - protected interface Callback { - /** - * Handle message set. - * - * @param messageHelper mime message helper - * @throws Exception if something goes wrong - */ - void handle(@NonNull MimeMessageHelper messageHelper) throws Exception; - } } diff --git a/src/main/java/run/halo/app/mail/MailServiceImpl.java b/src/main/java/run/halo/app/mail/MailServiceImpl.java index 6ffd238a43..8d495abada 100644 --- a/src/main/java/run/halo/app/mail/MailServiceImpl.java +++ b/src/main/java/run/halo/app/mail/MailServiceImpl.java @@ -1,11 +1,15 @@ package run.halo.app.mail; import freemarker.template.Template; +import freemarker.template.TemplateException; +import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Map; +import javax.mail.MessagingException; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationListener; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.ui.freemarker.FreeMarkerTemplateUtils; import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer; @@ -35,9 +39,13 @@ public MailServiceImpl(FreeMarkerConfigurer freeMarker, @Override public void sendTextMail(String to, String subject, String content) { sendMailTemplate(true, messageHelper -> { - messageHelper.setSubject(subject); - messageHelper.setTo(to); - messageHelper.setText(content); + try { + messageHelper.setSubject(subject); + messageHelper.setTo(to); + messageHelper.setText(content); + } catch (MessagingException e) { + throw new RuntimeException("Failed to set message subject, to or test!", e); + } }); } @@ -46,13 +54,19 @@ public void sendTemplateMail(String to, String subject, Map cont String templateName) { sendMailTemplate(true, messageHelper -> { // build message content with freemarker - Template template = freeMarker.getConfiguration().getTemplate(templateName); - String contentResult = - FreeMarkerTemplateUtils.processTemplateIntoString(template, content); + try { + Template template = freeMarker.getConfiguration().getTemplate(templateName); + String contentResult = FreeMarkerTemplateUtils.processTemplateIntoString(template, + content); + messageHelper.setSubject(subject); + messageHelper.setTo(to); + messageHelper.setText(contentResult, true); + } catch (IOException | TemplateException e) { + throw new RuntimeException("Failed to convert template to html!", e); + } catch (MessagingException e) { + throw new RuntimeException("Failed to set message subject, to or test", e); + } - messageHelper.setSubject(subject); - messageHelper.setTo(to); - messageHelper.setText(contentResult, true); }); } @@ -60,11 +74,15 @@ public void sendTemplateMail(String to, String subject, Map cont public void sendAttachMail(String to, String subject, Map content, String templateName, String attachFilePath) { sendMailTemplate(true, messageHelper -> { - messageHelper.setSubject(subject); - messageHelper.setTo(to); - Path attachmentPath = Paths.get(attachFilePath); - messageHelper - .addAttachment(attachmentPath.getFileName().toString(), attachmentPath.toFile()); + try { + messageHelper.setSubject(subject); + messageHelper.setTo(to); + Path attachmentPath = Paths.get(attachFilePath); + messageHelper.addAttachment(attachmentPath.getFileName().toString(), + attachmentPath.toFile()); + } catch (MessagingException e) { + throw new RuntimeException("Failed to set message subject, to or test", e); + } }); } @@ -73,9 +91,8 @@ public void testConnection() { super.testConnection(); } - @Override - public void onApplicationEvent(OptionUpdatedEvent event) { + public void onApplicationEvent(@NonNull OptionUpdatedEvent event) { // clear the cached java mail sender clearCache(); } diff --git a/src/main/java/run/halo/app/repository/ThemeRepository.java b/src/main/java/run/halo/app/repository/ThemeRepository.java new file mode 100644 index 0000000000..f3185ad1a0 --- /dev/null +++ b/src/main/java/run/halo/app/repository/ThemeRepository.java @@ -0,0 +1,78 @@ +package run.halo.app.repository; + +import java.util.List; +import java.util.Optional; +import run.halo.app.handler.theme.config.support.ThemeProperty; + +/** + * Theme repository. + * + * @author johnniang + */ +public interface ThemeRepository { + + /** + * Get activated theme id. + * + * @return activated theme id + */ + String getActivatedThemeId(); + + /** + * Get activated theme property. + * + * @return activated theme property + */ + ThemeProperty getActivatedThemeProperty(); + + /** + * Fetch theme property by theme id. + * + * @param themeId theme id + * @return an optional theme property + */ + Optional fetchThemePropertyByThemeId(String themeId); + + /** + * List all themes + * + * @return theme list + */ + List listAll(); + + /** + * Set activated theme. + * + * @param themeId theme id + */ + void setActivatedTheme(String themeId); + + /** + * Attempt to add new theme. + * + * @param newThemeProperty new theme property + * @return theme property + */ + ThemeProperty attemptToAdd(ThemeProperty newThemeProperty); + + /** + * Delete theme by theme id. + * + * @param themeId theme id + */ + void deleteTheme(String themeId); + + /** + * Delete theme by theme property. + * + * @param themeProperty theme property + */ + void deleteTheme(ThemeProperty themeProperty); + + /** + * Check theme property compatibility + * + * @param themeProperty theme property + */ + boolean checkThemePropertyCompatibility(ThemeProperty themeProperty); +} \ No newline at end of file diff --git a/src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java b/src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java new file mode 100644 index 0000000000..9eb98b9e60 --- /dev/null +++ b/src/main/java/run/halo/app/repository/ThemeRepositoryImpl.java @@ -0,0 +1,159 @@ +package run.halo.app.repository; + +import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; +import static run.halo.app.utils.FileUtils.copyFolder; +import static run.halo.app.utils.FileUtils.deleteFolderQuietly; +import static run.halo.app.utils.VersionUtil.compareVersion; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Repository; +import org.springframework.util.Assert; +import run.halo.app.config.properties.HaloProperties; +import run.halo.app.exception.AlreadyExistsException; +import run.halo.app.exception.NotFoundException; +import run.halo.app.exception.ServiceException; +import run.halo.app.exception.ThemeNotSupportException; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.model.entity.Option; +import run.halo.app.model.properties.PrimaryProperties; +import run.halo.app.model.support.HaloConst; +import run.halo.app.theme.ThemePropertyScanner; +import run.halo.app.utils.FileUtils; + +/** + * Theme repository implementation. + * + * @author johnniang + */ +@Repository +@Slf4j +public class ThemeRepositoryImpl implements ThemeRepository { + + private final OptionRepository optionRepository; + + private final HaloProperties properties; + + public ThemeRepositoryImpl(OptionRepository optionRepository, + HaloProperties properties) { + this.optionRepository = optionRepository; + this.properties = properties; + } + + @Override + public String getActivatedThemeId() { + return optionRepository.findByKey(PrimaryProperties.THEME.getValue()) + .map(Option::getValue) + .orElse(HaloConst.DEFAULT_THEME_ID); + } + + @Override + public ThemeProperty getActivatedThemeProperty() { + return fetchThemePropertyByThemeId(getActivatedThemeId()).orElseThrow(); + } + + @Override + public Optional fetchThemePropertyByThemeId(String themeId) { + return listAll().stream() + .filter(property -> Objects.equals(themeId, property.getId())) + .findFirst(); + } + + @Override + public List listAll() { + return ThemePropertyScanner.INSTANCE.scan(getThemeRootPath(), getActivatedThemeId()); + } + + @Override + public void setActivatedTheme(@NonNull String themeId) { + Assert.hasText(themeId, "Theme id must not be blank"); + + final var newThemeOption = optionRepository.findByKey(PrimaryProperties.THEME.getValue()) + .map(themeOption -> { + // set theme id + themeOption.setValue(themeId); + return themeOption; + }) + .orElseGet(() -> new Option(PrimaryProperties.THEME.getValue(), themeId)); + optionRepository.save(newThemeOption); + } + + @Override + public ThemeProperty attemptToAdd(ThemeProperty newProperty) { + // 1. check existence + final var alreadyExist = fetchThemePropertyByThemeId(newProperty.getId()).isPresent(); + if (alreadyExist) { + throw new AlreadyExistsException("当前安装的主题已存在"); + } + + // 2. check version compatibility + // Not support current halo version. + if (checkThemePropertyCompatibility(newProperty)) { + throw new ThemeNotSupportException( + "当前主题仅支持 Halo " + newProperty.getRequire() + " 及以上的版本"); + } + + // 3. move the temp folder into templates/themes/{theme_id} + final var sourceThemePath = Paths.get(newProperty.getThemePath()); + final var targetThemePath = + getThemeRootPath().resolve(newProperty.getId() + "-" + randomAlphabetic(5)); + + // 4. clear target theme folder firstly + deleteFolderQuietly(targetThemePath); + + log.info("Copying new theme({}) from {} to {}", + newProperty.getId(), + sourceThemePath, + targetThemePath); + + try { + copyFolder(sourceThemePath, targetThemePath); + } catch (IOException e) { + // clear data + deleteFolderQuietly(targetThemePath); + throw new ServiceException("复制主题文件失败!", e); + } finally { + log.info("Clean temporary theme folder {}", sourceThemePath); + deleteFolderQuietly(sourceThemePath); + } + + // or else throw should never happen + return ThemePropertyScanner.INSTANCE.fetchThemeProperty(targetThemePath).orElseThrow(); + } + + @Override + public void deleteTheme(String themeId) { + final var themeProperty = fetchThemePropertyByThemeId(themeId) + .orElseThrow(() -> new NotFoundException("主题 ID 为 " + themeId + " 不存在或已删除!")); + deleteTheme(themeProperty); + } + + @Override + public void deleteTheme(ThemeProperty themeProperty) { + final var themePath = Paths.get(themeProperty.getThemePath()); + try { + FileUtils.deleteFolder(themePath); + } catch (IOException e) { + throw new ServiceException("Failed to delete theme path: " + themePath, e); + } + } + + @Override + public boolean checkThemePropertyCompatibility(ThemeProperty themeProperty) { + // check version compatibility + // Not support current halo version. + return StringUtils.isNotEmpty(themeProperty.getRequire()) + && !compareVersion(HaloConst.HALO_VERSION, themeProperty.getRequire()); + } + + private Path getThemeRootPath() { + return Paths.get(properties.getWorkDir()).resolve("templates/themes"); + } +} diff --git a/src/main/java/run/halo/app/service/ThemeService.java b/src/main/java/run/halo/app/service/ThemeService.java index 4538d88522..fb46276cdf 100644 --- a/src/main/java/run/halo/app/service/ThemeService.java +++ b/src/main/java/run/halo/app/service/ThemeService.java @@ -112,7 +112,6 @@ public interface ThemeService { * @return theme property */ @NonNull - @Deprecated ThemeProperty getThemeOfNonNullBy(@NonNull String themeId); /** @@ -305,6 +304,7 @@ void saveTemplateContent(@NonNull String themeId, @NonNull String absolutePath, * @throws IOException IOException */ @NonNull + @Deprecated ThemeProperty add(@NonNull Path themeTmpPath) throws IOException; /** @@ -323,6 +323,7 @@ void saveTemplateContent(@NonNull String themeId, @NonNull String absolutePath, * @return theme property */ @NonNull + @Deprecated(since = "1.4.2", forRemoval = true) ThemeProperty fetchLatestRelease(@NonNull String uri); /** @@ -332,6 +333,7 @@ void saveTemplateContent(@NonNull String themeId, @NonNull String absolutePath, * @return list of theme properties */ @NonNull + @Deprecated(since = "1.4.2", forRemoval = true) List fetchBranches(@NonNull String uri); /** @@ -341,6 +343,7 @@ void saveTemplateContent(@NonNull String themeId, @NonNull String absolutePath, * @return list of theme properties */ @NonNull + @Deprecated(since = "1.4.2", forRemoval = true) List fetchReleases(@NonNull String uri); /** @@ -351,6 +354,7 @@ void saveTemplateContent(@NonNull String themeId, @NonNull String absolutePath, * @return theme property */ @NonNull + @Deprecated(since = "1.4.2", forRemoval = true) ThemeProperty fetchRelease(@NonNull String uri, @NonNull String tagName); /** @@ -361,6 +365,7 @@ void saveTemplateContent(@NonNull String themeId, @NonNull String absolutePath, * @return theme property */ @NonNull + @Deprecated(since = "1.4.2", forRemoval = true) ThemeProperty fetchBranch(@NonNull String uri, @NonNull String branchName); /** diff --git a/src/main/java/run/halo/app/service/TraceService.java b/src/main/java/run/halo/app/service/TraceService.java deleted file mode 100644 index 991437ee41..0000000000 --- a/src/main/java/run/halo/app/service/TraceService.java +++ /dev/null @@ -1,24 +0,0 @@ -//package run.halo.app.service; -// -//import org.springframework.boot.actuate.trace.http.HttpTrace; -//import org.springframework.lang.NonNull; -// -//import java.util.List; -// -///** -// * Trace service interface. -// * -// * @author johnniang -// * @date 2019-06-18 -// */ -//public interface TraceService { -// -// /** -// * Gets all http traces. -// * -// * @return -// */ -// @NonNull -// List listHttpTraces(); -// -//} diff --git a/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java b/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java index 88bee1fffa..5c50772fae 100644 --- a/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java @@ -1,47 +1,33 @@ package run.halo.app.service.impl; import static run.halo.app.model.support.HaloConst.DEFAULT_ERROR_PATH; -import static run.halo.app.model.support.HaloConst.DEFAULT_THEME_ID; +import static run.halo.app.utils.FileUtils.copyFolder; +import static run.halo.app.utils.FileUtils.deleteFolderQuietly; +import static run.halo.app.utils.VersionUtil.compareVersion; import java.io.IOException; -import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.zip.ZipInputStream; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.PullResult; -import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.lib.Constants; -import org.eclipse.jgit.lib.Ref; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevWalk; -import org.eclipse.jgit.transport.RemoteConfig; -import org.eclipse.jgit.transport.URIish; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.ResponseEntity; import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.Assert; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; -import run.halo.app.cache.AbstractStringCacheStore; import run.halo.app.config.properties.HaloProperties; import run.halo.app.event.theme.ThemeActivatedEvent; import run.halo.app.event.theme.ThemeUpdatedEvent; @@ -53,24 +39,26 @@ import run.halo.app.exception.ThemeNotSupportException; import run.halo.app.exception.ThemePropertyMissingException; import run.halo.app.exception.ThemeUpdateException; -import run.halo.app.exception.UnsupportedMediaTypeException; import run.halo.app.handler.theme.config.ThemeConfigResolver; import run.halo.app.handler.theme.config.support.Group; import run.halo.app.handler.theme.config.support.ThemeProperty; -import run.halo.app.model.properties.PrimaryProperties; import run.halo.app.model.support.HaloConst; import run.halo.app.model.support.ThemeFile; +import run.halo.app.repository.ThemeRepository; import run.halo.app.repository.ThemeSettingRepository; -import run.halo.app.service.OptionService; import run.halo.app.service.ThemeService; +import run.halo.app.theme.GitThemeFetcher; +import run.halo.app.theme.GitThemeUpdater; +import run.halo.app.theme.MultipartFileThemeUpdater; +import run.halo.app.theme.MultipartZipFileThemeFetcher; +import run.halo.app.theme.ThemeFetcherComposite; import run.halo.app.theme.ThemeFileScanner; import run.halo.app.theme.ThemePropertyScanner; +import run.halo.app.theme.ZipThemeFetcher; import run.halo.app.utils.FileUtils; -import run.halo.app.utils.FilenameUtils; import run.halo.app.utils.GitUtils; import run.halo.app.utils.GithubUtils; import run.halo.app.utils.HaloUtils; -import run.halo.app.utils.VersionUtil; /** * Theme service implementation. @@ -87,10 +75,6 @@ public class ThemeServiceImpl implements ThemeService { */ private final Path themeWorkDir; - private final OptionService optionService; - - private final AbstractStringCacheStore cacheStore; - private final ThemeConfigResolver themeConfigResolver; private final RestTemplate restTemplate; @@ -99,69 +83,48 @@ public class ThemeServiceImpl implements ThemeService { private final ThemeSettingRepository themeSettingRepository; - /** - * Activated theme id. - */ - @Nullable - private volatile String activatedThemeId; + private final ThemeFetcherComposite fetcherComposite; - /** - * Activated theme property. - */ - private volatile ThemeProperty activatedTheme; + private final ThemeRepository themeRepository; public ThemeServiceImpl(HaloProperties haloProperties, - OptionService optionService, - AbstractStringCacheStore cacheStore, ThemeConfigResolver themeConfigResolver, RestTemplate restTemplate, ApplicationEventPublisher eventPublisher, - ThemeSettingRepository themeSettingRepository) { - this.optionService = optionService; - this.cacheStore = cacheStore; + ThemeSettingRepository themeSettingRepository, + ThemeRepository themeRepository) { this.themeConfigResolver = themeConfigResolver; this.restTemplate = restTemplate; - themeWorkDir = Paths.get(haloProperties.getWorkDir(), THEME_FOLDER); + this.themeWorkDir = Paths.get(haloProperties.getWorkDir(), THEME_FOLDER); this.eventPublisher = eventPublisher; this.themeSettingRepository = themeSettingRepository; + this.themeRepository = themeRepository; + + this.fetcherComposite = new ThemeFetcherComposite(); + this.fetcherComposite.addFetcher(new ZipThemeFetcher()); + this.fetcherComposite.addFetcher(new GitThemeFetcher()); + this.fetcherComposite.addFetcher(new MultipartZipFileThemeFetcher()); } @Override @NonNull public ThemeProperty getThemeOfNonNullBy(@NonNull String themeId) { - return fetchThemePropertyBy(themeId).orElseThrow( - () -> new NotFoundException(themeId + " 主题不存在或已删除!").setErrorData(themeId)); + return fetchThemePropertyBy(themeId) + .orElseThrow( + () -> new NotFoundException(themeId + " 主题不存在或已删除!").setErrorData(themeId)); } @Override @NonNull public Optional fetchThemePropertyBy(String themeId) { - if (StringUtils.isBlank(themeId)) { - return Optional.empty(); - } - - // Get all themes - List themes = getThemes(); - - // filter and find first - return themes.stream() - .filter(themeProperty -> StringUtils.equals(themeProperty.getId(), themeId)) - .findFirst(); + return themeRepository.fetchThemePropertyByThemeId(themeId); } @Override @NonNull public List getThemes() { - ThemeProperty[] themeProperties = - cacheStore.getAny(THEMES_CACHE_KEY, ThemeProperty[].class).orElseGet(() -> { - List properties = - ThemePropertyScanner.INSTANCE.scan(getBasePath(), getActivatedThemeId()); - // Cache the themes - cacheStore.putAny(THEMES_CACHE_KEY, properties); - return properties.toArray(new ThemeProperty[0]); - }); - return Arrays.asList(themeProperties); + return themeRepository.listAll(); } @Override @@ -185,13 +148,11 @@ public List listCustomTemplates(@NonNull String themeId, @NonNull String // Get the theme path Path themePath = Paths.get(themeProperty.getThemePath()); try (Stream pathStream = Files.list(themePath)) { - return pathStream.filter( - path -> StringUtils.startsWithIgnoreCase(path.getFileName().toString(), prefix)) - .filter(path -> !(HaloConst.POST_PASSWORD_TEMPLATE + HaloConst.SUFFIX_FTL) - .equals(path.getFileName().toString())) + return pathStream.filter(path -> + StringUtils.startsWithIgnoreCase(path.getFileName().toString(), prefix)) .map(path -> { // Remove prefix - String customTemplate = StringUtils + final var customTemplate = StringUtils .removeStartIgnoreCase(path.getFileName().toString(), prefix); // Remove suffix return StringUtils @@ -239,7 +200,7 @@ public String getTemplateContent(@NonNull String absolutePath) { // Read file Path path = Paths.get(absolutePath); try { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + return Files.readString(path); } catch (IOException e) { throw new ServiceException("读取模板内容失败 " + absolutePath, e); } @@ -253,7 +214,7 @@ public String getTemplateContent(@NonNull String themeId, @NonNull String absolu // Read file Path path = Paths.get(absolutePath); try { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + return Files.readString(path); } catch (IOException e) { throw new ServiceException("读取模板内容失败 " + absolutePath, e); } @@ -296,7 +257,7 @@ public void deleteTheme(@NonNull String themeId, @NonNull Boolean deleteSettings if (themeId.equals(getActivatedThemeId())) { // Prevent to delete the activated theme - throw new BadRequestException("不能删除正在使用的主题").setErrorData(themeId); + throw new BadRequestException("无法删除正在使用的主题!").setErrorData(themeId); } try { @@ -339,8 +300,7 @@ public List fetchConfig(@NonNull String themeId) { } // Read the yaml file - String optionContent = - new String(Files.readAllBytes(optionsPath), StandardCharsets.UTF_8); + String optionContent = Files.readString(optionsPath); // Resolve it return themeConfigResolver.resolve(optionContent); @@ -355,8 +315,8 @@ public List fetchConfig(@NonNull String themeId) { @Override public String render(String pageName) { return fetchActivatedTheme() - .map(themeProperty -> String - .format(RENDER_TEMPLATE, themeProperty.getFolderName(), pageName)) + .map(themeProperty -> + String.format(RENDER_TEMPLATE, themeProperty.getFolderName(), pageName)) .orElse(DEFAULT_ERROR_PATH); } @@ -371,41 +331,13 @@ public String renderWithSuffix(String pageName) { @Override @NonNull public String getActivatedThemeId() { - if (activatedThemeId == null) { - synchronized (this) { - if (activatedThemeId == null) { - activatedThemeId = optionService - .getByPropertyOrDefault(PrimaryProperties.THEME, String.class, - DEFAULT_THEME_ID); - } - } - } - return activatedThemeId; + return themeRepository.getActivatedThemeId(); } @Override @NonNull public ThemeProperty getActivatedTheme() { - if (activatedTheme == null) { - synchronized (this) { - if (activatedTheme == null) { - // Get theme property - activatedTheme = getThemeOfNonNullBy(getActivatedThemeId()); - } - } - } - return activatedTheme; - } - - /** - * Sets activated theme. - * - * @param activatedTheme activated theme - */ - private void setActivatedTheme(@Nullable ThemeProperty activatedTheme) { - this.activatedTheme = activatedTheme; - this.activatedThemeId = - Optional.ofNullable(activatedTheme).map(ThemeProperty::getId).orElse(null); + return themeRepository.getActivatedThemeProperty(); } @Override @@ -417,14 +349,8 @@ public Optional fetchActivatedTheme() { @Override @NonNull public ThemeProperty activateTheme(@NonNull String themeId) { - // Check existence of the theme - ThemeProperty themeProperty = getThemeOfNonNullBy(themeId); - - // Save the theme to database - optionService.saveProperty(PrimaryProperties.THEME, themeId); - - // Set activated theme - setActivatedTheme(themeProperty); + // set activated theme + themeRepository.setActivatedTheme(themeId); // Clear the cache eventPublisher.publishEvent(new ThemeUpdatedEvent(this)); @@ -432,7 +358,7 @@ public ThemeProperty activateTheme(@NonNull String themeId) { // Publish a theme activated event eventPublisher.publishEvent(new ThemeActivatedEvent(this)); - return themeProperty; + return themeRepository.getActivatedThemeProperty(); } @Override @@ -440,54 +366,23 @@ public ThemeProperty activateTheme(@NonNull String themeId) { public ThemeProperty upload(@NonNull MultipartFile file) { Assert.notNull(file, "Multipart file must not be null"); - if (!StringUtils.endsWithIgnoreCase(file.getOriginalFilename(), ".zip")) { - throw new UnsupportedMediaTypeException("不支持的文件类型: " + file.getContentType()) - .setErrorData(file.getOriginalFilename()); - } - - ZipInputStream zis = null; - Path tempPath = null; - - try { - // Create temp directory - tempPath = FileUtils.createTempDirectory(); - String basename = - FilenameUtils.getBasename(Objects.requireNonNull(file.getOriginalFilename())); - Path themeTempPath = tempPath.resolve(basename); - - // Check directory traversal - FileUtils.checkDirectoryTraversal(tempPath, themeTempPath); - - // New zip input stream - zis = new ZipInputStream(file.getInputStream()); - - // Unzip to temp path - FileUtils.unzip(zis, themeTempPath); - - Path themePath = getThemeRootPath(themeTempPath); - - // Go to the base folder and add the theme into system - return add(themePath); - } catch (IOException e) { - throw new ServiceException("主题上传失败: " + file.getOriginalFilename(), e); - } finally { - // Close zip input stream - FileUtils.closeQuietly(zis); - // Delete folder after testing - FileUtils.deleteFolderQuietly(tempPath); - } + final var newThemeProperty = this.fetcherComposite.fetch(file); + return this.themeRepository.attemptToAdd(newThemeProperty); } @Override @NonNull + @Deprecated public ThemeProperty add(@NonNull Path themeTmpPath) throws IOException { Assert.notNull(themeTmpPath, "Theme temporary path must not be null"); Assert.isTrue(Files.isDirectory(themeTmpPath), "Theme temporary path must be a directory"); - log.debug("Children path of [{}]:", themeTmpPath); + if (log.isTraceEnabled()) { + log.trace("Children path of [{}]:", themeTmpPath); - try (Stream pathStream = Files.list(themeTmpPath)) { - pathStream.forEach(path -> log.debug(path.toString())); + try (Stream pathStream = Files.list(themeTmpPath)) { + pathStream.forEach(path -> log.trace(path.toString())); + } } // Check property config @@ -495,8 +390,8 @@ public ThemeProperty add(@NonNull Path themeTmpPath) throws IOException { // Check theme existence boolean isExist = getThemes().stream() - .anyMatch( - themeProperty -> themeProperty.getId().equalsIgnoreCase(tmpThemeProperty.getId())); + .anyMatch(themeProperty -> themeProperty.getId() + .equalsIgnoreCase(tmpThemeProperty.getId())); if (isExist) { throw new AlreadyExistsException("当前安装的主题已存在"); @@ -504,14 +399,14 @@ public ThemeProperty add(@NonNull Path themeTmpPath) throws IOException { // Not support current halo version. if (StringUtils.isNotEmpty(tmpThemeProperty.getRequire()) - && !VersionUtil.compareVersion(HaloConst.HALO_VERSION, tmpThemeProperty.getRequire())) { + && !compareVersion(HaloConst.HALO_VERSION, tmpThemeProperty.getRequire())) { throw new ThemeNotSupportException( "当前主题仅支持 Halo " + tmpThemeProperty.getRequire() + " 以上的版本"); } // Copy the temporary path to current theme folder Path targetThemePath = themeWorkDir.resolve(tmpThemeProperty.getId()); - FileUtils.copyFolder(themeTmpPath, targetThemePath); + copyFolder(themeTmpPath, targetThemePath); // Get property again ThemeProperty property = getProperty(targetThemePath); @@ -527,28 +422,8 @@ public ThemeProperty add(@NonNull Path themeTmpPath) throws IOException { public ThemeProperty fetch(@NonNull String uri) { Assert.hasText(uri, "Theme remote uri must not be blank"); - Path tmpPath = null; - - try { - // Create temp path - tmpPath = FileUtils.createTempDirectory(); - // Create temp path - Path themeTmpPath = tmpPath.resolve(HaloUtils.randomUUIDWithoutDash()); - - if (StringUtils.endsWithIgnoreCase(uri, ".zip")) { - downloadZipAndUnzip(uri, themeTmpPath); - } else { - String repoUrl = StringUtils.appendIfMissingIgnoreCase(uri, ".git", ".git"); - // Clone from git - GitUtils.cloneFromGit(repoUrl, themeTmpPath); - } - - return add(themeTmpPath); - } catch (IOException | GitAPIException e) { - throw new ServiceException("主题拉取失败 " + uri, e); - } finally { - FileUtils.deleteFolderQuietly(tmpPath); - } + final var themeProperty = fetcherComposite.fetch(uri); + return this.themeRepository.attemptToAdd(themeProperty); } @Override @@ -570,7 +445,7 @@ public ThemeProperty fetchBranch(String uri, String branchName) { } catch (IOException | GitAPIException e) { throw new ServiceException("主题拉取失败 " + uri + "。" + e.getMessage(), e); } finally { - FileUtils.deleteFolderQuietly(tmpPath); + deleteFolderQuietly(tmpPath); } } @@ -602,7 +477,7 @@ public ThemeProperty fetchRelease(@NonNull String uri, @NonNull String tagName) } catch (IOException e) { throw new ServiceException("主题拉取失败 " + uri, e); } finally { - FileUtils.deleteFolderQuietly(tmpPath); + deleteFolderQuietly(tmpPath); } } @@ -610,28 +485,14 @@ public ThemeProperty fetchRelease(@NonNull String uri, @NonNull String tagName) public ThemeProperty fetchLatestRelease(@NonNull String uri) { Assert.hasText(uri, "Theme remote uri must not be blank"); - Path tmpPath = null; - try { - tmpPath = FileUtils.createTempDirectory(); - - Path themeTmpPath = tmpPath.resolve(HaloUtils.randomUUIDWithoutDash()); - - Map releaseInfo = GithubUtils.getLatestRelease(uri); - - if (releaseInfo == null) { - throw new ServiceException("主题拉取失败" + uri); - } - - String zipUrl = (String) releaseInfo.get(ZIP_FILE_KEY); - - downloadZipAndUnzip(zipUrl, themeTmpPath); - - return add(themeTmpPath); - } catch (IOException e) { - throw new ServiceException("主题拉取失败 " + uri, e); - } finally { - FileUtils.deleteFolderQuietly(tmpPath); + Map releaseInfo = GithubUtils.getLatestRelease(uri); + if (releaseInfo == null) { + throw new ServiceException("主题拉取失败" + uri); } + String zipUrl = (String) releaseInfo.get(ZIP_FILE_KEY); + + final var themeProperty = this.fetcherComposite.fetch(zipUrl); + return this.themeRepository.attemptToAdd(themeProperty); } @Override @@ -639,7 +500,7 @@ public List fetchBranches(String uri) { Assert.hasText(uri, "Theme remote uri must not be blank"); String repoUrl = StringUtils.appendIfMissingIgnoreCase(uri, ".git", ".git"); - List branches = GitUtils.getAllBranches(repoUrl); + List branches = GitUtils.getAllBranchesFromRemote(repoUrl); List themeProperties = new ArrayList<>(); @@ -680,22 +541,16 @@ public void reload() { @Override public ThemeProperty update(String themeId) { + final var themeUpdater = new GitThemeUpdater(themeRepository, fetcherComposite); Assert.hasText(themeId, "Theme id must not be blank"); - ThemeProperty updatingTheme = getThemeOfNonNullBy(themeId); - try { - pullFromGit(updatingTheme); - + final var themeProperty = themeUpdater.update(themeId); } catch (Exception e) { if (e instanceof ThemeNotSupportException) { throw (ThemeNotSupportException) e; } - if (e instanceof GitAPIException) { - throw new ThemeUpdateException("主题更新失败!" + e.getMessage(), e); - } - throw new ThemeUpdateException("主题更新失败!您与主题作者可能同时更改了同一个文件,您也可以尝试删除主题并重新拉取最新的主题", e) - .setErrorData(themeId); + throw new ThemeUpdateException("主题更新失败!", e).setErrorData(themeId); } eventPublisher.publishEvent(new ThemeUpdatedEvent(this)); @@ -706,155 +561,15 @@ public ThemeProperty update(String themeId) { @Override public ThemeProperty update(String themeId, MultipartFile file) { Assert.hasText(themeId, "Theme id must not be blank"); - Assert.notNull(themeId, "Theme file must not be blank"); - - if (!StringUtils.endsWithIgnoreCase(file.getOriginalFilename(), ".zip")) { - throw new UnsupportedMediaTypeException("不支持的文件类型: " + file.getContentType()) - .setErrorData(file.getOriginalFilename()); - } - - ThemeProperty updatingTheme = getThemeOfNonNullBy(themeId); - - ZipInputStream zis = null; - Path tempPath = null; + Assert.notNull(file, "Theme file must not be null"); + final var themeUpdater = + new MultipartFileThemeUpdater(file, fetcherComposite, themeRepository); try { - // Create temp directory - tempPath = FileUtils.createTempDirectory(); - - String basename = FilenameUtils.getBasename(file.getOriginalFilename()); - Path themeTempPath = tempPath.resolve(basename); - - // Check directory traversal - FileUtils.checkDirectoryTraversal(tempPath, themeTempPath); - - // New zip input stream - zis = new ZipInputStream(file.getInputStream()); - - // Unzip to temp path - FileUtils.unzip(zis, themeTempPath); - - Path preparePath = getThemeRootPath(themeTempPath); - - ThemeProperty prepareThemeProperty = getProperty(preparePath); - - if (!prepareThemeProperty.getId().equals(updatingTheme.getId())) { - throw new ServiceException("上传的主题包不是该主题的更新包: " + file.getOriginalFilename()); - } - - // Not support current halo version. - if (StringUtils.isNotEmpty(prepareThemeProperty.getRequire()) && !VersionUtil - .compareVersion(HaloConst.HALO_VERSION, prepareThemeProperty.getRequire())) { - throw new ThemeNotSupportException( - "新版本主题仅支持 Halo " + prepareThemeProperty.getRequire() + " 以上的版本"); - } - - // Coping new theme files to old theme folder. - FileUtils.copyFolder(preparePath, Paths.get(updatingTheme.getThemePath())); - - eventPublisher.publishEvent(new ThemeUpdatedEvent(this)); - - // Gets theme property again. - return getProperty(Paths.get(updatingTheme.getThemePath())); + return themeUpdater.update(themeId); } catch (IOException e) { - throw new ServiceException("更新主题失败: " + file.getOriginalFilename(), e); - } finally { - // Close zip input stream - FileUtils.closeQuietly(zis); - // Delete folder after testing - FileUtils.deleteFolderQuietly(tempPath); - } - } - - private void pullFromGit(@NonNull ThemeProperty themeProperty) throws - IOException, GitAPIException, URISyntaxException { - Assert.notNull(themeProperty, "Theme property must not be null"); - - // Get branch - String branch = StringUtils.isBlank(themeProperty.getBranch()) - ? DEFAULT_REMOTE_BRANCH : themeProperty.getBranch(); - - Git git = null; - - try { - git = GitUtils.openOrInit(Paths.get(themeProperty.getThemePath())); - - Repository repository = git.getRepository(); - - // Add all changes - git.add() - .addFilepattern(".") - .call(); - // Commit the changes - git.commit().setMessage("Commit by halo automatically").call(); - - RevWalk revWalk = new RevWalk(repository); - - Ref ref = repository.findRef(Constants.HEAD); - - Assert.notNull(ref, Constants.HEAD + " ref was not found!"); - - RevCommit lastCommit = revWalk.parseCommit(ref.getObjectId()); - - // Force to set remote name - git.remoteRemove().setRemoteName(THEME_PROVIDER_REMOTE_NAME).call(); - RemoteConfig remoteConfig = git.remoteAdd() - .setName(THEME_PROVIDER_REMOTE_NAME) - .setUri(new URIish(themeProperty.getRepo())) - .call(); - - // Check out to specified branch - if (!StringUtils.equalsIgnoreCase(branch, git.getRepository().getBranch())) { - boolean present = git.branchList() - .call() - .stream() - .map(Ref::getName) - .anyMatch(name -> StringUtils.equalsIgnoreCase(name, branch)); - - git.checkout() - .setCreateBranch(true) - .setForced(!present) - .setName(branch) - .call(); - } - - // Pull with rebasing - PullResult pullResult = git.pull() - .setRemote(remoteConfig.getName()) - .setRemoteBranchName(branch) - .setRebase(true) - .call(); - - if (!pullResult.isSuccessful()) { - log.debug("Rebase result: [{}]", pullResult.getRebaseResult()); - log.debug("Merge result: [{}]", pullResult.getMergeResult()); - - throw new ThemeUpdateException("拉取失败!您与主题作者可能同时更改了同一个文件"); - } - - String latestTagName = - (String) GithubUtils.getLatestRelease(themeProperty.getRepo()).get(TAG_KEY); - git.checkout().setName(latestTagName).call(); - - // updated successfully. - ThemeProperty updatedThemeProperty = - getProperty(Paths.get(themeProperty.getThemePath())); - - // Not support current halo version. - if (StringUtils.isNotEmpty(updatedThemeProperty.getRequire()) && !VersionUtil - .compareVersion(HaloConst.HALO_VERSION, updatedThemeProperty.getRequire())) { - // reset theme version - git.reset() - .setMode(ResetCommand.ResetType.HARD) - .setRef(lastCommit.getName()) - .call(); - throw new ThemeNotSupportException( - "新版本主题仅支持 Halo " + updatedThemeProperty.getRequire() + " 以上的版本"); - } - } finally { - GitUtils.closeQuietly(git); + throw new ServiceException("更新主题失败:" + e.getMessage(), e); } - } /** @@ -875,8 +590,10 @@ private void downloadZipAndUnzip(@NonNull String zipUrl, @NonNull Path targetPat log.debug("Download response: [{}]", downloadResponse.getStatusCode()); if (downloadResponse.getStatusCode().isError() || downloadResponse.getBody() == null) { - throw new ServiceException( - "下载失败 " + zipUrl + ", 状态码: " + downloadResponse.getStatusCode()); + throw new ServiceException("下载失败 " + + zipUrl + + ", 状态码: " + + downloadResponse.getStatusCode()); } log.debug("Downloaded [{}]", zipUrl); @@ -927,10 +644,12 @@ private ThemeProperty getProperty(@NonNull Path themePath) { * @throws IOException IO exception */ @NonNull + @Deprecated(since = "1.4.2", forRemoval = true) private Path getThemeRootPath(@NonNull Path themePath) throws IOException { return FileUtils.findRootPath(themePath, - path -> StringUtils.equalsAny(path.getFileName().toString(), "theme.yaml", "theme.yml")) - .orElseThrow( - () -> new BadRequestException("无法准确定位到主题根目录,请确认主题目录中包含 theme.yml(theme.yaml)。")); + path -> StringUtils.equalsAny(path.getFileName().toString(), + "theme.yaml", "theme.yml")) + .orElseThrow(() -> + new BadRequestException("无法准确定位到主题根目录,请确认主题目录中包含 theme.yml(theme.yaml)。")); } } diff --git a/src/main/java/run/halo/app/theme/GitThemeFetcher.java b/src/main/java/run/halo/app/theme/GitThemeFetcher.java new file mode 100644 index 0000000000..35f53fffbf --- /dev/null +++ b/src/main/java/run/halo/app/theme/GitThemeFetcher.java @@ -0,0 +1,75 @@ +package run.halo.app.theme; + +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.transport.TagOpt; +import run.halo.app.exception.ThemePropertyMissingException; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.utils.FileUtils; +import run.halo.app.utils.GitUtils; + +/** + * Git theme fetcher. + * + * @author johnniang + */ +@Slf4j +public class GitThemeFetcher implements ThemeFetcher { + + @Override + public boolean support(Object source) { + if (source instanceof String) { + return ((String) source).endsWith(".git"); + } + return false; + } + + @Override + public ThemeProperty fetch(Object source) { + final var repoUrl = source.toString(); + + try { + // create temp folder + final var tempDirectory = FileUtils.createTempDirectory(); + + // clone from git + log.info("Cloning git repo {} to {}", repoUrl, tempDirectory); + try (final var git = Git.cloneRepository() + .setTagOption(TagOpt.FETCH_TAGS) + .setNoCheckout(false) + .setDirectory(tempDirectory.toFile()) + .setCloneSubmodules(false) + .setURI(repoUrl) + .setRemote("upstream") + .call()) { + log.info("Cloned git repo {} to {} successfully", repoUrl, tempDirectory); + + // find latest tag + final var latestTag = GitUtils.getLatestTag(git); + final var checkoutCommand = git.checkout() + .setName("halo") + .setCreateBranch(true); + if (latestTag != null) { + // checkout latest tag + checkoutCommand.setStartPoint(latestTag.getValue()); + } + Ref haloBranch = checkoutCommand.call(); + log.info("Checkout branch: {}", haloBranch.getName()); + } + + // locate theme property location + var themePropertyPath = ThemeMetaLocator.INSTANCE.locateProperty(tempDirectory) + .orElseThrow(() -> new ThemePropertyMissingException("主题配置文件缺失,请确认后重试!")); + + // fetch property + return ThemePropertyScanner.INSTANCE.fetchThemeProperty(themePropertyPath.getParent()) + .orElseThrow(); + } catch (IOException | GitAPIException e) { + throw new RuntimeException("主题拉取失败!(" + e.getMessage() + ")", e); + } + } + +} diff --git a/src/main/java/run/halo/app/theme/GitThemeUpdater.java b/src/main/java/run/halo/app/theme/GitThemeUpdater.java new file mode 100644 index 0000000000..e1bad664e7 --- /dev/null +++ b/src/main/java/run/halo/app/theme/GitThemeUpdater.java @@ -0,0 +1,137 @@ +package run.halo.app.theme; + +import static run.halo.app.theme.ThemeUpdater.backup; +import static run.halo.app.theme.ThemeUpdater.restore; +import static run.halo.app.utils.GitUtils.commitAutomatically; +import static run.halo.app.utils.GitUtils.logCommit; +import static run.halo.app.utils.GitUtils.removeRemoteIfExists; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Paths; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.RebaseCommand; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.RepositoryState; +import org.eclipse.jgit.transport.URIish; +import run.halo.app.exception.NotFoundException; +import run.halo.app.exception.ServiceException; +import run.halo.app.exception.ThemeUpdateException; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.repository.ThemeRepository; + +/** + * Update from theme property config. + * + * @author johnniang + */ +public class GitThemeUpdater implements ThemeUpdater { + + private final ThemeRepository themeRepository; + + private final ThemeFetcherComposite fetcherComposite; + + public GitThemeUpdater(ThemeRepository themeRepository, + ThemeFetcherComposite fetcherComposite) { + this.themeRepository = themeRepository; + this.fetcherComposite = fetcherComposite; + } + + @Override + public ThemeProperty update(String themeId) throws IOException { + // get theme property + final var oldThemeProperty = themeRepository.fetchThemePropertyByThemeId(themeId) + .orElseThrow( + () -> new NotFoundException("主题 " + themeId + " 不存在或以删除!").setErrorData(themeId)); + + // get update config + final var gitRepo = oldThemeProperty.getRepo(); + + // fetch latest theme + final var newThemeProperty = fetcherComposite.fetch(gitRepo); + + // merge old theme and new theme + final var mergedThemeProperty = merge(oldThemeProperty, newThemeProperty); + + // backup old theme + final var backupPath = backup(oldThemeProperty); + + try { + // delete old theme + themeRepository.deleteTheme(oldThemeProperty); + + // copy new theme to old theme folder + return themeRepository.attemptToAdd(mergedThemeProperty); + } catch (Throwable t) { + log.error("Failed to add new theme, and restoring old theme from " + backupPath, t); + // restore old theme + restore(backupPath, oldThemeProperty); + log.info("Restored old theme from path: {}", backupPath); + throw t; + } + } + + public ThemeProperty merge(ThemeProperty oldThemeProperty, ThemeProperty newThemeProperty) + throws IOException { + + final var oldThemePath = Paths.get(oldThemeProperty.getThemePath()); + // open old git repo + try (final var oldGit = Git.open(oldThemePath.toFile())) { + // 0. commit old repo + commitAutomatically(oldGit); + + final var newThemePath = Paths.get(newThemeProperty.getThemePath()); + // trying to open new git repo + try (final var ignored = Git.open(newThemePath.toFile())) { + // remove remote + removeRemoteIfExists(oldGit, "newTheme"); + // add this new git to remote for old repo + final var addedRemoteConfig = oldGit.remoteAdd() + .setName("newTheme") + .setUri(new URIish(newThemePath.toString())) + .call(); + log.info("git remote add newTheme {} {}", + addedRemoteConfig.getName(), + addedRemoteConfig.getURIs()); + + // fetch remote data + final var remote = "newTheme/halo"; + log.info("git fetch newTheme/halo"); + final var fetchResult = oldGit.fetch() + .setRemote("newTheme") + .call(); + log.info("Fetch result: {}", fetchResult.getMessages()); + + // rebase upstream + log.info("git rebase newTheme"); + final var rebaseResult = oldGit.rebase() + .setUpstream(remote) + .call(); + log.info("Rebase result: {}", rebaseResult.getStatus()); + logCommit(rebaseResult.getCurrentCommit()); + + // check rebase result + if (!rebaseResult.getStatus().isSuccessful()) { + if (oldGit.getRepository().getRepositoryState() != RepositoryState.SAFE) { + // if rebasing stopped or failed, you can get back to the original state by + // running it + // with setOperation(RebaseCommand.Operation.ABORT) + final var abortRebaseResult = oldGit.rebase() + .setUpstream(remote) + .setOperation(RebaseCommand.Operation.ABORT) + .call(); + log.error("Aborted rebase with state: {} : {}", + abortRebaseResult.getStatus(), + abortRebaseResult.getConflicts()); + } + throw new ThemeUpdateException("无法自动合并最新文件!请尝试删除主题并重新拉取。"); + } + } + } catch (URISyntaxException | GitAPIException e) { + throw new ServiceException("合并主题失败!请确认该主题支持在线更新。", e); + } + + return newThemeProperty; + } + +} diff --git a/src/main/java/run/halo/app/theme/MultipartFileThemeUpdater.java b/src/main/java/run/halo/app/theme/MultipartFileThemeUpdater.java new file mode 100644 index 0000000000..fc750cc55c --- /dev/null +++ b/src/main/java/run/halo/app/theme/MultipartFileThemeUpdater.java @@ -0,0 +1,73 @@ +package run.halo.app.theme; + +import java.io.IOException; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.exception.BadRequestException; +import run.halo.app.exception.NotFoundException; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.repository.ThemeRepository; + +/** + * Multipart file theme updater. + * + * @author johnniang + */ +@Slf4j +public class MultipartFileThemeUpdater implements ThemeUpdater { + + private final MultipartFile file; + + private final ThemeFetcherComposite fetcherComposite; + + private final ThemeRepository themeRepository; + + public MultipartFileThemeUpdater(MultipartFile file, + ThemeFetcherComposite fetcherComposite, + ThemeRepository themeRepository) { + this.file = file; + this.fetcherComposite = fetcherComposite; + this.themeRepository = themeRepository; + } + + @Override + public ThemeProperty update(String themeId) throws IOException { + // check old theme id + final var oldThemeProperty = this.themeRepository.fetchThemePropertyByThemeId(themeId) + .orElseThrow(() -> new NotFoundException("主题 ID 为 " + themeId + " 不存在或已删除!")); + + // fetch new theme + final var newThemeProperty = this.fetcherComposite.fetch(this.file); + + if (!Objects.equals(oldThemeProperty.getId(), newThemeProperty.getId())) { + log.error("Expected theme: {}, but provided theme: {}", + oldThemeProperty.getId(), + newThemeProperty.getId()); + // clear new theme folder + this.themeRepository.deleteTheme(newThemeProperty); + throw new BadRequestException("上传的主题 " + + newThemeProperty.getId() + + " 和当前主题的 " + + oldThemeProperty.getId() + + " 不一致,无法进行更新操作!"); + } + + // backup old theme + final var backupPath = ThemeUpdater.backup(oldThemeProperty); + + try { + // delete old theme + themeRepository.deleteTheme(oldThemeProperty); + + // add new theme + return themeRepository.attemptToAdd(newThemeProperty); + } catch (Throwable t) { + log.error("Failed to add new theme, and restoring old theme from " + backupPath, t); + ThemeUpdater.restore(backupPath, oldThemeProperty); + log.info("Restored old theme from path: {}", backupPath); + throw t; + } + } + +} diff --git a/src/main/java/run/halo/app/theme/MultipartZipFileThemeFetcher.java b/src/main/java/run/halo/app/theme/MultipartZipFileThemeFetcher.java new file mode 100644 index 0000000000..d9a2993dd4 --- /dev/null +++ b/src/main/java/run/halo/app/theme/MultipartZipFileThemeFetcher.java @@ -0,0 +1,46 @@ +package run.halo.app.theme; + +import static run.halo.app.utils.FileUtils.unzip; + +import java.io.IOException; +import java.util.zip.ZipInputStream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; +import run.halo.app.exception.ServiceException; +import run.halo.app.exception.ThemePropertyMissingException; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.utils.FileUtils; + +/** + * Multipart zip file theme fetcher. + * + * @author johnniang + */ +@Slf4j +public class MultipartZipFileThemeFetcher implements ThemeFetcher { + + @Override + public boolean support(Object source) { + if (source instanceof MultipartFile) { + final var filename = ((MultipartFile) source).getOriginalFilename(); + return filename != null && filename.endsWith(".zip"); + } + return false; + } + + @Override + public ThemeProperty fetch(Object source) { + final var file = (MultipartFile) source; + + try (var zis = new ZipInputStream(file.getInputStream())) { + final var tempDirectory = FileUtils.createTempDirectory(); + log.info("Unzipping {} to path {}", file.getOriginalFilename(), tempDirectory); + unzip(zis, tempDirectory); + return ThemePropertyScanner.INSTANCE.fetchThemeProperty(tempDirectory) + .orElseThrow(() -> new ThemePropertyMissingException("主题配置文件缺失!请确认后重试。")); + } catch (IOException e) { + throw new ServiceException("主题上传失败!", e); + } + } + +} diff --git a/src/main/java/run/halo/app/theme/ThemeFetcher.java b/src/main/java/run/halo/app/theme/ThemeFetcher.java new file mode 100644 index 0000000000..d46faac838 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeFetcher.java @@ -0,0 +1,28 @@ +package run.halo.app.theme; + +import run.halo.app.handler.theme.config.support.ThemeProperty; + +/** + * Remote theme fetcher interface. + * + * @author johnniang + */ +public interface ThemeFetcher { + + /** + * Check whether source is supported or not. + * + * @param source input stream or remote git uri + * @return true if supported, false otherwise + */ + boolean support(Object source); + + /** + * Fetch theme from source. + * + * @param source input stream or remote git uri + * @return theme property + */ + ThemeProperty fetch(Object source); + +} diff --git a/src/main/java/run/halo/app/theme/ThemeFetcherComposite.java b/src/main/java/run/halo/app/theme/ThemeFetcherComposite.java new file mode 100644 index 0000000000..19b0254ad0 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeFetcherComposite.java @@ -0,0 +1,64 @@ +package run.halo.app.theme; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.springframework.lang.NonNull; +import run.halo.app.handler.theme.config.support.ThemeProperty; + +/** + * Theme fetcher composite. + * + * @author johnniang + */ +public class ThemeFetcherComposite implements ThemeFetcher { + + /** + * Theme fetcher container. + */ + private final List themeFetchers = new ArrayList<>(4); + + /** + * Fallback theme fetcher. + */ + private final ThemeFetcher fallbackFetcher = new GitThemeFetcher(); + + public ThemeFetcherComposite addFetcher(ThemeFetcher fetcher) { + this.themeFetchers.add(fetcher); + return this; + } + + public ThemeFetcherComposite addFetcher(ThemeFetcher... fetchers) { + if (fetchers != null) { + Collections.addAll(this.themeFetchers, fetchers); + } + return this; + } + + public List getFetchers() { + return Collections.unmodifiableList(this.themeFetchers); + } + + public void clear() { + this.themeFetchers.clear(); + } + + @Override + public boolean support(Object source) { + return getThemeFetcher(source).isPresent(); + } + + @Override + public ThemeProperty fetch(Object source) { + final var themeFetcher = getThemeFetcher(source).orElse(fallbackFetcher); + return themeFetcher.fetch(source); + } + + @NonNull + private Optional getThemeFetcher(Object source) { + return themeFetchers.stream() + .filter(fetcher -> fetcher.support(source)) + .findFirst(); + } +} diff --git a/src/main/java/run/halo/app/theme/ThemeMetaLocator.java b/src/main/java/run/halo/app/theme/ThemeMetaLocator.java new file mode 100644 index 0000000000..602651eb1e --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeMetaLocator.java @@ -0,0 +1,120 @@ +package run.halo.app.theme; + +import static org.apache.commons.lang3.StringUtils.equalsAnyIgnoreCase; +import static run.halo.app.utils.FileUtils.findPath; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Predicate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; + +/** + * Theme meta data locator. + * + * @author johnniang + */ +@Slf4j +public enum ThemeMetaLocator { + + INSTANCE; + + /** + * Theme property filenames. + */ + private static final String[] THEME_PROPERTY_FILENAMES = new String[] { + "theme.yaml", + "theme.yml", + }; + + private static final String[] THEME_SETTING_FILENAMES = new String[] { + "settings.yaml", + "settings.yml", + }; + + /** + * Theme screenshots name. + */ + private static final String THEME_SCREENSHOTS_NAME = "screenshot"; + + /** + * Locate theme root folder. + * + * @param path the given path must not be null + * @return root path or empty + */ + @NonNull + public Optional locateThemeRoot(@NonNull Path path) { + return locateProperty(path).map(Path::getParent); + } + + /** + * Locate theme property path. + * + * @return theme property path or empty + */ + @NonNull + public Optional locateProperty(@NonNull Path path) { + try { + var predicate = ((Predicate) + Files::isRegularFile) + .and(Files::isReadable) + .and( + p -> equalsAnyIgnoreCase(p.getFileName().toString(), THEME_PROPERTY_FILENAMES)); + + log.debug("Locating property in path: {}", path); + return findPath(path, 3, predicate); + } catch (IOException e) { + log.warn("Error occurred while finding theme root path", e); + } + return Optional.empty(); + } + + /** + * Locate theme setting path. + * + * @return theme setting path or empty + */ + @NonNull + public Optional locateSetting(@NonNull Path path) { + return locateThemeRoot(path).flatMap(root -> { + try { + var predicate = ((Predicate) + Files::isRegularFile) + .and(Files::isReadable) + .and(p -> equalsAnyIgnoreCase(p.getFileName().toString(), + THEME_SETTING_FILENAMES)); + log.debug("Locating setting from {}", path); + return findPath(path, 3, predicate); + } catch (IOException e) { + log.warn("Error occurred while finding theme root path", e); + } + return Optional.empty(); + } + ); + } + + /** + * Locate screenshot. + * + * @param path root path + * @return screenshot path or empty + */ + @NonNull + public Optional locateScreenshot(@NonNull Path path) { + return locateThemeRoot(path).flatMap(root -> { + try (var pathStream = Files.list(root)) { + var predicate = ((Predicate) Files::isRegularFile) + .and(Files::isReadable) + .and(p -> p.getFileName().toString().startsWith(THEME_SCREENSHOTS_NAME)); + log.debug("Locating screenshot from path: {}", path); + return pathStream.filter(predicate).findFirst(); + } catch (IOException e) { + log.warn("Failed to list path: " + path, e); + } + return Optional.empty(); + }); + } +} diff --git a/src/main/java/run/halo/app/theme/ThemePropertyScanner.java b/src/main/java/run/halo/app/theme/ThemePropertyScanner.java index e864b86f11..8c9ab67d35 100644 --- a/src/main/java/run/halo/app/theme/ThemePropertyScanner.java +++ b/src/main/java/run/halo/app/theme/ThemePropertyScanner.java @@ -1,9 +1,6 @@ package run.halo.app.theme; -import static run.halo.app.service.ThemeService.SETTINGS_NAMES; - import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; @@ -21,7 +18,6 @@ import run.halo.app.handler.theme.config.ThemePropertyResolver; import run.halo.app.handler.theme.config.impl.YamlThemePropertyResolver; import run.halo.app.handler.theme.config.support.ThemeProperty; -import run.halo.app.utils.FilenameUtils; /** * Theme property scanner. @@ -33,14 +29,6 @@ public enum ThemePropertyScanner { INSTANCE; - /** - * Theme property file name. - */ - private static final String[] THEME_PROPERTY_FILE_NAMES = {"theme.yaml", "theme.yml"}; - /** - * Theme screenshots name. - */ - private static final String THEME_SCREENSHOTS_NAME = "screenshot"; private final ThemePropertyResolver propertyResolver = new YamlThemePropertyResolver(); /** @@ -91,93 +79,42 @@ public List scan(@NonNull Path themePath, @Nullable String active /** * Fetch theme property * - * @param themePath theme path must not be null + * @param themeRootPath theme root path must not be null * @return an optional theme property */ @NonNull - public Optional fetchThemeProperty(@NonNull Path themePath) { - Assert.notNull(themePath, "Theme path must not be null"); - - Optional optionalPath = fetchPropertyPath(themePath); - - if (!optionalPath.isPresent()) { - return Optional.empty(); - } - - Path propertyPath = optionalPath.get(); - - try { - // Get property content - String propertyContent = - new String(Files.readAllBytes(propertyPath), StandardCharsets.UTF_8); - - // Resolve the base properties - ThemeProperty themeProperty = propertyResolver.resolve(propertyContent); - - // Resolve additional properties - themeProperty.setThemePath(themePath.toString()); - themeProperty.setFolderName(themePath.getFileName().toString()); - themeProperty.setHasOptions(hasOptions(themePath)); - themeProperty.setActivated(false); - - // Set screenshots - getScreenshotsFileName(themePath).ifPresent( - screenshotsName -> themeProperty.setScreenshots(StringUtils.join("/themes/", - FilenameUtils.getBasename(themeProperty.getThemePath()), - "/", - screenshotsName))); - - return Optional.of(themeProperty); - } catch (Exception e) { - log.warn("Failed to load theme property file", e); - } - return Optional.empty(); - } - - /** - * Gets screenshots file name. - * - * @param themePath theme path must not be null - * @return screenshots file name or null if the given theme path has not screenshots - * @throws IOException throws when listing files - */ - @NonNull - private Optional getScreenshotsFileName(@NonNull Path themePath) throws IOException { - Assert.notNull(themePath, "Theme path must not be null"); - - try (Stream pathStream = Files.list(themePath)) { - return pathStream.filter(path -> Files.isRegularFile(path) - && Files.isReadable(path) - && - FilenameUtils.getBasename(path.toString()).equalsIgnoreCase(THEME_SCREENSHOTS_NAME)) - .findFirst() - .map(path -> path.getFileName().toString()); - } - } - - /** - * Gets property path of nullable. - * - * @param themePath theme path. - * @return an optional property path - */ - @NonNull - private Optional fetchPropertyPath(@NonNull Path themePath) { - Assert.notNull(themePath, "Theme path must not be null"); - - for (String propertyPathName : THEME_PROPERTY_FILE_NAMES) { - Path propertyPath = themePath.resolve(propertyPathName); - - log.debug("Attempting to find property file: [{}]", propertyPath); - if (Files.exists(propertyPath) && Files.isReadable(propertyPath)) { - log.debug("Found property file: [{}]", propertyPath); - return Optional.of(propertyPath); + public Optional fetchThemeProperty(@NonNull Path themeRootPath) { + Assert.notNull(themeRootPath, "Theme path must not be null"); + + return ThemeMetaLocator.INSTANCE.locateProperty(themeRootPath).map(propertyPath -> { + final var rootPath = propertyPath.getParent(); + try { + // Get property content + final var propertyContent = Files.readString(propertyPath); + + // Resolve the base properties + final var themeProperty = propertyResolver.resolve(propertyContent); + + // Resolve additional properties + themeProperty.setThemePath(rootPath.toString()); + themeProperty.setFolderName(rootPath.getFileName().toString()); + themeProperty.setHasOptions(hasOptions(rootPath)); + themeProperty.setActivated(false); + + // resolve screenshot + ThemeMetaLocator.INSTANCE.locateScreenshot(rootPath).ifPresent(screenshotPath -> { + final var screenshotRelPath = StringUtils.join("/themes/", + themeProperty.getFolderName(), + "/", + screenshotPath.getFileName().toString()); + themeProperty.setScreenshots(screenshotRelPath); + }); + return themeProperty; + } catch (Exception e) { + log.warn("Failed to load theme property file", e); } - } - - log.warn("Property file was not found in [{}]", themePath); - - return Optional.empty(); + return null; + }); } /** @@ -189,16 +126,6 @@ private Optional fetchPropertyPath(@NonNull Path themePath) { private boolean hasOptions(@NonNull Path themePath) { Assert.notNull(themePath, "Path must not be null"); - for (String optionsName : SETTINGS_NAMES) { - // Resolve the options path - Path optionsPath = themePath.resolve(optionsName); - - log.debug("Check options file for path: [{}]", optionsPath); - - if (Files.exists(optionsPath)) { - return true; - } - } - return false; + return ThemeMetaLocator.INSTANCE.locateSetting(themePath).isPresent(); } } diff --git a/src/main/java/run/halo/app/theme/ThemeUpdater.java b/src/main/java/run/halo/app/theme/ThemeUpdater.java new file mode 100644 index 0000000000..d0b5eaf9b0 --- /dev/null +++ b/src/main/java/run/halo/app/theme/ThemeUpdater.java @@ -0,0 +1,67 @@ +package run.halo.app.theme; + +import static run.halo.app.utils.FileUtils.copyFolder; +import static run.halo.app.utils.FileUtils.deleteFolderQuietly; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.utils.FileUtils; + +/** + * Theme updater. + * + * @author johnniang + */ +public interface ThemeUpdater { + + Logger log = LoggerFactory.getLogger(ThemeUpdater.class); + + /** + * Update theme property. + * + * @param themeId theme id + * @return updated theme property + */ + ThemeProperty update(String themeId) throws IOException; + + /** + * Backup old theme. + * + * @param themeProperty theme property + * @return theme backup path + * @throws IOException throws io exception + */ + static Path backup(final ThemeProperty themeProperty) throws IOException { + final var themePath = Paths.get(themeProperty.getThemePath()); + Path tempDirectory = null; + try { + tempDirectory = FileUtils.createTempDirectory(); + copyFolder(themePath, tempDirectory); + log.info("Backup theme: {} to {} successfully!", themeProperty.getId(), tempDirectory); + return tempDirectory; + } catch (IOException e) { + // clear temp directory + deleteFolderQuietly(tempDirectory); + throw e; + } + } + + static void restore(final Path backupPath, final ThemeProperty oldThemeProperty) + throws IOException { + final var targetPath = Paths.get(oldThemeProperty.getThemePath()); + log.info("Restoring backup path: {} to target path: {}", backupPath, targetPath); + // copy backup to target path + FileUtils.copyFolder(backupPath, targetPath); + log.debug("Copied backup path: {} to target path: {} successfully!", backupPath, + targetPath); + // delete backup + FileUtils.deleteFolderQuietly(backupPath); + log.debug("Deleted backup path: {} successfully!", backupPath); + log.info("Restored backup path: {} to target path: {} successfully!", backupPath, + targetPath); + } +} diff --git a/src/main/java/run/halo/app/theme/ZipThemeFetcher.java b/src/main/java/run/halo/app/theme/ZipThemeFetcher.java new file mode 100644 index 0000000000..b5825a380f --- /dev/null +++ b/src/main/java/run/halo/app/theme/ZipThemeFetcher.java @@ -0,0 +1,75 @@ +package run.halo.app.theme; + +import static run.halo.app.utils.FileUtils.unzip; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.zip.ZipInputStream; +import lombok.extern.slf4j.Slf4j; +import run.halo.app.exception.ThemePropertyMissingException; +import run.halo.app.handler.theme.config.support.ThemeProperty; +import run.halo.app.utils.FileUtils; + +/** + * Zip theme fetcher. + * + * @author johnniang + */ +@Slf4j +public class ZipThemeFetcher implements ThemeFetcher { + + private final HttpClient httpClient; + + public ZipThemeFetcher() { + this.httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .connectTimeout(Duration.ofMinutes(5)) + .build(); + } + + @Override + public boolean support(Object source) { + if (source instanceof String) { + return ((String) source).endsWith(".zip"); + } + return false; + } + + @Override + public ThemeProperty fetch(Object source) { + final var themeZipLink = source.toString(); + + // build http request + final var request = HttpRequest.newBuilder() + .uri(URI.create(themeZipLink)) + .timeout(Duration.ofMinutes(2)) + .GET() + .build(); + + try { + // request from remote + log.info("Fetching theme from {}", themeZipLink); + var inputStreamResponse = + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + var inputStream = inputStreamResponse.body(); + + // unzip zip archive + try (var zipInputStream = new ZipInputStream(inputStream)) { + var tempDirectory = FileUtils.createTempDirectory(); + log.info("Unzipping theme {} to {}", themeZipLink, tempDirectory); + unzip(zipInputStream, tempDirectory); + + // resolve theme property + return ThemePropertyScanner.INSTANCE.fetchThemeProperty(tempDirectory) + .orElseThrow(() -> new ThemePropertyMissingException("主题配置文件缺失!请确认后重试。")); + } + } catch (InterruptedException | IOException e) { + throw new RuntimeException("主题拉取失败!(" + e.getMessage() + ")", e); + } + } + +} diff --git a/src/main/java/run/halo/app/utils/FileUtils.java b/src/main/java/run/halo/app/utils/FileUtils.java index ac0fe085ce..4a3bdb7b34 100644 --- a/src/main/java/run/halo/app/utils/FileUtils.java +++ b/src/main/java/run/halo/app/utils/FileUtils.java @@ -13,7 +13,6 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; -import java.util.Arrays; import java.util.LinkedList; import java.util.List; import java.util.Optional; @@ -39,11 +38,6 @@ @Slf4j public class FileUtils { - /** - * Ignored folders while finding root path. - */ - private static final List IGNORED_FOLDERS = Arrays.asList(".git"); - private FileUtils() { } @@ -57,12 +51,12 @@ public static void copyFolder(@NonNull Path source, @NonNull Path target) throws Assert.notNull(source, "Source path must not be null"); Assert.notNull(target, "Target path must not be null"); - Files.walkFileTree(source, new SimpleFileVisitor() { + Files.walkFileTree(source, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - Path current = target.resolve(source.relativize(dir).toString()); + Path current = target.resolve(source.relativize(dir)); Files.createDirectories(current); return FileVisitResult.CONTINUE; } @@ -70,7 +64,7 @@ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - Files.copy(file, target.resolve(source.relativize(file).toString()), + Files.copy(file, target.resolve(source.relativize(file)), StandardCopyOption.REPLACE_EXISTING); return FileVisitResult.CONTINUE; } @@ -255,44 +249,117 @@ private static void zip(@NonNull Path fileToZip, @NonNull String fileName, */ @NonNull public static Optional findRootPath(@NonNull final Path path, - @Nullable final Predicate pathPredicate) throws IOException { + @Nullable final Predicate pathPredicate) + throws IOException { + return findRootPath(path, Integer.MAX_VALUE, pathPredicate); + } + + /** + * Find root path. + * + * @param path super root path starter + * @param maxDepth max loop depth + * @param pathPredicate path predicate + * @return empty if path is not a directory or the given path predicate is null + * @throws IOException IO exception + */ + @NonNull + public static Optional findRootPath(@NonNull final Path path, + int maxDepth, + @Nullable final Predicate pathPredicate) + throws IOException { + return findPath(path, maxDepth, pathPredicate).map(Path::getParent); + } + + /** + * Find path. + * + * @param path super root path starter + * @param pathPredicate path predicate + * @return empty if path is not a directory or the given path predicate is null + * @throws IOException IO exception + */ + @NonNull + public static Optional findPath(@NonNull final Path path, + @Nullable final Predicate pathPredicate) + throws IOException { + return findPath(path, Integer.MAX_VALUE, pathPredicate); + } + + /** + * Find path. + * + * @param path super root path starter + * @param pathPredicate path predicate + * @return empty if path is not a directory or the given path predicate is null + * @throws IOException IO exception + */ + @NonNull + public static Optional findPath(@NonNull final Path path, + int maxDepth, + @Nullable final Predicate pathPredicate) + throws IOException { + Assert.isTrue(maxDepth > 0, "Max depth must not be less than 1"); if (!Files.isDirectory(path) || pathPredicate == null) { // if the path is not a directory or the given path predicate is null, then return an // empty optional return Optional.empty(); } - log.debug("Trying to find root path from [{}]", path); + log.debug("Trying to find path from [{}]", path); // the queue holds folders which may be root - final LinkedList queue = new LinkedList<>(); + final var queue = new LinkedList(); + // depth container + final var depthQueue = new LinkedList(); + + // init queue queue.push(path); - while (!queue.isEmpty()) { + depthQueue.push(1); + + boolean found = false; + Path result = null; + while (!found && !queue.isEmpty()) { // pop the first path as candidate root path - final Path rootPath = queue.pop(); + final var rootPath = queue.pop(); + final int depth = depthQueue.pop(); + if (log.isDebugEnabled()) { + log.debug("Peek({}) into {}", depth, rootPath); + } try (final Stream childrenPaths = Files.list(rootPath)) { - List subFolders = new LinkedList<>(); - Optional matchedPath = childrenPaths.peek(child -> { - if (Files.isDirectory(child)) { - // collect directory - subFolders.add(child); + final var subFolders = new LinkedList(); + var resultPath = childrenPaths + .peek(p -> { + if (Files.isDirectory(p)) { + subFolders.add(p); + } + }) + .filter(pathPredicate) + .findFirst(); + if (resultPath.isPresent()) { + queue.clear(); + depthQueue.clear(); + // return current result path + found = true; + result = resultPath.get(); + } else { + // put all directory into queue + if (depth < maxDepth) { + for (Path subFolder : subFolders) { + if (!Files.isHidden(subFolder)) { + // skip hidden folder + queue.push(subFolder); + depthQueue.push(depth + 1); + } + } } - }).filter(pathPredicate).findAny(); - if (matchedPath.isPresent()) { - log.debug("Found root path: [{}]", rootPath); - return Optional.of(rootPath); } - // add all folder into queue - subFolders.forEach(e -> { - // if - if (!IGNORED_FOLDERS.contains(e.getFileName().toString())) { - queue.push(e); - } - }); + subFolders.clear(); } } - // if tests are failed completely - return Optional.empty(); + + log.debug("Found path: [{}]", result); + return Optional.ofNullable(result); } /** @@ -427,7 +494,7 @@ public static void deleteFolderQuietly(@Nullable Path deletingPath) { FileUtils.deleteFolder(deletingPath); } } catch (IOException e) { - log.warn("Failed to delete " + deletingPath); + log.warn("Failed to delete {}", deletingPath); } } @@ -440,7 +507,9 @@ public static void deleteFolderQuietly(@Nullable Path deletingPath) { */ @NonNull public static Path createTempDirectory() throws IOException { - return Files.createTempDirectory("halo"); + final var tempDirectory = Files.createTempDirectory("halo"); + Runtime.getRuntime().addShutdownHook(new Thread(() -> deleteFolderQuietly(tempDirectory))); + return tempDirectory; } } diff --git a/src/main/java/run/halo/app/utils/GitUtils.java b/src/main/java/run/halo/app/utils/GitUtils.java index 0d4fc464a9..55a80b06c6 100644 --- a/src/main/java/run/halo/app/utils/GitUtils.java +++ b/src/main/java/run/halo/app/utils/GitUtils.java @@ -5,17 +5,26 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.api.errors.InvalidRemoteException; import org.eclipse.jgit.api.errors.TransportException; -import org.eclipse.jgit.errors.RepositoryNotFoundException; import org.eclipse.jgit.lib.Ref; -import org.eclipse.jgit.storage.file.WindowCacheConfig; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevSort; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.RemoteConfig; +import org.eclipse.jgit.treewalk.filter.TreeFilter; import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; /** * Git utilities. @@ -27,66 +36,27 @@ public class GitUtils { private GitUtils() { - // Config packed git MMAP - WindowCacheConfig config = new WindowCacheConfig(); - config.setPackedGitMMAP(false); - config.install(); - } - - public static void cloneFromGit(@NonNull String repoUrl, @NonNull Path targetPath) - throws GitAPIException { - Assert.hasText(repoUrl, "Repository remote url must not be blank"); - Assert.notNull(targetPath, "Target path must not be null"); - - log.debug("Trying to clone git repo [{}] to [{}]", repoUrl, targetPath); - - // Use try-with-resource-statement - Git git = null; - try { - git = Git.cloneRepository() - .setURI(repoUrl) - .setDirectory(targetPath.toFile()) - .call(); - log.debug("Cloned git repo [{}] successfully", repoUrl); - } finally { - closeQuietly(git); - } } + @Deprecated(since = "1.4.2", forRemoval = true) public static void cloneFromGit(@NonNull String repoUrl, @NonNull Path targetPath, @NonNull String branchName) throws GitAPIException { Assert.hasText(repoUrl, "Repository remote url must not be blank"); Assert.notNull(targetPath, "Target path must not be null"); - Git git = null; - try { - git = Git.cloneRepository() + try ( + Git ignored = Git.cloneRepository() .setURI(repoUrl) .setDirectory(targetPath.toFile()) .setBranchesToClone(Collections.singletonList("refs/heads/" + branchName)) + .setCloneSubmodules(true) .setBranch("refs/heads/" + branchName) - .call(); - } finally { - closeQuietly(git); + .call()) { + // empty block placeholder } } - public static Git openOrInit(Path repoPath) throws IOException, GitAPIException { - Git git; - - try { - git = Git.open(repoPath.toFile()); - } catch (RepositoryNotFoundException e) { - log.warn( - "Git repository may not exist, we will try to initialize an empty repository: [{}]", - e.getMessage()); - git = Git.init().setDirectory(repoPath.toFile()).call(); - } - - return git; - } - - public static List getAllBranches(@NonNull String repoUrl) { + public static List getAllBranchesFromRemote(@NonNull String repoUrl) { List branches = new ArrayList<>(); try { Collection refs = Git.lsRemoteRepository() @@ -106,11 +76,84 @@ public static List getAllBranches(@NonNull String repoUrl) { return branches; } - public static void closeQuietly(Git git) { - if (git != null) { - git.getRepository().close(); - git.close(); + @Nullable + public static Pair getLatestTag(final Git git) + throws GitAPIException, IOException { + final var tags = git.tagList().call(); + if (CollectionUtils.isEmpty(tags)) { + return null; + } + try (final var revWalk = new RevWalk(git.getRepository())) { + revWalk.reset(); + revWalk.setTreeFilter(TreeFilter.ANY_DIFF); + revWalk.sort(RevSort.TOPO, true); + revWalk.sort(RevSort.COMMIT_TIME_DESC, true); + + final var commitTagMap = new HashMap(tags.size()); + + for (final var tag : tags) { + final var commit = revWalk.parseCommit(tag.getObjectId()); + commitTagMap.put(commit, tag); + if (log.isDebugEnabled()) { + log.debug("tag: {} with commit: {} {}", tag.getName(), + commit.getFullMessage(), new Date(commit.getCommitTime() * 1000L)); + } + } + + return commitTagMap.keySet() + .stream() + .max(Comparator.comparing(RevCommit::getCommitTime)) + .map(latestCommit -> Pair.of(commitTagMap.get(latestCommit), latestCommit)) + .orElse(null); + } + } + + public static void removeRemoteIfExists(final Git git, String remote) throws GitAPIException { + final var remoteExists = git.remoteList() + .call() + .stream().map(RemoteConfig::getName) + .anyMatch(name -> name.equals(remote)); + if (remoteExists) { + // remove newRepo remote + final var removedRemoteConfig = git.remoteRemove() + .setRemoteName(remote) + .call(); + log.info("git remote remove {} {}", removedRemoteConfig.getName(), + removedRemoteConfig.getURIs()); + } + } + + public static void logCommit(final RevCommit commit) { + if (commit == null) { + return; } + log.info("Commit result: {} {} {}", + commit.getName(), + commit.getFullMessage(), + new Date(commit.getCommitTime() * 1000L)); } + public static void commitAutomatically(final Git git) throws GitAPIException, IOException { + // git status + if (git.status().call().isClean()) { + final var branch = git.getRepository().getBranch(); + final var fullBranch = git.getRepository().getFullBranch(); + log.info("Current branch {}", branch); + log.info("Your branch is up to date with {}.", fullBranch); + log.info(""); + log.info("nothing to commit, working tree clean"); + return; + } + // git add . + git.add().addFilepattern(".").call(); + log.info("git add ."); + // git commit -m "Committed by halo automatically." + final var commit = git.commit() + .setSign(false) + .setAuthor("halo", "hi@halo.run") + .setMessage("Committed by halo automatically.") + .call(); + log.info("git commit -m \"Committed by halo automatically.\""); + logCommit(commit); + } } diff --git a/src/main/java/run/halo/app/utils/RemoteGitHelper.java b/src/main/java/run/halo/app/utils/RemoteGitHelper.java new file mode 100644 index 0000000000..1585b1e990 --- /dev/null +++ b/src/main/java/run/halo/app/utils/RemoteGitHelper.java @@ -0,0 +1,19 @@ +package run.halo.app.utils; + +/** + * Remote git helper. + * + * @author johnniang + */ +public class RemoteGitHelper { + + /** + * Remote git url. like: https://github.com/halo/halo-dev.git + */ + private final String remoteGitUrl; + + public RemoteGitHelper(String remoteGitUrl) { + this.remoteGitUrl = remoteGitUrl; + } + +} diff --git a/src/test/java/run/halo/app/theme/GitThemeFetcherTest.java b/src/test/java/run/halo/app/theme/GitThemeFetcherTest.java new file mode 100644 index 0000000000..f056141493 --- /dev/null +++ b/src/test/java/run/halo/app/theme/GitThemeFetcherTest.java @@ -0,0 +1,62 @@ +package run.halo.app.theme; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Ref; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import run.halo.app.utils.FileUtils; + +/** + * Git theme fetcher test. + * + * @author johnniang + */ +@Slf4j +class GitThemeFetcherTest { + + GitThemeFetcher gitThemeFetcher; + + @BeforeEach + void setUp() { + this.gitThemeFetcher = new GitThemeFetcher(); + } + + @Test + @Disabled("Time consumption") + void fetchTest() throws IOException, GitAPIException { + final var repo = "https://gitee.com/xzhuz/halo-theme-xue"; + final var property = this.gitThemeFetcher.fetch(repo); + final var themePath = Paths.get(property.getThemePath()); + try (final var git = Git.open(themePath.toFile())) { + final var remoteConfigs = git.remoteList().call(); + assertEquals(1, remoteConfigs.size()); + assertEquals("upstream", remoteConfigs.get(0).getName()); + + List refs = git.branchList().call(); + assertEquals(2, refs.size()); + assertEquals("refs/heads/halo", refs.get(0).getName()); + } + + Path tempDirectory = FileUtils.createTempDirectory(); + // copy repo to temp folder + FileUtils.copyFolder(themePath, tempDirectory); + try (final var git = Git.open(tempDirectory.toFile())) { + final var remoteConfigs = git.remoteList().call(); + assertEquals(1, remoteConfigs.size()); + assertEquals("upstream", remoteConfigs.get(0).getName()); + + List refs = git.branchList().call(); + assertEquals(2, refs.size()); + assertEquals("refs/heads/halo", refs.get(0).getName()); + } + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/ZipThemeFetcherTest.java b/src/test/java/run/halo/app/theme/ZipThemeFetcherTest.java new file mode 100644 index 0000000000..4ec1931146 --- /dev/null +++ b/src/test/java/run/halo/app/theme/ZipThemeFetcherTest.java @@ -0,0 +1,22 @@ +package run.halo.app.theme; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** + * Zip remote theme fetcher test. + * + * @author johnniang + */ +@Slf4j +class ZipThemeFetcherTest { + + @Test + @Disabled("Disabled due to time consumed") + void fetch() { + var themeFetcher = new ZipThemeFetcher(); + var themeProperty = themeFetcher.fetch("https://github.com/halo-dev/halo-theme-hshan/archive/master.zip"); + log.debug("{}", themeProperty); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/utils/FileUtilsTest.java b/src/test/java/run/halo/app/utils/FileUtilsTest.java index 50bab085f0..297cc5a8f8 100644 --- a/src/test/java/run/halo/app/utils/FileUtilsTest.java +++ b/src/test/java/run/halo/app/utils/FileUtilsTest.java @@ -16,7 +16,7 @@ import java.util.stream.Stream; import java.util.zip.ZipOutputStream; import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import run.halo.app.model.support.HaloConst; @@ -30,19 +30,14 @@ class FileUtilsTest { Path tempDirectory = null; - @AfterEach - void cleanUp() throws IOException { - if (tempDirectory != null) { - FileUtils.deleteFolder(tempDirectory); - assertTrue(Files.notExists(tempDirectory)); - } + + @BeforeEach + void setUp() throws IOException { + tempDirectory = FileUtils.createTempDirectory(); } @Test void deleteFolder() throws IOException { - // Create a temp folder - tempDirectory = Files.createTempDirectory("halo-test"); - Path testPath = tempDirectory.resolve("test/test/test"); // Create test folders @@ -75,8 +70,6 @@ void deleteFolder() throws IOException { @Test void zipFolderTest() throws IOException { - // Create some temporary files - tempDirectory = Files.createTempDirectory("zip-root-"); log.debug("Folder name: [{}]", tempDirectory.getFileName()); Files.createTempFile(tempDirectory, "zip-file1-", ".txt"); Files.createTempFile(tempDirectory, "zip-file2-", ".txt"); @@ -118,9 +111,6 @@ void dbFileReadTest() throws IOException { @Test void testRenameFile() throws IOException { - // Create a temp folder - tempDirectory = Files.createTempDirectory("halo-test"); - Path testPath = tempDirectory.resolve("test/test"); Path filePath = tempDirectory.resolve("test/test/test.file"); @@ -143,9 +133,6 @@ void testRenameFile() throws IOException { @Test void testRenameFolder() throws IOException { - // Create a temp folder - tempDirectory = Files.createTempDirectory("halo-test"); - Path testPath = tempDirectory.resolve("test/test"); Path filePath = tempDirectory.resolve("test/test.file"); @@ -163,9 +150,6 @@ void testRenameFolder() throws IOException { @Test void testRenameRepeat() throws IOException { - // Create a temp folder - tempDirectory = Files.createTempDirectory("halo-test"); - Path testPathOne = tempDirectory.resolve("test/testOne"); Path testPathTwo = tempDirectory.resolve("test/testTwo"); Path filePathOne = tempDirectory.resolve("test/testOne.file"); @@ -205,9 +189,6 @@ void findRootPathTest() throws IOException { // file2 // folder3 // expected_file - // expected: folder2 - tempDirectory = Files.createTempDirectory("halo-test"); - log.info("Preparing test folder structure"); Path folder1 = tempDirectory.resolve("folder1"); Files.createDirectory(folder1); @@ -223,11 +204,12 @@ void findRootPathTest() throws IOException { Files.createFile(expectedFile); log.info("Prepared test folder structure"); - // find the root folder where expected file locates, and we expect folder3 - Optional rootPath = FileUtils.findRootPath(tempDirectory, + // find expected_file + final var resultPath = FileUtils.findRootPath(tempDirectory, path -> path.getFileName().toString().equals("expected_file")); - assertTrue(rootPath.isPresent()); - assertEquals(folder3.toString(), rootPath.get().toString()); + assertTrue(resultPath.isPresent()); + log.debug("Got result path: {}", resultPath.get()); + assertEquals(folder3.toString(), resultPath.get().toString()); } @@ -242,9 +224,6 @@ void findRootPathIgnoreTest() throws IOException { // file2 // folder3 // file3 - // expected: folder2 - tempDirectory = Files.createTempDirectory("halo-test"); - log.info("Preparing test folder structure"); Path folder1 = tempDirectory.resolve("folder1"); Files.createDirectory(folder1); @@ -269,4 +248,62 @@ void findRootPathIgnoreTest() throws IOException { path -> path.getFileName().toString().equals("expected_file")); assertFalse(rootPath.isPresent()); } + + @Test + void findRootPathWithLowerMaxDepth() throws IOException { + // build folder structure + // folder1 + // file1 + // folder2 + // file2 + // folder3 + // expected_file + log.info("Preparing test folder structure"); + Path folder1 = tempDirectory.resolve("folder1"); + Files.createDirectory(folder1); + Path file1 = tempDirectory.resolve("file1"); + Files.createFile(file1); + Path folder2 = tempDirectory.resolve("folder2"); + Files.createDirectory(folder2); + Path file2 = folder2.resolve("file2"); + Files.createFile(file2); + Path folder3 = folder2.resolve("folder3"); + Files.createDirectory(folder3); + Path expectedFile = folder3.resolve("expected_file"); + Files.createFile(expectedFile); + log.info("Prepared test folder structure"); + + // find the root folder where expected file locates, and we expect folder3 + var filePathResult = FileUtils.findPath(tempDirectory, 1, + path -> path.getFileName().toString().equals("expected_file")); + assertFalse(filePathResult.isPresent()); + + filePathResult = FileUtils.findPath(tempDirectory, 2, + path -> path.getFileName().toString().equals("expected_file")); + assertFalse(filePathResult.isPresent()); + + filePathResult = FileUtils.findPath(tempDirectory, 3, + path -> path.getFileName().toString().equals("expected_file")); + assertTrue(filePathResult.isPresent()); + assertEquals(expectedFile.toString(), filePathResult.get().toString()); + } + + @Test + void copyHiddenFolder() throws IOException { + // source + // .hidden + // test.txt # contain: test + final var source = tempDirectory.resolve("source"); + Files.createDirectories(source); + var hidden = source.resolve(".git"); + Files.createDirectories(hidden); + var testTxt = hidden.resolve("test.txt"); + Files.writeString(testTxt, "test"); + + final var target = tempDirectory.resolve("target"); + FileUtils.copyFolder(source, target); + + assertTrue(Files.exists(target.resolve(".git"))); + assertEquals("test", Files.readString(target.resolve(".git").resolve("test.txt"))); + } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/utils/GitTest.java b/src/test/java/run/halo/app/utils/GitTest.java index 30e16cb3af..30e8358cdc 100644 --- a/src/test/java/run/halo/app/utils/GitTest.java +++ b/src/test/java/run/halo/app/utils/GitTest.java @@ -2,20 +2,30 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; +import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ListBranchCommand; import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.transport.RemoteConfig; import org.eclipse.jgit.transport.URIish; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -25,7 +35,7 @@ * Git test. * * @author johnniang - * @date 19-5-21 + * @date 2020.01.21 */ @Slf4j class GitTest { @@ -35,11 +45,11 @@ class GitTest { @BeforeEach void setUp() throws IOException { tempPath = Files.createTempDirectory("git-test"); - } - - @AfterEach - void destroy() throws IOException { - FileUtils.deleteFolder(tempPath); + final var thread = new Thread(() -> { + log.info("Clear temporary folder."); + FileUtils.deleteFolderQuietly(tempPath); + }); + Runtime.getRuntime().addShutdownHook(thread); } @Test @@ -88,9 +98,9 @@ void pullTest() throws GitAPIException { @Test @Disabled("Due to time-consumption fetching") - void getAllBranchesTest() { + void getAllBranchesFromRemote() { List branches = - GitUtils.getAllBranches("https://github.com/halo-dev/halo-theme-hux.git"); + GitUtils.getAllBranchesFromRemote("https://github.com/halo-dev/halo-theme-hux.git"); assertNotNull(branches); } @@ -98,14 +108,165 @@ void getAllBranchesTest() { @Disabled("Due to time-consumption fetching") void getAllBranchesWithInvalidURL() { List branches = - GitUtils.getAllBranches("https://github.com/halo-dev/halo-theme.git"); + GitUtils.getAllBranchesFromRemote("https://github.com/halo-dev/halo-theme.git"); assertNotNull(branches); assertEquals(0, branches.size()); } + @Test + void getAllBranchesTest() throws GitAPIException { + try (Git git = Git.init().setDirectory(tempPath.toFile()).call()) { + git.add().addFilepattern(".").call(); + git.commit().setAllowEmpty(true).setSign(false).setMessage("Empty commit").call(); + + git.branchCreate().setName("main").call(); + git.branchCreate().setName("dev").call(); + Set branches = git.branchList() + .call() + .stream() + .map(ref -> { + String refName = ref.getName(); + return refName.substring(refName.lastIndexOf('/') + 1); + }).collect(Collectors.toSet()); + assertTrue(branches.containsAll(Arrays.asList("main", "dev"))); + } + } + + @Test + void getBranchesFromRemote() throws GitAPIException { + Map refMap = Git.lsRemoteRepository() + .setRemote("https://github.com/halo-dev/halo.git") + .setHeads(true) + .setTags(true) + .callAsMap(); + refMap.forEach((name, ref) -> { + log.debug("name: [{}], ref: [{}]", name, ref); + }); + } + + @Test + void mergeTwoLocalRepo() throws GitAPIException, IOException, URISyntaxException { + final var oldRepoPath = tempPath.resolve("old-repo"); + final var newRepoPath = tempPath.resolve("new-repo"); + + // prepare one local repo + try (final var oldGit = Git.init() + .setDirectory(oldRepoPath.toFile()) + .call()) { + + final var testTextInOldRepoPath = oldRepoPath.resolve("test.txt"); + Files.writeString(testTextInOldRepoPath, "hello old git"); + oldGit.add().addFilepattern(".").call(); + oldGit.commit() + .setSign(false) + .setMessage("commit test.txt at old repo") + .call(); + + printAllLog(oldGit); + + // copy old repo path to new repo path + FileUtils.copyFolder(oldRepoPath, newRepoPath); + + try (final var newGit = Git.init() + .setDirectory(newRepoPath.toFile()) + .call()) { + + Files.writeString(newRepoPath.resolve("test.txt"), "hello old git\nhello new git"); + newGit.add().addFilepattern(".").call(); + newGit.commit() + .setSign(false) + .setMessage("commit test.txt at new repo") + .call(); + + final var refs = newGit.branchList() + .setListMode(ListBranchCommand.ListMode.ALL) + .call(); + refs.forEach(ref -> { + log.debug("Ref in new repo: {}", ref); + }); + + printAllLog(newGit); + } + + // add new repo as old repo remote + oldGit.remoteAdd().setName("newRepo") + .setUri(new URIish(newRepoPath.toString())) + .call(); + + oldGit.fetch() + .setRemote("newRepo") + .call(); + + final var refs = oldGit.branchList() + .setListMode(ListBranchCommand.ListMode.ALL) + .call(); + + refs.forEach(ref -> log.debug("Ref in old repo: {}", ref)); + + final var testTextInOldRepo = Files.readString(testTextInOldRepoPath); + Assertions.assertEquals("hello old git", testTextInOldRepo); + + final var rebaseResult = oldGit.rebase() + .setUpstream("newRepo/master") + .setStrategy(MergeStrategy.THEIRS) + .call(); + + log.debug("{} | {} | {}", rebaseResult.getCurrentCommit(), rebaseResult.getConflicts(), + rebaseResult.getStatus()); + assertTrue(rebaseResult.getStatus().isSuccessful()); + + final var testTextAfterRebase = Files.readString(testTextInOldRepoPath); + Assertions.assertEquals("hello old git\nhello new git", testTextAfterRebase); + + printAllLog(oldGit); + } + } + + @Test + @Disabled("Time consume") + void findTags() throws GitAPIException, IOException { + Git.lsRemoteRepository() + .setRemote("https://gitee.com/xzhuz/halo-theme-xue.git") + .setTags(true) + .setHeads(false) + .call() + .forEach(ref -> { + log.info("ref: {}, object id: {}", ref.getName(), ref.getObjectId()); + }); + + try (final var git = cloneRepository("https://gitee.com/xzhuz/halo-theme-xue.git")) { + git.branchList() + .setListMode(ListBranchCommand.ListMode.ALL) + .call() + .forEach( + ref -> log.debug("ref: {}, object id: {}", ref.getName(), ref.getObjectId())); + + Pair latestTagPair = GitUtils.getLatestTag(git); + assertNotNull(latestTagPair); + Ref latestTag = latestTagPair.getKey(); + RevCommit tagCommit = latestTagPair.getValue(); + + log.debug("Latest tag: {} with commit: {} {}", + latestTag.getName(), + tagCommit.getFullMessage(), + new Date(tagCommit.getCommitTime() * 1000L)); + } + } + + void printAllLog(Git git) throws IOException, GitAPIException { + var commits = git.log().all().call(); + for (var commit : commits) { + log.debug("{}: {} {}", git.toString(), commit, commit.getFullMessage()); + } + } + Git cloneRepository() throws GitAPIException { + return cloneRepository("https://github.com/halo-dev/halo-theme-pinghsu.git"); + } + + Git cloneRepository(String url) throws GitAPIException { return Git.cloneRepository() - .setURI("https://github.com/halo-dev/halo-theme-pinghsu.git") + .setURI(url) .setDirectory(tempPath.toFile()) .call(); }