# Halo 附件支持 GitHub
Halo 附件支持很多,但我还是喜欢用 GitHub 存储,可能是贫穷使我奋斗吧,用其他云商总会怕各种意外,如价格上涨,跑路,感觉图片没有存在一个自己能掌握的地方,但不能否认的是它们提供的各种图片增值服务还是很给力的,如 cdn、在线图片压缩、在线格式转换等功能。
# 前言
本次改动源码:
后端 halo-dev/halo:GitHub 链接地址
前端 halo-dev/console:GitHub 链接地址
界面效果
# 新增 GitHub 类型的附件
后端 halo AttachmentType 类新增枚举 GITHUB (9)
/** | |
* GitHub 图床 | |
*/ | |
GITHUB(9); |
# 新增 GitHub 参数映射类
在 run.halo.app.model.properties 目录下新增 GitHubProperties 类
package run.halo.app.model.properties; | |
/** | |
* GitHub properties. | |
* | |
* @author XiRiZhi | |
* @date 2022-14-11 | |
*/ | |
public enum GitHubProperties implements PropertyEnum { | |
/** | |
* GitHub owner | |
*/ | |
GITHUB_OWNER("github_api_owner", String.class, ""), | |
/** | |
* GitHub repo | |
*/ | |
GITHUB_REPO("github_api_repo", String.class, ""), | |
/** | |
* GitHub branch | |
*/ | |
GITHUB_BRANCH("github_api_branch", String.class, ""), | |
/** | |
* GitHub repo path | |
*/ | |
GITHUB_PATH("github_api_path", String.class, ""), | |
/** | |
* GitHub secret token | |
*/ | |
GITHUB_SECRET_TOKEN("github_api_secret_token", String.class, ""), | |
/** | |
* GitHub commit email | |
*/ | |
GITHUB_SECRET_COMMIT_EMAIL("github_api_commit_email", String.class, "[email protected]"), | |
/** | |
* GitHub cloudflare url | |
*/ | |
GITHUB_SECRET_CLOUDFLARE_URL("github_api_cloudflare_url", String.class, "https://gcore.jsdelivr.net"), | |
/** | |
* GitHub compression enable | |
*/ | |
GITHUB_API_COMPRESSION_ENABLE("github_api_compression_enable", Boolean.class, "false"), | |
/** | |
* webp enable | |
*/ | |
GITHUB_API_WEBP_ENABLE("github_api_webp_enable", Boolean.class, "false"); | |
private final String value; | |
private final Class<?> type; | |
private final String defaultValue; | |
GitHubProperties(String value, Class<?> type, String defaultValue) { | |
this.defaultValue = defaultValue; | |
if (!PropertyEnum.isSupportedType(type)) { | |
throw new IllegalArgumentException("Unsupported blog property type: " + type); | |
} | |
this.value = value; | |
this.type = type; | |
} | |
@Override | |
public String getValue() { | |
return value; | |
} | |
@Override | |
public Class<?> getType() { | |
return type; | |
} | |
@Override | |
public String defaultValue() { | |
return defaultValue; | |
} | |
} |
# 新增返回结果处理
修改 run.halo.app.model.properties.PropertyEnum 接口类 getValuePropertyEnumMap 方法
基本在 162 行新增如下
propertyEnumClasses.add(GitHubProperties.class); |
这个的作用是用于返回结果时处理 Boolean 类型的
# 新增两个 SDK 包
这两个包一个在这个功能中是做压缩图片的,另一个是用来图片转 webp 格式的,可以自行判断是否新增。
实现是 build.gradle 文件 dependencies 模块内新增如下
implementation "cn.hutool:hutool-all:5.8.9"
implementation 'com.github.gotson:webp-imageio:0.2.2'
# 新增 GitHub 上传删除处理器
在 run.halo.app.handler.file 目录下新增 GitHubFileHandler 类
package run.halo.app.handler.file;
import cn.hutool.core.img.Img;
import cn.hutool.core.io.FileUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.luciad.imageio.webp.WebPWriteParam;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.Base64Utils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import run.halo.app.exception.FileOperationException;
import run.halo.app.exception.ServiceException;
import run.halo.app.handler.prehandler.ByteMultipartFile;
import run.halo.app.model.enums.AttachmentType;
import run.halo.app.model.properties.GitHubProperties;
import run.halo.app.model.support.HaloConst;
import run.halo.app.model.support.UploadResult;
import run.halo.app.repository.AttachmentRepository;
import run.halo.app.service.OptionService;
/**
* GitHub file handler.
*
* @author xiaoliyue
* @author xiaoliyue
* @date 2022-14-11
*/
@Slf4j
@Component
public class GitHubFileHandler implements FileHandler {
private static final String HEADER_ACCEPT = "application/vnd.github+json";
private static final String SELECT_API = "https://api.github.com/repos/%s/%s/contents/%s?ref=%s";
private static final String UPLOAD_API = "https://api.github.com/repos/%s/%s/contents/%s";
private static final String DELETE_API = "https://api.github.com/repos/%s/%s/contents/%s";
private static final List<String> ImgCompressionType = Arrays.asList("jpg","JPG","png","PNG","jpeg","JPEG");
private final RestTemplate httpsRestTemplate;
private final OptionService optionService;
private final AttachmentRepository attachmentRepository;
private final HttpHeaders headers = new HttpHeaders();
public GitHubFileHandler(RestTemplate httpsRestTemplate,OptionService optionService,
AttachmentRepository attachmentRepository) {
this.httpsRestTemplate = httpsRestTemplate;
this.optionService = optionService;
this.attachmentRepository = attachmentRepository;
headers.set(HttpHeaders.USER_AGENT, "Halo/" + HaloConst.HALO_VERSION);
headers.set(HttpHeaders.ACCEPT,HEADER_ACCEPT);
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
}
@Override
public @NonNull UploadResult upload(@NonNull MultipartFile file) {
Assert.notNull(file, "Multipart file must not be null");
// Get config
String owner =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_OWNER).toString();
String repo =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_REPO).toString();
String branch =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_BRANCH).toString();
String path =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_PATH).toString();
String email =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_SECRET_COMMIT_EMAIL).toString();
String jsdelivrUrl =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_SECRET_CLOUDFLARE_URL).toString();
final String originalFilename = file.getOriginalFilename();
String extName = FileUtil.extName(originalFilename);
// 是否启用图片转 webp
Boolean webpEnable = optionService.getByPropertyOrDefault(GitHubProperties.GITHUB_API_WEBP_ENABLE, Boolean.class, false);
if (webpEnable) {
try(ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
final MemoryCacheImageOutputStream memoryCacheImageOutputStream = new MemoryCacheImageOutputStream(outputStream)) {
final BufferedImage bufferedImage = ImageIO.read(file.getInputStream());
// Obtain a WebP ImageWriter instance
ImageWriter writer = ImageIO.getImageWritersByMIMEType("image/webp").next();
// Configure encoding parameters
WebPWriteParam writeParam = new WebPWriteParam(writer.getLocale());
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
writeParam.setCompressionType(writeParam.getCompressionTypes()[WebPWriteParam.LOSSY_COMPRESSION]);
writer.setOutput(memoryCacheImageOutputStream);
writer.write(null,new IIOImage(bufferedImage,null,null),writeParam);
memoryCacheImageOutputStream.flush();
file = new ByteMultipartFile(outputStream.toByteArray(),file.getOriginalFilename(),file.getName(),"image/webp");
// 仅支持有损压缩
extName = "webp";
} catch (Exception e) {
log.warn("转webp异常");
e.printStackTrace();
}
}else {
// 是否启动图片压缩
Boolean compression =
optionService.getByPropertyOrDefault(GitHubProperties.GITHUB_API_COMPRESSION_ENABLE, Boolean.class, false);
if(compression && ImgCompressionType.contains(extName)){
try(ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Img.from(file.getInputStream())
.setQuality(0.8)//压缩比率
.write(outputStream);
file = new ByteMultipartFile(outputStream.toByteArray(),file.getOriginalFilename(),file.getName(),"image/jpg");
// 仅支持有损压缩
extName = "jpg";
} catch (IOException e) {
log.warn("压缩异常");
e.printStackTrace();
}
}
}
final String fileName = System.currentTimeMillis() + "." + extName;
String fileNamePath = path + fileName;
HashMap<String, Object> body = new HashMap<>();
HashMap<String, String> authorMap = new HashMap<>();
authorMap.put("name",owner);
authorMap.put("email",email);
body.put("message","Upload file");
body.put("branch",branch);
body.put("author",authorMap);
body.put("committer",authorMap);
try {
body.put("content", Base64Utils.encodeToString(file.getBytes()));
} catch (IOException e) {
throw new ServiceException("bytes 取出异常",e);
}
final String jsonData = JSONUtil.toJsonStr(body);
setHeaders();
String url = String.format(UPLOAD_API,owner,repo,fileNamePath);
HttpEntity<String> httpEntity = new HttpEntity<>(jsonData, headers);
final ResponseEntity<String> responseEntity = httpsRestTemplate
.exchange(url, HttpMethod.PUT,httpEntity, String.class);
if (responseEntity.getStatusCode().isError() || responseEntity.getBody() == null) {
log.warn("返回数据为:{}",responseEntity.getBody());
throw new ServiceException("上传失败 "
+ ", 状态码: "
+ responseEntity.getStatusCode());
}
// https://gcore.jsdelivr.net/gh/guicaiyue/FigureBed/MImg/202111192355940.png
jsdelivrUrl = jsdelivrUrl+String.format("/gh/%s/%s/%s",owner,repo,path);
try {
FilePathDescriptor pathDescriptor = new FilePathDescriptor.Builder()
// .setBasePath(endpoint + bucketName)
.setSubPath(jsdelivrUrl)
.setAutomaticRename(true)
.setRenamePredicate(relativePath ->
attachmentRepository
.countByFileKeyAndType(relativePath, AttachmentType.GITHUB) > 0)
.setOriginalName(fileName)
.build();
UploadResult uploadResult = new UploadResult();
uploadResult.setFilename(pathDescriptor.getName());
uploadResult.setFilePath(pathDescriptor.getFullPath());
uploadResult.setKey(pathDescriptor.getRelativePath());
uploadResult
.setMediaType(MediaType.valueOf(Objects.requireNonNull(file.getContentType())));
uploadResult.setSuffix(pathDescriptor.getExtension());
uploadResult.setSize(file.getSize());
// Handle thumbnail
handleImageMetadata(file, uploadResult, pathDescriptor::getFullPath);
return uploadResult;
} catch (Exception e) {
log.error("upload file to GITHUB failed", e);
throw new FileOperationException("上传附件 " + originalFilename + " 到 GITHUB 失败 ",
e).setErrorData(e.getMessage());
}
}
@Override
public void delete(@NonNull String key) {
Assert.notNull(key, "File key must not be blank");
// Get config
String owner =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_OWNER).toString();
String repo =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_REPO).toString();
String branch =
optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_BRANCH).toString();
int index = key.indexOf("/gh/")+4+owner.length()+1+repo.length()+1;
String path = key.substring(index);
setHeaders();
String selectUrl = String.format(SELECT_API,owner,repo,path,branch);
String forObject = httpsRestTemplate.getForObject(selectUrl, String.class);
JSONObject entries = JSONUtil.parseObj(forObject);
String sha = entries.getStr("sha");
if(StringUtils.isBlank(sha)){
log.warn("返回数据为:{}",forObject);
throw new FileOperationException("sha值没有,无法删除");
}
String deleteUrl = String.format(DELETE_API,owner,repo,path);
HashMap<String, String> deleteMap = new HashMap<>();
deleteMap.put("sha",sha);
deleteMap.put("message","delete a file");
deleteMap.put("branch",branch);
String jsonStr = JSONUtil.toJsonStr(deleteMap);
HttpEntity<String> httpEntity = new HttpEntity<>(jsonStr, headers);
final ResponseEntity<String> responseEntity = httpsRestTemplate
.exchange(deleteUrl, HttpMethod.DELETE,httpEntity, String.class);
if (responseEntity.getStatusCode().isError() || responseEntity.getBody() == null) {
log.warn("返回数据为:{}",responseEntity.getBody());
throw new ServiceException("删除失败 "
+ ", 状态码: "
+ responseEntity.getStatusCode());
}
}
/**
* Set headers.
*/
private void setHeaders() {
headers.set(HttpHeaders.AUTHORIZATION,
"Bearer "+ optionService.getByPropertyOfNonNull(GitHubProperties.GITHUB_SECRET_TOKEN));
}
@Override
public AttachmentType getAttachmentType() {
return AttachmentType.GITHUB;
}
}
# 前端新增 GitHub 下拉选项
src\core\constant.js 文件内 attachmentTypes 对象新增如下
GITHUB: {
type: 'GITHUB',
text: 'GitHub'
}
# 前端新增 GitHub 参数选项
src\views\system\optiontabs\AttachmentTab.vue 在 MINIO 类型下新增 GITHUB 类型
<div v-show="options.attachment_type === 'GITHUB'" id="gitHubForm">
<a-form-model-item label="owner(拥有者):">
<a-input v-model="options.github_api_owner" placeholder="用户名" />
</a-form-model-item>
<a-form-model-item label="repo(仓库名):">
<a-input v-model="options.github_api_repo" placeholder="代码库名" />
</a-form-model-item>
<a-form-model-item label="branch :">
<a-input v-model="options.github_api_branch" placeholder="分支名" />
</a-form-model-item>
<a-form-model-item label="path :">
<a-input v-model="options.github_api_path" placeholder="目录前缀(请先创建好目录)" />
</a-form-model-item>
<a-form-model-item label="email :">
<a-input v-model="options.github_api_commit_email" placeholder="commit邮箱" />
</a-form-model-item>
<a-form-model-item label="cloudflare url :">
<a-input v-model="options.github_api_cloudflare_url" placeholder="cloudflare cdn 前缀" />
</a-form-model-item>
<a-form-model-item label="Secret Token :">
<a-input v-model="options.github_api_secret_token" placeholder="token" />
</a-form-model-item>
<a-form-model-item label="是否压缩(0.6):">
<a-switch v-model="options.github_api_compression_enable" />
</a-form-model-item>
<a-form-model-item label="是否转 webp:">
<a-switch v-model="options.github_api_webp_enable" />
</a-form-model-item>
</div>
# 如何使用
# 首先创建一个 GitHub 公共仓库
owner 就是你的用户名
repo 就是你的仓库名
# 申请 token
- 点击右上角用户头像 Settings > Developer settings > Personal access tokens 界面
- 点击 Generate new token 申请 token
- 重点是选中新建的公共库,然后给个读写权限就好了
# cloudflare url 是什么
存放在 GitHub 上的图片一般是由 jsdelivr 代理的,以前的前缀是:cdn.jsdelivr.net
所以填写:https://cdn.jsdelivr.net。但自从国内屏蔽后这个就不行了
代码内部默认:https://gcore.jsdelivr.net
提供下 jsdelivr cdn 域名列表,可以测试下自己哪个加载最快
https://cdn.jsdelivr.net
https://gcore.jsdelivr.net
https://fastly.jsdelivr.net
https://originfastly.jsdelivr.net
https://quantil.jsdelivr.net
# 然后就是正常使用了
压缩和转 webp 我没有做成可以并行,因为可能出现压缩完后,转 webp 反而没有压缩前转 webp 文件更小。
path:路径是公共仓库图片存放的路径,不填就是根目录
email:是上传图片时,提交人的邮箱 (必填参数,可以用自己的 qq 邮箱)
branch:是公共仓库存放图片使用的分支,一般填 master
# 前端构建代码
改完前端后执行 pnpm build 构建代码
再将构建物替换到 resources\admin 目录
# 总结
下次再搞个用户访问时判断它的网络环境适合哪个 jsdelivr 然后动态替换掉文章中图片的路径,◔.̮◔✧ 这样就不用担心页面体验问题了,加油!!!