halo Documentation

Halo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。

RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy halo on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name halo -p 8090:8090 --restart always halo-dev/halo:latest

1. Project Overview & Quickstart (halo-dev/halo)

File: README.md


Halo 是什么?

Halo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。

快速开始

如果你的设备有 Docker 环境,可以使用以下命令快速启动一个 Halo 的体验环境:

bash
docker run -d --name halo -p 8090:8090 -v ~/.halo2:/root/.halo2 halohub/halo:2.25

以上方式仅作为体验使用,推荐使用开源 Linux 服务器运维管理面板 1Panel 进行部署(查看文档),轻松搞定反向代理、SSL 证书及升级备份任务。更多部署方式,请查看文档。

在线体验

版本对比

Halo 社区版:开源免费, 遵循 GPLv3 协议

  • 适合个人开发者、技术爱好者、开源项目
  • 零成本搭建博客、作品集、技术文档站
  • 超过 100 款免费主题和插件

Halo 专业版:在社区版基础上,集成 10+ 高价值功能

  • 移动端 APP:随时随地管理内容
  • AI 智能建站:快速生成专业站点
  • 手机号验证登录:提升安全与用户体验
  • 全站私有化部署:保障数据主权
  • 付费主题/插件市场:专享精品主题和 SEO 优化、付费阅读、AI 助手等 10 款付费插件

Halo 商城版:在专业版基础上,集成在线商城重磅功能

  • 一体化在线商城:商品管理、订单处理、支付对接全流程
  • 为中国商家定制:无缝集成微信支付、支付宝等本土生态
  • 品牌官网 + CMS + 线上店铺一站式落地,助力生意高效增长

关于三个版本的详细对比,请参考版本对比。

应用生态

许可证

Halo 使用 GPL-v3.0 协议开源,请遵守开源协议。

贡献

参考 CONTRIBUTING。

状态


File: docs/authentication/README.md

Halo 认证方式

目前 Halo 支持的认证方式有:

  • 基本认证(Basic Auth)
  • 表单登录(Form Login)

计划支持的认证方式有:

基本认证

这是最简单的一种认证方式,通过简单设置 HTTP 请求头 Authorization: Basic xxxyyyzzz== 即可实现认证,访问 Halo API,例如:

bash
╰─❯ curl -u "admin:P@88w0rd" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users

或者
╰─❯ echo -n "admin:P@88w0rd" | base64
YWRtaW46UEA4OHcwcmQ=
╰─❯ curl -H "Authorization: Basic YWRtaW46UEA4OHcwcmQ=" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users

表单认证

这是一种比较常用的认证方式,只需提供用户名和密码以及 CSRF 令牌(用于防止重复提交和跨站请求伪造)。

  • 表单参数

    参数名 类型 说明
    username form 用户名
    password form 密码
    _csrf form CSRF 令牌。由客户端随机生成。
    XSRF-TOKEN cookie 跨站请求伪造令牌,和 _csrf 的值一致
  • HTTP 200 响应

    仅在请求头 Accept 中包含 application/json 时发生,响应示例如下所示:

    bash
    ╰─❯ curl 'http://localhost:8090/login' \
      -H 'Accept: application/json' \
      -H 'Cookie: XSRF-TOKEN=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5' \
      -H 'Content-Type: application/x-www-form-urlencoded' \
      --data-raw '_csrf=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5&username=admin&password=P@88w0rd'
    bash
    < HTTP/1.1 200 OK
    < Vary: Origin
    < Vary: Access-Control-Request-Method
    < Vary: Access-Control-Request-Headers
    < Content-Type: application/json
    < Content-Length: 161
    < Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    < Pragma: no-cache
    < Expires: 0
    < X-Content-Type-Options: nosniff
    < X-Frame-Options: DENY
    < X-XSS-Protection: 1 ; mode=block
    < Referrer-Policy: no-referrer
    < Set-Cookie: SESSION=d04db9f7-d2a6-4b7c-9845-ef790eb4a980; Path=/; HttpOnly; SameSite=Lax
    json
    {
      "username": "admin",
      "authorities": [
        {
          "authority": "ROLE_super-role"
        }
      ],
      "accountNonExpired": true,
      "accountNonLocked": true,
      "credentialsNonExpired": true,
      "enabled": true
    }
  • HTTP 302 响应

    仅在请求头 Accept 中不包含 application/json才会发生,响应示例如下所示:

    bash
    ╰─❯ curl 'http://localhost:8090/login' \
      -H 'Accept: */*' \
      -H 'Cookie: XSRF-TOKEN=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5' \
      -H 'Content-Type: application/x-www-form-urlencoded' \
      --data-raw '_csrf=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5&username=admin&password=P@88w0rd'
    bash
    < HTTP/1.1 302 Found
    < Vary: Origin
    < Vary: Access-Control-Request-Method
    < Vary: Access-Control-Request-Headers
    < Location: /console/
    < Cache-Control: no-cache, no-store, max-age=0, must-revalidate
    < Pragma: no-cache
    < Expires: 0
    < X-Content-Type-Options: nosniff
    < X-Frame-Options: DENY
    < X-XSS-Protection: 1 ; mode=block
    < Referrer-Policy: no-referrer
    < Set-Cookie: SESSION=9ce6ad3f-7eba-4de5-abca-650b4721c7ac; Path=/; HttpOnly; SameSite=Lax
    < content-length: 0

未来计划支持“记住我(Remember Me)”功能。

Personal Access Token

背景

Halo 是一款现代化的开源 CMS / 建站系统,为了便于开发者和用户利用 API 访问网站数据,Halo 支持了 Personal Access Token(以下简称
PAT)功能。
用户可以在 Halo 的后台生成 PAT,它是一个随机字符串,用于在 API 请求头里提供验证身份用。Halo 后端在接收请求时会校验 PAT
的值,如果匹配就会允许访问相应的 API 数据。
这种 PAT 机制避免了直接使用用户名密码的安全隐患,开发者可以为每个 PAT 设置访问范围、过期时间等。同时使用随机 PAT
也增加了安全性。这为开发 Halo 插件和应用提供了更安全简便的认证方式。
相比直接暴露服务端 API,这种 PAT 机制也更标准化和安全可控。Halo 在参考业内主流做法的基础上,引入了 PAT,以便于生态系统的开放与丰富。

设计

PAT 以 pat_ 开头,剩余部分为随机字符串,随机字符串可以是 JWT、UUID
或其他经过加密的随机字符串。目前,Halo 的实现是 pat_ + JWT 的形式,例如:

text
pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbInN1cGVyLXJvbGUiXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tSVdvbFEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0NjcyMDc5LCJpYXQiOjE2OTQ1ODU3MjAsImp0aSI6IjE3ZWFkNzlkLTRkMjctYjg4NS02YjAzLTM4Y2JlYzQxMmFlMyJ9.xiq36NZIM3_ynBx-l0scGdfX-89aJi6uV7HJz_kNnuT78CFmxD-XTpncK1E-hqPdQSrSwyG4gT1pVO17UmUCoyoAkZKKKVk_seFwxdbygIueo2UJA5kVw1Naf_6iLtNkAXxAiYUpd8ihIwvVedhmOMQ9UUfd4QKZDR1XnTW4EAteWBi7b0pWqSa4h5lv7TpmAECY_KDAGrBRGGhc9AxsrGYPNZo68n2QGJ5BjH29vfdQaZz4vwsgKxG1WJ9Y7c8cQI9JN8EyQD_n560NWAaoFnRi1qL3nexvhjq8EVyGVyM48aKA02UcyvI9cxZFk6ZgnzmUsMjyA6ZL7wuexkujVqmc3iO5plBDCjW7oMe1zPQq-gEJXJU6gdr_SHcGG1BjamoekCkOeNT3CPzA_-5j3AVlj7FTFQkbn_h-kV07mfNO45BVVKsMb08HrN6iEk7TOX7SxN0s2gFc3xYVcXBMveLtftOfXs04SvSFCfTDeJH_Jy-3lYb_GLOji7xSc6FgRbuAwmzHLlsgBT4NJhR_0dZ-jNsCDIQCIC3iDc0qbcNTJYYocT77YaQzIkleFIXyPiV0RsNPmSTEDGiDlctsZ-AmcGCDQ-UmW8SIFBrA93OHncvb47o0-uBwZLdF_we4S90hJlNiAPVhhrBMtCoTJotyrODMEzwbLIukvewFXp8

示例 Token 中 JWT 部分所对应的 Header 如下:

json
{
  "kid": "ZmCmqhI_anhYVAnZFUSKINrLWDXjhJu6OYDdmqmwFz8",
  "alg": "RS256"
}

Payload 如下:

json
{
  "sub": "admin",
  "roles": [
    "super-role"
  ],
  "pat_name": "pat-admin-IWolQ",
  "iss": "http://localhost:8090/",
  "exp": 1694672079,
  "iat": 1694585720,
  "jti": "17ead79d-4d27-b885-6b03-38cbec412ae3"
}

使用方式

生成 PAT

Halo 专门提供了生成 PAT 的端口:/apis/api.console.security.halo.run/v1alpha1/users/-/personalaccesstokens。创建 PAT
请求示例如下:

shell
curl -u admin:admin -X 'POST' \
  'http://localhost:8090/apis/api.console.security.halo.run/v1alpha1/users/-/personalaccesstokens' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "spec": {
    "name": "My PAT",
    "description": "This is my first PAT.",
    "expiresAt": "2023-09-15T02:42:35.136Z"
    "roles": [""]
  }
}'
json
{
  "spec": {
    "description": "This is my first PAT.",
    "expiresAt": "2023-09-16T02:42:35.136Z",
    "roles": [],
    "username": "admin",
    "revoked": false,
    "tokenId": "0b897d9c-56d7-5541-2662-110b70e3f9fd"
  },
  "apiVersion": "security.halo.run/v1alpha1",
  "kind": "PersonalAccessToken",
  "metadata": {
    "generateName": "pat-admin-",
    "name": "pat-admin-lobkm",
    "annotations": {
      "security.halo.run/access-token": "pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tbG9ia20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0ODMyMTU1LCJpYXQiOjE2OTQ3NDcyOTgsImp0aSI6IjBiODk3ZDljLTU2ZDctNTU0MS0yNjYyLTExMGI3MGUzZjlmZCJ9.UVFYzKmz3bUk7fV6xh_CpuNJA-BR8bci-DIJ7o0fk-hayHXFHr_-7HMrVn7iZcphryqmk0RLv7Zsu_AjY9Qn9iCYybBJBycU0tUJzhDexRtj1ViJtlsraoYxLNSYpJK1hcPngeJuiMa9FZrYGp0k_7GX1NddoXLUBI9orN9DbdKmmJXtvigaxPCp52Mu7fBtVsTmO5fk_y2CglqRl_tkLRpFSgUbERKOqKItctDFRg-WUALBYEpXbhZIXBMuTCsJwhniBMpc1Uu_a1Dqa3K5hDgfHTeUADY2BuhEdYJCODPCzmdfWMNqxYSKQT5JFYoDv-ed6cRqNjKeNvd1IPT3RDkVt_fbo8KPrzvkgIjIzni-Wlwe-pXXQbj_n8iax-jkeK526iu8q2CLptxYxLGD0j8htKZramrov4UkK_eIsotEZZfqig9sYVU5_b442WhOWatdB_pbKj7h-YK1Cb2ueg5kl73bcbBu63b8edJZClp6xr72az343SfBZdwrT_JJ5HR0hJmckAMR_U4qvGWrJ-dobXDgY9Oz-qObfiyglzn0Wrz4HRPlmqDFr2o6TMV7UVjQiV77tDzaNbaXVevXGPS5MaZr313dia7XLpIV3QopXma7rDR6Xnqg7ftDQb5vAvsjwN-JsVabAsdFeCo6ejE1slAD9ZQrD88kgfAIuX4"
    },
    "version": 0,
    "creationTimestamp": "2023-09-15T03:08:18.875350Z"
  }
}

请求体说明如下表所示:

属性名 描述
name PAT 名称。必填。
description PAT 描述。非必填。
expiresAt PAT 过期时间,一旦创建不可修改,或修改无效。如果不填写,则表示 PAT 无过期时间。
roles 授权给 PAT 的角色,必须包含在当前用户所拥有的角色内。如果设置为 null 或者 [],则表示当前 PAT 仅会拥有 anonymous 和 authenticated 角色。

响应体说明如下所示:

属性路径 描述
security.halo.run/access-token 生成好的 PAT。需要注意的是,这个 PAT 不会保存在数据库中,所以仅有一次保存机会。

使用 PAT

向 Halo 发送请求时,携带 Header:Authorization: Bearer $PAT 即可。示例如下:

shell
curl http://localhost:8090/apis/api.console.halo.run/v1alpha1/users/- \
  -H "Authorization: Bearer pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tbG9ia20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0ODMyMTU1LCJpYXQiOjE2OTQ3NDcyOTgsImp0aSI6IjBiODk3ZDljLTU2ZDctNTU0MS0yNjYyLTExMGI3MGUzZjlmZCJ9.UVFYzKmz3bUk7fV6xh_CpuNJA-BR8bci-DIJ7o0fk-hayHXFHr_-7HMrVn7iZcphryqmk0RLv7Zsu_AjY9Qn9iCYybBJBycU0tUJzhDexRtj1ViJtlsraoYxLNSYpJK1hcPngeJuiMa9FZrYGp0k_7GX1NddoXLUBI9orN9DbdKmmJXtvigaxPCp52Mu7fBtVsTmO5fk_y2CglqRl_tkLRpFSgUbERKOqKItctDFRg-WUALBYEpXbhZIXBMuTCsJwhniBMpc1Uu_a1Dqa3K5hDgfHTeUADY2BuhEdYJCODPCzmdfWMNqxYSKQT5JFYoDv-ed6cRqNjKeNvd1IPT3RDkVt_fbo8KPrzvkgIjIzni-Wlwe-pXXQbj_n8iax-jkeK526iu8q2CLptxYxLGD0j8htKZramrov4UkK_eIsotEZZfqig9sYVU5_b442WhOWatdB_pbKj7h-YK1Cb2ueg5kl73bcbBu63b8edJZClp6xr72az343SfBZdwrT_JJ5HR0hJmckAMR_U4qvGWrJ-dobXDgY9Oz-qObfiyglzn0Wrz4HRPlmqDFr2o6TMV7UVjQiV77tDzaNbaXVevXGPS5MaZr313dia7XLpIV3QopXma7rDR6Xnqg7ftDQb5vAvsjwN-JsVabAsdFeCo6ejE1slAD9ZQrD88kgfAIuX4"

File: docs/cache/page.md

缓存

缓存在各个领域用得非常广泛,例如 CPU 的三级缓存,可加速从主内存中获取数据到处理器。Halo 的主要应用以博客为主,页面更新不会特别频繁,大多数情况下,实时渲染的结果都是没有变化的。如果能够缓存这些不经常变更的页面,可减少数据库访问,加快访问速度。

Halo 采用由 Spring 框架提供的 Caching 作为缓存框架。该缓存框架面对各种缓存实现,提供了统一的访问入口,后续更换缓存仅需修改少量代码和配置。

Halo 默认提供了 CacheProperties 用于启用/禁用缓存,示例如下:

yaml
halo:
  caches:
    page:
      disabled: true
    others:
      disabled: false

页面缓存

页面缓存包括缓存响应体、响应头和响应状态。页面缓存规则如下:

  1. 仅缓存模板引擎所渲染的页面。
  2. 仅缓存 Content-Type 为 text/html 的页面。
  3. 仅缓存响应状态为 HTTP 200(OK)。
  4. 请求访问为 GET。

缓存详情见下表:

术语 值
名称 page
失效时间 距最近一次访问 1 小时
缓存数量 10,000 个

File: docs/developer-guide/custom-endpoint.md

系统自定义 API

系统自定义 API 是一组特殊的 API,因为自定义模型 API 无法满足要求,需要开发者自己实现。

但是系统自定义 API 有一个统一的前缀:/apis/api.console.halo.run/v1alpha1/,剩余的部分可随意定义。

如何在系统中创建一个系统自定义 API

  1. 实现 run.halo.app.core.extension.endpoint.CustomEndpoint 接口
  2. 将实现类设置为 Spring Bean

关于用户的自定义 API 实现类如下:

java
@Component
public class UserEndpoint implements CustomEndpoint {

    private final ExtensionClient client;

    public UserEndpoint(ExtensionClient client) {
        this.client = client;
    }

    Mono<ServerResponse> me(ServerRequest request) {
        return ReactiveSecurityContextHolder.getContext()
                .map(ctx -> {
                    var name = ctx.getAuthentication().getName();
                    return client.fetch(User.class, name)
                            .orElseThrow(() -> new ExtensionNotFoundException(name));
                })
                .flatMap(user -> ServerResponse.ok()
                        .contentType(MediaType.APPLICATION_JSON)
                        .bodyValue(user));
    }

    @Override
    public RouterFunction<ServerResponse> endpoint() {
        return SpringdocRouteBuilder.route()
                .GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail")
                        .description("Get current user detail")
                        .tag("api.console.halo.run/v1alpha1/User")
                        .response(responseBuilder().implementation(User.class)))
                // 这里可添加其他自定义 API
                .build();
    }
}

这样我们就可以启动 Halo,访问 Swagger UI 文档地址,并进行测试。


File: docs/developer-guide/plugin-configuration-properties.md

插件外部配置

插件外部配置功能允许用户在特定目录添加插件相关的配置,插件启动的时候能够自动读取到该配置。

配置优先级

优先级从上到下由高到低。

  1. ${halo.work-dir}/plugins/configs/${plugin-id}.{yaml|yml}
  2. classpath:/config.{yaml|yml}

插件开发者可在 Class Path 下 添加 config.{yaml|yml} 作为默认配置。当 .yaml 和 .yml 同时出现时,以 .yml 的配置将会被忽略。

插件中定义配置并使用

  • src/main/java/my/plugin/MyPluginProperties.java

    java
    @Data
    @ConfigurationProperties
    public class MyPluginProperties {
    
        private String encryptKey;
    
        private String certPath;
    }
  • src/main/java/my/plugin/MyPluginConfiguration.java

    java
    @EnableConfigurationProperties(MyPluginProperties.class)
    @Configuration
    public class MyPluginConfiguration {
    
    }
  • src/main/java/my/plugin/MyPlugin.java

    java
    @Component
    @Slf4j
    public class MyPlugin extends BasePlugin {
    
        private final MyPluginProperties storeProperties;
    
        public MyPlugin(PluginWrapper wrapper, MyPluginProperties storeProperties) {
            super(wrapper);
            this.storeProperties = storeProperties;
        }
    
        @Override
        public void start() {
            log.info("My plugin properties: {}", storeProperties);
        }
    }
  • src/main/resources/config.yaml

    yaml
    encryptKey: encrytkey==
    certPath: /path/to/cert

插件使用者配置

  • ${halo.work-dir}/plugins/configs/${plugin-id}.{yaml|yml}

    yaml
    encryptKey: override encrytkey==
    certPath: /another/path/to/cert

可能存在的问题

  • 增加未来实现"集群"架构的难度。

File: docs/email-verification/README.md

背景

在 Halo 中,邮箱作为用户主要的身份识别和通信方式,不仅有助于确保用户提供的邮箱地址的有效性和所有权,还对于减少滥用行为、提高账户安全性以及确保用户可以接收重要通知(如密码重置、注册新账户、确认重要操作等)至关重要。

邮箱验证是用户管理过程中的一个关键组成部分,可以帮助维护了一个健康、可靠的用户基础,并且为系统管理员提供了一个额外的安全和管理手段,因此实现一个高效、安全且用户友好的邮箱验证功能至关重要。

需求

  1. 用户注册验证:确保新用户在注册过程中提供有效的邮箱地址。邮箱验证作为新用户激活其账户的必要步骤,有助于减少虚假账户和提升用户的整体质量。
  2. 密码重置和安全操作:在用户忘记密码或需要重置密码时,向已验证的邮箱地址发送密码重置链接来确保安全性。
  3. 用户通知:验证邮箱地址有助于确保用户可以接收到重要通知,如文章被评论、有新回复等。

目标

  • 支持用户在修改邮箱后支持重新进行邮箱验证。
  • 允许用户在未收到邮件或邮件过期时重新请求发送验证邮件。
  • 避免邮件通知被滥用,如频繁发送验证邮件,需要添加限制。
  • 验证码过期机制,以确保验证邮件的有效性和安全性。

非目标

  • 不考虑用户多邮箱地址的验证。

方案

EmailVerificationManager

通过使用 guava 提供的 Cache 来实现一个 EmailVerificationManager 来管理邮箱验证的缓存。

java
class EmailVerificationManager {
  private final Cache emailVerificationCodeCache =
          CacheBuilder.newBuilder()
                  .expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
                  .maximumSize(10000)
                  .build();

  private final Cache blackListCache = CacheBuilder.newBuilder()
          .expireAfterWrite(Duration.ofHours(1))
          .maximumSize(1000)
          .build();

  record UsernameEmail(String username, String email) {
  }

  @Data
  @Accessors(chain = true)
  static class Verification {
    private String code;
    private AtomicInteger attempts;
  }
}

当用户请求发送验证邮件时,会生成一个随机的验证码,并将其存储在缓存中,默认有效期为 10 分钟,当十分钟内用户未验证成功,验证码会自动过期被缓存清除。

用户可以在十分钟内重新请求发送验证邮件,此时会生成一个新的验证码有效期依然为 10 分钟。但会限制用户发送频率,同一个用户的邮箱发送验证邮件的时间间隔不得小于
1 分钟,以防止滥用。

当用户请求验证邮箱时,会从缓存中获取验证码,如果验证码不存在或已过期,会提示验证码无效或已过期,如果验证码存在且未过期,会进行验证码的比对,如果验证码不正确,会提示验证码无效,如果验证码正确,会将用户邮箱地址标记为已验证,并从缓存中清除验证码。

如果用户反复使用 code 验证邮箱,会记录失败次数,如果达到了默认的最大尝试次数(默认为 5 次),将被加入黑名单,需要 1
小时后才能重新验证邮件。

根据上述规则:

  • 每个验证码有10分钟的有效期。
  • 在这10分钟内,如果失败次数超过5次,用户会被加入黑名单,禁止验证1小时。
  • 如果在10分钟内尝试了5次且失败,然后请求重新发送验证码,可以再次尝试5次。

那么:

  • 在不触发黑名单的情况下,每10分钟可以尝试5次。
  • 一小时内,可以尝试 (60/10) * 5 = 30 次,前提是每10分钟都请求一次新的验证码。
  • 但是,如果在任何10分钟内尝试超过5次,则会被禁止1小时。

因此,为了最大化尝试次数而不触发黑名单,每小时可以尝试 30 次,预计一天内(24h)最多可以尝试 720 次验证码。
验证码的组成为随机的 6 为数字,可能组合总数:一个 6 位数字的验证码可以从 000000 到 999999,总共有 10 6 种可能的组合。
10 6 / 720 = 1388,因此,预计最坏情况下需要 1388 天可以破解验证码。这个时间足够长,可以认为非常安全的。

提供 APIs 用于处理验证请求

  • POST /apis/v1alpha1/users/-/send-verification-email:用于请求发送验证邮件来验证邮箱地址。
  • POST /apis/v1alpha1/users/-/verify-email:用于根据邮箱验证码来验证邮箱地址。

以上两个 APIs 认证用户都可以访问,但会对请求进行限制,请求间隔不得小于 1 分钟,以防止滥用。

并且会在用户个人资料 API 中添加 emailVerified 字段,用于标识用户邮箱是否已验证。

验证码邮件通知

只会通过用户请求验证的邮箱地址发送验证邮件,并且提供了以下变量用户自定义通知模板:

  • username: 请求验证邮件地址的用户名。
  • code: 验证码。
  • expirationAtMinutes: 验证码过期时间(分钟)。

验证邮件默认模板示例内容如下:

markdown
guqing 你好:

使用下面的动态验证码(OTP)验证您的电子邮件地址。

277436

动态验证码的有效期为 10 分钟。如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。

guqing's blog

安全和异常处理

  • 确保所有敏感数据安全传输,当验证码不正确或过期时,只应该提示一个通用的错误信息防止用户猜测或爆破验证码。
  • 异常提示多语言支持。

结论

通过实施上述方案,考虑到了以下情况:

  1. 新邮箱验证请求
  2. 用户邮箱地址更新
  3. 用户请求重新发送验证邮件
  4. 邮件发送失败
  5. 验证码有效期
  6. 发送频率限制
  7. 验证状态的指示和反馈

我们将能够提供一个安全、可靠且用户友好的邮箱验证功能。


File: docs/extension-points/authentication.md

Halo 认证扩展点

此前,Halo 提供了 AdditionalWebFilter 作为扩展点供插件扩展认证相关的功能。但是近期我们明确了 AdditionalWebFilter
的使用用途,故不再作为认证的扩展点。

目前,Halo 提供了三种认证扩展点:表单登录认证、普通认证和匿名认证。

表单登录(FormLogin)

示例如下:

java
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.security.FormLoginSecurityWebFilter;

@Component
public class MyFormLoginSecurityWebFilter implements FormLoginSecurityWebFilter {

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    // Do your logic here
    return chain.filter(exchange);
  }
}

普通认证(Authentication)

示例如下:

java
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.security.AuthenticationSecurityWebFilter;

@Component
public class MyAuthenticationSecurityWebFilter implements AuthenticationSecurityWebFilter {

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    // Do your logic here
    return chain.filter(exchange);
  }
}

匿名认证(Anonymous Authentication

示例如下:

java
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import run.halo.app.security.AnonymousAuthenticationSecurityWebFilter;

@Component
public class MyAnonymousAuthenticationSecurityWebFilter
        implements AnonymousAuthenticationSecurityWebFilter {

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    // Do your logic here
    return chain.filter(exchange);
  }
}

前置过滤器(BeforeSecurityWebFilter)

主要用于在进行认证之前的一些处理。需要注意的是,当前过滤器中无法直接通过 ReactiveSecurityContextHolder 获取
SecurityContext。示例如下:

java
public class MyBeforeSecurityWebFilter implements BeforeSecurityWebFilter {

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    // Do your logic here
    return chain.filter(exchange);
  }
}

后置过滤器(AfterSecurityWebFilter)

主要用于进行认证之后的一些处理。在当前过滤器中,可以通过 ReactiveSecurityContextHolder 获取 SecurityContext。示例如下:

java
public class MyAfterSecurityWebFilter implements AfterSecurityWebFilter {

  @Override
  public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    return ReactiveSecurityContextHolder.getContext()
            .switchIfEmpty(Mono.defer(() -> {
              // do something...
              return chain.filter(exchange).then(Mono.empty());
            }))
            .flatMap(securityContext -> {
              // do something...
              return chain.filter(exchange);
            });
  }
}

我们在实现扩展点的时候需要注意:如果当前请求不满足认证条件,请一定要调用 chain.filter(exchange),给其他 filter 留下机会。

后续会根据需求实现其他认证相关的扩展点。


File: docs/extension-points/content.md

内容扩展点

文章内容扩展点

文章内容扩展点用于在主题端文章内容渲染之前对文章内容进行修改,比如添加广告、添加版权声明、插入脚本等。

使用方式

在插件中通过实现 run.halo.app.theme.ReactivePostContentHandler 接口来实现文章内容扩展。

以下是一个扩展文章内容支持 Katex 的示例:

javascript
String katexScript="""
    <link rel="stylesheet" href="/plugins/plugin-katex/assets/static/katex.min.css">
    <script defer src="/plugins/plugin-katex/assets/static/katex.min.js"></script>
    <script defer src="/plugins/plugin-katex/assets/static/contrib/auto-render.min.js"></script>
    <script>
        document.addEventListener("DOMContentLoaded", function() {
            renderMathInElement(document.body, {
              // customised options
              // • auto-render specific keys, e.g.:
              delimiters: [
                  {left: '$$', right: '$$', display: true},
                  {left: '$', right: '$', display: false},
                  {left: '\\(', right: '\\)', display: false},
                  {left: '\\[', right: '\\]', display: true}
              ],
              // • rendering keys, e.g.:
              throwOnError : false
            });
        });
    </script>
    """;

然后在 handle 方法中将 Katex 的脚本字符串插入到内容前面:

java
@Component
public class KatexPostContentHandler implements ReactivePostContentHandler {

    @Override
    public Mono handle(PostContentContext postContent) {
        postContent.setContent(katexScript + "\n" + postContent.getContent());
        return Mono.just(postContent);
    }
}

定义了扩展点实现(扩展),还需要在插件的 resources/extensions 目录下添加对扩展的声明:

yaml
# resources/extensions/extension-definitions.yml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
  name: ext-def-katex-post-content
spec:
  className: run.halo.katex.KatexPostContentHandler
  # 文章内容扩展点的名称,固定值
  extensionPointName: reactive-post-content-handler
  displayName: "KatexPostContentHandler"

自定义页面内容扩展点

自定义页面(SinglePage)内容扩展点用于在主题端自定义页面内容渲染之前对内容进行修改,比如添加广告、添加版权声明、插入脚本等。

使用方式

在插件中通过实现 run.halo.app.theme.ReactiveSinglePageContentHandler 接口来实现内容扩展。

以下是一个扩展内容支持 Katex 的示例:

java
@Component
public class KatexSinglePageContentHandler implements ReactiveSinglePageContentHandler {

    @Override
    public Mono<SinglePageContentContext> handle(SinglePageContentContext pageContent) {

        String katexScript = ""; // 参考文章内容扩展点的示例脚本块
        pageContent.setContent(katexScript + "\n" + pageContent.getContent());
        return Mono.just(pageContent);
    }
}

在插件的 resources/extensions 目录下添加对自定义页面内容扩展的声明:

yaml
# resources/extensions/extension-definitions.yml
apiVersion: plugin.halo.run/v1alpha1
kind: ExtensionDefinition
metadata:
  name: ext-def-katex-singlepage-content
spec:
  className: run.halo.katex.KatexSinglePageContentHandler
  # 自定义页面内容扩展点的名称,固定值
  extensionPointName: reactive-post-content-handler
  displayName: "KatexSinglePageContentHandler"

File: docs/extension-points/search-engine.md

搜索引擎扩展点

随着 Halo 的不断发展,搜索引擎模块也逐渐完善。搜索引擎模块是 Halo 的核心模块之一,它负责为 Halo
提供全文搜索功能。搜索引擎模块目前仅支持本地全文搜索引擎 Lucene,其他搜索引擎的支持,如
Solr、MeiliSearch 或 ElasticSearch,需要通过插件来实现。

搜索引擎模块包含两个扩展点,分别是搜索引擎扩展和搜索文档扩展。搜索引擎扩展主要负责索引文档的添加、更新、删除和重建,搜索文档扩展则主要用于扩展文档类型,不仅限于文章类型。

从 Halo 2.17 开始,Halo 利用事件机制收集来自核心和插件中所发布的文档,其中也包含了文档类型用于区分。所以插件中可以通过发布事件的方式来控制文档的添加、更新、删除和重建,重建操作所需要的数据则由搜索文档扩展提供。

搜索引擎扩展(`run.halo.app.search.SearchEngine`)

如果插件想要扩展搜索引擎,如 Solr、MeiliSearch 或者 ElasticSearch,可以通过实现 SearchEngine 接口来实现。

具体实现可参考 Halo 的 Lucene 搜索引擎实现:run.halo.app.search.LuceneSearchEngine。

搜索文档扩展(`run.halo.app.search.HaloDocumentsProvider`)

如果插件想要扩展搜索文档类型,可以通过实现 HaloDocumentsProvider 接口来实现。具体实现可参考 Halo
的默认实现:run.halo.app.search.post.PostHaloDocumentsProvider。

  • 添加文档示例如下所示

    java
    class HaloDocumentAddExample {
    
      private final ApplicationEventPublisher eventPublisher;
    
      void addDocuments() {
        // concrete Halo documents
        List<HaloDocument> documents = ...;
        eventPublisher.publishEvent(new HaloDocumentAddRequestEvent(this, documents));
      }
    }
  • 删除文档示例如下所示

    java
    class HaloDocumentDeleteExample {
    
      private final ApplicationEventPublisher eventPublisher;
    
      void deleteDocuments() {
        Set<String> docIds = ...;
        eventPublisher.publishEvent(new HaloDocumentDeleteRequestEvent(this, docIds));
      }
    }
  • 重建索引示例如下所示:

    java
    class HaloDocumentRebuildExample {
    
      private final ApplicationEventPublisher eventPublisher;
    
      void rebuildDocument() {
        eventPublisher.publishEvent(new HaloDocumentRebuildRequestEvent(this));
      }
    
    }

File: docs/full-text-search/README.md

在 Halo 中实践全文搜索

主题端需全文搜索接口用于模糊搜索文章,且对效率要求极高。已经有对应的 Issue
提出,可参考:https://github.com/halo-dev/halo/issues/2637。

实现全文搜索的本地方案最好的就是 Apache 旗下开源的 Lucene
,不过 Hibernate Search 也基于 Lucene 实现了全文搜索。Halo 2.0 的自定义模型并不是直接在
Hibernate 上构建的,也就是说 Hibernate 在 Halo 2.0 只是一个可选项,故我们最终可能并不会采用 Hibernate Search,即使它有很多优势。

Halo 也可以学习 Hibernate 适配多种搜索引擎,如 Lucene、ElasticSearch、MeiliSearch 等。默认实现为 Lucene,对于用户来说,这种实现方式部署成本最低。

搜索接口设计

搜索参数

字段如下所示:

  • keyword: string. 关键字
  • sort: string[]. 搜索字段和排序方式
  • offset: number. 本次查询结果偏移数
  • limit: number. 本次查询的结果最大条数

例如:

bash
http://localhost:8090/apis/api.halo.run/v1alpha1/posts?keyword=halo&sort=title.asc&sort=publishTimestamp,desc&offset=20&limit=10

搜索结果

yaml
hits:
  - name: halo01
    title: Halo 01
    permalink: /posts/halo01
    categories:
      - a
      - b
    tags:
      - c
      - d
  - name: halo02
    title: Halo 02
    permalink: /posts/halo02
    categories:
      - a
      - b
    tags:
      - c
      - d
query: "halo"
total: 100
limit: 20
offset: 10
processingTimeMills: 2

搜索结果分页问题

目前,大多数搜索引擎为了性能问题,并没有直接提供分页功能,或者不推荐分页。

请参考:

综合以上讨论,我们暂定不支持分页。不过允许设置单次查询的记录数(limit <= max_limit)。

中文搜索优化

Lucene 默认的分析器,对中文的分词不够友好,我们需要借助外部依赖或者外部整理好的词库帮助我们更好的对中文句子分词,以便优化中文搜索结果。

以下是关于中文分析器的 Java 库:

搜索引擎样例

MeiliSearch

bash
curl 'http://localhost:7700/indexes/movies/search' \
  -H 'Accept: */*' \
  -H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5' \
  -H 'Authorization: Bearer MASTER_KEY' \
  -H 'Connection: keep-alive' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: logged_in=yes; adminer_permanent=; XSRF-TOKEN=75995791-980a-4f3e-81fb-2e199d8f3934' \
  -H 'Origin: http://localhost:7700' \
  -H 'Referer: http://localhost:7700/' \
  -H 'Sec-Fetch-Dest: empty' \
  -H 'Sec-Fetch-Mode: cors' \
  -H 'Sec-Fetch-Site: same-origin' \
  -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26' \
  -H 'X-Meilisearch-Client: Meilisearch mini-dashboard (v0.2.2) ; Meilisearch instant-meilisearch (v0.8.2) ; Meilisearch JavaScript (v0.27.0)' \
  -H 'sec-ch-ua: "Microsoft Edge";v="107", "Chromium";v="107", "Not=A?Brand";v="24"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "Windows"' \
  --data-raw '{"q":"halo","attributesToHighlight":["*"],"highlightPreTag":"<ais-highlight-0000000000>","highlightPostTag":"</ais-highlight-0000000000>","limit":21}' \
  --compressed
text
/* Detailed source-code truncated for AI context efficiency. */

Algolia

bash
curl 'https://og53ly1oqh-dsn.algolia.net/1/indexes/*/queries?x-algolia-agent=Algolia%20for%20JavaScript%20(4.14.2)%3B%20Browser%20(lite)%3B%20docsearch%20(3.2.1)%3B%20docsearch-react%20(3.2.1)%3B%20docusaurus%20(2.1.0)&x-algolia-api-key=739f2a55c6d13d93af146c22a4885669&x-algolia-application-id=OG53LY1OQH' \
  -H 'Accept: */*' \
  -H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5' \
  -H 'Connection: keep-alive' \
  -H 'Origin: https://docs.halo.run' \
  -H 'Referer: https://docs.halo.run/' \
  -H 'Sec-Fetch-Dest: empty' \
  -H 'Sec-Fetch-Mode: cors' \
  -H 'Sec-Fetch-Site: cross-site' \
  -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26' \
  -H 'content-type: application/x-www-form-urlencoded' \
  -H 'sec-ch-ua: "Microsoft Edge";v="107", "Chromium";v="107", "Not=A?Brand";v="24"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "Windows"' \
  --data-raw '{"requests":[{"query":"halo","indexName":"docs","params":"attributesToRetrieve=%5B%22hierarchy.lvl0%22%2C%22hierarchy.lvl1%22%2C%22hierarchy.lvl2%22%2C%22hierarchy.lvl3%22%2C%22hierarchy.lvl4%22%2C%22hierarchy.lvl5%22%2C%22hierarchy.lvl6%22%2C%22content%22%2C%22type%22%2C%22url%22%5D&attributesToSnippet=%5B%22hierarchy.lvl1%3A5%22%2C%22hierarchy.lvl2%3A5%22%2C%22hierarchy.lvl3%3A5%22%2C%22hierarchy.lvl4%3A5%22%2C%22hierarchy.lvl5%3A5%22%2C%22hierarchy.lvl6%3A5%22%2C%22content%3A5%22%5D&snippetEllipsisText=%E2%80%A6&highlightPreTag=%3Cmark%3E&highlightPostTag=%3C%2Fmark%3E&hitsPerPage=20&facetFilters=%5B%22language%3Azh-Hans%22%2C%5B%22docusaurus_tag%3Adefault%22%2C%22docusaurus_tag%3Adocs-default-1.6%22%5D%5D"}]}' \
  --compressed
text
/* Detailed source-code truncated for AI context efficiency. */

Wiki

bash
curl 'https://wiki.fit2cloud.com/rest/api/search?cql=siteSearch%20~%20%22halo%22%20AND%20type%20in%20(%22space%22%2C%22user%22%2C%22com.atlassian.confluence.extra.team-calendars%3Acalendar-content-type%22%2C%22attachment%22%2C%22page%22%2C%22com.atlassian.confluence.extra.team-calendars%3Aspace-calendars-view-content-type%22%2C%22blogpost%22)&start=20&limit=20&excerpt=highlight&expand=space.icon&includeArchivedSpaces=false&src=next.ui.search' \
  -H 'authority: wiki.fit2cloud.com' \
  -H 'accept: */*' \
  -H 'accept-language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5' \
  -H 'cache-control: no-cache, no-store, must-revalidate' \
  -H 'cookie: _ga=GA1.2.1720479041.1657188862; seraph.confluence=89915546%3A6fc1394f8d537ffa08fb679e6e4dd64993448051; mywork.tab.tasks=false; JSESSIONID=5347D8618AC5883DE9B702E77152170D' \
  -H 'expires: 0' \
  -H 'pragma: no-cache' \
  -H 'referer: https://wiki.fit2cloud.com/' \
  -H 'sec-ch-ua: "Microsoft Edge";v="107", "Chromium";v="107", "Not=A?Brand";v="24"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "Windows"' \
  -H 'sec-fetch-dest: empty' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-site: same-origin' \
  -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26' \
  --compressed
text
/* Detailed source-code truncated for AI context efficiency. */

FAQ

是否需要统一参数和响应体结构?

以下是关于统一参数和响应体结构的优缺点分析:

优点:

  • 主题端搜索结果 UI 更加一致,不会因为使用不同搜索引擎导致 UI 上的变动

缺点:

  • 无法完全发挥出对应的搜索引擎的实力。比如某个搜索引擎有很实用的功能,而某些搜索引擎没有。
  • Halo Core 需要适配不同的搜索引擎,比较繁琐

是否需要提供扩展点集成其他搜索引擎?

既然 Lucene 非常强大,且暂时已经能够满足我们的要求,我们为什么还需要集成其他搜索引擎呢?

  • Lucene 目前是作为 Halo 的依赖使用的,也就意味着只支持 Halo 单实例部署,阻碍未来 Halo 无状态化的趋势。
  • 相反,其他搜索引擎(例如 Solr、MeiliSearch、ElasticSearch 等)都可以独立部署,Halo 只需要利用对应的 SDK 和搜索引擎沟通即可,无论 Halo 是否是多实例部署。

2. Official Technical Reference & Guides (halo-dev/docs)

File: README.md

The open-source repo for [docs.halo.run](https://docs.halo.run)

This website is built using Docusaurus 2, a modern static website generator.

Installation

text
$ pnpm install

If you don’t have pnpm installed, you can install it with the following command:

text
npm install pnpm

Local Development

text
$ pnpm start

This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.

Build

text
$ pnpm build

This command generates static content into the build directory and can be served using any static contents hosting service.


File: docs/contribution/issue.md


title: 问题反馈

:::info
如果您在使用过程中遇到了什么问题,您可以通过下面的方式反馈,但请尽量按照要求提出反馈。
:::

GitHub

如果你在使用过程中,遇到了一些 bug 或者需要添加某些新特性,请尽量在 GitHub 进行反馈,这非常有助于我们跟踪解决此问题,您也可以很方便的接收到处理状态。

建议步骤:

  1. 在 GitHub 搜索相关问题,看看是否有其他人已经提到了此问题。
  2. 如果当前还没有人遇到您类似的问题,可以创建新的 Issue 或者 Discussion。
  3. 选择正确的反馈类型。
  4. 请尽可能详细的按照模板填写内容。
  5. 提交反馈。

Halo 官方社区

链接:https://bbs.halo.run

此平台主要目的用于与其他 Halo 用户进行交流。但如果您对 GitHub 不是很熟悉或者没有账号,您也可以在此平台进行反馈。


File: docs/contribution/pr.md


title: 代码贡献

欢迎关注并有想法参与 Halo 的开发,以下是关于如何参与到 Halo 项目的指南,仅供参考。

发现 Issue

所有的代码尽可能都有依据(Issue),不是凭空产生。

寻找一个 Good First Issue

这个步骤非常适合首次贡献者。

在 halo-dev 和 halo-sigs 组织下,有非常多的仓库。每个仓库下都有可能包含一些“首次贡献者”友好的 Issue,主要是为了给贡献者提供一个友好的体验。该类 Issue
一般会用 good-first-issue 标签标记。标签 good-first-issue 表示该 Issue 不需要对 Halo 有深入的理解也能够参与。

请点击:good-first-issue
查看关于 Halo 的 Good First Issue。

认领 Issue

若对任何一个 Issue 感兴趣,请尝试在 Issue 进行回复,讨论解决 Issue 的思路。确定后可直接通过 /assign 或者 /assign @GitHub 用户名 认领这个
Issue。这样可避免两位贡献者在同一个问题上花时间。

代码贡献步骤

  1. Fork 此仓库

    点击 Halo 仓库主页右上角的 Fork 按钮即可。

  2. Clone 仓库到本地

    bash
    git clone https://github.com/{YOUR_USERNAME}/halo --recursive
    # 或者 git clone [email protected]:{YOUR_USERNAME}/halo.git --recursive
  3. 添加主仓库

    添加主仓库方便未来同步主仓库最新的 commits 以及创建新的分支。

    bash
    git remote add upstream https://github.com/halo-dev/halo.git
    # 或者 git remote add upstream [email protected]:halo-dev/halo.git
    git fetch upstream main
  4. 创建新的开发分支

    我们需要从主仓库的主分支创建一个新的开发分支。

    bash
    git checkout upstream/main
    git checkout -b {BRANCH_NAME}
  5. 提交代码

    bash
    git add .
    git commit -s -m "Fix a bug king"
    git push origin {BRANCH_NAME}
  6. 合并主分支

    在提交 Pull Request 之前,尽量保证当前分支和主分支的代码尽可能同步,这时需要我们手动操作。示例:

    bash
    git fetch upstream/main
    git merge upstream/main
    git push origin {BRANCH_NAME}

Pull Request

进入此阶段说明已经完成了代码的编写,测试和自测,并且准备好接受 Code Review。

创建 Pull Request

回到自己的仓库页面,选择 New pull request 按钮,创建 Pull request 到原仓库的 main 分支。
然后等待我们 Review 即可,如有 Change Request,再本地修改之后再次提交即可。

提交 Pull Request 的注意事项:

  • 提交 Pull Request 请充分自测。
  • 每个 Pull Request 尽量只解决一个 Issue,特殊情况除外。
  • 应尽可能多的添加单元测试,其他测试(集成测试和 E2E 测试)可看情况添加。
  • 不论需要解决的 Issue 发生在哪个版本,提交 Pull Request 的时候,请将主仓库的主分支设置为 main。例如:即使某个 Bug 于 Halo 2.0.x 被发现,但是提交 Pull Request 仍只针对
    main 分支,等待 Pull Request 合并之后,我们会通过 /cherrypick release-2.0 或者 /cherry-pick release-2.1 指令将此 Pull Request
    的修改应用到 release-2.0 和 release-2.1 分支上。

更新 commits

Code Review 阶段可能需要 Pull Request 作者重新修改代码,请直接在当前分支 commit 并 push 即可,无需关闭并重新提交 Pull Request。示例:

bash
git add .
git commit -s -m "Refactor some code according code review"
git push origin bug/king

同时,若已经进入 Code Review 阶段,请不要强制推送 commits 到当前分支。否则 Reviewers 需要从头开始 Code Review。

开发规范

请参考 https://docs.halo.run/developer-guide/core/code-style
,请确保所有代码格式化之后再提交。


File: docs/developer-guide/core/build.md


title: 构建

:::info
在此之前,我们推荐你先阅读《准备工作》,检查本地环境是否满足要求。
:::

一般情况下,为了保证版本一致性和可维护性,我们并不推荐自行构建和二次开发。

克隆项目

如果你已经 Fork 了相关仓库,请将以下命令中的 halo-dev 替换为你的 GitHub 用户名。

bash
git clone https://github.com/halo-dev/halo

# 或者使用 ssh 的方式 clone(推荐)
# git clone [email protected]:halo-dev/halo.git

# 或者使用 GitHub CLI 克隆(推荐)
# gh repo clone halo-dev/halo 

# 或者使用 GitHub CLI Fork(推荐)
# gh repo fork halo-dev/halo

cd halo

# 切换到特定的分支或标签,请替换 ${branch_name}
git checkout ${branch_name}

构建 Fat Jar

构建之前需要修改 gradle.properties 中的 version 属性(推荐遵循 SemVer 规范),例如:version=2.25.0

bash
cd path/to/halo

下载预设插件(可选):

bash
# Windows
./gradlew.bat downloadPluginPresets

# macOS / Linux
./gradlew downloadPluginPresets

构建:

bash
# Windows
./gradlew.bat clean build -x check

# macOS / Linux
./gradlew clean build -x check

构建完成之后,在 Halo 项目下产生的 application/build/libs/halo-${version}.jar 即为构建完成的文件。

最终部署文档可参考:使用 JAR 文件部署。

构建 Docker 镜像

在此之前,请确认已经构建好了 Fat Jar。

bash
cd path/to/halo
bash
# 请替换 ${tag_name}
docker build -t halo-dev/halo:${tag_name} .
bash
# 插件构建完成的版本
docker images | grep halo

最终部署文档可参考:使用 Docker Compose 部署。


File: docs/developer-guide/core/code-style.md


title: 代码风格

Halo 添加了 checkstyle 插件,来保证每位提交者代码的风格保持一致,减少无效代码的修改。本篇文章主要讲解如何在 IDEA 中添加 CheckStyle 插件,并引入项目所提供的 checkstyle.xml 配置。

安装 CheckStyle-IDEA

  • 进入 IDEA 插件市场。
  • 搜索 CheckStyle-IDEA,点击安装即可。

配置 CheckStyle

  • 进入 CheckStyle 配置(File | Settings | Tools | Checkstyle)。
  • 选择 Checkstyle 版本:9.3(以文件 application/build.gradle 中指定的版本为准)。
  • 在配置文件中点击添加按钮,配置描述可随便填写(推荐 Halo Checks),选择 ./config/checkstyle/checkstyle.xml,点击下一步和完成。
  • 勾选刚刚创建的配置文件。

配置 Editor

  • 进入编辑器配置(File | Settings | Editor | Code Style)

  • 导入 checkstyle.xm 配置:

  • 选择 ./config/checkstyle/checkstyle.xml 配置文件,点击确定即可。

至此,有关代码风格检查工具和格式化配置已经完成。


File: docs/developer-guide/core/framework.md


title: Halo 架构概览

Halo 是一个基于 Spring Boot 的 Java Web 应用,Web 层不再使用 Servlet 技术,而是充分向异步和非阻塞的反应式编程靠拢,使用 Netty 作为 Web 服务器,使用 Reactor 作为异步编程框架,使用 R2DBC 作为数据库访问框架,使用 WebFlux 作为 Web 层框架。

Halo 由以下几个核心模块组成:

  • 安全模块:提供用户认证、授权、用户管理等功能。
  • 插件模块:提供插件管理、插件加载、插件通信、扩展点等功能。
  • 主题模块:提供主题管理、模板渲染、主题配置等功能。
  • 内置内容管理模块:提供文章、分类、标签、评论、附件、页面、菜单、设置等功能。

Halo 核心概念和 Extension

自定义模型 {#extension}

Extension 自定义模型提供了一种声明和管理数据模型的方法,它是 Halo 的核心概念之一。Halo 中的所有数据模型都是通过 Extension 来定义的,包括文章、分类、标签、评论、附件、页面、菜单、设置等,这便于插件系统可以灵活的进行数据模型的扩展,设计文档参考:自定义模型设计。

每个自定义模型都有三大类属性:metadata、spec、和 status。

  1. metadata 用于标识自定义模型,每个自定义模型都至少有三个 metadata 属性:name、creationTimestamp、version,除此之外还有 labels 用于标识自定义模型的标签,annotations 用于存放扩展信息,deletionTimestamp 用于标识自定义模型是否被删除,finalizers 用于标识自定义模型的是否可回收。
  2. spec 描述用户期望达到的理想状态(Desired State),比如用户可以配置插件的 spec.enable 属性为 true 来启用插件或者为 false 来停用插件,这就是用户期望达到的理想状态,然后插件控制器会根据用户的期望状态来实现插件的启用或停用,它是声明式的,用户只需要声明期望状态,实际状态由具体的控制器来维护,最终达到用户期望的状态。
  3. status 描述当前实际状态(Actual State),比如用户可以通过 status.phase 属性来查看插件启用进行到了哪一步,中间过程可能包含多个步骤,比如插件解析、加载、资源准备等,这些步骤都是由插件控制器来实现的,它是实际状态,只要插件控制器还在运行,它就会一直更新状态,最终达到用户期望的状态。

每个自定义模型注册后都会默认生成 CRUD APIs,通过这些 APIs 就可以对自定义模型对象进行增删改查的操作,然后只需要编写控制器来实现自定义模型的业务逻辑即可,这就是 Halo 的异步编程模型。

控制器 {#controller}

在 Halo 中,用户通过自定义模型定义资源的期望状态,Controller 负责监视资源的实际状态,当资源的实际状态和“期望状态”不一致时,Controller 则对系统进行必要的更改,以确保两者一致,这个过程被称之为调谐(Reconcile),而实现调谐的逻辑被称之为 Reconciler。Reconciler 获取对象的名称并返回是否需要重试(例如发生一些错误),如果需要重试,则 Controller 会在稍后再次调用 Reconciler,而这个过程会一直重复,直到 Reconciler 返回成功为止,这个过程被称之为调谐循环(Reconciliation Loop)。

自定义模型生命周期 {#extension-lifecycle}

所有 Halo 的自定义模型对象都遵循一个共同的生命周期,可以将其视为状态机,尽管某些特定的自定义模型扩展了这一点并提供了更多状态。要编写正确的控制器,了解公共对象生命周期非常重要。

所有自定义模型对象都存在以下状态之一:

  • DOES_NOT_EXIST:Halo 不知道该对象。该状态不区分“尚未创建”和“已删除”。
  • ACTIVE:Halo 知道该对象并且该对象尚未被删除(未设置 metadata.deletionTimestamp)。在此状态下,任何更新操作(PUT、PATCH、服务器端处理等)都将导致相同的状态。
  • DELETING:Halo 知道该对象,该对象已被删除,但尚未完全删除。这可能是因为对象有一个或多个终结器(在 metadata.finilizers 中),客户端仍然可以访问该对象,并且可以看到它正在删除,因为设置了 metadata.deleteTimestamp 字段。当最后一个终结器被删除时,该对象将从存储中删除,并真正不存在。

下图描述了上述状态:

text
+---- object
                                  |     updated
                                  v        |
                           +----------+    |
                           |          +----+
          object --------->|  ACTIVE  |
          created          |          +-----------+
             |             +---+------+           |
             |                 |                  |
             |                 |                  |
+------------+---+             |                  |
|                |     object deleted             |
|                |<--- without finalizers         |
|                |                           object deleted
| DOES_NOT_EXIST |                           with finalizers
|                |                                |
|                |<--- finalizers removed         |
|                |             |                  |
+----------------+             |                  |
                               |                  |
                               |                  |
                           +---+------+           |
                           |          |           |
                           | DELETING |<----------+
                           |          |
                           +----------+

总结:自定义模型对象的删除并不是立即生效的,而是需要经过两个步骤,第一步是将对象的 metadata.deletionTimestamp 字段设置为当前时间,第二步是将对象的 metadata.finalizers 字段设置为空,这样对象才会真正被删除,第一步是由用户发起的,第二步是由 Halo 控制器发起的。

Secret {#secret}

Secret 用于解决密码、token、密钥等敏感数据的配置问题,而不需要把这些敏感数据暴露到自定义模型的 Spec 中,或 API 响应中。

ConfigMap {#configmap}

ConfigMap 自定义模型用来保存 key-value pair 配置数据,这个数据可以在 Reconciler 里使用,或者被用来为插件或者主题存储配置数据。

虽然 ConfigMap 跟 Secret 类似,但是 ConfigMap 更方便的处理不含敏感信息的字符串。

Setting {#setting}

Setting 自定义模型用于提供用户配置声明,用户可以通过 Setting 来声明一些模板需要的配置,比如主题设置、插件设置、系统设置等都可以通过 Setting 来声明,就能在 UI 层面提供配置入口,用户可以通过 UI 来配置这些设置,而不需要修改配置文件。

基于角色的访问控制(RBAC){#rbac}

Halo 使用基于角色的访问控制(Role-based Access Control,RBAC)来控制用户对资源的访问权限,RBAC 通过将角色分配给用户来实现访问控制,用户可以通过角色来访问资源,角色可以通过权限来访问资源。

RBAC 主要引入了角色(Role)和角色绑定(RoleBinding)的抽象概念,插件可以通过定义角色来提供用户对资源的分配入口,用户可以通过角色绑定来获取角色,从而获取资源的访问权限。

而对于底层角色,用户分配起来比较麻烦,因此 Halo 提供了角色模板的概念,通过将角色标记为模板来使用一组功能相关的角色,如文章查看的角色可能必须包含标签和分类的查看才算是一组完整的功能,因此可以将文章查看的角色标记为模板,并依赖标签和分类的查看角色,这样用户就可以通过角色模板来获取一组功能相关的角色,而不需要一个一个的分配角色。


File: docs/developer-guide/core/prepare.md


title: 准备工作

环境要求

名词解释

工作目录

指 Halo 所依赖的工作目录,在 Halo 运行的时候会在系统当前用户目录下产生一个 halo-next 的文件夹,绝对路径为 ~/halo-next。里面通常包含下列目录或文件:

  1. db:存放 H2 Database 的物理文件,如果你使用其他数据库,那么不会存在这个目录。
  2. themes:里面包含用户所安装的主题。
  3. plugins:里面包含用户所安装的插件。
  4. attachments:附件目录。
  5. logs:运行日志目录。

File: docs/developer-guide/core/run.md


title: 开发环境运行

:::info
在此之前,我们推荐你先阅读《准备工作》,检查本地环境是否满足要求。
:::

项目结构说明

目前如果需要完整的运行 Halo,总共需要三个部分:

  1. Halo 主项目(halo-dev/halo)
  2. UI,包括 Console 控制台和 UC 个人中心(托管在 Halo 主项目)
  3. 主题(Halo 主项目内已包含默认主题)

:::info[说明]
从 Halo 2.11 开始,Halo 项目的 ui 目录同时包含了 Console(管理控制台)和 UC(个人中心),以下统称为 UI。

当前 Halo 主项目并不会将 UI 的构建资源托管到 Git 版本控制,所以在开发环境是需要同时运行 UI 项目的。当然,在我们的最终发布版本的时候会在 CI 中自动构建 UI 到 Halo 主项目。
:::

克隆项目

如果你已经 Fork 了相关仓库,请将以下命令中的 halo-dev 替换为你的 GitHub 用户名。

bash
git clone https://github.com/halo-dev/halo

# 或者使用 ssh 的方式 clone(推荐)
# git clone [email protected]:halo-dev/halo.git

# 或者使用 GitHub CLI 克隆(推荐)
# gh repo clone halo-dev/halo 

# 或者使用 GitHub CLI Fork(推荐)
# gh repo fork halo-dev/halo

运行 UI 服务

bash
cd path/to/halo/ui
pnpm install
pnpm build:packages
pnpm dev

最终控制台打印了如下信息即代表运行正常:

bash
VITE v8.0.0  ready in 805 ms

➜  Local:   http://localhost:3000/
➜  Network: http://192.168.1.7:3000/

:::info[提示]
请不要直接使用 UI 的运行端口 3000 访问,会因为跨域问题导致无法正常登录,建议按照后续的步骤以 dev 的配置文件运行 Halo,在 dev 的配置文件中,我们默认代理了 UI 页面的访问地址,所以后续访问 UI 页面使用 http://localhost:8090/console 和 http://localhost:8090/uc 访问即可,代理的相关配置:

yaml
halo:
  ui:
    proxy:
      endpoint: http://localhost:3000/
      enabled: true

:::

运行 Halo

  1. 在 IntelliJ IDEA 中打开 Halo 项目,等待 Gradle 初始化和依赖下载完成。

  2. 下载预设插件(可选)

    bash
    # Windows
    ./gradlew.bat downloadPluginPresets
    
    # macOS / Linux
    ./gradlew downloadPluginPresets
  3. 修改 IntelliJ IDEA 的运行配置

    • Windows

      将 Active Profiles 改为 dev,win,如图所示:

    • macOS / Linux

      将 Active Profiles 改为 dev,如图所示:

  4. 点击 IntelliJ IDEA 的运行按钮,等待项目启动完成。

  5. 或者使用 Gradle 运行

    bash
    # macOS / Linux
    ./gradlew bootRun --args="--spring.profiles.active=dev"
    
    # Windows
    gradlew.bat bootRun --args="--spring.profiles.active=dev,win"
  6. 最终提供以下访问地址:

    1. 网站首页:http://localhost:8090
    2. Console 控制台:http://localhost:8090/console
    3. UC 个人中心:http://localhost:8090/uc

File: docs/developer-guide/core/structure.md


title: 系统结构

Halo 博客系统分为以下四个部分:

项目名称 简介
halo 提供整个系统的服务,采用 Spring Boot 开发
halo-admin 负责后台管理的渲染,采用 Vue 开发,已集成在 Halo 运行包内,无需独立部署。
halo-comment 评论插件,采用 Vue 开发,在主题中运行方式引入构建好的 JavaScript 文件即可
halo-theme-* 主题项目集,采用 FreeMarker 模板引擎编写,需要包含一些特殊的配置才能够被 halo 所使用

自定义配置

为什么要提前讲自定义配置呢?是因为在这里让大家了解到 Halo 的配置方式,以及配置优先级,不至于未来运行项目的时候不知道如何优雅地修改配置。

Halo 配置目录优先级如下(从上到下优先级越来越小,上层的配置将会覆盖下层):

  • Halo 自定义配置
    • file:~/.halo/
    • file:~/.halo-dev/
  • Spring Boot 默认配置
    • file:./config/
    • file:./
    • classpath:/config/
    • classpath:/

参考:Application Property Files

在开发的时候,希望大家能够在 ~/halo-dev/application.yml 中进行添加自定义配置。当然后面也会讲到如何用运行参数 和 VM options 进行控制配置,届时可根据具体情况进行选择。

:::warning
开发的时候,我们不建议直接更改项目源码中的所包含的配置文件,包括 application.yml、application-dev.yml、application-test.yml 和 application-user.yml。
:::