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
pull/1215/head^2
John Niang 2021-01-27 00:16:31 +08:00 committed by GitHub
parent eaa3a80358
commit 30c9baf92b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1717 additions and 670 deletions

View File

@ -172,4 +172,5 @@ dependencies {
test {
useJUnitPlatform()
testLogging.showStandardStreams = true
}

View File

@ -14,7 +14,10 @@ import java.util.List;
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.CacheControl;
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 @@ public class HaloMvcConfiguration implements WebMvcConfigurer {
@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());

View File

@ -177,6 +177,7 @@ public class ThemeController {
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 class ThemeController {
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<ThemeProperty> fetchBranches(@RequestParam("uri") String uri) {
return themeService.fetchBranches(uri);
}
@PostMapping("fetchingReleases")
@ApiOperation("Fetches all releases")
@Deprecated(since = "1.4.2", forRemoval = true)
public List<ThemeProperty> 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 class ThemeController {
@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 class ThemeController {
@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);
}

View File

@ -31,11 +31,15 @@ import run.halo.app.utils.JsonUtils;
@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();

View File

@ -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);
}
}

View File

@ -10,7 +10,6 @@ import java.nio.file.Path;
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 @@ public class LocalFileHandler implements FileHandler {
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

View File

@ -8,6 +8,7 @@ import lombok.Data;
* 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 class ThemeProperty {
return Objects.hash(id);
}
/**
* Theme author info.
*
* @author johnniang
*/
@Data
private static class Author {
public static class Author {
/**
* Author name.
@ -133,4 +144,22 @@ public class ThemeProperty {
*/
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;
}
}

View File

@ -13,6 +13,8 @@ import java.sql.DatabaseMetaData;
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.AnsiColor;
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 @@ import run.halo.app.utils.FileUtils;
* @date 2018-12-05
*/
@Slf4j
@Configuration
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class StartedListener implements ApplicationListener<ApplicationStartedEvent> {
@ -71,9 +73,19 @@ public class StartedListener implements ApplicationListener<ApplicationStartedEv
} 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() {

View File

@ -4,6 +4,7 @@ import java.io.UnsupportedEncodingException;
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 @@ import run.halo.app.service.OptionService;
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 abstract class AbstractMailService implements MailService {
return executorService;
}
public void setExecutorService(ExecutorService executorService) {
public void setExecutorService(@Nullable ExecutorService executorService) {
this.executorService = executorService;
}
@ -72,7 +78,7 @@ public abstract class AbstractMailService implements MailService {
*
* @param callback mime message callback.
*/
protected void sendMailTemplate(@Nullable Callback callback) {
protected void sendMailTemplate(@Nullable Consumer<MimeMessageHelper> callback) {
if (callback == null) {
log.info("Callback is null, skip to send email");
return;
@ -101,7 +107,7 @@ public abstract class AbstractMailService implements MailService {
// 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 @@ public abstract class AbstractMailService implements MailService {
* @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<MimeMessageHelper> callback) {
ExecutorService executorService = getExecutorService();
if (tryToAsync && executorService != null) {
if (tryToAsync) {
// send mail asynchronously
executorService.execute(() -> sendMailTemplate(callback));
} else {
@ -233,16 +240,4 @@ public abstract class AbstractMailService implements MailService {
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;
}
}

View File

@ -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 class MailServiceImpl extends AbstractMailService
@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 class MailServiceImpl extends AbstractMailService
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 class MailServiceImpl extends AbstractMailService
public void sendAttachMail(String to, String subject, Map<String, Object> 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 class MailServiceImpl extends AbstractMailService
super.testConnection();
}
@Override
public void onApplicationEvent(OptionUpdatedEvent event) {
public void onApplicationEvent(@NonNull OptionUpdatedEvent event) {
// clear the cached java mail sender
clearCache();
}

View File

@ -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<ThemeProperty> fetchThemePropertyByThemeId(String themeId);
/**
* List all themes
*
* @return theme list
*/
List<ThemeProperty> 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);
}

View File

@ -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<ThemeProperty> fetchThemePropertyByThemeId(String themeId) {
return listAll().stream()
.filter(property -> Objects.equals(themeId, property.getId()))
.findFirst();
}
@Override
public List<ThemeProperty> 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");
}
}

View File

@ -112,7 +112,6 @@ public interface ThemeService {
* @return theme property
*/
@NonNull
@Deprecated
ThemeProperty getThemeOfNonNullBy(@NonNull String themeId);
/**
@ -305,6 +304,7 @@ public interface ThemeService {
* @throws IOException IOException
*/
@NonNull
@Deprecated
ThemeProperty add(@NonNull Path themeTmpPath) throws IOException;
/**
@ -323,6 +323,7 @@ public interface ThemeService {
* @return theme property
*/
@NonNull
@Deprecated(since = "1.4.2", forRemoval = true)
ThemeProperty fetchLatestRelease(@NonNull String uri);
/**
@ -332,6 +333,7 @@ public interface ThemeService {
* @return list of theme properties
*/
@NonNull
@Deprecated(since = "1.4.2", forRemoval = true)
List<ThemeProperty> fetchBranches(@NonNull String uri);
/**
@ -341,6 +343,7 @@ public interface ThemeService {
* @return list of theme properties
*/
@NonNull
@Deprecated(since = "1.4.2", forRemoval = true)
List<ThemeProperty> fetchReleases(@NonNull String uri);
/**
@ -351,6 +354,7 @@ public interface ThemeService {
* @return theme property
*/
@NonNull
@Deprecated(since = "1.4.2", forRemoval = true)
ThemeProperty fetchRelease(@NonNull String uri, @NonNull String tagName);
/**
@ -361,6 +365,7 @@ public interface ThemeService {
* @return theme property
*/
@NonNull
@Deprecated(since = "1.4.2", forRemoval = true)
ThemeProperty fetchBranch(@NonNull String uri, @NonNull String branchName);
/**

View File

@ -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<HttpTrace> listHttpTraces();
//
//}

View File

@ -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.ServiceException;
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<ThemeProperty> fetchThemePropertyBy(String themeId) {
if (StringUtils.isBlank(themeId)) {
return Optional.empty();
}
// Get all themes
List<ThemeProperty> 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<ThemeProperty> getThemes() {
ThemeProperty[] themeProperties =
cacheStore.getAny(THEMES_CACHE_KEY, ThemeProperty[].class).orElseGet(() -> {
List<ThemeProperty> 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 class ThemeServiceImpl implements ThemeService {
// Get the theme path
Path themePath = Paths.get(themeProperty.getThemePath());
try (Stream<Path> 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 class ThemeServiceImpl implements ThemeService {
// 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 class ThemeServiceImpl implements ThemeService {
// 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 class ThemeServiceImpl implements ThemeService {
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 class ThemeServiceImpl implements ThemeService {
}
// 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 class ThemeServiceImpl implements ThemeService {
@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 class ThemeServiceImpl implements ThemeService {
@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 class ThemeServiceImpl implements ThemeService {
@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 class ThemeServiceImpl implements ThemeService {
// Publish a theme activated event
eventPublisher.publishEvent(new ThemeActivatedEvent(this));
return themeProperty;
return themeRepository.getActivatedThemeProperty();
}
@Override
@ -440,54 +366,23 @@ public class ThemeServiceImpl implements ThemeService {
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<Path> pathStream = Files.list(themeTmpPath)) {
pathStream.forEach(path -> log.debug(path.toString()));
try (Stream<Path> pathStream = Files.list(themeTmpPath)) {
pathStream.forEach(path -> log.trace(path.toString()));
}
}
// Check property config
@ -495,8 +390,8 @@ public class ThemeServiceImpl implements ThemeService {
// 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 class ThemeServiceImpl implements ThemeService {
// 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 class ThemeServiceImpl implements ThemeService {
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 class ThemeServiceImpl implements ThemeService {
} catch (IOException | GitAPIException e) {
throw new ServiceException("主题拉取失败 " + uri + "。" + e.getMessage(), e);
} finally {
FileUtils.deleteFolderQuietly(tmpPath);
deleteFolderQuietly(tmpPath);
}
}
@ -602,7 +477,7 @@ public class ThemeServiceImpl implements ThemeService {
} catch (IOException e) {
throw new ServiceException("主题拉取失败 " + uri, e);
} finally {
FileUtils.deleteFolderQuietly(tmpPath);
deleteFolderQuietly(tmpPath);
}
}
@ -610,28 +485,14 @@ public class ThemeServiceImpl implements ThemeService {
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<String, Object> 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<String, Object> 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 class ThemeServiceImpl implements ThemeService {
Assert.hasText(uri, "Theme remote uri must not be blank");
String repoUrl = StringUtils.appendIfMissingIgnoreCase(uri, ".git", ".git");
List<String> branches = GitUtils.getAllBranches(repoUrl);
List<String> branches = GitUtils.getAllBranchesFromRemote(repoUrl);
List<ThemeProperty> themeProperties = new ArrayList<>();
@ -680,22 +541,16 @@ public class ThemeServiceImpl implements ThemeService {
@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,157 +561,17 @@ public class ThemeServiceImpl implements ThemeService {
@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);
throw new ServiceException("更新主题失败:" + e.getMessage(), e);
}
}
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);
}
}
/**
* Downloads zip file and unzip it into specified path.
*
@ -875,8 +590,10 @@ public class ThemeServiceImpl implements ThemeService {
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 @@ public class ThemeServiceImpl implements ThemeService {
* @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.ymltheme.yaml。"));
path -> StringUtils.equalsAny(path.getFileName().toString(),
"theme.yaml", "theme.yml"))
.orElseThrow(() ->
new BadRequestException("无法准确定位到主题根目录,请确认主题目录中包含 theme.ymltheme.yaml。"));
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}

View File

@ -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<ThemeFetcher> 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<ThemeFetcher> 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<ThemeFetcher> getThemeFetcher(Object source) {
return themeFetchers.stream()
.filter(fetcher -> fetcher.support(source))
.findFirst();
}
}

View File

@ -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<Path> locateThemeRoot(@NonNull Path path) {
return locateProperty(path).map(Path::getParent);
}
/**
* Locate theme property path.
*
* @return theme property path or empty
*/
@NonNull
public Optional<Path> locateProperty(@NonNull Path path) {
try {
var predicate = ((Predicate<Path>)
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<Path> locateSetting(@NonNull Path path) {
return locateThemeRoot(path).flatMap(root -> {
try {
var predicate = ((Predicate<Path>)
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<Path> locateScreenshot(@NonNull Path path) {
return locateThemeRoot(path).flatMap(root -> {
try (var pathStream = Files.list(root)) {
var predicate = ((Predicate<Path>) 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();
});
}
}

View File

@ -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 org.springframework.util.CollectionUtils;
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 enum ThemePropertyScanner {
/**
* 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<ThemeProperty> fetchThemeProperty(@NonNull Path themePath) {
Assert.notNull(themePath, "Theme path must not be null");
public Optional<ThemeProperty> fetchThemeProperty(@NonNull Path themeRootPath) {
Assert.notNull(themeRootPath, "Theme path must not be null");
Optional<Path> optionalPath = fetchPropertyPath(themePath);
return ThemeMetaLocator.INSTANCE.locateProperty(themeRootPath).map(propertyPath -> {
final var rootPath = propertyPath.getParent();
try {
// Get property content
final var propertyContent = Files.readString(propertyPath);
if (!optionalPath.isPresent()) {
return Optional.empty();
}
// Resolve the base properties
final var themeProperty = propertyResolver.resolve(propertyContent);
Path propertyPath = optionalPath.get();
// Resolve additional properties
themeProperty.setThemePath(rootPath.toString());
themeProperty.setFolderName(rootPath.getFileName().toString());
themeProperty.setHasOptions(hasOptions(rootPath));
themeProperty.setActivated(false);
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<String> getScreenshotsFileName(@NonNull Path themePath) throws IOException {
Assert.notNull(themePath, "Theme path must not be null");
try (Stream<Path> 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<Path> 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);
// 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 @@ public enum ThemePropertyScanner {
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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -13,7 +13,6 @@ import java.nio.file.Paths;
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 @@ import run.halo.app.exception.ForbiddenException;
@Slf4j
public class FileUtils {
/**
* Ignored folders while finding root path.
*/
private static final List<String> IGNORED_FOLDERS = Arrays.asList(".git");
private FileUtils() {
}
@ -57,12 +51,12 @@ public class FileUtils {
Assert.notNull(source, "Source path must not be null");
Assert.notNull(target, "Target path must not be null");
Files.walkFileTree(source, new SimpleFileVisitor<Path>() {
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 class FileUtils {
@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 @@ public class FileUtils {
*/
@NonNull
public static Optional<Path> findRootPath(@NonNull final Path path,
@Nullable final Predicate<Path> pathPredicate) throws IOException {
@Nullable final Predicate<Path> 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<Path> findRootPath(@NonNull final Path path,
int maxDepth,
@Nullable final Predicate<Path> 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<Path> findPath(@NonNull final Path path,
@Nullable final Predicate<Path> 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<Path> findPath(@NonNull final Path path,
int maxDepth,
@Nullable final Predicate<Path> 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<Path> queue = new LinkedList<>();
final var queue = new LinkedList<Path>();
// depth container
final var depthQueue = new LinkedList<Integer>();
// 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<Path> childrenPaths = Files.list(rootPath)) {
List<Path> subFolders = new LinkedList<>();
Optional<Path> matchedPath = childrenPaths.peek(child -> {
if (Files.isDirectory(child)) {
// collect directory
subFolders.add(child);
final var subFolders = new LinkedList<Path>();
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 class FileUtils {
FileUtils.deleteFolder(deletingPath);
}
} catch (IOException e) {
log.warn("Failed to delete " + deletingPath);
log.warn("Failed to delete {}", deletingPath);
}
}
@ -440,7 +507,9 @@ public class FileUtils {
*/
@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;
}
}

View File

@ -5,17 +5,26 @@ import java.nio.file.Path;
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 @@ import org.springframework.util.Assert;
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<String> getAllBranches(@NonNull String repoUrl) {
public static List<String> getAllBranchesFromRemote(@NonNull String repoUrl) {
List<String> branches = new ArrayList<>();
try {
Collection<Ref> refs = Git.lsRemoteRepository()
@ -106,11 +76,84 @@ public class GitUtils {
return branches;
}
public static void closeQuietly(Git git) {
if (git != null) {
git.getRepository().close();
git.close();
@Nullable
public static Pair<Ref, RevCommit> 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<RevCommit, Ref>(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);
}
}

View File

@ -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;
}
}

View File

@ -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<Ref> 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<Ref> refs = git.branchList().call();
assertEquals(2, refs.size());
assertEquals("refs/heads/halo", refs.get(0).getName());
}
}
}

View File

@ -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);
}
}

View File

@ -16,7 +16,7 @@ import java.util.stream.Collectors;
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 @@ class FileUtilsTest {
@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 @@ class FileUtilsTest {
@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 @@ class FileUtilsTest {
@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 @@ class FileUtilsTest {
@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 @@ class FileUtilsTest {
// 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 @@ class FileUtilsTest {
Files.createFile(expectedFile);
log.info("Prepared test folder structure");
// find the root folder where expected file locates, and we expect folder3
Optional<Path> 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 @@ class FileUtilsTest {
// 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 @@ class FileUtilsTest {
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")));
}
}

View File

@ -2,20 +2,30 @@ package run.halo.app.utils;
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 @@ import org.junit.jupiter.api.Test;
* 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 @@ class GitTest {
@Test
@Disabled("Due to time-consumption fetching")
void getAllBranchesTest() {
void getAllBranchesFromRemote() {
List<String> 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 @@ class GitTest {
@Disabled("Due to time-consumption fetching")
void getAllBranchesWithInvalidURL() {
List<String> 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<String> 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<String, Ref> 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<Ref, RevCommit> 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();
}