├── .gitignore ├── .husky └── pre-commit ├── .markdownlint.json ├── OWNERS ├── README.md ├── attachment └── Thumbnails.md ├── extension └── README.md ├── identity ├── 001-access.md ├── 002-security.md ├── 003-encryption.md └── assets │ └── 158128456-ef984faa-bff6-4b8b-aed0-c92f6ae3ddd8.jpg ├── package.json ├── plugin ├── assets │ └── image-20220507180723198.png ├── pluggable-design.drawio └── pluggable-design.md ├── pnpm-lock.yaml ├── setting └── README.md ├── template.md └── theme └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Misc 5 | .DS_Store 6 | 7 | # Editor 8 | .idea 9 | 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint 5 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD033": false, 3 | "MD024": false, 4 | "MD029": false, 5 | "MD010": { "code_blocks": true, "spaces_per_tab": 4 }, 6 | "MD046": { "style": "fenced" }, 7 | "line-length": false 8 | } 9 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | reviewers: 2 | - ruibaby 3 | - guqing 4 | - JohnNiang 5 | - lan-yonghui 6 | - wangzhen-fit2cloud 7 | 8 | approvers: 9 | - ruibaby 10 | - guqing 11 | - JohnNiang 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rfcs 2 | 3 | ## 如何贡献RFC 4 | 5 | 使用模板 [RFC Template](./template.md) 来撰写新功提案,然后对 [rfcs](https://github.com/halo-dev/rfcs) 仓库发起 PR。 6 | 7 | > RFC Template 灵感来源:[planning-with-requests-for-comments](https://increment.com/planning/planning-with-requests-for-comments/) 8 | -------------------------------------------------------------------------------- /attachment/Thumbnails.md: -------------------------------------------------------------------------------- 1 | ## 背景 2 | 3 | 在当前的 Halo 系统中,附件管理功能虽然支持多种存储策略(如本地存储和通过插件扩展的 S3 协议存储),但缺乏缩略图生成功能。这导致在管理端上传大尺寸图片时,页面加载速度显著降低。 4 | 5 | 具体来说,在管理后台的附件管理界面中,由于图片文件往往较大,浏览器需要加载原始尺寸的图片,这会导致页面加载时间过长,影响管理效率。同样地,在主题端,图片加载缓慢也会显著影响用户体验,尤其是在网络状况不佳或访问量较大的情况下,这种问题会更加突出。 6 | 7 | 为了提升图片加载性能,缩略图功能显得尤为重要。缩略图不仅可以显著减少管理端加载图片的时间,还可以让主题端在展示图片时,根据不同设备和网络情况加载合适尺寸的图片,提升整体访问速度和用户体验。因此,引入一个灵活且可扩展的缩略图生成机制,支持不同存储策略下的缩略图生成与管理,成为当前 Halo 系统优化的关键。 8 | 9 | ## 已有需求 10 | 11 | 用户和开发者对于附件管理的需求日益增加,尤其是在高效管理和展示图片资源方面。以下是系统中已经存在的一些需求,这些需求促使我们考虑引入缩略图功能: 12 | 13 | 1. **附件展示问题**: 在管理后台中,图片仅有原图的访问地址,这导致图片列表加载时非常缓慢,影响页面流畅度 ​ ([halo-dev/halo#3167](https://github.com/halo-dev/halo/issues/3167)) 14 | 2. **渲染性能问题**: 多个 issue 提到在附件库中,加载大量大图会导致页面卡顿,甚至出现浏览器崩溃的情况,这凸显了对缩略图的迫切需求 ​ ([halo-dev/halo#3830](https://github.com/halo-dev/halo/issues/3830))​ ([halo-dev/halo#2387](https://github.com/halo-dev/halo/issues/2387)) 15 | 3. **用户体验问题**: 当用户上传过大的图片时,访问附件库和前端页面时加载速度缓慢,影响了整体用户体验 ​ ([halo-dev/halo#5196](https://github.com/halo-dev/halo/issues/5196)) 16 | 17 | 由于 Halo 中缺乏缩略图功能,导致在管理和展示图片时出现性能瓶颈。为了解决这个问题,Halo 被迫进行了一些功能上的调整: 18 | 19 | - **使用 CSS3 硬件加速**:通过利用 CSS3 的硬件加速特性,优化了附件库中图片的渲染性能,试图减轻大尺寸图片带来的加载压力。([halo-dev/halo#3831](https://github.com/halo-dev/halo/pull/3831)) 20 | - **调整默认排版模式**:为了减少界面渲染的负担,附件管理界面的默认排版模式从缩略图视图调整为列表模式,以减少页面加载时间。([halo-dev/console#827](https://github.com/halo-dev/console/pull/827)) 21 | 22 | 这些调整在一定程度上缓解了由于缺少缩略图功能而带来的在附件管理上的性能问题,但它们并不能根本上解决问题。因此,缩略图功能的引入仍然是满足当前系统需求的关键所在。 23 | 24 | ## 目标 25 | 26 | 本 RFC 的主要目标是在 Halo 博客平台中引入一个灵活且可扩展的缩略图生成功能,以解决目前由于缺乏缩略图功能而导致的性能问题和用户体验下降。具体目标如下: 27 | 28 | 1. **提升系统性能**: 29 | 30 | - 通过生成和使用缩略图,减少管理端和主题端加载大尺寸图片时的网络带宽占用和页面渲染时间,从而显著提升系统的整体性能。 31 | 32 | 2. **优化用户体验**: 33 | 34 | - 在主题端使用缩略图替代原始图片,以加快页面加载速度,提升用户浏览体验,减少因图片加载慢而导致的页面卡顿或延迟问题。 35 | 36 | 3. **支持多样化的存储策略**: 37 | 38 | - 为不同的存储策略(如本地存储、S3 协议等)提供灵活的缩略图生成和调用机制。针对本地存储,系统将自动生成并存储缩略图;针对支持参数化缩略图生成的云存储,允许实现者根据配置动态生成缩略图 URL。 39 | 40 | 4. **提供可扩展的开发接口**: 41 | 42 | - 设计并实现一个扩展点,使开发者能够根据具体的存储需求和策略自定义缩略图生成逻辑。通过这个扩展点,开发者可以轻松地集成自定义的缩略图生成插件,以满足不同的业务需求。 43 | 44 | 5. **提高管理效率**: 45 | 46 | - 在管理后台,使用缩略图替代大尺寸图片,提升附件管理界面的加载和操作速度,使管理者能够更高效地浏览和处理附件资源。 47 | 48 | 6. **保持兼容性与灵活性**: 49 | - 在引入缩略图功能的同时,确保与现有的附件管理和存储机制保持兼容,并为未来的功能扩展预留灵活性。 50 | 51 | 通过实现这些目标,Halo 博客平台将能够更好地应对当前和未来的需求,不仅提升性能和用户体验,还为开发者提供更强大的扩展能力。 52 | 53 | ## 非目标 54 | 55 | 本 RFC 的目标是引入和实现缩略图生成功能,以提升系统性能和用户体验,但以下内容不在本 RFC 的范围之内: 56 | 57 | 1. **不涉及图像内容的分析与处理**: 58 | 59 | - 本功能不包含任何图像内容分析(如图像识别、分类)或处理(如滤镜应用、图像增强)的功能。缩略图功能仅限于生成和管理不同尺寸的图像缩略图。 60 | 61 | 2. **不改变现有的存储策略实现**: 62 | 63 | - 本 RFC 并不涉及对现有存储策略(如本地存储、S3 协议扩展)的底层逻辑和实现方式进行修改。缩略图功能将以插件或扩展点的形式集成到现有系统中。 64 | 65 | 3. **不提供图片的格式转换**: 66 | 67 | - 本功能不会提供图片格式的转换功能,所有缩略图将使用与原图相同的格式进行生成和存储,避免涉及到图片格式的兼容性问题。 68 | 69 | 4. **不影响现有的附件管理功能**: 70 | - 引入缩略图功能不会影响现有的附件上传、下载、删除等基本功能。这些功能将保持不变,并与缩略图功能兼容。 71 | 72 | ## 方案 73 | 74 | 实现方案分为两大部分:**缩略图生成**和**缩略图的使用**。下面将分别介绍这两部分的设计和实现。 75 | 76 | ### 缩略图生成 77 | 78 | 在缩略图生成方面,有以下几个关键问题需要解决: 79 | 80 | 1. **缩略图尺寸的设计**:如何定义和选择合适的缩略图尺寸,以满足不同场景的需求。 81 | 2. **缩略图生成策略**:如何根据原始图片生成不同尺寸的缩略图,以保证图片质量和加载速度。 82 | 3. **缩略图存储策略**:如何将生成的缩略图存储在系统中,并与原始图片关联。 83 | 4. **缩略图生成的性能优化**:如何通过缓存和预生成等方式,提升缩略图生成的性能和效率。 84 | 5. **缩略图生成的扩展性**:如何设计和实现一个可扩展的缩略图生成机制,以支持不同的存储策略和需求。 85 | 86 | 考虑到使用方式和性能优化等因素,我们选择使用固定尺寸的缩略图,而非由具体宽度动态生成。这种设计有以下优势: 87 | 88 | - **性能优化**:使用预定义的固定尺寸缩略图,可以减少缩略图滥用的计算和生成压力,在满足图片加载速度的同时,可以减小缩略图的数量和对资源的消耗。 89 | - **一致性和可控性**:固定尺寸的缩略图可以保证图片展示的一致性和布局稳定性,过定义固定的缩略图尺寸,开发者可以更好地控制图片在不同场景中的展示效果。这样可以避免动态生成的图片尺寸不一致,导致页面布局问题或视觉不统一。 90 | - **缓存利用**:固定尺寸的缩略图更容易被浏览器和 CDN 缓存,有助于加快图片加载速度,并减少对服务器的重复请求。 91 | 92 | #### 缩略图尺寸设计 93 | 94 | 基于以上考虑,我们为系统预定义了以下几种常见的缩略图尺寸: 95 | 96 | - **Small (S)**:宽度 400px 97 | - **Medium (M)**:宽度 800px 98 | - **Large (L)**:宽度 1200px 99 | - **Extra Large (XL)**:宽度 1600px 100 | 101 | 定义枚举类型以便扩展点接口中使用: 102 | 103 | ```java 104 | public enum ThumbnailSize { 105 | S(400), 106 | M(800), 107 | L(1200), 108 | XL(1600); 109 | 110 | private final int width; 111 | 112 | ThumbnailSize(int width) { 113 | this.width = width; 114 | } 115 | 116 | public int getWidth() { 117 | return width; 118 | } 119 | } 120 | ``` 121 | 122 | #### 自定义模型设计 123 | 124 | 为了将图片与缩略图关联同时避免重复生成,需要定义一个自定义模型类,用于存储图片的 URL 和缩略图的 URL 之间的映射关系: 125 | 126 | ```yaml 127 | apiVersion: storage.halo.run/v1alpha1 128 | kind: Thumbnail 129 | metadata: 130 | name: thumbnail-1 131 | spec: 132 | imageSignature: e99a18c428cb38d5f260853678922e03 133 | imageUri: /path/to/original.jpg 134 | size: L 135 | thumbnailUri: /path/to/thumbnail-L.jpg 136 | ``` 137 | 138 | 基于 `imageUri` 生成的 MD5 签名,然后为 `imageSignature` 和 `size` 建立组合索引,一方面可以显著提高查询性能,同时也可以减少索引的大小。 139 | 140 | ```java 141 | indexSpec.add(new IndexSpec() 142 | .setName(Thumbnail.ID_INDEX) 143 | .setIndexFunc(simpleAttribute(Thumbnail.class, Thumbnail::idIndexFunc)) 144 | ); 145 | 146 | public static String idIndexFunc(Thumbnail thumbnail) { 147 | return idIndexFunc(thumbnail.getSpec().getImageSignature(), 148 | thumbnail.getSpec().getSize().name()); 149 | } 150 | 151 | public static String idIndexFunc(String imageHash, String size) { 152 | return imageHash + "-" + size; 153 | } 154 | ``` 155 | 156 | #### 扩展点接口设计 157 | 158 | 为了保证缩略图功能的灵活性,我们设计了一个扩展点接口,使开发者能够根据不同的存储策略自定义缩略图生成逻辑。 159 | 例如,OSS 可能提供了根据参数生成缩略图的 API,可以避免在 Halo 生成: 160 | 161 | ```java 162 | public interface ThumbnailProvider extends ExtensionPoint { 163 | 164 | /** 165 | * 根据给定的图片 URL 和尺寸生成缩略图 URL。 166 | * @param context 缩略图上下文,包含图片 URL 和尺寸等信息 167 | * @return 缩略图 URL 168 | */ 169 | Mono generate(ThumbnailContext context); 170 | 171 | /** 172 | * 根据给定的图片 URL 删除对应的缩略图文件 173 | * @param imageUrl 原图片的 URL 174 | */ 175 | Mono delete(String imageUrl); 176 | 177 | /** 178 | * 判断当前提供者是否支持给定的图片 URL。 179 | * 180 | * @param imageUrl 图片的 URL 181 | * @return 如果支持,返回 true;否则返回 false 182 | */ 183 | Mono supports(ThumbnailContext context); 184 | 185 | @Data 186 | @Builder 187 | public static class ThumbnailContext { 188 | private final String imageUrl; 189 | private final ThumbnailSize size; 190 | } 191 | } 192 | ``` 193 | 194 | 系统将提供一个默认的 `ThumbnailProvider` 实现,针对本地存储生成缩略图。对于其他存储策略(如 S3 协议的 OSS),开发者可以通过插件实现该接口,动态生成和返回缩略图 URL。 195 | 如果找不到合适的 `ThumbnailProvider` 实现,则不生成缩略图,避免因为用户本来就不需要缩略图而浪费资源。 196 | 197 | 缩略图会在首次使用时调用 `generate` 方法生成链接,然后将其记录到 `Thumbnail` 自定义模型中,而这个链接对应的缩略图是否已经生成取决于实现者,可能在 `generate` 方法调用时就已经生成,也可能是缩略图 URL 被访问时生成。 198 | 199 | 当附件被删除时,会调用 `delete` 方法删除对应的缩略图文件,以避免无用的缩略图占用存储空间。 200 | 201 | #### 本地存储缩略图生成 202 | 203 | 对于本地存储,我们将提供一个默认的 `ThumbnailProvider` 实现,用于生成和存储缩略图。具体实现如下: 204 | 205 | 1. 根据参数给定的图片 URL 和尺寸,生成缩略图 URL,但不生成缩略图文件。 206 | 2. 将生成的缩略图 URL 和原始图片 URL 以及存储路径等信息保存到自定义模型中。 207 | 3. 通过 Reconciler 监听资源变化,为其生成缩略图文件。 208 | 4. 当缩略图 URL 被访问时,根据缩略图 URL 查询关联的缩略图资源并在查询到时检查缩略图文件是否存在,如果不存在则生成缩略图文件,否则返回 404。 209 | 210 | ##### 本地存储的自定义模型设计 211 | 212 | 自定义模型设计如下: 213 | 214 | ```yaml 215 | apiVersion: storage.halo.run/v1alpha1 216 | kind: LocalThumbnail 217 | metadata: 218 | name: local-thumbnail-1 219 | spec: 220 | imageSignature: e99a18c428cb38d5f260853678922e03 221 | imageUri: /path/to/original.jpg 222 | thumbnailUri: /path/to/thumbnail-L.jpg 223 | thumbSignature: e99a18c428cb38d5f260853678922e03 224 | filePath: /attachments/thumbnails/2024/w1200/2024-08-09.jpg 225 | size: L 226 | ``` 227 | 228 | 索引设计如下: 229 | 230 | ```java 231 | schemeManager.register(LocalThumbnail.class, indexSpec -> { 232 | indexSpec.add(new IndexSpec() 233 | .setName("spec.imageSignature") 234 | .setIndexFunc(simpleAttribute(LocalThumbnail.class, 235 | thumbnail -> thumbnail.getSpec().getImageSignature()) 236 | )); 237 | indexSpec.add(new IndexSpec() 238 | .setName("spec.thumbSignature") 239 | .setUnique(true) 240 | .setIndexFunc(simpleAttribute(LocalThumbnail.class, 241 | thumbnail -> thumbnail.getSpec().getThumbSignature()) 242 | )); 243 | }); 244 | ``` 245 | 246 | 定义 `LocalThumbnailEndpoint` 用于提供缩略图 URL 的访问,本地存储策略的图片缩略图规则为: 247 | 248 | - Endpoint: `/upload/thumbnails/{year}/w{size}/{image-name}` 249 | - 存储路径: `attachments/thumbnails/{year}/w{size}/{image-name}` 250 | 251 | 示例: 252 | 253 | - Endpoint: `/upload/thumbnails/2024/w1200/2022-01-01.jpg` 254 | - 存储路径: `attachments/thumbnails/2024/w1200/2022-01-01.jpg` 255 | 256 | ##### 缩略图生成库选择 257 | 258 | 在库的选择上,[Thumbnailator](https://github.com/coobird/thumbnailator) 和 [imgscalr](https://github.com/rkalla/imgscalr) 是最常用的两个缩略图生成库,简单易用且高效。 259 | 260 | 我们选择使用 imgscalr 作为 Halo 的缩略图生成库,主要基于以下考虑: 261 | 262 | - **性能优化:** imgscalr 对性能进行了较好的优化,尤其适合处理大量图像或要求较高图像处理速度的场景。 263 | - **高质量的图像处理:** imgscalr 提供了多种图像处理模式,可以在性能和图像质量之间找到平衡。 264 | 265 | 示例代码: 266 | 267 | ```java 268 | private final URL imageUrl; 269 | private final ThumbnailSize size; 270 | private final Path storePath; 271 | 272 | public Mono generate() { 273 | return Mono.fromRunnable(() -> { 274 | String formatName = getFormatName(imageUrl); 275 | try { 276 | var img = ImageIO.read(imageUrl); 277 | BufferedImage thumbnail = Scalr.resize(img, size.getWidth()); 278 | var thumbnailFile = getThumbnailFile(formatName); 279 | ImageIO.write(thumbnail, formatName, thumbnailFile); 280 | } catch (IOException e) { 281 | throw Exceptions.propagate(e); 282 | } 283 | }); 284 | } 285 | 286 | private static String getFormatName(URL input) { 287 | try { 288 | return doGetFormatName(input); 289 | } catch (IOException e) { 290 | // 获取不到图片格式时,返回 jpg 291 | return "jpg"; 292 | } 293 | } 294 | 295 | private static String doGetFormatName(URL input) throws IOException { 296 | try (var inputStream = input.openStream(); 297 | ImageInputStream imageStream = ImageIO.createImageInputStream(inputStream)) { 298 | Iterator readers = ImageIO.getImageReaders(imageStream); 299 | if (!readers.hasNext()) { 300 | throw new IOException("No ImageReader found for the image."); 301 | } 302 | ImageReader reader = readers.next(); 303 | return reader.getFormatName().toLowerCase(); 304 | } catch (IOException e) { 305 | throw new IIOException("Can't get input stream from URL!", e); 306 | } 307 | } 308 | ``` 309 | 310 | ### 缩略图使用 311 | 312 | #### 主题端 313 | 314 | 在主题端展示图片时,开发者可以利用 `srcset` 来实现响应式图片加载。通过在 `srcset` 属性中定义多个尺寸的图片 URL,浏览器会根据设备的屏幕分辨率和窗口大小,自动选择最合适的图片加载。 315 | 316 | 选择使用 srcset 的原因包括: 317 | 318 | - 响应式设计:srcset 能够根据不同设备的分辨率和屏幕大小,自动选择最佳的图片尺寸。这种机制确保了在高分辨率设备上显示高质量图片的同时,也能够在低分辨率设备上节省带宽。 319 | 320 | - 提升加载性能:通过为图片提供多个尺寸的缩略图,浏览器可以选择最适合当前视窗的图片进行加载,从而减少不必要的带宽使用,提升页面加载速度,改善用户体验。 321 | 322 | - 简单集成:srcset 的使用非常直观,并且已经得到了主流浏览器的广泛支持。通过在图片标签中定义不同尺寸的图片 URL,开发者可以很容易地利用这个功能。 323 | 324 | 开发者可以通过定义缩略图的不同尺寸,在 `srcset` 中实现自动选择最适合的图片。例如: 325 | 326 | ```html 327 | Example Image 338 | ``` 339 | 340 | 通过调用 `thumbnail.gen(imageUri, size)` 方法,开发者可以简便地生成缩略图 URL,并将其应用在 `srcset` 中,实现图片的响应式加载。 341 | 342 | #### 文章内容中的图片 343 | 344 | 可以考虑同时使用以下两种方式支持: 345 | 346 | 1. 通过实现 `ReactivePostContentHandler` 扩展点,然后在 `handle` 方法中获取文章的 HTML 内容并解析 `img` 标签的 `src` 属性得到图片 URL,再根据图片 URL 生成缩略图 URL 347 | 设置到 `srcset` 属性中,设置 `srcset` 属性前需要先判断是否已经存在 `srcset` 属性,如果存在则不再设置,避免覆盖开发者自定义的 `srcset` 属性。 348 | 2. 富文本编辑器中插入图片时,自动为图片设置 `srcset` 属性,以便在文章发布时自动加载缩略图。 349 | 350 | #### 管理端 351 | 352 | 在管理端,通过获取 Attachment 的 status 中保存的 thumbnails 使用。该值是由 AttachmentReconciler 监听附件的变化,当 permalink 被生成后,生成缩略图 URL 并填充到 status 中。 353 | 354 | ```java 355 | // when permalink is generated 356 | if (AttachmentUtils.isImage(attachment)) { 357 | populateThumbnails(permalink, status); 358 | } 359 | 360 | void populateThumbnails(String permalink, AttachmentStatus status) { 361 | var imageUri = URI.create(permalink); 362 | Flux.fromArray(ThumbnailSize.values()) 363 | .flatMap(size -> thumbnailService.generate(imageUri, size) 364 | .map(thumbUri -> Map.entry(size.name(), thumbUri.toString())) 365 | ) 366 | .collectMap(Map.Entry::getKey, Map.Entry::getValue) 367 | .doOnNext(status::setThumbnails) 368 | .block(); 369 | } 370 | ``` 371 | 372 | ### 总结 373 | 374 | 通过以上详细的实现方案设计,Halo 的缩略图功能将具备高度的灵活性和可扩展性,能够满足不同存储策略下的需求,并显著提升系统的性能和用户体验。 375 | 开发者在实现和使用该功能时,将能够享受更简洁的代码风格和更优雅的集成方式。 376 | -------------------------------------------------------------------------------- /extension/README.md: -------------------------------------------------------------------------------- 1 | # 自定义模型设计 2 | 3 | ## 简介 4 | 5 | 本片文章主要讲解为什么需要设计“自定义模型”,以及“自定义模型”的概要设计。 6 | 7 | ## 动机 8 | 9 | 截止 Halo 1.5 的发布,Halo 几乎没有扩展性,插件机制在社区的呼声比较高,比如访问统计需求。插件机制可以非常好的解决自定义需求的问题,且预计在 2.0 10 | 中完成插件机制的功能。虽然我们可以提供一些扩展点供插件使用,但是插件的自定义数据需求是必不可少的。例如,访问统计插件需要记录某些文章或者页面的 pv,uv 等,需要将数据持久化到 Halo 11 | 中。所以,我们非常有必要设计自定义模型为插件提供数据支持。 12 | 13 | ### 目标 14 | 15 | - 能够任意定义自定义资源,并生成对应的 schema 配置应用到 Halo Core 中。 16 | - 能够方便对自定义资源进行查询,获取,更新和删除操作。 17 | - 监听资源的变更,包括创建,更新。 18 | 19 | ### 非目标 20 | 21 | - 允许多个模型版本共存。 22 | - 允许模型定义者删除或修改字段。 23 | - 通过 YAML 的方式导入自定义模型。 24 | - 开发 haloctl 命令工具。 25 | 26 | ## 需求 27 | 28 | - Halo Core 的模型也能够抽象成自定义模型,如用户,文章,分类,标签,评论等。 29 | - 为插件提供自定义数据支持。比如某插件需要存储自定义数据,同时也想读取和操作自定义数据。 30 | 31 | ## 提议 32 | 33 | 在 Halo Core 中提供自定义模型的机制,支持通过预定义的 API 注册自定义模型,提供自定义模型数据访问,操作和验证能力。 34 | 35 | ## 自定义模型设计 36 | 37 | Halo 38 | 自定义模型主要参考自 [Kubernetes CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) 39 | 。自定义模型主要遵循 [OpenAPI v3](https://spec.openapis.org/oas/v3.1.0)。自定义模型的 YAML 模型如下: 40 | 41 | ```yaml 42 | groupVersion: extensions.halo.run/v1 43 | kind: ExtensionDefinition 44 | metadata: 45 | # CRD 名称,<名称复数形式.组名> 46 | name: person.myplugin.johnniang.me 47 | spec: 48 | names: 49 | # 组名,主要用于生成 RESTful API: /apis/<组名>/<版本>。 50 | group: my-plugin.johnniang.me 51 | # 名称复数形式,用于生成 RESTful API:/apis/<组名>/<版本>/<名称复数形式> 52 | plural: persons 53 | # 用于插件注册使用,采用驼峰命名法。 54 | kind: Person 55 | # 名称单数形式。 56 | singular: person 57 | # 全限定类名 58 | class: person.my-plugin.johnniang.me.extension.Person 59 | schema: 60 | type: object 61 | properties: 62 | group: 63 | type: string 64 | version: 65 | type: string 66 | kind: 67 | type: string 68 | metadata: 69 | $ref: '#/components/schemas/Metadata' 70 | name: 71 | maxLength: 100 72 | type: string 73 | description: The description on name field 74 | age: 75 | maximum: 150 76 | minimum: 0 77 | type: integer 78 | description: The description on age field 79 | format: int32 80 | gender: 81 | type: string 82 | description: The description on gender field 83 | enum: 84 | - MALE 85 | - FEMALE 86 | referencedSchemas: 87 | Metadata: 88 | type: object 89 | properties: 90 | name: 91 | type: string 92 | annotations: 93 | type: object 94 | additionalProperties: 95 | type: string 96 | labels: 97 | type: object 98 | additionalProperties: 99 | type: string 100 | resourceVersion: 101 | type: integer 102 | format: int32 103 | creationTimestamp: 104 | type: string 105 | format: date-time 106 | updationTimestamp: 107 | type: string 108 | format: date-time 109 | deletionTimestamp: 110 | type: string 111 | format: date-time 112 | ``` 113 | 114 | Extension 基本模型: 115 | 116 | ```java 117 | public interface Extensible { 118 | 119 | GroupVersionKind groupVersionKind(); 120 | 121 | void groupVersionKind(GroupVersionKind gvk); 122 | 123 | Metadata metadata(); 124 | 125 | void metadata(Metadata metadata); 126 | } 127 | 128 | @Data 129 | public abstract class Extension implements Extensible { 130 | 131 | private String group; 132 | private String version; 133 | private String kind; 134 | 135 | private Metadata metadata; 136 | 137 | @Override 138 | public GroupVersionKind groupVersionKind() { 139 | return new GroupVersionKind(group, version, kind); 140 | } 141 | 142 | public void groupVersionKind(String group, String version, String kind) { 143 | this.group = group; 144 | this.version = version; 145 | this.kind = kind; 146 | } 147 | 148 | public void groupVersionKind(GroupVersionKind gvk) { 149 | this.groupVersionKind(gvk.group(), gvk.version(), gvk.kind()); 150 | } 151 | 152 | public Metadata metadata() { 153 | return metadata; 154 | } 155 | 156 | public void metadata(Metadata metadata) { 157 | this.metadata = metadata; 158 | } 159 | } 160 | 161 | @Data 162 | public final class Metadata { 163 | private String name; 164 | private Map annotations; 165 | private Map labels; 166 | private long resourceVersion; 167 | } 168 | ``` 169 | 170 | 对应的 Person class 如下: 171 | 172 | ```java 173 | 174 | @Data 175 | @EqualsAndHashCode(callSuper = true) 176 | @ToString(callSuper = true) 177 | @GVK(group = "my-plugin.johnniang.me", 178 | version = "v1alpha1", 179 | kind = "Person", 180 | plural = "persons", 181 | singular = "person") 182 | public class Person extends Extension { 183 | 184 | @Schema(description = "The description on name field", maxLength = 100) 185 | private String name; 186 | 187 | @Schema(description = "The description on age field", maximum = "150", minimum = "0") 188 | private Integer age; 189 | 190 | @Schema(description = "The description on gender field") 191 | private Gender gender; 192 | 193 | private Person otherPerson; 194 | 195 | public enum Gender { 196 | MALE, FEMALE, 197 | } 198 | } 199 | ``` 200 | 201 | 对应的 Person 资源样例如下: 202 | 203 | ```yaml 204 | groupVersion: my-plugin.johnniang.me/v1alpha1 205 | kind: Person 206 | metadata: 207 | name: johnniang 208 | annotations: 209 | my-plugin.johnniang.me/first_name: John 210 | my-plugin.johnniang.me/last_name: Niang 211 | resourceVersion: 123 212 | name: johnniang 213 | age: 18 214 | gender: male 215 | ``` 216 | 217 | ### Halo Core 设计 218 | 219 | 注册 Person 到 Halo Core 中: 220 | 221 | ```java 222 | schema.register(Person.class); 223 | ``` 224 | 225 | 此时,可通过 `ExtensionClient` 接口获取和操作数据: 226 | 227 | ```java 228 | interface ExtensionClient { 229 | 230 | List list(Class type, Predicate criteria); 231 | 232 | Page page(Class type, Predicate criteria, Pageable pageable); 233 | 234 | Optional get(Class type, String name); 235 | 236 | void create(T extension); 237 | 238 | T update(T extension); 239 | 240 | T delete(T extension); 241 | } 242 | ``` 243 | 244 | ### Extension 存储设计 245 | 246 | 关系型数据库设计: 247 | 248 | ```sql 249 | create table halo 250 | ( 251 | name varchar(630), 252 | version bigint, 253 | value mediumblob, 254 | primary key (name) 255 | ); 256 | 257 | -- 列表查询仅依赖索引的左前缀查询。 258 | create unique index halo_name_idx on halo (name); 259 | ``` 260 | 261 | 我们仅需提供几个简单的操作,接口定义如下: 262 | 263 | ```java 264 | public interface Client { 265 | 266 | List list(String key); 267 | 268 | List listByPrefix(String prefix); 269 | 270 | Optional get(String key); 271 | 272 | void create(String key, byte[] value); 273 | 274 | void update(String key, long version, byte[] value); 275 | 276 | void delete(String key, long version); 277 | 278 | void watch(String prefix, Consumer consumer); 279 | 280 | record Value(String key, byte[] value, long version) { 281 | } 282 | 283 | record ValueChange(ChangeEvent event, Value oldValue, Value newValue) { 284 | } 285 | } 286 | 287 | ``` 288 | 289 | ### Schema 验证设计 290 | 291 | Scheme Validator 接口设计: 292 | 293 | ```java 294 | interface Validator { 295 | 296 | void onCreate(T extension) throws ValidationException; 297 | 298 | void onUpdate(T extension) throws ValidationException; 299 | } 300 | ``` 301 | 302 | ```java 303 | class PersonValidator implements Validator { 304 | 305 | public void onCreate(Person person) throws ValidationException { 306 | if (!StringUtils.hasText(person.name)) { 307 | throw new ValidationException("name required.") 308 | } 309 | if (person.age == null) { 310 | person.age = 0; 311 | } 312 | } 313 | 314 | void onUpdate(Person person) throws ValidationException { 315 | if (person.age == null) { 316 | person.age = 0; 317 | } 318 | } 319 | } 320 | ``` 321 | 322 | 注册 Schema Validator: 323 | 324 | ```java 325 | schema.registerValidator(new PersonValidator()); 326 | ``` 327 | 328 | ### API 设计 329 | 330 | #### Halo Core API 331 | 332 | 核心 API 组成模式为:`/api///{extensionname}/`。例如: 333 | 334 | ```shell 335 | GET /api/v1/posts 336 | GET /api/v1/posts/my-post 337 | GET /api/v1/posts/my-post/categories 338 | ``` 339 | 340 | #### Extension API 341 | 342 | 注册 Extension 后,Halo API Server 将会为它生成 Extension 343 | API,组成模式为:`/apis////{extensionname}/`,例如: 344 | 345 | ```shell 346 | GET /apis/my-plugin.johnniang.me/v1alpha1/persons 347 | GET /apis/my-plugin.johnniang.me/v1alpha1/persons/johnniang 348 | ``` 349 | 350 | ## 问题 351 | 352 | 1. 查询效率问题:如何低内存实现查询? 353 | 354 | 目前查询过程都在内存中进行。后续可实现磁盘查询(牺牲时间换空间)。 355 | 356 | 2. 前端模型如何兼容? 357 | 358 | 我们可以创建“前端模型”的自定义模型,用于描述前端模型。 359 | 360 | 3. 是否支持通过接口创建自定义模型? 361 | 362 | 目前暂时不支持。不过设计和实现的过程中考虑一下这种场景,方便未来添加该功能。 363 | 364 | 4. 如何查询自定义模型数据? 365 | 366 | 目前查询的排序和过滤逻辑都需要手动编写。系统可仅对 metadata 和 labels 创建索引进行过滤和排序。 367 | 368 | 5. 如何注入模型? 369 | 370 | 1. 核心模型会默认实现在 Halo 的核心部分; 371 | 2. 其他模型则通过插件机制注册。 372 | -------------------------------------------------------------------------------- /identity/001-access.md: -------------------------------------------------------------------------------- 1 | # Halo 身份和访问管理概述 2 | 3 | 本文档为 Halo 系统中的身份和访问管理提出了一个方向。 4 | 5 | ## 高层次的目标 6 | 7 | - 制定方案来确定如何将身份、授权和认证融入 API 8 | - 轻松与现有企业和托管方案集成 9 | 10 | ### 背景和动机 11 | 12 | 安全性是非常重要的服务之一,任何应用程序都需要以一种非常优雅和简单的方式全面处理所有重要部分。 13 | 14 | 而我们应该从系统开发的一开始就考虑安全性,如果不及早考虑会随着时间的推移产生对软件品质从短期到长期的影响,甚至可能引发其他的系统故障。 15 | 16 | ## 设计 17 | 18 | ### 参与者 19 | 20 | 这些中的每一个都可以充当普通用户或攻击者: 21 | 22 | - 外部用户:访问 Halo 应用的人(例如访问 Halo 应用前台,但没有 API 访问权限) 23 | - Halo 访客用户:拥有部分基础功能访问权限的用户(例如通过 Halo 注册成为访客允许用该账号登录以对该系统的文章进行评论) 24 | - Halo 普通用户:访问 Halo API 的人 25 | - Halo 管理员:管理某些 Halo 用户访问权限的人员 26 | 27 | ### 威胁 28 | 29 | 故意攻击和意外使用特权都值得关注。 30 | 31 | 对于这两种情况,以不同的方式考虑这些类别可能很有用: 32 | 33 | - 应用程序路径——通过 Halo 暴露出的应用程序输入端向服务器进行攻击 34 | - Halo API 路径 ——通过响任何 Halo 的 API 端点发送网络消息进行攻击 35 | - 内部路径——对 Halo 的系统功能的攻击。攻击者可能拥有访问管理后台或一些API的特权 36 | 37 | ### 要保护的资产 38 | 39 | 外部用户资产: 40 | 41 | - 用户评论内容、上传的图片和个人信息 42 | 43 | Halo 用户资产: 44 | 45 | - 上传的图片、文件和个人信息 46 | - Halo 应用私有的东西,例如: 47 | - 访问系统 API 的凭证 48 | - 系统 API 49 | - 系统主题文件 50 | - 备份文件 51 | - 插件文件 52 | 53 | ### 身份 54 | 55 | > 一种跟踪 API 参与者的方式 56 | 57 | Halo 会有一个 User 对象: 58 | 59 | - user 有一个不可变的 id。这用于将用户与对象关联并在审计日志中记录操作 60 | - user 有一个字符串 name,它在 user 中是人类可读且唯一的。它用于系统登录或展示以确保引用到 user 的对象是可读的。邮件地址可以作为此字段的默认值 61 | - user 对象可以有其他属性以更多的标识特征 62 | - user 具有指示组成员身份和担任某些角色的能力标签 63 | 64 | 系统可以将一个或多个身份验证方法应用到一个 user,但他们不是 user 的正式组成部分。 65 | 66 | 初始特点: 67 | 68 | - 默认有一个超级用户 user 69 | 70 | ### 身份认证 71 | 72 | > 如何确定和检查参与者的身份 73 | 74 | 认证的目标: 75 | 76 | - 包括一个内置身份验证系统 77 | - 避免混合认证和授权,以便集中在那里授权策略,并允许在不影响授权代码的情况下更改认证方法 78 | 79 | 初始阶段: 80 | 81 | - 设计用于对用户进行身份验证的令牌 82 | - 长期存在的令牌表示特定于 user 83 | - 管理员可以在系统创建令牌 84 | - OAuth2.0 Bearer Token 协议 85 | - Token 具有 scopes,认证发生在 Halo 主应用 86 | - 由 Halo 主应用生成令牌,用于识别 API 调用 87 | - 在 Halo 中检查令牌 88 | - 身份验证可以通过标志禁用,允许在未授权的情况下进行测试 89 | 90 | 改进: 91 | 92 | - refresh token 93 | - 允许外部系统处理身份验证,以允许与现有的企业授权系统集成。 94 | 95 | ### 授权 96 | 97 | > 身份可以在哪个上下文中执行哪些操作 98 | 99 | Halo 授权应该: 100 | 101 | - 通过有明确语义的 scope 分配策略在进行系统授权 102 | - 简化操作路径,必要时尽量通过对 scope 的分配来生成对后台菜单资源的访问策略,从而避免还要再次对菜单资源授权 103 | - 允许集中管理用户和授权 104 | - 尽可能与身份验证分开,以允许身份验证方法岁时间和空间变化,而不会影响授权策略 105 | - 将授权公开为 API 对象,以便可以由管理员添加 scope,进而为自定义 API 提供授权服务 106 | 107 | Halo 将实现基于 scope 的访问控制模型,为了简化批量用户的权限分配系统带有角色功能。 108 | 109 | ### 审计日志 110 | 111 | 可以记录 API 操作。 112 | 113 | 初步实施: 114 | 115 | - 所有 API 调用都记录到 `access.log` 中 116 | 117 | 改进: 118 | 119 | - 将现有的操作日志使用同一个 `access.log` 来完成,不在存储到数据库 120 | - 提供日志的删除策略 121 | - 高速受信 API 调用的记录策略 122 | - 受信用户的访问记录 123 | - 其他敏感功能的访问记录 124 | -------------------------------------------------------------------------------- /identity/002-security.md: -------------------------------------------------------------------------------- 1 | # Halo 中的安全性草案 2 | 3 | ## 目标 4 | 5 | - 提出针对系统安全性的具体实施方案 6 | - 充分考虑到对安全机制的可扩展性、易用性和可维护性 7 | - 提出如何降低针对后续版本升级可能存在的对安全性改动难度的解决方案 8 | 9 | ## 动机 10 | 11 | 基于现有 Halo 1.0 基础,我们有如下需求: 12 | 13 | - Halo 需要一种可以方便接入其他认证方式的安全性机制 14 | - 需要一种可以同时兼容 API key 和 basic 认证的解决方案 15 | - 需要可以灵活创建 API Key 16 | - 需要一种简单分配权限的机制 17 | - 要考虑到自定义 API 和插件 API 的有效认证和授权 18 | - 可以针对不同的使用场景分配不同的 API Key 19 | 20 | ## 设计 21 | 22 | ### 用户认证 23 | 24 | Halo 中的用户分为三类,管理员、普通用户、和访客。 25 | 26 | Halo 允许管理员查看或管理普通用户和访客,访客用户可由管理员开启注册功能后自行注册。 27 | 28 | #### 身份认证策略 29 | 30 | Halo 通过身份认证模块利用持有者令牌(Bearer Token)来认证 API 请求的身份。HTTP 请求发给服务器时,认证模块会将以下属性关联到请求本身: 31 | 32 | - 用户名:用来辩识最终用户的字符串。常见的值可以是 `admin` 或 `example@example.com`。 33 | - 用户 ID:用来辩识最终用户的字符串,旨在比用户名有更好的一致性和唯一性。 34 | - 附加字段:一组额外的键 - 值映射,键是字符串,值是一组字符串;用来保存一些鉴权组件可能 觉得有用的额外信息。 35 | 36 | 所有(属性)值对于身份认证系统而言都是不透明的,只有被 **鉴权组件** 解释过之后才有意义。 37 | 38 | 你可以同时启用多种身份认证方法,当启用了多个身份认证模块时,第一个成功地对请求完成身份认证的模块会直接做出评估决定。服务器并不保证身份认证模块的运行顺序。 39 | 40 | #### 令牌格式 41 | 42 | - 用户登录成功后统一生成 `jwt` 格式的 `token`,`jwt` 使用的密钥由系统安装时生成 `RSAKey` 到工作目录。 43 | - 管理员可以创建 `personal access token`, 格式为:`类型前缀_36 位短 token` 例如:`hc_ac5fQe9pERSXRlud3WydzpRVDI4nSh3Iqkcq` 44 | 45 | [正如我们从 Slack](https://api.slack.com/authentication/token-types) 和 [Stripe](https://stripe.com/docs/api/authentication) 等公司看到的那样,令牌前缀是使令牌可识别的一种明确方法。`h` 表示 `halo`,`c` 表示令牌类型的第一个字母 46 | 47 | - `ha` 用于访问 `admin api` 的令牌 48 | - `hc` 用于访问 `content api` 的令牌 49 | 50 | 使这些前缀在令牌中清晰可辨,以提高可读性。因此,添加了一个分隔符:`_` 并且双击它时,它会可靠地选择整个令牌。 51 | 52 | 当需要身份验证(例如,对于跨域请求)进行身份验证,便可以使用 `-H"Authorization: Bearer ha_ac5fQe9pERSXRlud3WydzpRVDI4nSh3Iqkcq"` 53 | 54 | 所有 `person access token` 颁发时都需要选择 `scope` 以确保令牌的访问权限在一个可控的范围内。 55 | 56 | 前端的菜单访问权限由前端根据登录用户的 scope 自行限制。 57 | 58 | #### 认证方式 59 | 60 | 针对不同的 token 验证方式可以提供不同的 `Provider` 而后统一使用 `AuthenticationManager` 来进行认证,当然这会由 `Spring security` 帮我们处理,我们只需要根据不同的认证方式创建不同的 `Provider` 和过滤器即可。 61 | 62 | 例如: 63 | 64 | 针对使用用户名密码登录时颁发 `jwt token` 的过滤器和 Provider 65 | 66 | ```java 67 | org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 68 | ``` 69 | 70 | ```java 71 | org.springframework.security.authentication.dao.DaoAuthenticationProvider 72 | ``` 73 | 74 | 根据以上类针对 `personal access token` 提供 75 | 76 | - RestrictedApiKeyAuthenticationFilter 77 | - RestrictedApiKeyAuthenticationProvider 78 | 79 | 优化: 80 | 81 | - `Content api` 可以不为其指定 `scope` 即允许访问所有公共的 `api` 资源,可以称之为 `publishable key` 82 | - 一次性令牌也可使用同样的认证方式,例如格式为:`ho_ac5fQe9pERSXRlud3WydzpRVDI4nSh3Iqkcq`,并提供 `OttAuthenticationFilter` 和 `OttAuthenticationProvider` 来为一次性令牌策略进行认证。 83 | - 对短令牌使用 CRC32 进行 checksum,可以过滤掉伪造的 token 的请求,减少查询数据库的次数 84 | 85 | ![authentication processing filter](https://docs.spring.io/spring-security/reference/_images/servlet/authentication/architecture/abstractauthenticationprocessingfilter.png) 86 | 87 | #### 在请求中放入持有者令牌 88 | 89 | 当使用持有者令牌来对某 HTTP 客户端执行身份认证时,API 服务器希望看到 一个名为 `Authorization` 的 HTTP 头,其值格式为 `Bearer THETOKEN`。 持有者令牌必须是一个可以放入 HTTP 头部值字段的字符序列,至多可使用 HTTP 的编码和引用机制。 例如:如果持有者令牌为 `31ada4fd-adec-460c-809a-9e56ceb75269`,则其 出现在 HTTP 头部时如下所示: 90 | 91 | ```plaintext 92 | Authorization: Bearer 31ada4fd-adec-460c-809a-9e56ceb75269 93 | ``` 94 | 95 | #### 匿名请求 96 | 97 | 启用匿名请求支持之后,如果请求没有被已配置的其他身份认证方法拒绝,则被视作 匿名请求(Anonymous Requests)。这类请求获得用户名 `system:anonymous` 和 对应的角色为 `system:unauthenticated`。 98 | 99 | 例如,在一个配置了令牌身份认证且启用了匿名访问的服务器上,如果请求提供了非法的 持有者令牌,则会返回 `401 Unauthorized` 错误。 如果请求没有提供持有者令牌,则被视为匿名请求。 100 | 101 | ### 鉴权概述 102 | 103 | 为了避免做一个全面的权限系统,本方案计划通过 [基于角色的权限控制(RBAC)](https://en.wikipedia.org/wiki/Role-based_access_control) 实施一个简单易用的授权方案。 104 | 105 | 相比与其他授权方式: 106 | 107 | - [基于表达式的访问控制 (EBAC)](https://docs.spring.io/spring-security/site/docs/5.0.7.RELEASE/reference/html/el-access.html) 108 | - [基于属性的访问控制 (ABAC)](https://en.wikipedia.org/wiki/Attribute-based_access_control) 109 | 110 | ### 使用 RBAC 鉴权 111 | 112 | 基于角色(Role)的访问控制(RBAC)是一种基于组织中用户的角色来调节控制对 计算机或网络资源的访问的方法。 113 | 114 | 提供一种通过 API 动态配置策略的方式来进行授权,配置示例如下: 115 | 116 | ```yaml 117 | apiVersion: halo.run/v1 118 | kind: "Role" 119 | metadata: 120 | name: role-template-manage-categories 121 | labels: 122 | halo.run/role-template: true 123 | annotations: 124 | halo.run/dependencies: ["role-template-view-categories"] 125 | halo.run/module: "Categories Management" 126 | halo.run/display-name: "Categories Management" 127 | rules: 128 | - apiGroups: ["halo.run"] 129 | resources: ["categories"] 130 | verbs: ["*"] #["create", "delete", "deletecollection", "get", "list", "patch", "update"] 131 | --- 132 | apiVersion: halo.run/v1 133 | kind: "Role" 134 | metadata: 135 | name: role-template-view-posts 136 | labels: 137 | halo.run/role-template: true 138 | annotations: 139 | halo.run/module: "Posts Management" 140 | halo.run/display-name: "Posts View" 141 | rules: 142 | - apiGroups: ["halo.run"] 143 | resources: ["posts", categories","tags","options","settings","users"] 144 | verbs: ["get", "list"] 145 | --- 146 | apiVersion: halo.run/v1 147 | kind: "Role" 148 | metadata: 149 | name: role-template-manage-posts 150 | # creationTimestamp: "2022-03-23T03:10:55Z" 151 | labels: 152 | halo.run/role-template: true 153 | annotations: 154 | halo.run/dependencies: 155 | ["role-template-view-posts", "role-template-manage-categories"] 156 | halo.run/module: "Posts Management" 157 | halo.run/display-name: "Posts Management" 158 | rules: [] 159 | --- 160 | apiVersion: halo.run/v1 161 | # 此角色绑定允许 "jane" 读取 "default" 名字空间中的 Pods 162 | kind: RoleBinding 163 | metadata: 164 | name: manage-posts 165 | subjects: 166 | # 你可以指定不止一个 “subject(主体)” 167 | - kind: User 168 | name: jane # "name" 是区分大小写的 169 | apiGroup: halo.run 170 | roleRef: 171 | # "roleRef" 指定与某 Role 的绑定关系 172 | kind: Role # 此字段必须是 Role 173 | name: role-template-manage-posts # 此字段必须与你要绑定的 Role 的名称匹配 174 | apiGroup: halo.run 175 | ``` 176 | 177 | #### API 对象 178 | 179 | RBAC 声明了两种可用对象:*Role*、 *RoleBinding* 来描述或修改对象。 180 | 181 | #### Role 182 | 183 | RBAC *角色* 包含表示一组权限的规则。这些权限是纯粹累加的(不存在拒绝某操作的规则)。 184 | 185 | #### Role 示例 186 | 187 | ```yaml 188 | apiVersion: halo.run/v1 189 | kind: "Role" 190 | metadata: 191 | name: role-manage-categories 192 | rules: 193 | - apiGroups: [""] #"" 标明 core API 组 194 | resources: ["categories"] 195 | verbs: ["*"] 196 | ``` 197 | 198 | 它表示定义了一个名为 `role-manage-categories` 的角色,拥有核心 `API` 下所有 `categories` 的权限。 199 | 200 | Halo API (以及生态系统中的相关 API)的约定旨在简化客户端开发并确保可以实现在各种不同用例中一致地工作的配置机制。 201 | 202 | Halo API 的一般风格是 `RESTful`—— 客户端通过标准 HTTP 动词(POST、PUT、DELETE 和 GET)创建、更新、删除或检索对象的描述 —— 这些 API 优先接受和返回 JSON。Halo 还为非标准动词公开了额外的端点,并允许替代 `contentTypes`.。服务器接受和返回的所有 JSON 都有一个模式,由 “kind” 和 “apiVersion” 字段标识。如果存在相关的 HTTP 标头字段,它们应该反映 JSON 字段的内容,但信息不应仅在 HTTP 标头中表示。 203 | 204 | 以下的术语定义: 205 | 206 | - **Kind** - 特定对象 `schema` 的名称(例如,“Cat” 和 “Dog” 类型将具有不同的特征和属性) 207 | - **Resource** - 系统实体的表示,通过 HTTP 以 JSON 形式发送或检索到服务器。资源通过以下方式公开: 208 | - 集合 - 相同类型的资源列表,可能是可查询的 209 | - 元素 - 单个资源,可通过 URL 寻址 210 | - **apiVersion** - 表示一组被暴露在一起的资源以及在 “apiVersion” 字段中暴露的版本,如 “GROUP/VERSION”,例如 “组 / 版本” 为 “halo.run/v1”。 211 | 212 | 每个资源通常接受并返回单一种类的数据。一个种类可以被反映特定用例的多个资源接受或返回。 213 | 214 | 资源在 API 组中绑定在一起 —— 每个组可能有一个或多个独立于其他 API 组发展的版本,并且组内的每个版本都有一个或多个资源。组名通常采用域名形式。——Halo 项目保留使用空组,选择群组名称时,我们建议选择您的组或组织拥有的子域,例如 `widget.mycompany.com`。 215 | 216 | 资源集合应该都是小写和复数,而种类是 CamelCase 和单数。组名必须小写并且是有效的 DNS 子域。 217 | 218 | > Reference: [types-kinds](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#types-kinds) 219 | 220 | #### 资源动词 221 | 222 | API 资源应该使用传统的 REST 模式: 223 | 224 | - GET`/` - 检索 `` 类型的列表,例如 GET /posts 返回 Post 列表。 225 | - POST `/` - 从客户端提供的 JSON 对象创建一个新资源。 226 | - GET `//` - 检索具有给定名称的单个资源,例如 GET `/users/halo` 返回一个名为 “halo” 的 用户。 227 | - DELETE `//` - 删除具有给定名称的单个资源。 228 | - DELETE `/` - 删除 `` 类型的列表,例如 DELETE /categories 一个 Category 列表。 229 | - PUT `//` - 使用客户端提供的 JSON 对象更新或创建具有给定名称的资源。 230 | - PUT `///` - 更新给定名称的资源的数据。例如 `PUT /plugins/s3/enabled` 可表示启用 S3 插件。 231 | - PATCH `//` - 有选择地修改资源的指定字段。 232 | 233 | #### 确定请求动词 234 | 235 | **非资源请求** 对除 `/api/v1/...` 或 `/apis///...` 之外的端点的请求被视为 “非资源请求”,并使用请求的小写 HTTP 方法作为动词。例如,`GET` 对端点的请求就像 `/api` 或 `/healthz` 将 `get` 用作动词。 236 | 237 | | HTTP verb | request verb | 238 | | --------- | ------------------------------------------------------------ | 239 | | POST | create | 240 | | GET, HEAD | get (for individual resources), list (for collections, including full object content), watch (for watching an individual resource or collection of resources) | 241 | | PUT | update | 242 | | PATCH | patch | 243 | | DELETE | delete (for individual resources), deletecollection (for collections) | 244 | 245 | 允许针对非资源端点 `/healthz` 和其子路径上发起 GET 和 POST 请求示例: 246 | 247 | ```yaml 248 | rules: 249 | - nonResourceURLs: ["/healthz", "/healthz/*"] # nonResourceURL 中的 '*' 是一个全局通配符 250 | verbs: ["get", "post"] 251 | ``` 252 | 253 | #### RoleBinding 254 | 255 | 角色绑定(Role Binding)是将角色中定义的权限赋予一个或者一组用户。 它包含若干 **主体**(用户、组或服务账户)的列表和对这些主体所获得的角色的引用。 256 | 257 | 一个 RoleBinding 可以引用任何 Role。 258 | 259 | RoleBinding 示例: 260 | 261 | 下面的例子中的 RoleBinding 将 "manage-posts" Role 授予用户 "jane"。 这样,用户 "jane" 就具有了管理文章的权限。 262 | 263 | ```yaml 264 | apiVersion: halo.run/v1 265 | # 此角色绑定允许 "jane" 读取 "default" 名字空间中的 Pods 266 | kind: RoleBinding 267 | metadata: 268 | name: manage-posts 269 | subjects: 270 | # 你可以指定不止一个 “subject(主体)” 271 | - kind: User 272 | name: jane # "name" 是区分大小写的 273 | apiGroup: halo.run 274 | roleRef: 275 | # "roleRef" 指定与某 Role 的绑定关系 276 | kind: Role # 此字段必须是 Role 277 | name: role-template-manage-posts # 此字段必须与你要绑定的 Role 的名称匹配 278 | apiGroup: halo.run 279 | ``` 280 | 281 | 创建了绑定之后,你不能再修改绑定对象所引用的 Role 。 试图改变绑定对象的 `roleRef` 将导致合法性检查错误。 如果你想要改变现有绑定对象中 `roleRef` 字段的内容,必须删除重新创建绑定对象。 282 | 283 | #### 对资源的引用 284 | 285 | 在 Halo API 中,大多数资源都是使用对象名称的字符串表示来呈现与访问的。 例如,对于 Post 应使用 "posts"。 RBAC 使用对应 API 端点的 URL 中呈现的名字来引用资源。 有一些 Halo API 涉及 **子资源(subresource)**,例如 分类下的文章。 对 根据分类获取文章的请求看起来像这样: 286 | 287 | ```http 288 | GET /api/v1/categories/{name}/posts 289 | ``` 290 | 291 | 在这里,`categories` 对应名字空间作用域的 Category 资源,而 `posts` 是 `categories` 的子资源。 在 RBAC 角色表达子资源时,使用斜线(`/`)来分隔资源和子资源。 要允许某主体读取 `categories` 同时访问这些 Category 的 `posts` 子资源,你可以这么写: 292 | 293 | ```yaml 294 | apiVersion: rbac.authorization.k8s.io/v1 295 | kind: Role 296 | metadata: 297 | name: category-and-posts-reader 298 | rules: 299 | - apiGroups: [""] 300 | resources: ["categories", "categories/posts"] 301 | verbs: ["get", "list"] 302 | ``` 303 | 304 | #### 聚合 Role 305 | 306 | 你可以将若干 Role **聚合(Aggregate)** 起来,形成一个复合的 Role。 307 | 308 | ```yaml 309 | apiVersion: halo.run/v1 310 | kind: "Role" 311 | metadata: 312 | name: role-template-manage-categories 313 | annotations: 314 | halo.run/dependencies: ["role-template-view-categories"] 315 | rules: 316 | - apiGroups: ["halo.run"] 317 | resources: ["categories"] 318 | verbs: ["*"] 319 | ``` 320 | 321 | 它表示将 `role-template-view-categories` 角色的规则和当前 `role-template-manage-categories` 的规则聚合起来形成 `role-template-manage-categories` 角色的规则。 322 | 323 | #### 对主体的引用 324 | 325 | RoleBinding 可绑定角色到某 *主体(Subject)* 上。 主体可以是组,用户或者 服务账户。 326 | 327 | Halo 用字符串来表示用户名。 用户名可以是普通的用户名,像 "alice";或者是邮件风格的名称,如 "bob@example.com", 或者是以字符串形式表达的数字 ID。 328 | 329 | > 前缀 `system:` 是系统保留的,所以你要确保 所配置的用户名或者组名不能出现上述 `system:` 前缀。 330 | 331 | RoleBinding 示例 : 332 | 333 | 下面示例是 `RoleBinding` 中的片段,仅展示其 `subjects` 的部分。 334 | 335 | 对于名称为 `alice@example.com` 的用户: 336 | 337 | ```yaml 338 | subjects: 339 | # 你可以指定不止一个 “subject(主体)” 340 | - kind: User 341 | name: "alice@example.com" 342 | apiGroup: halo.run 343 | ``` 344 | 345 | #### 角色模板 346 | 347 | 系统模块或插件可以通过预设角色来作为模板由管理员通过 API 或客户端创建角色时绑定,比如文章管理模板创建有两个角色模板,文章管理角色和文章查看角色,而文章管理角色依赖文章查看角色。 348 | 349 | 如下示例,通过制定 `metadata.labels.role-template:true` 来指定该角色属于角色模板,那么在客户端角色列表界面将不会展示该角色,而是在权限分配页展示。 350 | 351 | ```yaml 352 | apiVersion: halo.run/v1 353 | kind: "Role" 354 | metadata: 355 | name: role-template-manage-categories 356 | labels: 357 | halo.run/role-template: true 358 | annotations: 359 | halo.run/dependencies: ["role-template-view-posts"] 360 | halo.run/module: "Posts Management" 361 | halo.run/display-name: "Posts Management" 362 | rules: 363 | - apiGroups: ["halo.run"] 364 | resources: ["posts"] 365 | verbs: ["*"] 366 | ``` 367 | 368 | ## 考虑的替代方案 369 | 370 | - [Generate a Secure Random](https://www.baeldung.com/java-generate-secure-password) 371 | - [AES Encryption and Decryption](https://www.baeldung.com/java-aes-encryption-decryption) 372 | - [Java Security Standard Algorithm Names](https://docs.oracle.com/en/java/javase/13/docs/specs/security/standard-names.html) 373 | - 通过 url 匹配的方式去授权 374 | - 用户、角色和权限之间绑定关系的 yaml 设计 375 | - apiGroups 和 apiResources 的 yaml 结构 376 | -------------------------------------------------------------------------------- /identity/003-encryption.md: -------------------------------------------------------------------------------- 1 | # Halo 授权的加密验证机制 2 | 3 | ## 动机 4 | 5 | 在安全性方面,为了防止请求伪造和数据泄露经常需要很多加密方式来阻止这一切,Halo 也如此,我们需要一种更为安全的方式来传输数据和身份验证。 6 | 7 | ## 目标和非目标 8 | 9 | - 提出针对后台用户身份确认的密钥机密机制 10 | - 提出针对自定义 API Token 的安全的验证机制 11 | - 提出用户密码的机密方式,防止密码泄露 12 | - 提出一种可以减少伪造密钥对系统资源占用的方案 13 | 14 | ## 设计 15 | 16 | ### API token 17 | 18 | 如果用户不配置 `salt` 则在安装时在工作空间创建名为 `apiToken.salt` 的文件,使用如下代码生成 `salt`。 19 | 20 | ```java 21 | BytesKeyGenerator bytesKeyGenerator = KeyGenerators.secureRandom(16); 22 | String salt = new String(Hex.encode(bytesKeyGenerator.generateKey())); 23 | ``` 24 | 25 | 通过 CRC32 对生成的 `API_TOKEN+salt` 进行签名后拼接到 `API_TOKEN` 后在对其进行 `Base62` 编码 26 | 27 | ```java 28 | public class EncryptionTest { 29 | // 安装时生成的 salt 30 | private final String salt = "1c4a4c069de3e9d16bdc300f8af21a36"; 31 | 32 | @Test 33 | public void test () { 34 | // 生成 32 位的随机 API Token 35 | String apiToken = new String(Hex.encode(KeyGenerators.secureRandom(16).generateKey())); 36 | // apiToken + salt 并生成 crc32 37 | String checksum = crc32((apiToken + salt).getBytes()); 38 | // 将 checksum 拼接 39 | String finalApiToken = Base62.encode(apiToken + checksum); 40 | 41 | // 最终结果示例: 4IuENoNS1YaVJlVs8KetWnWQXJLKyobzncU6zIBf7Y4MjjDtLOC2pN 42 | } 43 | 44 | public String crc32 (byte [] array) { 45 | CRC32 crc32 = new CRC32(); 46 | crc32.update(array); 47 | return Long.toHexString(crc32.getValue()); 48 | } 49 | } 50 | ``` 51 | 52 | ### JWT token 53 | 54 | 配置 `JwtDecoder` 和 `JwtEncoder` 使用 `RSAKey` 55 | 56 | ```java 57 | @Configuration 58 | public class SecurityConfiguration { 59 | 60 | @Value ("${jwt.public.key}") 61 | RSAPublicKey key; 62 | 63 | @Value ("${jwt.private.key}") 64 | RSAPrivateKey priv; 65 | 66 | @Bean 67 | JwtDecoder jwtDecoder () { 68 | return NimbusJwtDecoder.withPublicKey(this.key).build(); 69 | } 70 | 71 | @Bean 72 | JwtEncoder jwtEncoder () { 73 | JWK jwk = new RSAKey.Builder(this.key).privateKey(this.priv).build(); 74 | JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(jwk)); 75 | return new NimbusJwtEncoder(jwks); 76 | } 77 | } 78 | ``` 79 | 80 | 配置密钥位置 81 | 82 | ```yaml 83 | jwt: 84 | private.key: classpath:app.key 85 | public.key: classpath:app.pub 86 | ``` 87 | 88 | 使用实例: 89 | 90 | ```java 91 | @RestController 92 | public class TokenController { 93 | 94 | @Autowired 95 | JwtEncoder encoder; 96 | 97 | @PostMapping ("/token") 98 | public String token (Authentication authentication) { 99 | Instant now = Instant.now(); 100 | long expiry = 36000L; 101 | 102 | String scope = authentication.getAuthorities().stream() 103 | .map (GrantedAuthority::getAuthority) 104 | .collect (Collectors.joining(" ")); 105 | JwtClaimsSet claims = JwtClaimsSet.builder() 106 | .issuer("self") 107 | .issuedAt(now) 108 | .expiresAt(now.plusSeconds(expiry)) 109 | .subject (authentication.getName()) 110 | .claim("scope", scope) 111 | .build(); 112 | 113 | return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); 114 | } 115 | } 116 | ``` 117 | 118 | ### Password 119 | 120 | ```java 121 | @Test 122 | public void test () { 123 | // Spring Security 默认的 strength 为 10,也可以使用 -1 表示使用默认值 124 | int strength = 10; 125 | String plainPassword = "12345678"; 126 | // 使用 SecureRandom 作为盐生成器,因为它提供了加密强的随机数 127 | BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(strength, new SecureRandom()); 128 | // 加密密码 129 | String encodedPassword = bCryptPasswordEncoder.encode(plainPassword); 130 | 131 | System.out.println(encodedPassword); 132 | 133 | // 密码匹配 134 | boolean matches = bCryptPasswordEncoder.matches(plainPassword, encodedPassword); 135 | assertThat(matches).isTrue(); 136 | } 137 | ``` 138 | 139 | ## 考虑的替代方案 140 | 141 | - [Generate a Secure Random](https://www.baeldung.com/java-generate-secure-password) 142 | - [AES Encryption and Decryption](https://www.baeldung.com/java-aes-encryption-decryption) 143 | - [Java Security Standard Algorithm Names](https://docs.oracle.com/en/java/javase/13/docs/specs/security/standard-names.html) 144 | -------------------------------------------------------------------------------- /identity/assets/158128456-ef984faa-bff6-4b8b-aed0-c92f6ae3ddd8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo-dev/rfcs/949294f821f1b4a5bb29d2bbe928dc6329f7327d/identity/assets/158128456-ef984faa-bff6-4b8b-aed0-c92f6ae3ddd8.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@halo-dev/rfcs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "lint": "markdownlint-cli2 '**/*.md' '#node_modules'", 7 | "prepare": "husky install" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/halo-dev/rfcs.git" 12 | }, 13 | "author": "halo-dev", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/halo-dev/rfcs/issues" 17 | }, 18 | "homepage": "https://github.com/halo-dev/rfcs#readme", 19 | "devDependencies": { 20 | "husky": "^7.0.4", 21 | "markdownlint": "^0.25.1", 22 | "markdownlint-cli2": "^0.4.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /plugin/assets/image-20220507180723198.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo-dev/rfcs/949294f821f1b4a5bb29d2bbe928dc6329f7327d/plugin/assets/image-20220507180723198.png -------------------------------------------------------------------------------- /plugin/pluggable-design.drawio: -------------------------------------------------------------------------------- 1 | 7Vxdd6M2EP01fkwOILDxo3HsdNvNqbvpaZu+yaDYNBixWE7s/fUVILCF+DRf3m3zYhjJMtw7M7oaQUZgvjs++tDbPmELOSNFso4j8DBSFFnTJfoRWE7MIk0nkWXj2xaznQ3P9jcUd2TWg22hPdeRYOwQ2+ONJnZdZBLOBn0ff/DdXrHD/6oHN0gwPJvQEa1/2hbZMqssSeeGn5C92bKf1jXWsIbm28bHB5f9notdFLXsYDwM67rfQgt/XJjAYgTmPsYkOtod58gJcI0Ri763zGlNLtlHLqnyhb9Xf+x//sC/bI13ZfWkvBlT73inMZ7eoXNgWLCrJacYnPD2UDCKNALGx9Ym6NmDZtD6Qf2B2rZk59AzmR5acL8N+wYnDlwjx0ggmmMH+7QpBCluxb6F/FQLuybkE3TMvVs5wZD6JcI7RPwT7ZI4JYOdueSEnX6c6dVjZrYXzCa+DJlLbZKhz9jSAwZvDaj1OkjL5UhfC+6r7TixaaSApb7QFxNq3xMfvyGuZT6bT9uhI4n0OEMAkY+JlMGHLLfAh/1ikm8nU3/8dWIdzN8Oh7X3eKcIfHxBXw9oT6jxCXqe7W7o0Re0sSky0G9GVg5eGajmQ6ikIJxoAoTyNANCVevIo9UKyQNZNNGyU+yTLd5gFzqLs9Xg08u5z2eMPYbeP4iQE5s14IFgHttX7JILt5XCP2qnUPunv4Jx77X49IX9THjycOTOTuwsuofgwoudnd4nPvgmKkCI4UGgv0GkoJ+cQ72PHEjsd/5CsngMvzrzfXi66OBh2yX7i5FXgeHsUWNJ4zwqTpLLnP5AK+xPD6IrOHtUcisNnEy/JZ8awjf078E3xkAawDemQ/jGtXnlhnzqQoDesE/p6gA+JcqCkTJ2SCCQPOhyzjb+egjEu8EdbYLPlXPY2O4TdOm6wY+/Ty8nGiLq04agaKTJNK2CoJD7FBRAwP7T7GlwnFRQAacs7dqd8Bok7R1tcpH16NnLRcs55wUnHUgpUDG1gYaZrRExmuDAiyNB7t7GQeZY2pSBVhYSjfx5rFfw56y1cWf+PBVgm2MfCUDRWyY8GvyaNWOBy0zQsTcuPTUpRJQBYAQA2iZ0ZqxhZ1tWGCZZ8POh00bmTWcUIDIwziAAdFWbUMTMK6AflBm83JtnlTm4jrtLdUFJlwiAJJYIlKz17aQrVGS5HJVaxbFrSzZCaWb+MFOMhVjMWagPRljMqeSkBa4gsjQcC6Iim60+7Zul0auLZzkavjQRBfW08K8TcpKiJx9BYlaZ9JlVZDGrfMGULjqLo51H52N00yyGlC2MhTEIlZJUxmVWFHbHpSpw+UyoojKpjYLzP5P581o5k7EA62euF/NptFy9kwUOb0Nxte0c3QVtKgFPKqk6pbPltFRV1QlhYWlIt9SsQNKVNRiPi1BsLgU1tVwKynqvIqTW7l2HUrB6wot37/IkIqWJQNsNgrP6uqbAzwolY89siQvKy3X4uU7Z/4TVPeqVtWCyRd7PtCPWRti0I0bSjznt9Ea9ylMvi8kzSzoqXTGvjstzJ3KtWfAcT0CdA/d7qis5/nlm6u74Jrs2eTsztSc/7jGXqFv84JASDF9ajjUP/nsyQARHYZm0cIIvLYpqpQk6KxfEtnq7QuI2TnYmigeIKr/sO2dXE4ZJimRsnDs5NVCEgjBQWxtDoEI1qOsa2WRSQRipfU61SgWN+Z0JowZ5OYe94dgRffYWVu3tIxwPo/LhMRajIy4n9iN6RCHKRA/4j4iezqhWdI5qXWBaVzM0jtIR0UAsj93f3zeLrkrQFYOkV3hmMKsk3N3OtVh8EkBqSQtmblcnO9SZ29U/sH6sus0ep8dLSZn/+OxQklIT/DqVwauKSi0lqOT0KqhjUalVkE8c1cMulK4JqgGCo814aPjcST/xkNSVT6n4qBsPYMoPpKrV4qHuo4Ag9QwKYIu73OtK9Y9vOK+/Wty/m0cHNVFtL30agTRmC5RAn88N6MZ0aiwzdsGUpbact6M50s9xaFVf9Rh3pcI1UXR8xtAKw26HCQqX5juPYjZYWZhLeS2QkPZ/Vcmo/2bV5LXOSBAVcpHyy8JFWBpdzgq3N/HVFHGpkBxLcCpnvpelyIsHZdnuNBeHSPnbCOqQ85yayi1qeuVedZ5TUxMmSL/217XuE3dDGkRDYF9BQhf/bmhRJNAgRlJ688qAKXX+PYWYxDe4drD59vvWduOGpe0k7+J0GBjllfNh1zlazlxae52TGkjt29/r7QF1lP3b8exOpoLbyvJ56qInr5+kxcuVXq+nvV5qy+tHgbKP39yPup//NQJY/As= -------------------------------------------------------------------------------- /plugin/pluggable-design.md: -------------------------------------------------------------------------------- 1 | # 插件化功能设计 2 | 3 | 实现 Halo 插件化系统,以便对核心功能进行扩展,在不缺失主要功能的同时防止 core 过大。插件能力有助于社区生态的构建。 4 | 5 | ## 目标 6 | 7 | 后端插件化 8 | 9 | - 后端支持 API 拓展机制,提供统一的 API 聚合,通过 core 的访问控制模块(IAM) 进行统一鉴权。 10 | - 支持扩展点机制,插件通过实现 core 中暴露的扩展点接口来增强 core 的能力,如扩展附件存储提供者。 11 | - 插件允许通过 core 提供的数据持久化机制来进行数据操作(CRUD)。 12 | - 插件允许调用 core 提供的公开接口对 core 的数据进行操作。 13 | 14 | 管理端前端插件化 15 | 16 | - 前端项目支持插件化,可通过插件在各级导航栏插入新的功能入口,实现功能页面的动态添加。 17 | 18 | - 通过各个页面或组件的扩展点来实现原有功能的扩展。 19 | 20 | 公共目标 21 | 22 | - 插件管理:提供可视化的插件管理机制,支持插件的安装、卸载、启用、停用、配置、升级。 23 | - 插件仓库:提供插件的打包、发布机制,提供内置的插件仓库。 24 | - 插件框架:提供插件开发、打包、发布相关的脚手架,提供完善的插件开发文档。 25 | 26 | ## 非目标 27 | 28 | - 插件安全检查。 29 | - 插件代码风格检查。 30 | 31 | 管理端前端: 32 | 33 | - 不适配多种前端渲染框架,仅支持与 Core 一致的技术栈(Vue)。 34 | 35 | ## 背景和动机 36 | 37 | 目前 1.0 版本中社区提出了很多非普适功能的 issue,为了控制 core 的大小会挑选重要的功能进行实现, 这对使用者来说很不方便。 38 | 包括但不限于以下 issue: 39 | 40 | - [期望增加微信公众号管理功能](https://github.com/halo-dev/halo/issues/1990) 41 | - [希望可以添加对 UML 渲染支持](https://github.com/halo-dev/halo/issues/1419) 42 | - [私有文章功能增强。附件功能增强。文章 url 混淆](https://github.com/halo-dev/halo/issues/994) 43 | - [集成 umami 到 halo](https://github.com/halo-dev/halo/issues/1285) 44 | 45 | 如果增加了插件化能力,社区可以根据自己的需要进行插件开发以扩展核心功能,使用者也可以在插件仓库查找自己需要的插件来满足功能。 46 | 47 | 该功能的实现会很大程度的提高社区活跃度并壮大社区,同时很多在 1.0 版本中加入的许多功能都可以抽取为独立插件以减小 core 的大小,用户可以根据需要选择合适的插件来达到目的。 48 | 49 | ## 设计 50 | 51 | ### 术语 52 | 53 | - **Extension Point** 54 | - 由 Halo 定义的用于添加特定功能的接口。 55 | - 扩展点应该在服务的核心功能和它所认为的集成之间的交叉点上。 56 | - 扩展点是对服务的扩充,但不是影响服务的核心功能:区别在于,如果没有其核心功能,服务就无法运行,而扩展点对于特定的配置可能至关重要该服务最终是可选的。 57 | - 扩展点应该小且可组合,并且在相互配合使用时,可为 Halo 提供比其各部分总和更大的价值。 58 | - **Extension** 59 | - Extension Point(扩展点)的一种具体实现。 60 | 61 | ### Backend 62 | 63 | #### 描述 64 | 65 | 插件启用时由 PluginManager 负责加载,包括 : 66 | 67 | - APIs: 委托给 Request mapping registrar 管理。 68 | - Extension Point:委托给 Extension Finder 管理。 69 | - Static files:由 PluginClassLoader 加载。 70 | - 类似 manifest 和 role template 的 yaml。 71 | - Listeners:由 PluginApplicationContext 管理。 72 | - Spring Bean Components:委托给 PluginApplicationContext 管理。 73 | - core 中标注了 `@SharedEvent` 注解的事件被发布时由 `PluginApplicationEventBridgeDispatcher` 桥接给已启用的插件使用。 74 | 75 | ![image-20220507180723198](assets/image-20220507180723198.png) 76 | 77 | 当插件被启用时由 `PluginManager` 创建一个新的 `PluginClassLoader` 实例负责加载插件类和资源,加载顺序符合双亲委派机制(见图 Figure 2–1 Class Loader Runtime Hierarchy) 78 | `PluginClassLoader` 的 `parent` 为 Halo 使用的类加载器,因此它能够访问 Halo 中已被加载的所有类。 79 | 80 | ![Figure 2–1 Class Loader Runtime Hierarchy](https://docs.oracle.com/cd/E19501-01/819-3659/images/dgdeploy2.gif) 81 | 82 | > 参考链接: 83 | > 84 | > [Chapter 5. Loading, Linking, and Initializing](https://docs.oracle.com/javase/specs/jvms/se17/html/jvms-5.html) 85 | > 86 | > [Chapter 12. Execution](https://docs.oracle.com/javase/specs/jls/se17/html/jls-12.html#jls-12.6) 87 | > 88 | > [Class Loader API Doc](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/ClassLoader.html) 89 | > 90 | > [Oracle Chapter 2 Class Loaders](https://docs.oracle.com/cd/E19501-01/819-3659/beade/index.html) 91 | 92 | #### 资源配置 93 | 94 | **plugin-manifest:** 95 | 96 | ```yaml 97 | apiVersion: v1 98 | kind: Plugin 99 | metadata: 100 | # 'name' must match the filename of the manifest. The name defines how 101 | # the plugin is invoked 102 | name: plugin-1 103 | labels: 104 | extensions.guqing.xyz/category: attachment 105 | spec: 106 | # 'version' is a valid semantic version string (see semver.org). 107 | version: 0.0.1 108 | requires: ">=2.0.0" 109 | author: guqing 110 | logo: https://guqing.xyz/avatar 111 | pluginClass: xyz.guqing.plugin.potatoes.PotatoesApp 112 | pluginDependencies: 113 | "plugin-2": 1.0.0 114 | # 'homepage' usually links to the GitHub repository of the plugin 115 | homepage: https://github.com/guqing/halo-plugin-1 116 | # 'displayName' explains what the plugin does in only a few words 117 | displayName: "a name to show" 118 | description: "Tell me more about this plugin." 119 | license: MIT 120 | ``` 121 | 122 | - `version`: 指定当前插件版本号,规则参考[插件版本控制](#插件版本控制) 123 | - `requires: >=2.0.0` 表示 halo 系统版本必须大于 2.0.0,支持使用`>`, `<`, `=`, `>=`or`<=`进行比较,或`-`指定包含范围,可以用来`||`结合 124 | 125 | > 例如: 126 | > 127 | > requires: 2.1 128 | > 129 | > requires: 1.0.0 - 1.2.0 (连字符两边必须有空格) 130 | > 131 | > requires: >2.0.x 132 | > 133 | > requires: <=2.0.x || >2.2.x 134 | 135 | - `author`:插件作者名称。 136 | - `logo`:插件的 logo。 137 | - `pluginClass` : 继承了`run.halo.app.extensions.SpringPlugin`的类全限定名,用于干预插件生命周期和类扫描。 138 | - `pluginDependencies`: 如果依赖了其他插件则使用`pluginId:version`的格式[可选]。 139 | - `homepage`: 插件的主页[可选]。 140 | - `displayName`:插件的显示名称。 141 | - `description`: 详细介绍[可选]。 142 | - `license`:插件遵循的软件协议[可选]。 143 | 144 | **plugin role templates:** 145 | 146 | 如果需要对插件提供的 API 进行权限控制,可以定义 role template,当插件启用时会被加载以使用 Halo 的权限控制体系进行统一的 API 权限控制。 147 | 148 | ```yaml 149 | apiVersion: v1 150 | kind: Role 151 | metadata: 152 | name: role-manage-plugin-apis 153 | labels: 154 | guqing.xyz/role-template: true 155 | annotations: 156 | guqing.xyz/dependencies: ["role-template-view-plugin-apis"] 157 | guqing.xyz/module: "Test Plugin" 158 | guqing.xyz/alias-name: "Test Plugin" 159 | rules: 160 | - apiGroups: ["plugin1.guqing.xyz"] 161 | resources: ["plugin-tests"] 162 | verbs: ["*"] 163 | ``` 164 | 165 | ##### Extension Point 插件 166 | 167 | Halo 使用 [Java 插件框架 (PF4J)](https://github.com/pf4j/pf4j) 来表示服务的 **扩展点** 接口。您可以创建一个插件来实现扩展点中声明的方法。基于扩展点创建插件有很多优点: 168 | 169 | - 这是最简单的 - 使用 `@Extension` 注解并实现扩展点中声明的方法。 170 | - Halo 将插件加载到隔离的类路径中。 171 | - 它的维护工作量最少。 172 | - Halo 的更新不太可能破坏你的插件。 173 | 174 | 这里有一个 [PoC](https://github.com/guqing/halo-plugin-experimental/tree/main/core/src/main/java/run/halo/app/extensions) 可供预览 175 | 176 | #### 定义 Extension Point 177 | 178 | 扩展点接口必须继承 `ExtensionPoint` 以表示该接口为扩展点 179 | 180 | ```java 181 | public interface FileHandler extends ExtensionPoint { 182 | 183 | UploadResult upload(@NonNull MultipartFile file); 184 | 185 | void delete(@NonNull String key); 186 | 187 | boolean supports(@NonNull FileHandler handler); 188 | } 189 | ``` 190 | 191 | 为了安全性考虑,不能让插件调用 core 中所有方法或 APIs,而是单独提供一个工具包,其中包含了插件可使用的 interface,这里称之为: `pluggable-suite`,插件依赖 `pluggable-suite` 后实现其中的某些扩展点或调用一些方法来制作插件。 192 | 193 | core 中会对已经定义的可扩展的代码使用 ExtensionPointFinder 来查找扩展点,当实现了指定扩展点的插件被启用时就会被发现,结果是一个有序集合。 194 | 195 | ```java 196 | @Slf4j 197 | @Component 198 | public class FileHandlers { 199 | 200 | private final ExtensionComponentsFinder extensionComponentsFinder; 201 | 202 | public FileHandlers(ExtensionComponentsFinder extensionComponentsFinder) { 203 | this.extensionComponentsFinder = extensionComponentsFinder; 204 | } 205 | 206 | @NonNull 207 | public UploadResult upload(@NonNull MultipartFile file, 208 | @NonNull AttachmentType attachmentType) { 209 | return getSupportedType(attachmentType).upload(file); 210 | } 211 | 212 | public void delete(@NonNull Attachment attachment) { 213 | Assert.notNull(attachment, "Attachment must not be null"); 214 | getSupportedType(attachment.getType()) 215 | .delete(attachment.getFileKey()); 216 | } 217 | 218 | public boolean supports(FileHandler fileHandler) { 219 | AttachmentType attachmentType = optionService.getEnumByPropertyOrDefault( 220 | AttachmentProperties.ATTACHMENT_TYPE, AttachmentType.class, AttachmentType.LOCAL); 221 | return instance.getAttachmentType() == attachmentType; 222 | } 223 | 224 | private FileHandler getSupportedType(AttachmentType type) { 225 | ExtensionList extensionList = extensionComponentsFinder.lookup(FileHandler.class); 226 | 227 | for (ExtensionComponent extensionComponent : extensionList.getComponents()) { 228 | FileHandler instance = extensionComponent.getInstance(); 229 | if (supports(instance.getAttachmentType())) { 230 | log.info("Used {} file handler(s)", instance); 231 | return instance; 232 | } 233 | } 234 | throw new FileOperationException("No available file handlers to operate the file") 235 | .setErrorData(type); 236 | } 237 | } 238 | ``` 239 | 240 | #### 生命周期方法 241 | 242 | 通过继承`Plugin`来表示该类为插件的主类,加载时会从此类开始并扫描此类同级目录及子目录下的类,将其加载到 Halo 中。 243 | 244 | 它具有`start`、`stop`、`delete`生命周期方法,分别会在插件启动、停止和卸载时被调用。 245 | 246 | ```java 247 | public class PotatoesApp extends Plugin { 248 | 249 | public PotatoesApp(PluginWrapper wrapper) { 250 | super(wrapper); 251 | } 252 | 253 | @Override 254 | public void start() { 255 | super.start(); 256 | } 257 | 258 | @Override 259 | public void stop() { 260 | super.stop(); 261 | } 262 | 263 | @Override 264 | public void delete() { 265 | super.delete(); 266 | } 267 | } 268 | ``` 269 | 270 | #### 定义 APIs 271 | 272 | 使用`@RestController`和`@Controller`来声明是插件的控制器 273 | 274 | ```java 275 | @RestController 276 | @RequestMapping("/plugins/potatoes") 277 | public class PotatoesController { 278 | 279 | @Autowired 280 | private PotatoService potatoService; 281 | 282 | @GetMapping("/name") 283 | public String name() { 284 | potatoService.create(); 285 | return "Lycopersicon esculentum"; 286 | } 287 | 288 | @GetMapping("/boom") 289 | public String boom() { 290 | return String.valueOf(1 / 0); 291 | } 292 | } 293 | ``` 294 | 295 | #### 发布订阅 296 | 297 | 1. 插件内部允许存在独立的事件和监听器 298 | 299 | ```java 300 | @Slf4j 301 | @Component 302 | public class PotatoesVisitListener { 303 | 304 | @EventListener(PotatoesVisitEvent.class) 305 | public void onPluginStarted(PotatoesVisitEvent event) { 306 | log.info("Potato visited event: {}", event); 307 | } 308 | } 309 | ``` 310 | 311 | 2. 插件监听 Halo 中发布的事件 312 | 313 | 当 Halo 中标注了 `@SharedEvent` 注解的事件被发布时插件中可以使用同样的方式监听到 314 | 315 | 例如: 316 | 317 | ```java 318 | @SharedEvent 319 | public class PostVisitEvent extends ApplicationEvent { 320 | private final Integer id; 321 | public PostVisitEvent(Object source, @NonNull Integer postId) { 322 | super(source); 323 | 324 | Assert.notNull(id, "The postId must not be null."); 325 | this.id = id; 326 | } 327 | 328 | @NonNull 329 | public Integer getId() { 330 | return id; 331 | } 332 | } 333 | ``` 334 | 335 | 插件中监听它,你可以使用两种方式 336 | 337 | 1. 使用 `@EventListener`注解监听事件 338 | 339 | ```java 340 | @Slf4j 341 | @Component 342 | public class HaloEventListener { 343 | 344 | @EventListener(PostVisitEvent.class) 345 | public void onPostVisited(PostVisitEvent event) { 346 | log.info("Post visited event listener: {}", event); 347 | } 348 | } 349 | ``` 350 | 351 | 2. 实现 `ApplicationListener` 指定范型来表明具体要监听的事件 352 | 353 | ```java 354 | @Component 355 | public class HaloPostVisitListener implements ApplicationListener { 356 | @Override 357 | public void onApplicationEvent(PostVisitEvent event) { 358 | System.out.println("The posts was visited..."); 359 | } 360 | } 361 | ``` 362 | 363 | #### 数据持久化 364 | 365 | 通过自定义模型来完成插件数据持久化功能。 366 | TODO 细节待补充 367 | 368 | #### 插件版本控制 369 | 370 | 为了保持 Halo 生态系统的健康、可靠和安全,每次您对自己拥有的插件进行重大更新时,我们建议在遵循 [semantic versioning spec](http://semver.org/) 的基础上,发布新版本。遵循语义版本控制规范有助于其他依赖你代码的开发人员了解给定版本的更改程度,并在必要时调整自己的代码。 371 | 372 | 我们建议你的包版本从`1.0.0`开始并递增,如下: 373 | 374 | | Code status | Stage | Rule | Example version | 375 | | ----------------------------------------- | ------------- | -------------------------------------------- | --------------- | 376 | | First release | New product | 从 1.0.0 开始 | 1.0.0 | 377 | | Backward compatible bug fixes | Patch release | 增加第三位数字 | 1.0.1 | 378 | | Backward compatible new features | Minor release | 增加中间数字并将最后一位重置为零 | 1.1.0 | 379 | | Changes that break backward compatibility | Major release | 增加第一位数字并将中间和最后一位数字重置为零 | 2.0.0 | 380 | 381 | #### 插件自定义 API 设计 382 | 383 | 插件自定义 API 应尽可能符合 Halo API 规范。且不同插件产生的 API 不会产生冲突。 384 | 385 | ##### 插件自定义 API 样例 386 | 387 | 我们假设: 388 | 389 | - 插件资源清单: 390 | 391 | ```yaml 392 | apiVersion: plugin.halo.run/v1alpha1 393 | kind: Plugin 394 | metadata: 395 | name: my-plugin 396 | ``` 397 | 398 | - Apple 并不是插件的自定义模型 399 | - AppleController 定义如下: 400 | 401 | ```java 402 | @ApiVersion("v1alpha1") // 该注解的 value 值将会作为 API 的 version 部分。“group.halo.run/v1alpha1” 和 “v1alpha1” 都将视为 “v1alpha1”。 403 | @RequestMapping("/apples") 404 | @RestController 405 | public class AppleController { 406 | 407 | @PostMapping("/starting") 408 | public void starting() { 409 | } 410 | 411 | } 412 | ``` 413 | 414 | 当插件被注册时,我们将会为 AppleController 生成统一路径的 API。API 前缀组成规则如下: 415 | 416 | ```text 417 | /apis/plugin.api.halo.run/{version}/plugins/{plugin-name}/** 418 | ``` 419 | 420 | 例如:`/apis/plugin.api.halo.run/v1alpha1/plugins/my-plugin/starting`。 421 | 422 | Role 配置样例如下: 423 | 424 | ```yaml 425 | apiVersion: v1alpha1 426 | kind: Role 427 | metadata: 428 | name: apple-role 429 | rules: 430 | - apiGroups: ["plugin.api.halo.run"] 431 | resources: ["plugins/starting"] 432 | verbs: ["list", "get"] 433 | ``` 434 | 435 | 当存在 API 为 `/apis/plugin.api.halo.run/v1alpha1/plugins/my-plugin/users/some-username` 时,Role 配置示例如下: 436 | 437 | ```yaml 438 | apiVersion: v1alpha1 439 | kind: Role 440 | metadata: 441 | name: some-role 442 | rules: 443 | - apiGroups: ["plugin.api.halo.run"] 444 | resources: ["plugins/users"] 445 | name: "my-plugin/some-username" 446 | verbs: ["list", "get"] 447 | ``` 448 | 449 | ##### API 构成讨论 450 | 451 | - [ ] `/apis/{group}/{version}/plugins/{plugin-name}/**` 452 | 453 | 由于 group 和 version 可任意指定,可能会和系统自动生成的 Plugin 的 API 冲突。例如:`/apis/plugin.halo.run/v1alpha1/plugins/my-plugin/**`。 454 | 455 | - [ ] `/apis/{plugin-name}/{version}/**` 456 | 457 | 由于 plugin-name 可任意指定,可能会和系统中的 API 产生冲突。例如:`/apis/plugin.halo.run/v1alpha1/plugins/**`。 458 | 459 | - [ ] `/api/plugins/{plugin-name}/{version}/**` 460 | 461 | 背离 API 构成规则,解析起来难度相对较大。 462 | 463 | - [ ] `/api/{version}/plugins/{plugin-name}/**` 464 | 465 | 符合 API 构成规则,避免 API 冲突并且方便识别并解析,但与常规 Role 配置风格存在较大差异,需要特殊处理 plugins 作为 API 中的固定名词。 466 | 467 | - [x] `/apis/plugin.api.halo.run/v1alpha1/plugins/{plugin-name}/**` 468 | 469 | 符合 API 构成规则,避免 API 冲突并且方便识别并解析,通过固定 apiGroup 来作为插件自定义 APIs 的组成部分,符合语意:插件下的 APIs 为 plugins 的子资源。 470 | 471 | #### 插件静态资源代理 472 | 473 | 插件允许通过 `ReverseProxy` 自定义模型来配置静态资源代理规则 474 | 475 | 假如插件 `plugin-links` 配置了如下的 `ReverseProxy` 资源 476 | 477 | ```yaml 478 | apiVersion: plugin.halo.run/v1alpha1 479 | kind: ReverseProxy 480 | metadata: 481 | name: reverse-proxy-template 482 | rules: 483 | - path: /res/** 484 | file: 485 | directory: static 486 | # filename: halo.png 487 | ``` 488 | 489 | 那么插件被启用后,访问 `/assets/plugin-links/res/halo.png` 会将插件 `src/main/resources/static` 下的 `halo.png` 文件返回,如果不存在则返回 `404`。 490 | 491 | `rules` 中 492 | 493 | - `path` 表示访问路径规则,最终会根据 `path` 生成实际访问路径,生成规则为:`/assets/{plugin-name}/{path}` 494 | - `file` 表示代理规则为文件系统 495 | - `file.directory` 表示将 `path` 中 `/**` 的通配符规则指向一个目录 496 | - `file.filename` 表示将 `path` 指向一个具体的文件 497 | 498 | 插件启动后如果在插件中的 `src/main/resources/admin` 目录下存在 `main.js` 或 `style.css` 文件则会自动注册相应的代理规则: 499 | 500 | ```yaml 501 | apiVersion: plugin.halo.run/v1alpha1 502 | kind: ReverseProxy 503 | metadata: 504 | name: a-generated-name 505 | rules: 506 | - path: /admin/main.js 507 | file: 508 | directory: admin 509 | filename: main.js 510 | - path: /admin/style.css 511 | file: 512 | directory: admin 513 | filename: style.css 514 | ``` 515 | 516 | 这两个文件由插件的前端工程打包后生成,用于为插件提供前端界面和交互能力。 517 | 518 | #### 插件依赖插件 519 | 520 | MVP(minimum viable product) 版本中不实现 521 | 522 | TBD 523 | 524 | #### 插件版本更新 525 | 526 | MVP(minimum viable product) 版本中不实现(可先通过先卸载后安装的方式解决) 527 | 528 | TBD 529 | 530 | #### 插件工程化 531 | 532 | 插件可以使用 Maven 或 Gradle 等项目构建工具依赖 `pluggable-suite`,该工具中提供了扩展点接口、公共接口和一些工具帮助快速构建插件。 533 | 534 | 一个常见的使用 Gradle 作为构建工具的插件目录结构如下: 535 | 536 | ```plaintext 537 | ├── LICENSE 538 | ├── README.md 539 | ├── admin-frontend 540 | │   ├── README.md 541 | │   ├── env.d.ts 542 | │   ├── package.json 543 | │   ├── pnpm-lock.yaml 544 | │   ├── src 545 | │   │   ├── assets 546 | │   │   │   └── logo.svg 547 | │   │   ├── components 548 | │   │   │   └── HelloWorld.vue 549 | │   │   ├── index.ts 550 | │   │   ├── styles 551 | │   │   │   └── index.css 552 | │   │   └── views 553 | │   │   └── DefaultView.vue 554 | │   └── vite.config.ts 555 | ├── build 556 | │   ├── classes 557 | │   │   └── java 558 | │   │   └── main 559 | │   │   ├── META-INF 560 | │   │   │   └── plugin-components.idx 561 | │   ├── libs 562 | │   │   └── halo-plugin-template-1.0-SNAPSHOT-plain.jar 563 | ├── build.gradle 564 | ├── gradlew 565 | ├── gradlew.bat 566 | ├── settings.gradle 567 | └── src 568 | └── main 569 | ├── java 570 | │   └── io 571 | │   └── github 572 | │   └── guqing 573 | │   └── template 574 | │   ├── ApplesController.java 575 | │   └── post 576 | │   ├── Post.java 577 | │   ├── PostController.java 578 | │   ├── PostRepository.java 579 | │   └── PostService.java 580 | └── resources 581 | ├── admin 582 | │   ├── halo-plugin-template.js 583 | │   └── style.css 584 | ├── extensions 585 | │   ├── reverseproxy.yaml 586 | │   └── roles.yaml 587 | ├── plugin.yaml 588 | ``` 589 | 590 | 插件可以引入 `pluggable-suite` 中没有提供的依赖,例如使用 `Gradle` 作为项目构建工具时,单独在插件中引入 `commons-lang3` 示例: 591 | 592 | ```java 593 | implementation "org.apache.commons:commons-lang3:3.10" 594 | ``` 595 | 596 | 需要注意的是:如果 Halo 已经存在了相同`group:name`的依赖,那么插件再引入该依赖即使版本不同,也会以 Halo 中的为准。 597 | Reason: 根据 [描述](#描述)中关于类加载的说明,插件使用的 `PluginClassLoader` 的 `parent` 为 Halo 的类加载器且插件类加载规则符合双亲委派机制, 598 | 所以 Halo 中已加载的类对插件是可见的,那么插件类加载时 Halo 中存在的类就[不会被重复加载](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/ClassLoader.html)。 599 | 600 | > - 每个类加载器都有自己的命名空间,命名空间由加载该类的加载器及所有父加载器所加载的类组成, 601 | > 因此各插件和 Halo 所属同一个命名空间,但插件与插件之间属于不同的命名空间。 602 | > - 在同一个命名空间中,不会出现类的完整名字(包括类的包名) 相同的两个类。 603 | > - 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类。 604 | 605 | **如何开发一个插件:** 606 | 607 | TBD. 608 | 609 | **如何调试:** 610 | 611 | TBD. 612 | 613 | ### Admin Frontend 614 | 615 | #### 名词定义 616 | 617 | 1. Admin Core:Halo 管理端核心项目 618 | 2. Monorepo: 619 | 620 | #### 前置条件 621 | 622 | 1. 插件应当使用与 Admin Core 的相同技术栈,即 Vue 3、Pinia、Vue Router 等。但不限制插件使用其他的三方依赖。 623 | 624 | 2. Admin Core 采用 Monorepo 进行管理,将分为 `core`、`@halo-dev/components`、`@halo-dev/shared` 等仓库。 625 | 626 | 1. core:即 Admin Core 的相关代码。 627 | 628 | 2. @halo-dev/components:公共 UI 组件,将被 Admin Core 和各个插件依赖,且插件在构建的时候应当排除掉这个包。 629 | 630 | 3. @halo-dev/shared:公共的一些代码,其中可能包括接口请求的封装、类型定义、状态管理库等。需要被 Admin Core 和各个插件依赖,且插件在构建的时候应当排除掉这个包。 631 | 632 | 3. 推荐使用 TypeScript 以获得更好的类型推断和编辑器提示,但不限制使用 JavaScript。 633 | 634 | #### 入口文件 635 | 636 | 此文件作为唯一的入口,里面包含如路由、Extension Point、菜单等定义,使用声明式的写法。此文件最终导出的应该是一个 Plugin 类型的对象。 637 | 638 | Plugin 类型定义(需要包含在 `@halo-dev/shared` 包): 639 | 640 | ```typescript 641 | import type { Component } from "vue"; 642 | import type { RouteRecordRaw } from "vue-router"; 643 | import type { MenuGroupType } from "./menu"; 644 | import type { PostsPagePublicState } from "./post"; 645 | import type { DashboardPublicState } from "./dashboard"; 646 | import type { UserProfileLayoutPublicStates } from "./user"; 647 | 648 | export type ExtensionPointType = 649 | | "POSTS" 650 | | "POST_EDITOR" 651 | | "DASHBOARD" 652 | | "USER_SETTINGS"; 653 | 654 | export type ExtensionPointState = 655 | | PostsPagePublicState 656 | | DashboardPublicState 657 | | UserProfileLayoutPublicStates; 658 | 659 | export interface HaloRouteRecord extends RouteRecordRaw { 660 | parent?: string; 661 | } 662 | 663 | export interface Plugin { 664 | name: string; 665 | 666 | components: Component[]; 667 | 668 | activated?: () => void; 669 | 670 | deactivated?: () => void; 671 | 672 | routes?: HaloRouteRecord[]; 673 | 674 | menus?: MenuGroupType[]; 675 | 676 | extensionPoints: Record< 677 | ?ExtensionPointType, 678 | (state: ExtensionPointState) => void 679 | >; 680 | } 681 | ``` 682 | 683 | 入口文件示例: 684 | 685 | ```typescript 686 | import type { Plugin } from "@halo-dev/admin-shared/src/types"; 687 | import DefaultView from "./views/DefaultView.vue"; 688 | import { IconGrid, VButton } from "@halo-dev/components"; 689 | 690 | const plugin: Plugin = { 691 | name: "PluginTemplate", 692 | components: [DefaultView], 693 | extensionPoints: { 694 | POSTS: (state: PostsPagePublicState) => { 695 | 696 | const visible = ref(false); 697 | 698 | state.actions.push({ 699 | component: VButton, 700 | props: { 701 | type: "secondary", 702 | }, 703 | slots: { 704 | default: '定时发布' 705 | }, 706 | events: { 707 | click: () => { 708 | visible.value = value; 709 | }, 710 | }, 711 | }); 712 | }, 713 | }, 714 | routes: [ 715 | { 716 | path: "/hello-world", 717 | name: "HelloWorld", 718 | component: DefaultView, 719 | }, 720 | ], 721 | menus: [ 722 | { 723 | name: "From PluginTemplate", 724 | items: [ 725 | { 726 | name: "HelloWorld", 727 | path: "/hello-world", 728 | icon: IconGrid, 729 | }, 730 | ], 731 | }, 732 | ], 733 | activated() { 734 | console.log("activated") 735 | }, 736 | deactivated() { 737 | console.log("deactivated") 738 | }, 739 | }; 740 | 741 | export default plugin; 742 | ``` 743 | 744 | #### 构建方式 745 | 746 | 统一采用 [Vite 的 Library 模式](https://vitejs.dev/guide/build.html#library-mode) 构建最终插件产物。如上所说,插件需要排除与 Admin Core 重复的依赖,包括但不限于 `vue`、`vue-router`、`@halo-dev/shared`、`@halo-dev/components`。另外,最终构建的 JavaScript 模块形式会在后面的插件加载部分做详细描述。 747 | 748 | > Note: 理想情况下,我们可以提供一个针对于插件开发的 CLI 工具来创建插件项目,那么此时构建插件的方式就会被内置。 749 | 750 | ```typescript 751 | import { fileURLToPath, URL } from "url"; 752 | 753 | import { defineConfig } from "vite"; 754 | import vue from "@vitejs/plugin-vue"; 755 | import vueJsx from "@vitejs/plugin-vue-jsx"; 756 | 757 | // https://vitejs.dev/config/ 758 | export default defineConfig({ 759 | plugins: [vue(), vueJsx()], 760 | resolve: { 761 | alias: { 762 | "@": fileURLToPath(new URL("./src", import.meta.url)), 763 | }, 764 | }, 765 | build: { 766 | lib: { 767 | entry: "src/index.ts", 768 | name: "PluginTemplate", 769 | formats: ["iife"], 770 | fileName: () => `halo-plugin-template.js`, 771 | }, 772 | rollupOptions: { 773 | external: ["vue", "vue-router", "@halo-dev/shared", "@halo-dev/components"], 774 | output: { 775 | globals: { 776 | vue: "Vue", 777 | "vue-router": "VueRouter", 778 | "@halo-dev/components": "components", 779 | }, 780 | }, 781 | }, 782 | }, 783 | }); 784 | ``` 785 | 786 | 最终构建产物目录可能会如下所示: 787 | 788 | ```plaintext 789 | ├── halo-plugin-template.js 790 | └── style.css 791 | ``` 792 | 793 | #### 插件加载 794 | 795 | 前置条件: 796 | 797 | 1. 后端需要提供获取已启用插件的接口。 798 | 2. 在插件工程的描述文件中,需要定义管理端前端插件所需的资源文件路径。 799 | 800 | ```typescript 801 | import router from '@/router' 802 | import { registerMenu } from '@/core/menus.config' 803 | import { apiClient } from '@halo-dev/shared' 804 | import { usePluginStore } from '@/store/plugins' 805 | 806 | const app = createApp(App); 807 | 808 | initApp(); 809 | 810 | function loadScript(src: string) { 811 | return new Promise(function (resolve, reject) { 812 | el = document.createElement("script"); 813 | el.src = src; 814 | 815 | el.addEventListener("error", reject); 816 | el.addEventListener("abort", reject); 817 | el.addEventListener("load", function () { 818 | resolve(el); 819 | }); 820 | 821 | document.head.prepend(el) 822 | }); 823 | } 824 | 825 | const pluginStore = usePluginStore() 826 | 827 | const initApp = async () => { 828 | // Gets all enabled plugins 829 | const enabledPlugins = await apiClient.plugins.list({ enabled: true }); 830 | 831 | for (let i = 0; i < enabledPlugins.length; i++) { 832 | const plugin = enabledPlugins[i] 833 | 834 | if(!plugin.assets) { 835 | continue; 836 | } 837 | 838 | try { 839 | if(plugin.assets.script) { 840 | await loadScript(plugin.assets.script) 841 | } 842 | if(plugin.assets.style) { 843 | await loadStyle(plugin.assets.style) 844 | } 845 | 846 | const pluginModule = window[plugin.assets.name]; 847 | 848 | plugin.module = pluginModule 849 | 850 | // register components 851 | pluginModule.components.forEach(component => { 852 | app.component(component.name, component); 853 | }) 854 | 855 | // register routes 856 | pluginModule.routes.forEach(route => { 857 | router.addRoute(route) 858 | }) 859 | 860 | // register menus 861 | pluginModule.menus.forEach(menu => { 862 | registerMenu(route) 863 | }) 864 | 865 | app.use(router) 866 | } catch (e) { 867 | // TODO needs a notification 868 | } 869 | 870 | pluginStore.plugins = enabledPlugins 871 | 872 | app.mount('#app') 873 | } 874 | } 875 | ``` 876 | 877 | 详细解释: 878 | 879 | 1. 在 Admin Core 的入口文件中挂载 Vue 实例前通过接口得到当前已经启用的插件。接口可能形如:`/api/admin/plugins?enabled=true` 880 | 2. 判断是否有注册管理端前端插件的静态资源(JavaScript 入口文件等)。 881 | 3. 通过创建 script 节点的形式动态加载 JavaScript 入口文件。 882 | 4. 通过上方构建方式部分我们可以知道,最终构建的 JavaScript 模块为 [IIFE](https://en.wikipedia.org/wiki/Immediately_invoked_function_expression) 形式,在加载完成 JavaScript 文件之后,会将整个函数表达式对象挂载到浏览器的 [window](https://developer.mozilla.org/zh-CN/docs/Web/API/Window) 对象。最终我们就可以通过 `window[pluginId]` 的形式获取到整个插件的对象。 883 | 5. 解析插件对象,注册 Vue 组件、路由、菜单等。 884 | 6. 将已启用的插件集合交给 Pinia(状态管理)管理,方便后续各个页面或者组件中扩展点的使用。 885 | 886 | #### 用户权限 887 | 888 | TDB. 889 | 890 | #### Extension Point 891 | 892 | 结合 Vue 数据驱动的思想,将页面或者组件中可拓展的位置使用数据动态渲染。而插件需要做的就是操作所需扩展点的数组即可。具体流程如下: 893 | 894 | 1. 在页面或者组件中定义好可拓展的响应式数据,并提供一个扩展点名称。 895 | 2. 通过上方插件加载部分我们可以知道,已启用的插件已经被放在了 Pinia 来管理,我们需要在已启用的插件里检查是否有注册当前扩展点。 896 | 3. 执行插件中的扩展点函数。 897 | 898 | 使用上方入口文件示例来举例: 899 | 900 | ```typescript 901 | extensionPoints: { 902 | POSTS: (state: PostsPagePublicState) => { 903 | 904 | const visible = ref(false); 905 | 906 | state.actions.push({ 907 | component: VButton, 908 | props: { 909 | type: "secondary", 910 | }, 911 | slots: { 912 | default: '定时发布' 913 | }, 914 | events: { 915 | click: () => { 916 | visible.value = value; 917 | }, 918 | }, 919 | }); 920 | }, 921 | }, 922 | ``` 923 | 924 | 对应提供 `POSTS` 扩展点的页面: 925 | 926 | ```vue 927 | 973 | 988 | ``` 989 | 990 | #### 网络请求 991 | 992 | 由 `@halo-dev/shared` 提供 apiClient 请求模块,并且需要提供注册 Client 的方法以供插件注册所需的 Client,如: 993 | 994 | ```typescript 995 | import { ApiClient } from '@halo-dev/admin-api' 996 | import apiClient from '@halo-dev/shared' 997 | 998 | class ForumClient extend ApiClient { 999 | 1000 | constructor(client) { 1001 | this.client = client; 1002 | } 1003 | 1004 | list() { 1005 | return this.client.get("/apis/forums") 1006 | } 1007 | 1008 | delete(id: number) { 1009 | return this.client.delete("/apis/forums", { id }) 1010 | } 1011 | } 1012 | 1013 | apiClient.registerClient(new ForumClient()); 1014 | 1015 | apiClient.forum.list().then(response => { 1016 | // TODO 1017 | }) 1018 | 1019 | apiClient.forum.delete({ id: 1 }).then(response => { 1020 | // TODO 1021 | }) 1022 | ``` 1023 | 1024 | ## 附录 1025 | 1026 | ### Halo 可扩展功能设想 1027 | 1028 | 1. 附件上传的方式可以默认提供本地文件上传功能,然后通过插件扩展其他上传方式如 OSS。 1029 | 2. 针对文章、评论、上传的文件流对象等提供可扩展的对象前置和后置处理器扩展点,可实现例如数据脱敏、文件去除 EXIF 元信息等功能。 1030 | 3. 独立页面功能抽取出去通过插件实现,例如友情链接、图库、日志页面通过插件实现。 1031 | 4. 可通过插件替换不同的编辑器类型,例如 Markdown 编辑器、富文本编辑器。 1032 | 5. 缓存策略可扩展,默认实现 InMemeryCache 插件可扩展 Redis 等缓存方式。 1033 | 6. 认证方式可扩展,默认提供用户名密码认证方式,可扩展手机号登录、邮件登录、三方认证等。 1034 | 7. 搜索功能可扩展,默认实现内存级搜索方式,可使用插件扩展为 Elasticsearch 等使用外部搜索引擎。 1035 | 8. 主题可使用插件来对渲染后的内容插入全局 JavaScript 或 CSS 实现比如图片点击预览,看板娘等功能。 1036 | 9. SEO 插件,例如通过插件对渲染后内容 Header 中插入 openGraph 标签等。 1037 | 10. 通知方式可扩展,例如文章被评论时可默认选择邮件通知,通过插件扩展其他通知方式如短信、telegram-bot 等。 1038 | 11. 静态存储可通过插件扩展,上传了文件后将该文件的访问路径注册到 request mapping 中。 1039 | 12. 插件实现资源监控和告警等功能。 1040 | 13. 系统日志功能通过插件实现。 1041 | 14. 插件实现小工具,如数据备份,导入导出 Markdown 或整站、导入导出 json 等。 1042 | 1043 | ### 插件启动速度优化 1044 | 1045 | 插件启动时扫描插件类所需耗时会随着插件包层次结构的复杂度而增加,例如 1046 | 1047 | ```markdown 1048 | ## Reading extensions storages from classpath 308ms -> StopWatch '': running time = 308620936 ns 1049 | 1050 | ## ns % Task name 1051 | 1052 | 308620936 100% readClasspathStorages 1053 | 1054 | ## Reading extensions storages from plugins 403ms -> StopWatch '': running time = 403391485 ns 1055 | 1056 | ## ns % Task name 1057 | 1058 | 403391485 100% readPluginsStorages 1059 | ``` 1060 | 1061 | 总计:711ms 1062 | 1063 | 而如果将这个扫描的过程前置到插件打包时通过在 `pluggable-suite` 中提供一个`PluggableAnnotationProcessor`处理器提前将类扫描好生成索引文件`META-INFO/spring-components.idx`,插件启动时直接读取索引文件进行操作,这样可以在插件被启用时快速完成初始化。 1064 | 1065 | 优化后(26ms): 1066 | 1067 | ```markdown 1068 | ## total millis: 26ms ->StopWatch 'findCandidateComponents': running time = 26146095 ns 1069 | 1070 | ## ns % Task name 1071 | 1072 | 023519087 090% getExtensionClassNames 1073 | 000607725 002% loadClass 1074 | 001025490 004% loadClass 1075 | 000718673 003% loadClass 1076 | 000275120 001% loadClass 1077 | ``` 1078 | 1079 | ### 插件卸载 1080 | 1081 | 插件卸载时要: 1082 | 1083 | 1. 插件 Class 对象不再被引用,即不可触及(没有引用指向)时 1084 | 2. 断开插件 Class 实例与 PluginClassLoader 之间的双向关联关系 1085 | 1086 | 有效性验证: 1087 | 1088 | 1. 启动 `Halo` 时添加 `JVM` 参数 1089 | 1090 | ```bash 1091 | -Xlog:class+load=info -Xlog:class+unload=info 1092 | ``` 1093 | 1094 | 2. 以启用 apples 插件为例,使用 `jconsole` 连接到 Halo 线程,观察 apples 启用前后和卸载前后的 Class load 和 unload 信息 1095 | 1096 | ```shell 1097 | # 启动前 jconsole class details 1098 | 已加装当前类: 17,671 1099 | 已加载类总数: 17,777 1100 | 已卸载类总数: 100 1101 | 1102 | # 启动 apples 插件,查看 class+load jvm info 1103 | [681.850s][info][class,load ] xyz.guqing.plugin.apples.controller.ApplesController source: file:/Users/guqing/Develop/workspace/halo-plugin-experimental/plugins/apples/build/classes/java/main/ 1104 | [681.851s][info][class,load ] xyz.guqing.plugin.apples.service.impl.TestExtImpl source: file:/Users/guqing/Develop/workspace/halo-plugin-experimental/plugins/apples/build/classes/java/main/ 1105 | [681.851s][info][class,load ] xyz.guqing.plugin.apples.service.AppleService source: file:/Users/guqing/Develop/workspace/halo-plugin-experimental/plugins/apples/build/classes/java/main/ 1106 | [681.851s][info][class,load ] xyz.guqing.plugin.apples.service.impl.AppleServiceImpl source: file:/Users/guqing/Develop/workspace/halo-plugin-experimental/plugins/apples/build/classes/java/main/ 1107 | [681.851s][info][class,load ] xyz.guqing.plugin.apples.controller.ApplesSimpleController source: file:/Users/guqing/Develop/workspace/halo-plugin-experimental/plugins/apples/build/classes/java/main/ 1108 | [681.856s][info][class,load ] xyz.guqing.plugin.apples.ApplesPlugin source: file:/Users/guqing/Develop/workspace/halo-plugin-experimental/plugins/apples/build/classes/java/main/ 1109 | 1110 | # 启动后 jconsole class details 1111 | 已加装当前类: 17,677 1112 | 已加载类总数: 17,777 1113 | 已卸载类总数: 100 1114 | 1115 | # 卸载 apples 插件后执行一次 gc,查看 class+unload jvm info 1116 | [716.579s][info][class,unload] unloading class xyz.guqing.plugin.apples.ApplesPlugin 0x0000000800d2f478 1117 | [716.579s][info][class,unload] unloading class xyz.guqing.plugin.apples.controller.ApplesSimpleController 0x0000000800d2f268 1118 | [716.579s][info][class,unload] unloading class xyz.guqing.plugin.apples.service.impl.AppleServiceImpl 0x0000000800d2f040 1119 | [716.579s][info][class,unload] unloading class xyz.guqing.plugin.apples.service.AppleService 0x0000000800d2fcb8 1120 | [716.579s][info][class,unload] unloading class xyz.guqing.plugin.apples.service.impl.TestExtImpl 0x0000000800d2fa70 1121 | [716.579s][info][class,unload] unloading class xyz.guqing.plugin.apples.controller.ApplesController 0x0000000800d2f840 1122 | 1123 | # 卸载后 jconsole class details 1124 | 已加装当前类: 17,671 1125 | 已加载类总数: 17,777 1126 | 已卸载类总数: 106 1127 | ``` 1128 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: 5.4 2 | 3 | specifiers: 4 | husky: ^7.0.4 5 | markdownlint: ^0.25.1 6 | markdownlint-cli2: ^0.4.0 7 | 8 | devDependencies: 9 | husky: 7.0.4 10 | markdownlint: 0.25.1 11 | markdownlint-cli2: 0.4.0 12 | 13 | packages: 14 | 15 | /@nodelib/fs.scandir/2.1.5: 16 | resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} 17 | engines: {node: '>= 8'} 18 | dependencies: 19 | '@nodelib/fs.stat': 2.0.5 20 | run-parallel: 1.2.0 21 | dev: true 22 | 23 | /@nodelib/fs.stat/2.0.5: 24 | resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} 25 | engines: {node: '>= 8'} 26 | dev: true 27 | 28 | /@nodelib/fs.walk/1.2.8: 29 | resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} 30 | engines: {node: '>= 8'} 31 | dependencies: 32 | '@nodelib/fs.scandir': 2.1.5 33 | fastq: 1.13.0 34 | dev: true 35 | 36 | /argparse/2.0.1: 37 | resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 38 | dev: true 39 | 40 | /array-union/3.0.1: 41 | resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==} 42 | engines: {node: '>=12'} 43 | dev: true 44 | 45 | /braces/3.0.2: 46 | resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} 47 | engines: {node: '>=8'} 48 | dependencies: 49 | fill-range: 7.0.1 50 | dev: true 51 | 52 | /dir-glob/3.0.1: 53 | resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} 54 | engines: {node: '>=8'} 55 | dependencies: 56 | path-type: 4.0.0 57 | dev: true 58 | 59 | /entities/2.1.0: 60 | resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} 61 | dev: true 62 | 63 | /fast-glob/3.2.11: 64 | resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} 65 | engines: {node: '>=8.6.0'} 66 | dependencies: 67 | '@nodelib/fs.stat': 2.0.5 68 | '@nodelib/fs.walk': 1.2.8 69 | glob-parent: 5.1.2 70 | merge2: 1.4.1 71 | micromatch: 4.0.4 72 | dev: true 73 | 74 | /fastq/1.13.0: 75 | resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} 76 | dependencies: 77 | reusify: 1.0.4 78 | dev: true 79 | 80 | /fill-range/7.0.1: 81 | resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} 82 | engines: {node: '>=8'} 83 | dependencies: 84 | to-regex-range: 5.0.1 85 | dev: true 86 | 87 | /glob-parent/5.1.2: 88 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} 89 | engines: {node: '>= 6'} 90 | dependencies: 91 | is-glob: 4.0.3 92 | dev: true 93 | 94 | /globby/12.1.0: 95 | resolution: {integrity: sha512-YULDaNwsoUZkRy9TWSY/M7Obh0abamTKoKzTfOI3uU+hfpX2FZqOq8LFDxsjYheF1RH7ITdArgbQnsNBFgcdBA==} 96 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 97 | dependencies: 98 | array-union: 3.0.1 99 | dir-glob: 3.0.1 100 | fast-glob: 3.2.11 101 | ignore: 5.2.0 102 | merge2: 1.4.1 103 | slash: 4.0.0 104 | dev: true 105 | 106 | /husky/7.0.4: 107 | resolution: {integrity: sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==} 108 | engines: {node: '>=12'} 109 | hasBin: true 110 | dev: true 111 | 112 | /ignore/5.2.0: 113 | resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} 114 | engines: {node: '>= 4'} 115 | dev: true 116 | 117 | /is-extglob/2.1.1: 118 | resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} 119 | engines: {node: '>=0.10.0'} 120 | dev: true 121 | 122 | /is-glob/4.0.3: 123 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 124 | engines: {node: '>=0.10.0'} 125 | dependencies: 126 | is-extglob: 2.1.1 127 | dev: true 128 | 129 | /is-number/7.0.0: 130 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 131 | engines: {node: '>=0.12.0'} 132 | dev: true 133 | 134 | /linkify-it/3.0.3: 135 | resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} 136 | dependencies: 137 | uc.micro: 1.0.6 138 | dev: true 139 | 140 | /markdown-it/12.3.2: 141 | resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} 142 | hasBin: true 143 | dependencies: 144 | argparse: 2.0.1 145 | entities: 2.1.0 146 | linkify-it: 3.0.3 147 | mdurl: 1.0.1 148 | uc.micro: 1.0.6 149 | dev: true 150 | 151 | /markdownlint-cli2-formatter-default/0.0.3_markdownlint-cli2@0.4.0: 152 | resolution: {integrity: sha512-QEAJitT5eqX1SNboOD+SO/LNBpu4P4je8JlR02ug2cLQAqmIhh8IJnSK7AcaHBHhNADqdGydnPpQOpsNcEEqCw==} 153 | peerDependencies: 154 | markdownlint-cli2: '>=0.0.4' 155 | dependencies: 156 | markdownlint-cli2: 0.4.0 157 | dev: true 158 | 159 | /markdownlint-cli2/0.4.0: 160 | resolution: {integrity: sha512-EcwP5tAbyzzL3ACI0L16LqbNctmh8wNX56T+aVvIxWyTAkwbYNx2V7IheRkXS3mE7R/pnaApZ/RSXcXuzRVPjg==} 161 | engines: {node: '>=12'} 162 | hasBin: true 163 | dependencies: 164 | globby: 12.1.0 165 | markdownlint: 0.25.1 166 | markdownlint-cli2-formatter-default: 0.0.3_markdownlint-cli2@0.4.0 167 | markdownlint-rule-helpers: 0.16.0 168 | micromatch: 4.0.4 169 | strip-json-comments: 4.0.0 170 | yaml: 1.10.2 171 | dev: true 172 | 173 | /markdownlint-rule-helpers/0.16.0: 174 | resolution: {integrity: sha512-oEacRUVeTJ5D5hW1UYd2qExYI0oELdYK72k1TKGvIeYJIbqQWAz476NAc7LNixSySUhcNl++d02DvX0ccDk9/w==} 175 | dev: true 176 | 177 | /markdownlint/0.25.1: 178 | resolution: {integrity: sha512-AG7UkLzNa1fxiOv5B+owPsPhtM4D6DoODhsJgiaNg1xowXovrYgOnLqAgOOFQpWOlHFVQUzjMY5ypNNTeov92g==} 179 | engines: {node: '>=12'} 180 | dependencies: 181 | markdown-it: 12.3.2 182 | dev: true 183 | 184 | /mdurl/1.0.1: 185 | resolution: {integrity: sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=} 186 | dev: true 187 | 188 | /merge2/1.4.1: 189 | resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} 190 | engines: {node: '>= 8'} 191 | dev: true 192 | 193 | /micromatch/4.0.4: 194 | resolution: {integrity: sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==} 195 | engines: {node: '>=8.6'} 196 | dependencies: 197 | braces: 3.0.2 198 | picomatch: 2.3.1 199 | dev: true 200 | 201 | /path-type/4.0.0: 202 | resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} 203 | engines: {node: '>=8'} 204 | dev: true 205 | 206 | /picomatch/2.3.1: 207 | resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 208 | engines: {node: '>=8.6'} 209 | dev: true 210 | 211 | /queue-microtask/1.2.3: 212 | resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 213 | dev: true 214 | 215 | /reusify/1.0.4: 216 | resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} 217 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'} 218 | dev: true 219 | 220 | /run-parallel/1.2.0: 221 | resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 222 | dependencies: 223 | queue-microtask: 1.2.3 224 | dev: true 225 | 226 | /slash/4.0.0: 227 | resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} 228 | engines: {node: '>=12'} 229 | dev: true 230 | 231 | /strip-json-comments/4.0.0: 232 | resolution: {integrity: sha512-LzWcbfMbAsEDTRmhjWIioe8GcDRl0fa35YMXFoJKDdiD/quGFmjJjdgPjFJJNwCMaLyQqFIDqCdHD2V4HfLgYA==} 233 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 234 | dev: true 235 | 236 | /to-regex-range/5.0.1: 237 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 238 | engines: {node: '>=8.0'} 239 | dependencies: 240 | is-number: 7.0.0 241 | dev: true 242 | 243 | /uc.micro/1.0.6: 244 | resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} 245 | dev: true 246 | 247 | /yaml/1.10.2: 248 | resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} 249 | engines: {node: '>= 6'} 250 | dev: true 251 | -------------------------------------------------------------------------------- /setting/README.md: -------------------------------------------------------------------------------- 1 | ## 背景和动机 2 | 3 | 主题和插件的都需要配置管理以用于运行参数的动态调整,例如配置主题的代码展示风格、分页大小、布局等。 4 | 5 | Halo 的受众用户可能并不是懂技术的开发者,为了让用户能方便的修改配置需要提供可视化的界面来修改配置的值。 6 | 7 | 本篇将设计提供一种能让主题或插件开发者定义 `Yaml` 的方式来动态生成配置表单实现可视化修改值的方式,同时提供通过 `ConfigMap` 数据结构来存储这种类型的配置值但 ConfigMap 在设计上不是用来保存大量数据的。 8 | 9 | ## 目标 10 | 11 | - 提供 ConfigMap 自定义模型 12 | - 提供主题和插件配置自定义模型 13 | 14 | ## 设计 15 | 16 | ### ConfigMap 17 | 18 | ConfigMap 是一个自定义模型, 让你可以存储其他对象所需要使用的配置。 ConfigMap 使用 `data` 和 `binaryData` 字段。这些字段能够接收键-值对作为其取值。`data` 和 `binaryData` 字段都是可选的。`data` 字段设计用来保存 UTF-8 字符串,而 `binaryData` 则被设计用来保存二进制数据作为 base64 编码的字串。 19 | 20 | ConfigMap 的名字必须是一个合法的 [DNS 子域名](https://kubernetes.io/zh-cn/docs/concepts/overview/working-with-objects/names#dns-subdomain-names)。`data` 或 `binaryData` 字段下面的每个键的名称都必须由字母数字字符或者 `-`、`_` 或 `.` 组成。在 `data` 下保存的键名不可以与在 `binaryData` 下出现的键名有重叠。 21 | 22 | ConfigMap 将你的环境配置信息同主题和插件等解耦,便于应用配置的修改。 23 | 24 | ConfigMap 并不提供保密或者加密功能。 25 | 26 | ```yaml 27 | apiVersion: v1alpha1 28 | kind: ConfigMap 29 | metadata: 30 | name: game-demo 31 | data: 32 | #... 33 | ``` 34 | 35 | 参考:[Kubernetes ConfigMap](https://kubernetes.io/docs/concepts/configuration/configmap/) 36 | 37 | ### 主题配置 38 | 39 | 主题配置示例: 40 | 41 | `formSchema` 遵循 [Formkit form generation](https://formkit.com/essentials/generation) 42 | 43 | ```yaml 44 | apiVersion: v1alpha1 45 | kind: Setting 46 | metadata: 47 | name: theme-setting-test 48 | spec: 49 | - group: sns 50 | label: 社交资料 51 | formSchema: 52 | - $el: h1 53 | children: Register 54 | - $formkit: text 55 | help: This will be used for your account. 56 | label: Email 57 | name: email 58 | validation: required|email 59 | - $formkit: password 60 | help: Enter your new password. 61 | label: Password 62 | name: password 63 | validation: required|length:5,16 64 | - $formkit: password 65 | help: Enter your new password again to confirm it. 66 | label: Confirm password 67 | name: password_confirm 68 | validation: required|confirm 69 | validationLabel: password confirmation 70 | - $formkit: checkbox 71 | id: eu 72 | label: Are you a european citizen? 73 | name: eu_citizen 74 | - $formkit: select 75 | help: How often should we display a cookie notice? 76 | if: $get(eu).value 77 | label: Cookie notice frequency 78 | name: cookie_notice 79 | options: 80 | daily: Every day 81 | hourly: Ever hour 82 | refresh: Every page load 83 | ``` 84 | 85 | 根据如上 Setting 配置将生成形如以下格式 HTML 86 | 87 | ```html 88 |
89 |

Register

90 |
91 |
92 | 93 |
94 | 101 |
102 |
103 |
104 | This will be used for your account. 105 |
106 |
107 |
108 |
109 | 110 |
111 | 118 |
119 |
120 |
Enter your new password.
121 |
122 |
123 |
124 | 125 |
126 | 133 |
134 |
135 |
136 | Enter your new password again to confirm it. 137 |
138 |
139 |
140 | 153 |
154 |
155 |
156 |
157 | 165 |
166 |
167 |
168 |
169 |
170 | {}
171 | 
172 | ``` 173 | 在主题描述文件中关联 Setting 和 ConfigMap 174 | ```yaml 175 | apiVersion: theme.halo.run/v1alpha1 176 | kind: Theme 177 | metadata: 178 | name: gtvg 179 | spec: 180 | displayName: GTVG 181 | author: 182 | name: guqing 183 | website: https://guqing.xyz 184 | description: 测试主题 185 | logo: https://guqing.xyz/logo.png 186 | website: https://github.com/guqing/halo-theme-test.git 187 | repo: https://github.com/guqing/halo-theme-test.git 188 | version: 1.0.0 189 | require: 2.0.0 190 | setting-name: theme-setting-test 191 | configmap-name: theme-configmap-test 192 | ``` 193 | 当 Setting 名为 `theme-setting-test` 的表单被提交时其表单值会被保存到名称为 `theme-configmap-test` 的 ConfigMap 中。 194 | 195 | `ConfigMap` 的格式如下: 196 | 197 | ```yaml 198 | apiVersion: v1alpha1 199 | kind: ConfigMap 200 | metadata: 201 | name: theme-configmap-test 202 | data: 203 | sns: | 204 | { 205 | "email": "111", 206 | "password": "xxx", 207 | "password_confirm": "xxx", 208 | "cookie_notice": ["hello", "world"] 209 | } 210 | basic: | 211 | { 212 | "color": "red" 213 | } 214 | ``` 215 | data 中的每个 key 都表示 group name,而 value 为 group 下的表单值 JSON。 216 | 217 | ### 插件配置 218 | 219 | 插件配置与主题大致相同 220 | 221 | ```yaml 222 | apiVersion: v1alpha1 223 | kind: Setting 224 | metadata: 225 | name: plugin-setting-name 226 | spec: 227 | - group: default 228 | label: 默认配置 229 | formSchema: 230 | #... 231 | ``` 232 | 233 | 配置值同为 ConfigMap 234 | 235 | ```yaml 236 | apiVersion: v1alpha1 237 | kind: ConfigMap 238 | metadata: 239 | name: plugin-setting-configmap 240 | data: 241 | some-group: | 242 | {} 243 | ``` 244 | -------------------------------------------------------------------------------- /template.md: -------------------------------------------------------------------------------- 1 | # RFC :*标题在这里* 2 | 3 | > 删除*斜体*注释。删除不适用的部分。 4 | 5 | | **概述** | *如果有人只读到这里,你想让他们知道什么?* | 6 | | ---------- | ------------------------------------------ | 7 | | **已创建** | 2022 年 03 月 15 日 | 8 | | **状态** | WIP \| In-Review \| Approved \| Obsolete | 9 | | **所有者** | *谁拥有该文件,应该联系谁?* | 10 | | **贡献者** | *任何有贡献的人* | 11 | 12 | ## 目标和非目标 13 | 14 | *你想解决什么问题?你不想解决哪些问题?* 15 | 16 | ## 背景和动机 17 | 18 | *现状如何?为什么要提出这种改变?* 19 | 20 | *在此处定义任何关键术语或内部名称。* 21 | 22 | ## 设计 23 | 24 | *你到底在做什么?包括架构和流程图。* 25 | 26 | *这通常是 RFC 中最长的部分。* 27 | 28 | ## 时间线 29 | 30 | *建议的实施时间表是什么?* 31 | 32 | ## 依赖项 33 | 34 | *这个依赖于哪些现有的内部和外部系统?它将如何使用它们?* 35 | 36 | ## 考虑的替代方案 / 现有技术 37 | 38 | *您还考虑了哪些其他方法?哪些现有的解决方案是接近但不完全正确的?该项目将如何替代或整合替代方案?* 39 | 40 | ## 操作 41 | 42 | *您是否正在为任何团队添加任何新的常规人工流程或额外工作?如果这是一个新系统,谁来运行它?* 43 | 44 | ## 安全 / 隐私 / 合规 45 | 46 | *应该考虑哪些安全 / 隐私 / 合规方面?* 47 | 48 | *如果你不确定,永远不要假设没有。始终与安全团队交谈。* 49 | 50 | ## 风险 51 | 52 | *存在哪些已知风险?哪些因素可能会使您的项目复杂化?* 53 | 54 | *包括:安全性、复杂性、兼容性、延迟、服务不成熟、缺乏团队专业知识等。* 55 | 56 | ## 修订 57 | 58 | 1. *已创建 RFC* 59 | 2. *重大更改的更新,包括状态更改。* 60 | -------------------------------------------------------------------------------- /theme/README.md: -------------------------------------------------------------------------------- 1 | ## 背景和动机 2 | 3 | 主题是一个很好的工具,可以帮助用户建立自己喜欢的门户风格,并使你的网站以最佳方式脱颖而出。它可以按照自己喜欢的方式进行定制。 4 | 5 | 主题是每个 CMS 网站不可或缺的一部分。除了为你提供多种自定义选项外,它还可以帮助您控制展示网站的方式。 6 | 7 | 主题会提高 Halo 的适用范围,只需要使用 Halo 进行内容管理并使用不同的主题即可帮助用户快速完成建站,只需要简单的界面点击可以切换门户外观。 8 | 9 | ## 目标 10 | 11 | - 主题开发 12 | 13 | - 主题渲染 14 | - 主题安装 15 | - 主题切换 16 | - 主题配置 17 | - 主题国际化 18 | - 主题扩展 19 | - 主题更新 20 | 21 | ## 非目标 22 | 23 | - 页面静态化 24 | 25 | ## 设计 26 | 27 | ### 主题开发与工程化 28 | 29 | 主题目录结构示例 30 | 31 | ```text 32 | ├── i18n 33 | │   └── zh.properties 34 | │   └── en.properties 35 | ├── templates 36 | │   └── assets 37 | ├── css 38 | │   └── style.css 39 | ├── js 40 | │   └── main.js 41 | │   └── index.html 42 | │   └── post.html 43 | │   └── archives.html 44 | │   └── categories.html 45 | │   └── links.html 46 | │   └── journals.html 47 | ├── README.md 48 | └── settings.yaml 49 | └── theme.yaml 50 | ``` 51 | 52 | 创建主题描述文件 `theme.yaml`,示例如下: 53 | 54 | ```yaml 55 | apiVersion: theme.halo.run/v1alpha1 56 | kind: Theme 57 | metadata: 58 | name: gtvg 59 | spec: 60 | displayName: GTVG 61 | author: 62 | name: guqing 63 | website: https://guqing.xyz 64 | description: 测试主题 65 | logo: https://guqing.xyz/logo.png 66 | website: https://github.com/guqing/halo-theme-test.git 67 | repo: https://github.com/guqing/halo-theme-test.git 68 | version: 1.0.0 69 | require: 2.0.0 70 | ``` 71 | 72 | 如果主题需要生成配置表单,则创建 `settings.yaml` 文件,示例参考: [Setting RFC](https://github.com/halo-dev/rfcs/blob/main/setting/README.md) 73 | 74 | 目前主题开发仅可通过主题模板仓库生成。 75 | 76 | ### 主题预览 77 | 78 | 为了支持在非启用状态下的主题预览,需要考虑链接生成,受影响的主要是静态资源引用链接和模板引擎引用链接。 79 | 80 | 为了防止模板被下载,静态资源的访问将被限制在`src/assets/**`目录下,即只会对外暴露主题的 `src/assets/` 目录。 81 | 82 | Halo 将覆盖 Thymeleaf 提供的 `@{}` 表达式链接生成逻辑以便于对启用主题和未启用主题的访问进行处理。 83 | 84 | 静态资源链接写法必须以 assets 开头,如下: 85 | 86 | ```html 87 | 88 | 94 | ``` 95 | 96 | 模板渲染时会将此链接变为 97 | 98 | ```html 99 | 105 | ``` 106 | 107 | 模版页面链接写法与 Thymeleaf 无差别: 108 | 109 | ```html 110 | Next 111 | ``` 112 | 113 | 对于激活的主题,渲染后得到实际内容如下 114 | 115 | ```html 116 | Next 117 | ``` 118 | 119 | 当开启主题预览后渲染内容如下 120 | 121 | ```html 122 | Next 123 | ``` 124 | 125 | 从而实现主题预览而无须先启用主题。 126 | 127 | ### 主题安装 128 | 129 | 通过上传 `Zip` 压缩文件到 Halo 的 `${workdir}/themes` 目录或拷贝解压缩后的主题目录到此 `themes` 目录。 130 | 131 | 主题安装的 Endpoint: 132 | 133 | ```sh 134 | curl --location --request POST '/apis/api.halo.run/v1alpha1/themes' \ 135 | --form 'file=@"/path/to/file"' 136 | ``` 137 | 138 | ### 主题切换 139 | 140 | 主题同时只能有一个被启用,当修改了正在使用的主题后,模板文件渲染指向的路径切换到被启用的主题目录。 141 | 142 | 激活的主题则挂载到根路径 143 | 144 | 切换主题的 Endpoint: 145 | 146 | ```sh 147 | curl --location --request PUT '/apis/api.halo.run/v1alpha1/themes/{name}/activation' 148 | ``` 149 | 150 | ### 主题配置 151 | 152 | 管理端通过解析 `settings.yaml `中的配置生成动态表单,填写后点击保存会将所有值保存到一个 ConfigMap 中。详情参考 [主题配置](https://github.com/halo-dev/rfcs/blob/main/setting/README.md#%E4%B8%BB%E9%A2%98%E9%85%8D%E7%BD%AE) 153 | 154 | 获取主题表单设置的 Endpoint: 155 | 156 | ```sh 157 | curl --location --request GET '/api/v1alpha1/settings/{name}' 158 | ``` 159 | 160 | 获取主题设置表单值的 Endpoint: 161 | 162 | ```sh 163 | curl --location --request GET '/api/v1alpha1/configmaps/{name}' 164 | ``` 165 | 166 | ### 主题国际化 167 | 168 | 如下目录结构所示 169 | 170 | ```text 171 | ├── i18n 172 | │   └── default.properties 173 | │   └── zh.properties 174 | │   └── en.properties 175 | ``` 176 | 177 | `i18n`目录表示此目录下的 `.properties` 文件多语言配置: 178 | 179 | - `default.properties` 表示默认语言时使用的文件。 180 | 181 | - `en.properties`表示 `Locale` 的语言为 `English` 时使用的配置文件。 182 | 183 | 命名规则为 `{language}.properties`,`language` 遵循 [ISO 639 alpha-2 or alpha-3 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes),语言字段不区分大小写,但语言环境总是规范化为小写,`default.properties`除外,它是找不到对应语言环境时默认使用的语言包。 184 | 185 | 示例:"en" (English), "ja" (Japanese), "kok" (Konkani) 186 | 187 | ### 主题渲染 188 | 189 | 主题渲染使用 [Thymeleaf](https://www.thymeleaf.org/) 模板引擎渲染。示例: 190 | 191 | 1. 定义模板视图 APIs 并填充 Context 192 | 193 | ```java 194 | @Bean 195 | public RouterFunction home() { 196 | return RouterFunctions.route(GET("/posts"), 197 | request -> ServerResponse.ok().contentType(MediaType.TEXT_HTML) 198 | .bodyValue(storeThemeService.renderPosts())); 199 | } 200 | 201 | public String renderPosts(PostParam postParam) { 202 | List posts = postService.list(postParam); 203 | // 获取文章列表 204 | Map params = Map.of();//... 205 | params.put("posts", posts); 206 | params.put("postService", postService); 207 | return renderService.render("posts", params); 208 | } 209 | ``` 210 | 211 | 2. 使用方法 212 | 213 | - 直接使用变量 214 | 215 | ```html 216 |
217 |
218 |
219 | ``` 220 | 221 | - 允许调用方法 222 | 223 | ```html 224 |
228 |
229 |
230 | ``` 231 | 232 | 附录: 233 | 234 | ```java 235 | public class RenderService { 236 | private final TemplateEngine templateEngine; 237 | private final FileTemplateResolver fileTemplateResolver; 238 | 239 | public RenderService() { 240 | fileTemplateResolver = fileTemplateResolver(); 241 | templateEngine = new TemplateEngine(); 242 | templateEngine.setTemplateResolver(fileTemplateResolver); 243 | templateEngine.setDialect(new SpringStandardDialect()); 244 | templateEngine.setMessageResolver(new HaloThemeMessageResolver()); 245 | } 246 | 247 | private FileTemplateResolver fileTemplateResolver() { 248 | FileTemplateResolver templateResolver = new FileTemplateResolver(); 249 | templateResolver.setCacheable(false); 250 | templateResolver.setPrefix(getActivateTheme(activateTheme)); 251 | templateResolver.setSuffix(".html"); 252 | return templateResolver; 253 | } 254 | 255 | public String render(String template, Map model) { 256 | Context context = new Context(getLocale(), model); 257 | // 加一些处理器 258 | return templateEngine.process(template, context); 259 | } 260 | 261 | private String getActivateTheme(String theme) { 262 | return themeBase + theme + "/"; 263 | } 264 | 265 | public Locale getLocale() {} 266 | } 267 | ``` 268 | 269 | #### 主题公共模板扩展 270 | 271 | 1. 主题中的扩展位只是占位标记,允许渲染时插入固定代码片段类似 [Halo 1.x 公共宏模板](https://docs.halo.run/developer-guide/theme/public-template-tag)。 272 | 273 | 2. 前期扩展模板只由 Halo 提供,后续可以使用自定义扩展模板内容,例如通过配置的方式使用其他评论模板。 274 | 275 | 当在主题模版中写了如下 `Tag` 276 | 277 | ```html 278 | 279 | ``` 280 | 281 | 渲染该模版时会获取其中的 `name` 属性表示扩展点名称来选择需要注入到此处的代码。代码片段为固定的内容用来简写公共组件。 282 | 283 | 示例: 284 | 285 | `index.html` 286 | 287 | ```html 288 | 289 | 290 | 291 | 测试标题 292 | 293 | 294 |
this is a short line
295 | 296 | 297 | 298 | ``` 299 | 300 | 扩展点对应的`comment` 对应的代码片段为 301 | 302 | ```html 303 |
oh this is comment!
304 | ``` 305 | 306 | 则渲染后的结果为: 307 | 308 | ```html 309 | 310 | 311 | 312 | 测试标题 313 | 314 | 315 |
this is a short line
316 |
oh this is comment!
317 | 318 | 319 | ``` 320 | 321 | #### 渲染后置处理 322 | 323 | 允许对渲染后的成品 HTML 内容进行后置处理,例如可以追加模板渲染时间等 324 | 325 | ```java 326 | // ... 327 | String raw = render(template, model); 328 | return applyTemplateRawPostProcessors(raw); 329 | ``` 330 | 331 | ### 主题更新 332 | 333 | 1. 通过上传主题 `ZIP` 包进行覆盖更新 334 | 2. TBD. 335 | 336 | ### 页面静态化 337 | 338 | 页面静态化应该只是一个可选项,允许用户设置是否开启。 339 | 340 | 静态渲染所带来的问题: 341 | 342 | 1. 主题开发时渲染不实时 343 | 344 | 2. 分页问题,添加数据后需要重新生成所有静态页面 345 | 3. 浏览量统计不实时 346 | 4. 评论显示不实时 347 | 348 | 为了加快网站的访问速度,可以使用模板渲染缓存来实现。 349 | --------------------------------------------------------------------------------