diff --git a/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java b/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java index 6f26bcd2e..a8080611e 100644 --- a/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java +++ b/src/main/java/run/halo/app/config/WebMvcAutoConfiguration.java @@ -31,6 +31,8 @@ import java.io.IOException; import java.util.List; import java.util.Properties; +import static run.halo.app.model.support.HaloConst.HALO_ADMIN_RELATIVE_PATH; + /** * Mvc configuration. * @@ -93,7 +95,7 @@ public class WebMvcAutoConfiguration implements WebMvcConfigurer { registry.addResourceHandler("/backup/**") .addResourceLocations(workDir + "backup/"); registry.addResourceHandler("/admin/**") - .addResourceLocations(workDir + "templates/admin/") + .addResourceLocations(workDir + HALO_ADMIN_RELATIVE_PATH) .addResourceLocations("classpath:/admin/"); if (!haloProperties.isDocDisabled()) { @@ -122,7 +124,7 @@ public class WebMvcAutoConfiguration implements WebMvcConfigurer { configurer.setDefaultEncoding("UTF-8"); Properties properties = new Properties(); - properties.setProperty("auto_import","/common/macro/common_macro.ftl as common"); + properties.setProperty("auto_import", "/common/macro/common_macro.ftl as common"); configurer.setFreemarkerSettings(properties); diff --git a/src/main/java/run/halo/app/controller/admin/api/AdminController.java b/src/main/java/run/halo/app/controller/admin/api/AdminController.java index b8906642e..b742f82c3 100644 --- a/src/main/java/run/halo/app/controller/admin/api/AdminController.java +++ b/src/main/java/run/halo/app/controller/admin/api/AdminController.java @@ -67,4 +67,10 @@ public class AdminController { return adminService.refreshToken(refreshToken); } + @PutMapping("halo-admin") + @ApiOperation("Updates halo-admin manually") + public void updateAdmin() { + adminService.updateAdminAssets(); + } + } diff --git a/src/main/java/run/halo/app/model/support/HaloConst.java b/src/main/java/run/halo/app/model/support/HaloConst.java index 6455ea3aa..b86a3cdf5 100644 --- a/src/main/java/run/halo/app/model/support/HaloConst.java +++ b/src/main/java/run/halo/app/model/support/HaloConst.java @@ -82,6 +82,20 @@ public class HaloConst { */ public static String USER_SESSION_KEY = "user_session"; + /** + * Github Api url for halo-admin release. + */ + public final static String HALO_ADMIN_RELEASES_LATEST = "https://api.github.com/repos/halo-dev/halo-admin/releases/latest"; + + /** + * Halo admin version regex. + */ + public final static String HALO_ADMIN_VERSION_REGEX = "halo-admin-\\d+\\.\\d+(\\.\\d+)?(-\\S*)?\\.zip"; + + public final static String HALO_ADMIN_RELATIVE_PATH = "templates/admin/"; + + public final static String HALO_ADMIN_RELATIVE_BACKUP_PATH = "templates/admin-backup/"; + static { // Set version HALO_VERSION = HaloConst.class.getPackage().getImplementationVersion(); diff --git a/src/main/java/run/halo/app/service/AdminService.java b/src/main/java/run/halo/app/service/AdminService.java index 8762b4699..6b60a9092 100644 --- a/src/main/java/run/halo/app/service/AdminService.java +++ b/src/main/java/run/halo/app/service/AdminService.java @@ -64,4 +64,9 @@ public interface AdminService { */ @NonNull AuthToken refreshToken(@NonNull String refreshToken); + + /** + * Updates halo admin assets. + */ + void updateAdminAssets(); } diff --git a/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java b/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java index 51b989f9b..8f8c5516a 100644 --- a/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/AdminServiceImpl.java @@ -2,15 +2,17 @@ package run.halo.app.service.impl; import cn.hutool.core.lang.Validator; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.ResponseEntity; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.util.Assert; +import org.springframework.web.client.RestTemplate; import run.halo.app.cache.StringCacheStore; +import run.halo.app.config.properties.HaloProperties; import run.halo.app.exception.BadRequestException; import run.halo.app.exception.NotFoundException; +import run.halo.app.exception.ServiceException; import run.halo.app.model.dto.EnvironmentDTO; import run.halo.app.model.dto.StatisticDTO; import run.halo.app.model.entity.User; @@ -24,11 +26,20 @@ import run.halo.app.security.context.SecurityContextHolder; import run.halo.app.security.token.AuthToken; import run.halo.app.security.util.SecurityUtils; import run.halo.app.service.*; +import run.halo.app.utils.FileUtils; import run.halo.app.utils.HaloUtils; +import java.io.IOException; import java.lang.management.ManagementFactory; import java.lang.management.RuntimeMXBean; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +import static run.halo.app.model.support.HaloConst.*; /** * Admin service implementation. @@ -61,7 +72,9 @@ public class AdminServiceImpl implements AdminService { private final StringCacheStore cacheStore; - private final ApplicationEventPublisher eventPublisher; + private final RestTemplate restTemplate; + + private final HaloProperties haloProperties; private final String driverClassName; @@ -77,7 +90,8 @@ public class AdminServiceImpl implements AdminService { UserService userService, LinkService linkService, StringCacheStore cacheStore, - ApplicationEventPublisher eventPublisher, + RestTemplate restTemplate, + HaloProperties haloProperties, @Value("${spring.datasource.driver-class-name}") String driverClassName, @Value("${spring.profiles.active:prod}") String mode) { this.postService = postService; @@ -90,7 +104,8 @@ public class AdminServiceImpl implements AdminService { this.userService = userService; this.linkService = linkService; this.cacheStore = cacheStore; - this.eventPublisher = eventPublisher; + this.restTemplate = restTemplate; + this.haloProperties = haloProperties; this.driverClassName = driverClassName; this.mode = mode; } @@ -220,6 +235,99 @@ public class AdminServiceImpl implements AdminService { return buildAuthToken(user); } + @Override + @SuppressWarnings("unchecked") + public void updateAdminAssets() { + // Request github api + ResponseEntity responseEntity = restTemplate.getForEntity(HaloConst.HALO_ADMIN_RELEASES_LATEST, Map.class); + + if (responseEntity == null || + responseEntity.getStatusCode().isError() || + responseEntity.getBody() == null) { + throw new ServiceException("Failed to request remote url: " + HALO_ADMIN_RELEASES_LATEST).setErrorData(HALO_ADMIN_RELEASES_LATEST); + } + + Object assetsObject = responseEntity.getBody().get("assets"); + + if (assetsObject instanceof List) { + try { + List assets = (List) assetsObject; + Map assetMap = (Map) assets.stream() + .filter(assetPredicate()) + .findFirst() + .orElseThrow(() -> new ServiceException("Halo admin 最新版暂无资源文件,请稍后再试")); + + Object browserDownloadUrl = assetMap.getOrDefault("browser_download_url", ""); + // Download the assets + ResponseEntity downloadResponseEntity = restTemplate.getForEntity(browserDownloadUrl.toString(), byte[].class); + + if (downloadResponseEntity == null || + downloadResponseEntity.getStatusCode().isError() || + downloadResponseEntity.getBody() == null) { + throw new ServiceException("Failed to request remote url: " + browserDownloadUrl.toString()).setErrorData(browserDownloadUrl.toString()); + } + + String adminTargetName = haloProperties.getWorkDir() + HALO_ADMIN_RELATIVE_PATH; + + Path adminPath = Paths.get(adminTargetName); + Path adminBackupPath = Paths.get(haloProperties.getWorkDir(), HALO_ADMIN_RELATIVE_BACKUP_PATH); + + backupAndClearAdminAssetsIfPresent(adminPath, adminBackupPath); + + // Create temp folder + Path assetTempPath = FileUtils.createTempDirectory() + .resolve(assetMap.getOrDefault("name", "halo-admin-latest.zip").toString()); + + // Unzip + FileUtils.unzip(downloadResponseEntity.getBody(), assetTempPath); + + // Copy it to template/admin folder + FileUtils.copyFolder(FileUtils.tryToSkipZipParentFolder(assetTempPath), adminPath); + } catch (Throwable t) { + throw new ServiceException(t.getMessage(), t); + } + } else { + throw new ServiceException("Github response error").setErrorData(assetsObject); + } + } + + @NonNull + @SuppressWarnings("unchecked") + private Predicate assetPredicate() { + return asset -> { + if (!(asset instanceof Map)) { + return false; + } + Map aAssetMap = (Map) asset; + // Get content-type + String contentType = aAssetMap.getOrDefault("content_type", "").toString(); + + Object name = aAssetMap.getOrDefault("name", ""); + return name.toString().matches(HALO_ADMIN_VERSION_REGEX) && contentType.equalsIgnoreCase("application/zip"); + }; + } + + private void backupAndClearAdminAssetsIfPresent(@NonNull Path sourcePath, @NonNull Path backupPath) throws IOException { + Assert.notNull(sourcePath, "Source path must not be null"); + Assert.notNull(backupPath, "Backup path must not be null"); + + if (!FileUtils.isEmpty(sourcePath)) { + // Clone this assets + Path adminPathBackup = Paths.get(haloProperties.getWorkDir(), HALO_ADMIN_RELATIVE_BACKUP_PATH); + + // Delete backup + FileUtils.deleteFolder(backupPath); + + // Copy older assets into backup + FileUtils.copyFolder(sourcePath, backupPath); + + // Delete older assets + FileUtils.deleteFolder(sourcePath); + } else { + FileUtils.createIfAbsent(sourcePath); + } + } + /** * Builds authentication token. * diff --git a/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java b/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java index 26b0e39c8..6ec4673ff 100644 --- a/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java +++ b/src/main/java/run/halo/app/service/impl/ThemeServiceImpl.java @@ -36,8 +36,6 @@ import run.halo.app.utils.FilenameUtils; import run.halo.app.utils.GitUtils; import run.halo.app.utils.HaloUtils; -import java.io.ByteArrayInputStream; -import java.io.File; import java.io.IOException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -378,7 +376,7 @@ public class ThemeServiceImpl implements ThemeService { try { // Create temp directory - tempPath = createTempPath(); + tempPath = FileUtils.createTempDirectory(); String basename = FilenameUtils.getBasename(file.getOriginalFilename()); Path themeTempPath = tempPath.resolve(basename); @@ -392,7 +390,7 @@ public class ThemeServiceImpl implements ThemeService { FileUtils.unzip(zis, themeTempPath); // Go to the base folder and add the theme into system - return add(FileUtils.skipZipParentFolder(themeTempPath)); + return add(FileUtils.tryToSkipZipParentFolder(themeTempPath)); } catch (IOException e) { throw new ServiceException("上传主题失败: " + file.getOriginalFilename(), e); } finally { @@ -447,7 +445,7 @@ public class ThemeServiceImpl implements ThemeService { try { // Create temp path - tmpPath = createTempPath(); + tmpPath = FileUtils.createTempDirectory(); // Create temp path Path themeTmpPath = tmpPath.resolve(HaloUtils.randomUUIDWithoutDash()); @@ -570,22 +568,8 @@ public class ThemeServiceImpl implements ThemeService { log.debug("Downloaded [{}]", zipUrl); - // New zip input stream - ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(downloadResponse.getBody())); - // Unzip it - FileUtils.unzip(zis, targetPath); - } - - /** - * Creates temporary path. - * - * @return temporary path - * @throws IOException if an I/O error occurs or the temporary-file directory does not exist - */ - @NonNull - private Path createTempPath() throws IOException { - return Files.createTempDirectory("halo"); + FileUtils.unzip(downloadResponse.getBody(), targetPath); } /** diff --git a/src/main/java/run/halo/app/utils/FileUtils.java b/src/main/java/run/halo/app/utils/FileUtils.java index 1547d94f0..b03d29452 100644 --- a/src/main/java/run/halo/app/utils/FileUtils.java +++ b/src/main/java/run/halo/app/utils/FileUtils.java @@ -6,6 +6,7 @@ import org.springframework.lang.Nullable; import org.springframework.util.Assert; import run.halo.app.exception.ForbiddenException; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.*; @@ -88,7 +89,7 @@ public class FileUtils { } /** - * Unzip content to the target path. + * Unzips content to the target path. * * @param zis zip input stream must not be null * @param targetPath target path must not be null and not empty @@ -125,14 +126,29 @@ public class FileUtils { } } + /** - * Skips zip parent folder. (Go into base folder) + * Unzips content to the target path. + * + * @param bytes zip bytes array must not be null + * @param targetPath target path must not be null and not empty + * @throws IOException + */ + public static void unzip(@NonNull byte[] bytes, @NonNull Path targetPath) throws IOException { + Assert.notNull(bytes, "Zip bytes must not be null"); + + ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(bytes)); + unzip(zis, targetPath); + } + + /** + * Try to skip zip parent folder. (Go into base folder) * * @param unzippedPath unzipped path must not be null * @return path containing base files * @throws IOException */ - public static Path skipZipParentFolder(@NonNull Path unzippedPath) throws IOException { + public static Path tryToSkipZipParentFolder(@NonNull Path unzippedPath) throws IOException { Assert.notNull(unzippedPath, "Unzipped folder must not be null"); // TODO May cause a latent problem. @@ -173,6 +189,10 @@ public class FileUtils { public static boolean isEmpty(@NonNull Path path) throws IOException { Assert.notNull(path, "Path must not be null"); + if (!Files.isDirectory(path) || Files.notExists(path)) { + return true; + } + try (Stream pathStream = Files.list(path)) { return pathStream.count() == 0; } @@ -275,4 +295,15 @@ public class FileUtils { } } + + /** + * Creates temp directory. + * + * @return temp directory path + * @throws IOException if an I/O error occurs or the temporary-file directory does not exist + */ + @NonNull + public static Path createTempDirectory() throws IOException { + return Files.createTempDirectory("halo"); + } } diff --git a/src/main/java/run/halo/app/utils/HaloUtils.java b/src/main/java/run/halo/app/utils/HaloUtils.java index 37354f7c6..5aeb6a35d 100755 --- a/src/main/java/run/halo/app/utils/HaloUtils.java +++ b/src/main/java/run/halo/app/utils/HaloUtils.java @@ -8,6 +8,7 @@ import org.springframework.util.Assert; import java.net.InetAddress; import java.net.UnknownHostException; +import java.nio.file.Path; import java.util.UUID; import static run.halo.app.model.support.HaloConst.FILE_SEPARATOR; diff --git a/src/test/java/run/halo/app/utils/GithubTest.java b/src/test/java/run/halo/app/utils/GithubTest.java index cdb33cab4..983041f83 100644 --- a/src/test/java/run/halo/app/utils/GithubTest.java +++ b/src/test/java/run/halo/app/utils/GithubTest.java @@ -41,9 +41,10 @@ public class GithubTest { } @Test + @SuppressWarnings("unchecked") public void getLatestReleaseTest() throws Throwable { ResponseEntity responseEntity = restTemplate.getForEntity(API_URL, Map.class); - System.out.println("Reponse: " + responseEntity); + System.out.println("Response: " + responseEntity); Object assetsObject = responseEntity.getBody().get("assets"); System.out.println("Assets class: " + assetsObject.getClass()); System.out.println("Assets: " + assetsObject);