# Halo 附件支持 GitHub

Halo 附件支持很多,但我还是喜欢用 GitHub 存储,可能是贫穷使我奋斗吧,用其他云商总会怕各种意外,如价格上涨,跑路,感觉图片没有存在一个自己能掌握的地方,但不能否认的是它们提供的各种图片增值服务还是很给力的,如 cdn、在线图片压缩、在线格式转换等功能。

# 前言

本次改动源码:
后端 halo-dev/halo:GitHub 链接地址
前端 halo-dev/console:GitHub 链接地址
界面效果
1668865793668

# 新增 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

  1. 点击右上角用户头像 Settings > Developer settings > Personal access tokens 界面
    1668869227659
  2. 点击 Generate new token 申请 token
  3. 重点是选中新建的公共库,然后给个读写权限就好了
    1668869694615
    1668869878091

# 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 目录
1668874485094

# 总结

下次再搞个用户访问时判断它的网络环境适合哪个 jsdelivr 然后动态替换掉文章中图片的路径,◔.̮◔✧ 这样就不用担心页面体验问题了,加油!!!