From e1f787902d917b292313aa487d500d8cfbcd79b2 Mon Sep 17 00:00:00 2001 From: dark Date: Fri, 18 Sep 2026 04:08:16 +0800 Subject: [PATCH 1/3] feat(community): add roster and content pilots - render deterministic ASF members across Community outputs - preserve last-good roster with atomic validated refresh - enrich bilingual search metadata and content components - port product commits d88167dd..bb270838 onto oink-918 - retain PR-A docs-root LLMSFULL output declarations --- assets/scss/community-members.scss | 62 ++ content/cn/docs/clients/_index.md | 2 + content/cn/docs/clients/restful-api/_index.md | 2 + content/cn/docs/clients/restful-api/vertex.md | 9 +- .../cn/docs/config/config-authentication.md | 2 + content/cn/docs/config/config-guide.md | 4 +- content/cn/docs/download/download.md | 2 + content/cn/docs/introduction/_index.md | 2 + .../computing/hugegraph-computer.md | 2 + .../quickstart/hugegraph/hugegraph-hstore.md | 6 +- .../docs/quickstart/hugegraph/hugegraph-pd.md | 2 + .../quickstart/hugegraph/hugegraph-server.md | 17 +- .../quickstart/toolchain/hugegraph-hubble.md | 2 + .../quickstart/toolchain/hugegraph-loader.md | 2 + content/en/docs/clients/_index.md | 2 + content/en/docs/clients/restful-api/_index.md | 2 + content/en/docs/clients/restful-api/vertex.md | 9 +- .../en/docs/config/config-authentication.md | 2 + content/en/docs/config/config-guide.md | 4 +- content/en/docs/download/download.md | 2 + content/en/docs/introduction/_index.md | 2 + .../computing/hugegraph-computer.md | 2 + .../quickstart/hugegraph/hugegraph-hstore.md | 6 +- .../docs/quickstart/hugegraph/hugegraph-pd.md | 2 + .../quickstart/hugegraph/hugegraph-server.md | 17 +- .../quickstart/toolchain/hugegraph-hubble.md | 2 + .../quickstart/toolchain/hugegraph-loader.md | 2 + data/community/github-map.json | 4 + data/community/roster.json | 209 +++++ data/landing/community/cn.yaml | 5 + data/landing/community/en.yaml | 5 + layouts/_partials/community/members.html | 37 + layouts/_partials/community/members.md | 23 + .../landing/sections/community-members.html | 1 + layouts/community/landing.md | 29 + scripts/community_roster.md | 41 + scripts/community_roster.py | 710 ++++++++++++++++ .../fixtures/community_search_queries.json | 26 + scripts/test_community_roster.py | 785 ++++++++++++++++++ 39 files changed, 2015 insertions(+), 30 deletions(-) create mode 100644 assets/scss/community-members.scss create mode 100644 data/community/github-map.json create mode 100644 data/community/roster.json create mode 100644 layouts/_partials/community/members.html create mode 100644 layouts/_partials/community/members.md create mode 100644 layouts/_partials/landing/sections/community-members.html create mode 100644 layouts/community/landing.md create mode 100644 scripts/community_roster.md create mode 100644 scripts/community_roster.py create mode 100644 scripts/fixtures/community_search_queries.json create mode 100644 scripts/test_community_roster.py diff --git a/assets/scss/community-members.scss b/assets/scss/community-members.scss new file mode 100644 index 0000000000..f5cfa240c4 --- /dev/null +++ b/assets/scss/community-members.scss @@ -0,0 +1,62 @@ +.hg-community-members { + --hg-community-columns: 5; + &__role + &__role { margin-top: 2.5rem; } + &__grid { + display: grid; + grid-template-columns: repeat(var(--hg-community-columns), minmax(0, 1fr)); + gap: 1rem; + padding: 0; + margin: 1rem 0 0; + list-style: none; + } +} +.hg-community-member { + min-width: 0; + padding: 0; + &__link { + display: flex; + min-height: 100%; + flex-direction: column; + align-items: center; + gap: .65rem; + padding: 1rem .75rem; + color: inherit; + text-align: center; + text-decoration: none; + border: 1px solid var(--td-border-color); + border-radius: var(--td-card-border-radius, .75rem); + background: var(--td-card-bg, var(--bs-body-bg)); + &:hover, &:focus-visible { + color: var(--td-link-color, var(--bs-link-color)); + border-color: currentColor; + } + } + &__avatar { + position: relative; + display: grid; + width: 8rem; + height: 8rem; + overflow: hidden; + place-items: center; + border-radius: 50%; + background: var(--td-secondary-bg, var(--bs-secondary-bg)); + img, .hg-community-member__initials { position: absolute; inset: 0; width: 100%; height: 100%; } + img { object-fit: cover; } + } + &__initials { + display: grid; + place-items: center; + font-size: 1.65rem; + font-weight: 700; + color: var(--td-body-color, var(--bs-body-color)); + } + &__identity { max-width: 100%; overflow-wrap: anywhere; font-weight: 650; } + &__role-label { font-size: .8rem; color: var(--td-secondary-color, var(--bs-secondary-color)); } +} +@media (max-width: 1199.98px) { + .hg-community-members { --hg-community-columns: 3; } +} +@media (max-width: 767.98px) { + .hg-community-members { --hg-community-columns: 2; } + .hg-community-member__avatar { width: 6rem; height: 6rem; } +} diff --git a/content/cn/docs/clients/_index.md b/content/cn/docs/clients/_index.md index b31bade126..26b2d129ff 100644 --- a/content/cn/docs/clients/_index.md +++ b/content/cn/docs/clients/_index.md @@ -2,6 +2,8 @@ title: "客户端与 API" linkTitle: "客户端与 API" weight: 5 +search_keywords: [HugeGraph 客户端, Java 客户端, 客户端库] +search_boost: 1.5 --- 本节包含 REST API、Gremlin Console 和客户端说明。当前 Server REST API 使用图空间和图名称组成资源路径;具体路径以各 API 页面和 Server 的 OpenAPI 页面为准。 diff --git a/content/cn/docs/clients/restful-api/_index.md b/content/cn/docs/clients/restful-api/_index.md index 6094321b29..ab79ed10b8 100644 --- a/content/cn/docs/clients/restful-api/_index.md +++ b/content/cn/docs/clients/restful-api/_index.md @@ -2,6 +2,8 @@ title: "HugeGraph RESTful API" linkTitle: "RESTful API" weight: 1 +search_keywords: [HugeGraph REST API, RESTful API, OpenAPI] +search_boost: 1.7 --- > ⚠️ **版本兼容性说明** diff --git a/content/cn/docs/clients/restful-api/vertex.md b/content/cn/docs/clients/restful-api/vertex.md index 1318da72f2..1d1158b281 100644 --- a/content/cn/docs/clients/restful-api/vertex.md +++ b/content/cn/docs/clients/restful-api/vertex.md @@ -5,7 +5,7 @@ weight: 7 description: "Vertex(顶点)REST 接口:创建、查询、更新和删除图中的顶点数据,支持批量操作和条件过滤。" --- -### 2.1 Vertex +### 2.1 Vertex {#vertex-api} 顶点类型中的 `Id` 策略决定了顶点的 `Id` 类型,其对应的 `id` 类型如下: @@ -16,6 +16,7 @@ description: "Vertex(顶点)REST 接口:创建、查询、更新和删除图 | CUSTOMIZE_STRING | string | | CUSTOMIZE_NUMBER | number | | CUSTOMIZE_UUID | uuid | +{#vertex-id-strategy .full-width caption="顶点 ID 策略"} 顶点的 `GET/PUT/DELETE` API 中 url 的 id 部分应该传入带有类型信息的 id 值,这个类型信息通过 json 串是否带引号来表示,也就是说: @@ -41,7 +42,7 @@ schema.vertexLabel("software").properties("name", "lang", "price").primaryKeys(" schema.indexLabel("personByAge").onV("person").by("age").range().ifNotExist().create(); ``` -#### 2.1.1 创建一个顶点 +#### 2.1.1 创建一个顶点 {#create-vertex} ##### Params @@ -58,7 +59,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Request Body -```json +```json {filename="request.json" wrap=true} { "label": "person", "properties": { @@ -76,7 +77,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Response Body -```json +```json {filename="response.json" wrap=true} { "id": "1:marko", "label": "person", diff --git a/content/cn/docs/config/config-authentication.md b/content/cn/docs/config/config-authentication.md index a6b67d1b5c..481edd5038 100644 --- a/content/cn/docs/config/config-authentication.md +++ b/content/cn/docs/config/config-authentication.md @@ -2,6 +2,8 @@ title: "HugeGraph 内置用户权限与扩展权限配置及使用" linkTitle: "权限配置" weight: 3 +search_keywords: [HugeGraph 认证, 权限配置, StandardAuthenticator] +search_boost: 1.7 --- ### 概述 diff --git a/content/cn/docs/config/config-guide.md b/content/cn/docs/config/config-guide.md index c8d8d3c942..7d76a80f6f 100644 --- a/content/cn/docs/config/config-guide.md +++ b/content/cn/docs/config/config-guide.md @@ -2,6 +2,8 @@ title: "Server 启动指南" linkTitle: "Server 启动指南" weight: 1 +search_keywords: [HugeGraph 配置, Server 配置, 配置指南] +search_boost: 1.6 --- ### 1 概述 @@ -21,7 +23,7 @@ HugeGraphServer 内部集成了 GremlinServer 和 RestServer,而 gremlin-serve `gremlin-server.yaml` 的主要结构如下。示例省略了部分导入项;完整内容以发布包中的文件为准。 -```yaml +```yaml {filename="conf/gremlin-server.yaml" wrap=true collapse=18} # host and port of gremlin server, need to be consistent with host and port in rest-server.properties #host: 127.0.0.1 #port: 8182 diff --git a/content/cn/docs/download/download.md b/content/cn/docs/download/download.md index 831c22faaa..bc96a8ca54 100644 --- a/content/cn/docs/download/download.md +++ b/content/cn/docs/download/download.md @@ -2,6 +2,8 @@ title: "下载 Apache HugeGraph" linkTitle: "Download" weight: 2 +search_keywords: [HugeGraph 下载, 发布包, SHA512] +search_boost: 3 --- > 指南: diff --git a/content/cn/docs/introduction/_index.md b/content/cn/docs/introduction/_index.md index d57559c4dc..72a07c23de 100644 --- a/content/cn/docs/introduction/_index.md +++ b/content/cn/docs/introduction/_index.md @@ -2,6 +2,8 @@ title: "Apache HugeGraph 介绍" linkTitle: "系统介绍" weight: 1 +search_keywords: [HugeGraph 介绍, 图数据库简介, 系统架构] +search_boost: 3 aliases: # Hugo 0.165 prefixes aliases with the current language path. - /docs/introduction/readme/ diff --git a/content/cn/docs/quickstart/computing/hugegraph-computer.md b/content/cn/docs/quickstart/computing/hugegraph-computer.md index 297d0f7fc4..134e9f3b1a 100644 --- a/content/cn/docs/quickstart/computing/hugegraph-computer.md +++ b/content/cn/docs/quickstart/computing/hugegraph-computer.md @@ -2,6 +2,8 @@ title: "HugeGraph-Computer Quick Start" linkTitle: "使用 Computer 进行 OLAP 分析" weight: 2 +search_keywords: [HugeGraph Computer, 图计算, OLAP] +search_boost: 1.6 --- ## 1 HugeGraph-Computer 概述 diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md b/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md index 34d5db1f01..3ab0ab64fd 100644 --- a/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md +++ b/content/cn/docs/quickstart/hugegraph/hugegraph-hstore.md @@ -3,10 +3,10 @@ title: "HugeGraph-Store Quick Start" linkTitle: "安装/构建 HugeGraph-Store" weight: 3 search_keywords: + - HugeGraph HStore + - 分布式存储 - server.port - - REST 端口 - - Store REST 端口 -search_boost: 1.5 +search_boost: 1.6 --- ### 1 HugeGraph-Store 概述 diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md b/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md index 532d11f893..403283cb3c 100644 --- a/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md +++ b/content/cn/docs/quickstart/hugegraph/hugegraph-pd.md @@ -2,6 +2,8 @@ title: "HugeGraph-PD Quick Start" linkTitle: "安装/构建 HugeGraph-PD" weight: 2 +search_keywords: [HugeGraph PD, 元数据管理, 集群调度] +search_boost: 1.6 --- ### 1 HugeGraph-PD 概述 diff --git a/content/cn/docs/quickstart/hugegraph/hugegraph-server.md b/content/cn/docs/quickstart/hugegraph/hugegraph-server.md index 1e08171ff7..390a137d72 100644 --- a/content/cn/docs/quickstart/hugegraph/hugegraph-server.md +++ b/content/cn/docs/quickstart/hugegraph/hugegraph-server.md @@ -2,6 +2,8 @@ title: "HugeGraph Server 快速开始" linkTitle: "安装/构建 HugeGraph Server" weight: 1 +search_keywords: [HugeGraph Server, Server 快速开始, 图数据库服务] +search_boost: 1.7 aliases: - /docs/quickstart/hugegraph-server/ --- @@ -34,10 +36,11 @@ HugeGraph 1.7.0 中的 `hugegraph-server` 模块使用 Java 11 编译,运行 有四种方式可以部署 Server 服务: -- 方式 1:使用 Docker 容器 (便于**测试**) -- 方式 2:下载 tar 包 -- 方式 3:源码编译 -- 方式 4:使用 tools 工具部署 (Outdated) +1. 使用 Docker 容器进行测试或开发。 +1. 下载二进制 tar 包。 +1. 从源码编译。 +1. 使用已过时的一键部署工具。 +{.steps} > 不要把 Gremlin、Cypher 等查询接口直接暴露到公网。生产环境应启用[认证与授权](/cn/docs/config/config-authentication/),限制网络访问并保留审计日志;部署建议见[安全指南](/cn/docs/guides/security/)。 @@ -72,7 +75,7 @@ HugeGraph 1.7.0 中的 `hugegraph-server` 模块使用 Java 11 编译,运行 | HA 参考 | `docker-compose-3pd-3store-3server.yml` | 3 PD + 3 Store + 3 Server + 1 Hubble | | 最小 HStore 拓扑的源码构建覆盖文件 | `docker-compose.dev.yml` | (需与 `docker-compose-hstore.yml` 一起使用) | -```bash +```bash {filename="docker/docker-compose.yml" wrap=true} cd hugegraph/docker # 注意版本号请随时保持更新 → 1.x.0 HUGEGRAPH_VERSION=1.7.0 docker compose -f docker-compose.yml up -d --wait @@ -92,7 +95,7 @@ compose 文件从 `HUGEGRAPH_ADMIN_PASSWORD` 读取管理员密码,从 `HUGEGR ### 3.2 下载 tar 包 -```bash +```bash {filename="download-release.sh" wrap=true collapse=2} # 1.7.0 是项目孵化期发布的历史版本,因此文件名仍带 incubating wget https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz tar zxf apache-hugegraph-incubating-1.7.0.tar.gz @@ -104,7 +107,7 @@ tar zxf apache-hugegraph-incubating-1.7.0.tar.gz 下载 HugeGraph 源代码 -```bash +```bash {filename="build-from-source.sh" wrap=true collapse=2} git clone https://github.com/apache/hugegraph.git ``` diff --git a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md index 92f2ac76e8..9b696593a0 100644 --- a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md +++ b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md @@ -2,6 +2,8 @@ title: "HugeGraph-Hubble Quick Start" linkTitle: "使用 Hubble 实现图可视化" weight: 1 +search_keywords: [HugeGraph Hubble, 图可视化, Web 管理界面] +search_boost: 1.6 --- ### 1 HugeGraph-Hubble 概述 diff --git a/content/cn/docs/quickstart/toolchain/hugegraph-loader.md b/content/cn/docs/quickstart/toolchain/hugegraph-loader.md index d58ff7785c..5a9916bdc1 100644 --- a/content/cn/docs/quickstart/toolchain/hugegraph-loader.md +++ b/content/cn/docs/quickstart/toolchain/hugegraph-loader.md @@ -2,6 +2,8 @@ title: "HugeGraph-Loader Quick Start" linkTitle: "使用 Loader 实时/流式导入数据" weight: 2 +search_keywords: [HugeGraph Loader, 批量导入, 数据导入] +search_boost: 1.6 --- ### 1 HugeGraph-Loader 概述 diff --git a/content/en/docs/clients/_index.md b/content/en/docs/clients/_index.md index 48a709f4f2..76a9ddfb55 100644 --- a/content/en/docs/clients/_index.md +++ b/content/en/docs/clients/_index.md @@ -2,6 +2,8 @@ title: "Clients and APIs" linkTitle: "Clients and APIs" weight: 5 +search_keywords: [HugeGraph clients, Java client, client libraries] +search_boost: 1.5 --- This section covers the REST API, Gremlin Console, and client libraries. The current Server REST API identifies graph resources with both a graph space and a graph name. Refer to each API page and the Server OpenAPI page for the exact paths. diff --git a/content/en/docs/clients/restful-api/_index.md b/content/en/docs/clients/restful-api/_index.md index 7c35ce9e30..3b651a5e8b 100644 --- a/content/en/docs/clients/restful-api/_index.md +++ b/content/en/docs/clients/restful-api/_index.md @@ -2,6 +2,8 @@ title: "HugeGraph RESTful API" linkTitle: "RESTful API" weight: 1 +search_keywords: [HugeGraph REST API, RESTful API, OpenAPI] +search_boost: 1.7 --- > ⚠️ **Version compatibility notes** diff --git a/content/en/docs/clients/restful-api/vertex.md b/content/en/docs/clients/restful-api/vertex.md index 52d604c4ee..207d9ac506 100644 --- a/content/en/docs/clients/restful-api/vertex.md +++ b/content/en/docs/clients/restful-api/vertex.md @@ -5,7 +5,7 @@ weight: 7 description: "Vertex REST API: Create, query, update, and delete vertex data in the graph with support for batch operations and conditional filtering." --- -### 2.1 Vertex +### 2.1 Vertex {#vertex-api} In vertex types, the `Id` strategy determines the type of the vertex `Id`, with the corresponding relationships as follows: @@ -16,6 +16,7 @@ In vertex types, the `Id` strategy determines the type of the vertex `Id`, with | CUSTOMIZE_STRING | string | | CUSTOMIZE_NUMBER | number | | CUSTOMIZE_UUID | uuid | +{#vertex-id-strategy .full-width caption="Vertex ID strategies"} For the `GET/PUT/DELETE` API of a vertex, the id part in the URL should be passed as the id value with type information. This type information is indicated by whether the JSON string is enclosed in quotes, meaning: @@ -41,7 +42,7 @@ schema.vertexLabel("software").properties("name", "lang", "price").primaryKeys(" schema.indexLabel("personByAge").onV("person").by("age").range().ifNotExist().create(); ``` -#### 2.1.1 Create a vertex +#### 2.1.1 Create a vertex {#create-vertex} ##### Method & Url @@ -51,7 +52,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Request Body -```json +```json {filename="request.json" wrap=true} { "label": "person", "properties": { @@ -69,7 +70,7 @@ POST http://localhost:8080/graphspaces/DEFAULT/graphs/hugegraph/graph/vertices ##### Response Body -```json +```json {filename="response.json" wrap=true} { "id": "1:marko", "label": "person", diff --git a/content/en/docs/config/config-authentication.md b/content/en/docs/config/config-authentication.md index f5de712c55..2461c43ea5 100644 --- a/content/en/docs/config/config-authentication.md +++ b/content/en/docs/config/config-authentication.md @@ -2,6 +2,8 @@ title: "Built-in User Authentication and Authorization Configuration and Usage in HugeGraph" linkTitle: "Config Authentication" weight: 3 +search_keywords: [HugeGraph authentication, authorization, StandardAuthenticator] +search_boost: 1.7 --- ### Overview diff --git a/content/en/docs/config/config-guide.md b/content/en/docs/config/config-guide.md index 3e24f49ed9..d8a583f422 100644 --- a/content/en/docs/config/config-guide.md +++ b/content/en/docs/config/config-guide.md @@ -2,6 +2,8 @@ title: "Server Startup Guide" linkTitle: "Server Startup Guide" weight: 1 +search_keywords: [HugeGraph configuration, server config, configuration guide] +search_boost: 1.6 --- ### 1 Overview @@ -21,7 +23,7 @@ Now let's introduce these three configuration files one by one. The main structure of `gremlin-server.yaml` is shown below. Some imports are omitted from this example; refer to the file included in the release package for the complete content. -```yaml +```yaml {filename="conf/gremlin-server.yaml" wrap=true collapse=18} # host and port of gremlin server, need to be consistent with host and port in rest-server.properties #host: 127.0.0.1 #port: 8182 diff --git a/content/en/docs/download/download.md b/content/en/docs/download/download.md index a23907c5c9..81be3cf18b 100644 --- a/content/en/docs/download/download.md +++ b/content/en/docs/download/download.md @@ -2,6 +2,8 @@ title: "Download Apache HugeGraph" linkTitle: "Download" weight: 2 +search_keywords: [HugeGraph download, release artifacts, SHA512] +search_boost: 3 --- diff --git a/content/en/docs/introduction/_index.md b/content/en/docs/introduction/_index.md index e83b690e18..87b87f30d8 100644 --- a/content/en/docs/introduction/_index.md +++ b/content/en/docs/introduction/_index.md @@ -2,6 +2,8 @@ title: "Apache HugeGraph Introduction" linkTitle: "System Introduction" weight: 1 +search_keywords: [HugeGraph overview, graph database introduction, architecture] +search_boost: 3 aliases: # Hugo 0.165 prefixes aliases with the current language path. - /docs/introduction/readme/ diff --git a/content/en/docs/quickstart/computing/hugegraph-computer.md b/content/en/docs/quickstart/computing/hugegraph-computer.md index a153e6b437..8c445841e4 100644 --- a/content/en/docs/quickstart/computing/hugegraph-computer.md +++ b/content/en/docs/quickstart/computing/hugegraph-computer.md @@ -2,6 +2,8 @@ title: "HugeGraph-Computer Quick Start" linkTitle: "Analysis with HugeGraph-Computer" weight: 2 +search_keywords: [HugeGraph Computer, graph computing, OLAP] +search_boost: 1.6 --- ## 1 HugeGraph-Computer Overview diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md b/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md index cabd39d74d..abdc14dfde 100644 --- a/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md +++ b/content/en/docs/quickstart/hugegraph/hugegraph-hstore.md @@ -3,10 +3,10 @@ title: "HugeGraph-Store Quick Start" linkTitle: "Install/Build HugeGraph-Store" weight: 3 search_keywords: + - HugeGraph HStore + - distributed storage - server.port - - REST port - - Store REST port -search_boost: 1.5 +search_boost: 1.6 --- ### 1 HugeGraph-Store Overview diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-pd.md b/content/en/docs/quickstart/hugegraph/hugegraph-pd.md index 2a8cefd7a8..8a5d56afa3 100644 --- a/content/en/docs/quickstart/hugegraph/hugegraph-pd.md +++ b/content/en/docs/quickstart/hugegraph/hugegraph-pd.md @@ -2,6 +2,8 @@ title: "HugeGraph-PD Quick Start" linkTitle: "Install/Build HugeGraph-PD" weight: 2 +search_keywords: [HugeGraph PD, placement driver, cluster metadata] +search_boost: 1.6 --- ### 1 HugeGraph-PD Overview diff --git a/content/en/docs/quickstart/hugegraph/hugegraph-server.md b/content/en/docs/quickstart/hugegraph/hugegraph-server.md index 5288908f5f..ec50175f73 100644 --- a/content/en/docs/quickstart/hugegraph/hugegraph-server.md +++ b/content/en/docs/quickstart/hugegraph/hugegraph-server.md @@ -2,6 +2,8 @@ title: "HugeGraph Server Quick Start" linkTitle: "Install/Build HugeGraph Server" weight: 1 +search_keywords: [HugeGraph Server, server quickstart, graph database] +search_boost: 1.7 aliases: - /docs/quickstart/hugegraph-server/ --- @@ -34,10 +36,11 @@ The `hugegraph-server` module in HugeGraph 1.7.0 is compiled with Java 11. Runni There are four ways to deploy the Server service: -- Method 1: Use Docker container (Convenient for Test/Dev) -- Method 2: Download the binary tarball -- Method 3: Source code compilation -- Method 4: One-click deployment +1. Use a Docker container for test or development. +1. Download the binary tarball. +1. Compile the source code. +1. Use the legacy one-click deployment tool. +{.steps} > Do not expose Gremlin, Cypher, or other query endpoints directly to the public Internet. In production, enable [authentication and authorization](/docs/config/config-authentication/), restrict network access, and retain audit logs. See the [Security Guide](/docs/guides/security/) for deployment guidance. @@ -70,7 +73,7 @@ Four compose files are available in the [`docker/`](https://github.com/apache/hu | HA reference | `docker-compose-3pd-3store-3server.yml` | 3 PD + 3 Store + 3 Server + 1 Hubble | | Source build override for the minimal HStore topology | `docker-compose.dev.yml` | (used together with `docker-compose-hstore.yml`) | -```bash +```bash {filename="docker/docker-compose.yml" wrap=true} cd hugegraph/docker # Keep the version aligned with the latest release, for example 1.x.0 HUGEGRAPH_VERSION=1.7.0 docker compose -f docker-compose.yml up -d --wait @@ -91,7 +94,7 @@ See [docker/README.md](https://github.com/apache/hugegraph/blob/master/docker/RE ### 3.2 Download the binary tarball You could download the binary tarball from the download page of the ASF site like this: -```bash +```bash {filename="download-and-verify.sh" wrap=true collapse=5} # 1.7.0 is a historical release from the incubation period, so its file name still includes "incubating" wget https://downloads.apache.org/hugegraph/1.7.0/apache-hugegraph-incubating-1.7.0.tar.gz tar zxf apache-hugegraph-incubating-1.7.0.tar.gz @@ -109,7 +112,7 @@ Download HugeGraph **source code** in either of the following 2 ways (so as the - download the stable/release version from the ASF site - clone the unstable/latest version by GitBox(ASF) or GitHub -```bash +```bash {filename="build-from-source.sh" wrap=true collapse=5} # Way 1. download release package from the ASF site wget https://downloads.apache.org/hugegraph/{version}/apache-hugegraph-incubating-src-{version}.tar.gz tar zxf *hugegraph*.tar.gz diff --git a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md index da7bb23e62..00df45dfc1 100644 --- a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md +++ b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md @@ -2,6 +2,8 @@ title: "HugeGraph-Hubble Quick Start" linkTitle: "Visual with HugeGraph-Hubble" weight: 1 +search_keywords: [HugeGraph Hubble, graph visualization, web console] +search_boost: 1.6 --- ### 1 HugeGraph-Hubble Overview diff --git a/content/en/docs/quickstart/toolchain/hugegraph-loader.md b/content/en/docs/quickstart/toolchain/hugegraph-loader.md index 5cf06bd120..ce448b5a7f 100644 --- a/content/en/docs/quickstart/toolchain/hugegraph-loader.md +++ b/content/en/docs/quickstart/toolchain/hugegraph-loader.md @@ -2,6 +2,8 @@ title: "HugeGraph-Loader Quick Start" linkTitle: "Load data with HugeGraph-Loader" weight: 2 +search_keywords: [HugeGraph Loader, bulk import, data loading] +search_boost: 1.6 --- ### 1 HugeGraph-Loader Overview diff --git a/data/community/github-map.json b/data/community/github-map.json new file mode 100644 index 0000000000..7529e5a32d --- /dev/null +++ b/data/community/github-map.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "mappings": {} +} diff --git a/data/community/roster.json b/data/community/roster.json new file mode 100644 index 0000000000..413853d88f --- /dev/null +++ b/data/community/roster.json @@ -0,0 +1,209 @@ +{ + "schema_version": 1, + "project": "hugegraph", + "retrieved_at": "2026-09-04T12:12:42Z", + "source": { + "committee": "https://whimsy.apache.org/public/committee-info.json", + "projects": "https://whimsy.apache.org/public/public_ldap_projects.json", + "people": "https://whimsy.apache.org/public/public_ldap_people.json", + "chair": "jermy", + "owners": [ + "hxd", + "jermy", + "jin", + "lidongdai", + "linary", + "liyu", + "ming", + "ningjiang", + "panjuan", + "vaughn", + "vgalaxies", + "zhaocong" + ], + "members": [ + "guoshoujing", + "hxd", + "jermy", + "jin", + "jsong010123", + "leizou", + "lidongdai", + "linary", + "liuxiaocs", + "liyu", + "ming", + "ningjiang", + "panjuan", + "pengjunzhi", + "spica", + "vaughn", + "vgalaxies", + "vichayturen", + "wangjing", + "yangjiaqi", + "zhangyi89817", + "zhaocong" + ] + }, + "roles": { + "pmc": [ + { + "asf_id": "jermy", + "name": "Jermy Li", + "initials": "JL", + "chair": true, + "profile_url": "https://people.apache.org/phonebook.html?uid=jermy" + }, + { + "asf_id": "zhaocong", + "name": "Cong Zhao", + "initials": "CZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=zhaocong" + }, + { + "asf_id": "jin", + "name": "Imba Jin", + "initials": "IJ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=jin" + }, + { + "asf_id": "panjuan", + "name": "Juan Pan", + "initials": "JP", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=panjuan" + }, + { + "asf_id": "lidongdai", + "name": "Lidong Dai", + "initials": "LD", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=lidongdai" + }, + { + "asf_id": "linary", + "name": "NingRui Li", + "initials": "NL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=linary" + }, + { + "asf_id": "ming", + "name": "Simon", + "initials": "S", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=ming" + }, + { + "asf_id": "ningjiang", + "name": "Willem Ning Jiang", + "initials": "WN", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=ningjiang" + }, + { + "asf_id": "hxd", + "name": "Xiangdong Huang", + "initials": "XH", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=hxd" + }, + { + "asf_id": "vaughn", + "name": "Yan Zhang", + "initials": "YZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vaughn" + }, + { + "asf_id": "liyu", + "name": "Yu Li", + "initials": "YL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=liyu" + }, + { + "asf_id": "vgalaxies", + "name": "Yuchen Ding", + "initials": "YD", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vgalaxies" + } + ], + "committers": [ + { + "asf_id": "yangjiaqi", + "name": "Jacky Yang", + "initials": "JY", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=yangjiaqi" + }, + { + "asf_id": "jsong010123", + "name": "Jason", + "initials": "J", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=jsong010123" + }, + { + "asf_id": "wangjing", + "name": "Jing Wang", + "initials": "JW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=wangjing" + }, + { + "asf_id": "pengjunzhi", + "name": "Junzhi Peng", + "initials": "JP", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=pengjunzhi" + }, + { + "asf_id": "vichayturen", + "name": "Kaiyichen Wei", + "initials": "KW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=vichayturen" + }, + { + "asf_id": "leizou", + "name": "Lei Zou", + "initials": "LZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=leizou" + }, + { + "asf_id": "guoshoujing", + "name": "Shoujing Guo", + "initials": "SG", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=guoshoujing" + }, + { + "asf_id": "liuxiaocs", + "name": "Xiao Liu", + "initials": "XL", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=liuxiaocs" + }, + { + "asf_id": "zhangyi89817", + "name": "Yi Zhang", + "initials": "YZ", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=zhangyi89817" + }, + { + "asf_id": "spica", + "name": "Zhe Wang", + "initials": "ZW", + "chair": false, + "profile_url": "https://people.apache.org/phonebook.html?uid=spica" + } + ] + } +} diff --git a/data/landing/community/cn.yaml b/data/landing/community/cn.yaml index 9bf9f66f4c..ec95ae75a2 100644 --- a/data/landing/community/cn.yaml +++ b/data/landing/community/cn.yaml @@ -34,6 +34,11 @@ sections: - [安全策略](/cn/docs/guides/security/) — 了解项目的安全问题报告流程。 - [贡献指南](/cn/docs/contribution-guidelines/) — 了解如何贡献代码和文档。 + - type: community-members + partial: landing/sections/community-members.html + data: + id: project-members + - type: markdown data: title: 了解项目运作方式 diff --git a/data/landing/community/en.yaml b/data/landing/community/en.yaml index 9f54604f72..d5b34325e2 100644 --- a/data/landing/community/en.yaml +++ b/data/landing/community/en.yaml @@ -34,6 +34,11 @@ sections: - [Security policy](/docs/guides/security/) — follow the project's security reporting process. - [Contribution guidelines](/docs/contribution-guidelines/) — learn how to contribute code and documentation. + - type: community-members + partial: landing/sections/community-members.html + data: + id: project-members + - type: cta data: title: Learn how the project works diff --git a/layouts/_partials/community/members.html b/layouts/_partials/community/members.html new file mode 100644 index 0000000000..459604b1a8 --- /dev/null +++ b/layouts/_partials/community/members.html @@ -0,0 +1,37 @@ +{{- $page := .page -}} +{{- $data := hugo.Data.community.roster -}} +{{- $labels := dict + "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from public ASF records." "chair" "Chair" "pmc" "PMC" "committers" "Committers") + "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 公开记录。" "chair" "主席" "pmc" "PMC" "committers" "Committers") +-}} +{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") -}} +{{- $style := resources.Get "scss/community-members.scss" | toCSS | minify | fingerprint -}} + +
+
+
+

{{ $copy.title }}

+

{{ $copy.lead }}

+
+ {{- range $role := slice "pmc" "committers" }} + {{- $members := index $data.roles $role }} +
+

{{ index $copy $role }}

+ +
+ {{- end }} +
+
diff --git a/layouts/_partials/community/members.md b/layouts/_partials/community/members.md new file mode 100644 index 0000000000..955aaee81a --- /dev/null +++ b/layouts/_partials/community/members.md @@ -0,0 +1,23 @@ +{{- $page := .page -}} +{{- $data := hugo.Data.community.roster -}} +{{- $labels := dict + "en" (dict "title" "Project members" "lead" "Current Apache HugeGraph PMC members and Committers, sourced from public ASF records." "chair" "Chair") + "cn" (dict "title" "项目成员" "lead" "Apache HugeGraph 当前的 PMC 成员与 Committers,数据来自 ASF 公开记录。" "chair" "主席") +-}} +{{- $copy := index $labels $page.Language.Lang | default (index $labels "en") -}} +## {{ $copy.title }} + +{{ $copy.lead }} + +{{ range $role := slice "pmc" "committers" -}} +### {{ if eq $role "pmc" }}PMC{{ else }}Committers{{ end }} + +{{ range (index $data.roles $role) -}} +{{- $label := .asf_id -}} +{{- with .github }}{{ $label = printf "@%s" .login }}{{ end -}} +{{- $label = partial "content/markdown-escape.html" $label -}} +{{- $url := partial "content/markdown-url.html" .profile_url -}} +- [{{ $label }}]({{ $url }}){{ if .chair }} — {{ $copy.chair }}{{ end }} +{{ end }} + +{{ end -}} diff --git a/layouts/_partials/landing/sections/community-members.html b/layouts/_partials/landing/sections/community-members.html new file mode 100644 index 0000000000..2e1abd08b0 --- /dev/null +++ b/layouts/_partials/landing/sections/community-members.html @@ -0,0 +1 @@ +{{- partial "community/members.html" (dict "page" .page) -}} diff --git a/layouts/community/landing.md b/layouts/community/landing.md new file mode 100644 index 0000000000..e884af8fe5 --- /dev/null +++ b/layouts/community/landing.md @@ -0,0 +1,29 @@ +{{- /* + Community Markdown follows the landing data order. Native OINK sections use + its text renderer unchanged; only the nested roster uses the site partial. +*/ -}} +{{- .Store.Set "tdOutputFormat" "markdown" -}} +# {{ .Title }} + +{{ with .Description }} +> {{ . }} + +{{ end }} + +{{ $page := . -}} +{{- $landing := partial "landing/data.html" . -}} +{{- $chunks := slice -}} +{{- range $entry := ($landing.sections | default slice) -}} + {{- $resolved := partial "landing/entry.html" (dict "home" $landing "entry" $entry) -}} + {{- if and $resolved.enabled $resolved.data -}} + {{- if eq $resolved.type "community-members" -}} + {{- $chunks = $chunks | append (partial "community/members.md" (dict "page" $page) | strings.TrimSpace) -}} + {{- else -}} + {{- $text := partial "landing/text.html" (dict "page" $page "data" (dict "sections" (slice $entry))) | strings.TrimSpace -}} + {{- with $text }} + {{- $chunks = $chunks | append . -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{ delimit $chunks "\n\n" | safeHTML }} diff --git a/scripts/community_roster.md b/scripts/community_roster.md new file mode 100644 index 0000000000..d317dbc724 --- /dev/null +++ b/scripts/community_roster.md @@ -0,0 +1,41 @@ +# Community roster data + +`roster.json` is the checked-in, visitor-facing snapshot of current Apache +HugeGraph PMC members and Committers. It is generated from the three ASF public +sources recorded in the file: + +```bash +python3 scripts/community_roster.py refresh +python3 scripts/community_roster.py validate --warn-after-days 90 +``` + +`refresh` is a maintainer-run operation. It finishes all source, role, mapping, +and avatar checks before atomically replacing the last-good roster. It never +pushes or opens a pull request. + +`github-map.json` is deliberately maintained by human review. A mapping must +record both the exact GitHub login and the account's numeric GitHub user ID. +Do not derive mappings from a person's name, email address, employer, or commit +history. Leave an ASF ID unmapped until a maintainer has confirmed the account. + +Mapped avatars are downloaded during refresh, converted with `cwebp` when +needed, stripped of metadata, checked as 128 by 128 WebP, and stored under a +SHA-256 content-addressed filename. Unmapped members render initials and link +to the ASF phonebook without requiring JavaScript. + +The fixed `validate` command is fully offline and checks checked-in data, +identity rules, and local avatar files. Render the site separately, then opt in +to artifact checks: + +```bash +hugo --destination /safe/prebuilt/site +python3 scripts/community_roster.py validate \ + --warn-after-days 90 \ + --artifact /safe/prebuilt/site +``` + +The artifact option never invokes Hugo or downloads modules itself. + +Once the verified assets and roster are published, removal of an unreferenced +old avatar is best effort. A cleanup failure emits an Actions warning but does +not invalidate or roll back the complete new bundle. diff --git a/scripts/community_roster.py b/scripts/community_roster.py new file mode 100644 index 0000000000..c72f885fe1 --- /dev/null +++ b/scripts/community_roster.py @@ -0,0 +1,710 @@ +#!/usr/bin/env python3 +"""Refresh and validate the offline Apache HugeGraph community roster.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import html.parser +import json +import os +import pathlib +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DATA_DIR = ROOT / "data" / "community" +ROSTER_PATH = DATA_DIR / "roster.json" +MAP_PATH = DATA_DIR / "github-map.json" +AVATAR_DIR = ROOT / "static" / "img" / "community" / "avatars" +PROJECT = "hugegraph" +SCHEMA_VERSION = 1 +SOURCES = { + "committee": "https://whimsy.apache.org/public/committee-info.json", + "projects": "https://whimsy.apache.org/public/public_ldap_projects.json", + "people": "https://whimsy.apache.org/public/public_ldap_people.json", +} +JSON_LIMIT = 16 * 1024 * 1024 +AVATAR_LIMIT = 5 * 1024 * 1024 +ASF_ID_PATTERN = re.compile(r"^[a-z][a-z0-9._-]*$") +GITHUB_LOGIN_PATTERN = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") +AVATAR_PATH_PATTERN = re.compile(r"^/img/community/avatars/([0-9a-f]{64})\.webp$") + + +class RosterError(ValueError): + pass + + +def _read_json(path: pathlib.Path) -> dict: + try: + result = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RosterError(f"{path}: invalid JSON: {exc}") from exc + if not isinstance(result, dict): + raise RosterError(f"{path}: JSON root must be an object") + return result + + +def _validate_remote_url(url: str, expected_hosts: set[str], kind: str) -> None: + try: + parsed = urllib.parse.urlparse(url) + port = parsed.port + except ValueError as exc: + raise RosterError(f"{kind}: malformed URL: {url}") from exc + if ( + parsed.scheme != "https" + or parsed.hostname not in expected_hosts + or port not in (None, 443) + or parsed.username is not None + or parsed.password is not None + ): + raise RosterError(f"{kind}: URL is not allowlisted: {url}") + + +class _AllowlistedRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, expected_hosts: set[str], kind: str): + super().__init__() + self.expected_hosts = expected_hosts + self.kind = kind + + def redirect_request(self, request, fp, code, msg, headers, newurl): + _validate_remote_url(newurl, self.expected_hosts, self.kind) + return super().redirect_request(request, fp, code, msg, headers, newurl) + + +def _open_allowlisted(request: urllib.request.Request, expected_hosts: set[str], kind: str): + _validate_remote_url(request.full_url, expected_hosts, kind) + opener = urllib.request.build_opener(_AllowlistedRedirectHandler(expected_hosts, kind)) + return opener.open(request, timeout=30) + + +def _read_bounded_response(response, *, expected_hosts: set[str], content_types: set[str], limit: int, kind: str) -> bytes: + final_url = response.geturl() + _validate_remote_url(final_url, expected_hosts, kind) + content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + if content_type not in content_types and not (kind == "JSON source" and content_type.endswith("+json")): + raise RosterError(f"{kind}: unsupported Content-Type {content_type!r}") + raw = response.read(limit + 1) + if len(raw) > limit: + raise RosterError(f"{kind}: response exceeds {limit} bytes") + return raw + + +def _fetch_json(url: str) -> dict: + request = urllib.request.Request(url, headers={"User-Agent": "apache-hugegraph-doc-community-roster/1"}) + with _open_allowlisted(request, {"whimsy.apache.org"}, "JSON source") as response: + if response.status != 200: + raise RosterError(f"{url}: HTTP {response.status}") + raw = _read_bounded_response( + response, + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=JSON_LIMIT, + kind="JSON source", + ) + try: + result = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RosterError(f"{url}: malformed JSON: {exc}") from exc + if not isinstance(result, dict): + raise RosterError(f"{url}: JSON root must be an object") + return result + + +def _person_name(people: dict, asf_id: str) -> str: + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"invalid ASF ID {asf_id!r}") + records = people.get("people") + if not isinstance(records, dict): + raise RosterError("people source must contain a people object") + record = records.get(asf_id) + name = record.get("name") if isinstance(record, dict) else None + if isinstance(name, list): + name = name[0] if name else "" + if not isinstance(name, str) or not name.strip(): + raise RosterError(f"people source has no public name for ASF ID {asf_id!r}") + return name.strip() + + +def _initials(name: str) -> str: + parts = [part for part in name.replace("-", " ").split() if part] + return "".join(part[0].upper() for part in parts[:2]) or "?" + + +def _sort_key(asf_id: str, names: dict[str, str]) -> tuple[str, str]: + return names[asf_id].casefold(), asf_id.casefold() + + +def _validate_mapping(data: dict, roster_ids: set[str] | None = None) -> dict: + if data.get("schema_version") != SCHEMA_VERSION: + raise RosterError("github-map.json: schema_version must be 1") + mappings = data.get("mappings") + if not isinstance(mappings, dict): + raise RosterError("github-map.json: mappings must be an object") + logins: set[str] = set() + user_ids: set[int] = set() + for asf_id, mapping in mappings.items(): + if not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError(f"github-map.json: invalid ASF ID {asf_id!r}") + if roster_ids is not None and asf_id not in roster_ids: + raise RosterError(f"github-map.json: unknown ASF ID {asf_id!r}") + if not isinstance(mapping, dict): + raise RosterError(f"github-map.json: mapping for {asf_id!r} must be an object") + login, user_id = mapping.get("login"), mapping.get("user_id") + if not isinstance(login, str) or not login.strip() or login != login.strip(): + raise RosterError(f"github-map.json: {asf_id!r} needs a reviewed login") + if not GITHUB_LOGIN_PATTERN.fullmatch(login) or "--" in login: + raise RosterError(f"github-map.json: invalid GitHub login {login!r}") + if not isinstance(user_id, int) or isinstance(user_id, bool) or user_id <= 0: + raise RosterError(f"github-map.json: {asf_id!r} needs a positive numeric user_id") + if login.casefold() in logins: + raise RosterError(f"github-map.json: duplicate GitHub login {login!r}") + if user_id in user_ids: + raise RosterError(f"github-map.json: duplicate GitHub user_id {user_id}") + logins.add(login.casefold()) + user_ids.add(user_id) + return mappings + + +def _webp_dimensions(raw: bytes) -> tuple[int, int]: + if len(raw) < 30 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise RosterError("avatar is not a WebP image") + chunk = raw[12:16] + if chunk == b"VP8X": + return 1 + int.from_bytes(raw[24:27], "little"), 1 + int.from_bytes(raw[27:30], "little") + if chunk == b"VP8L": + bits = int.from_bytes(raw[21:25], "little") + return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF) + if chunk == b"VP8 ": + marker = raw.find(b"\x9d\x01\x2a", 20) + if marker < 0 or marker + 7 > len(raw): + raise RosterError("avatar has an invalid VP8 frame") + width, height = struct.unpack_from(" list[bytes]: + if len(raw) < 20 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise RosterError("avatar is not a WebP image") + if int.from_bytes(raw[4:8], "little") != len(raw) - 8: + raise RosterError("avatar has an invalid RIFF length") + kinds: list[bytes] = [] + cursor = 12 + while cursor + 8 <= len(raw): + kind = raw[cursor : cursor + 4] + size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little") + cursor += 8 + size + (size % 2) + if cursor > len(raw): + raise RosterError("avatar has a truncated WebP chunk") + kinds.append(kind) + if cursor != len(raw): + raise RosterError("avatar has trailing WebP data") + return kinds + + +def _validate_webp(raw: bytes, expected_dimensions: tuple[int, int] | None = None) -> tuple[int, int]: + dimensions = _webp_dimensions(raw) + kinds = _webp_chunk_kinds(raw) + if not any(kind in {b"VP8 ", b"VP8L"} for kind in kinds): + raise RosterError("avatar has no decodable WebP image bitstream") + if any(kind in {b"EXIF", b"XMP ", b"ICCP"} for kind in kinds): + raise RosterError("avatar contains metadata") + if expected_dimensions and dimensions != expected_dimensions: + raise RosterError(f"avatar dimensions are {dimensions}, expected {expected_dimensions}") + decoder = shutil.which("dwebp") + if not decoder: + raise RosterError("validating mapped avatars requires dwebp") + with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-decode-") as work: + source = pathlib.Path(work) / "avatar.webp" + target = pathlib.Path(work) / "avatar.ppm" + source.write_bytes(raw) + try: + result = subprocess.run( + [decoder, str(source), "-o", str(target)], + text=True, + capture_output=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RosterError(f"dwebp could not decode avatar: {exc}") from exc + if result.returncode or not target.is_file(): + raise RosterError(f"dwebp rejected avatar: {result.stderr.strip()}") + return dimensions + + +def _strip_webp_metadata(raw: bytes) -> bytes: + """Remove optional metadata chunks while preserving the image bitstream.""" + _webp_dimensions(raw) + chunks: list[bytes] = [] + cursor = 12 + while cursor + 8 <= len(raw): + kind = raw[cursor : cursor + 4] + size = int.from_bytes(raw[cursor + 4 : cursor + 8], "little") + end = cursor + 8 + size + (size % 2) + if end > len(raw): + raise RosterError("avatar has a truncated WebP chunk") + chunk = bytearray(raw[cursor:end]) + if kind not in {b"EXIF", b"XMP ", b"ICCP"}: + if kind == b"VP8X": + chunk[8] &= ~0x2D + chunks.append(bytes(chunk)) + cursor = end + if cursor != len(raw): + raise RosterError("avatar has trailing WebP data") + payload = b"WEBP" + b"".join(chunks) + return b"RIFF" + len(payload).to_bytes(4, "little") + payload + + +def _avatar_bytes(user_id: int) -> bytes: + request = urllib.request.Request( + f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4", + headers={"Accept": "image/webp", "User-Agent": "apache-hugegraph-doc-community-roster/1"}, + ) + with _open_allowlisted(request, {"avatars.githubusercontent.com"}, "GitHub avatar") as response: + raw = _read_bounded_response( + response, + expected_hosts={"avatars.githubusercontent.com"}, + content_types={"image/png", "image/jpeg", "image/webp"}, + limit=AVATAR_LIMIT, + kind="GitHub avatar", + ) + try: + raw = _strip_webp_metadata(raw) + except RosterError: + converter = shutil.which("cwebp") + if not converter: + raise RosterError("mapped avatars require cwebp when GitHub does not return WebP") + with tempfile.TemporaryDirectory(prefix="hugegraph-avatar-") as work: + source = pathlib.Path(work) / "source" + target = pathlib.Path(work) / "avatar.webp" + source.write_bytes(raw) + try: + result = subprocess.run( + [converter, "-quiet", "-resize", "128", "128", "-metadata", "none", str(source), "-o", str(target)], + text=True, + capture_output=True, + timeout=20, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {exc}") from exc + if result.returncode: + raise RosterError(f"cwebp failed for numeric GitHub user ID {user_id}: {result.stderr.strip()}") + raw = _strip_webp_metadata(target.read_bytes()) + _validate_webp(raw, expected_dimensions=(128, 128)) + return raw + + +def _member(asf_id: str, name: str, chair: bool, mapping: dict | None) -> dict: + member = { + "asf_id": asf_id, + "name": name, + "initials": _initials(name), + "chair": chair, + "profile_url": f"https://people.apache.org/phonebook.html?uid={asf_id}", + } + if mapping: + member["github"] = {"login": mapping["login"], "user_id": mapping["user_id"]} + return member + + +def build_roster(committee_data: dict, projects_data: dict, people_data: dict, mapping_data: dict) -> dict: + projects = projects_data.get("projects") + committees = committee_data.get("committees") + if not isinstance(projects, dict) or not isinstance(committees, dict): + raise RosterError("ASF sources must contain projects and committees objects") + project = projects.get(PROJECT) + committee = committees.get(PROJECT) + if not isinstance(project, dict) or not isinstance(committee, dict): + raise RosterError("ASF sources do not contain the HugeGraph project") + owners, members = project.get("owners"), project.get("members") + chair_map, committee_roster = committee.get("chair"), committee.get("roster") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("LDAP project owners/members must be arrays") + if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in owners + members): + raise RosterError("LDAP project owners/members contain an invalid ASF ID") + for field, asf_ids in (("owners", owners), ("members", members)): + if len(asf_ids) != len(set(asf_ids)): + raise RosterError(f"LDAP project {field} contains duplicate ASF IDs") + if not isinstance(chair_map, dict) or len(chair_map) != 1: + raise RosterError("committee source must name exactly one Chair") + if not isinstance(committee_roster, dict): + raise RosterError("committee roster must be an object") + if any(not isinstance(item, str) or not ASF_ID_PATTERN.fullmatch(item) for item in [*chair_map, *committee_roster]): + raise RosterError("committee source contains an invalid ASF ID") + owner_ids, member_ids = set(owners), set(members) + chair = next(iter(chair_map)) + if not owner_ids <= member_ids: + raise RosterError("LDAP owners must be a subset of members") + if chair not in owner_ids: + raise RosterError("Chair must be an LDAP owner") + if owner_ids != set(committee_roster): + raise RosterError("committee roster and LDAP owners disagree") + mappings = _validate_mapping(mapping_data, member_ids) + names = {asf_id: _person_name(people_data, asf_id) for asf_id in member_ids} + pmc_ids = [chair] + sorted(owner_ids - {chair}, key=lambda item: _sort_key(item, names)) + committer_ids = sorted(member_ids - owner_ids, key=lambda item: _sort_key(item, names)) + return { + "schema_version": SCHEMA_VERSION, + "project": PROJECT, + "retrieved_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "source": { + **SOURCES, + "chair": chair, + "owners": sorted(owner_ids), + "members": sorted(member_ids), + }, + "roles": { + "pmc": [_member(i, names[i], i == chair, mappings.get(i)) for i in pmc_ids], + "committers": [_member(i, names[i], False, mappings.get(i)) for i in committer_ids], + }, + } + + +def _install_avatars(candidate: dict, target: pathlib.Path) -> None: + target.mkdir(parents=True, exist_ok=True) + for role in ("pmc", "committers"): + for member in candidate["roles"][role]: + github = member.get("github") + if not github: + continue + raw = _avatar_bytes(github["user_id"]) + digest = hashlib.sha256(raw).hexdigest() + path = target / f"{digest}.webp" + if not path.exists(): + path.write_bytes(raw) + member["avatar"] = f"/img/community/avatars/{path.name}" + member["profile_url"] = f"https://github.com/{github['login']}" + + +def validate_bundle(warn_after_days: int) -> list[str]: + _validate_repo_paths() + roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH) + if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") != PROJECT: + raise RosterError("roster.json: unsupported schema_version or project") + roles, source = roster.get("roles"), roster.get("source") + if not isinstance(roles, dict) or set(roles) != {"pmc", "committers"}: + raise RosterError("roster.json: roles must contain only pmc and committers") + if not isinstance(source, dict): + raise RosterError("roster.json: source must be an object") + for key, url in SOURCES.items(): + if source.get(key) != url: + raise RosterError(f"roster.json: source.{key} is not authoritative") + owners, members, chair = source.get("owners"), source.get("members"), source.get("chair") + if not isinstance(owners, list) or not isinstance(members, list): + raise RosterError("roster.json: source owners/members must be arrays") + if any(not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id) for asf_id in owners + members): + raise RosterError("roster.json: source owners/members contain an invalid ASF ID") + if owners != sorted(set(owners)) or members != sorted(set(members)): + raise RosterError("roster.json: source owners/members must be sorted and unique") + if not set(owners) <= set(members) or chair not in owners: + raise RosterError("roster.json: invalid owners/members/Chair relationship") + if not isinstance(chair, str) or not ASF_ID_PATTERN.fullmatch(chair): + raise RosterError("roster.json: source Chair must be a valid ASF ID") + pmc, committers = roles["pmc"], roles["committers"] + if not isinstance(pmc, list) or not isinstance(committers, list) or not pmc: + raise RosterError("roster.json: invalid role arrays") + people = pmc + committers + for person in people: + if not isinstance(person, dict): + raise RosterError("roster.json: every role entry must be an object") + asf_id = person.get("asf_id") + if not isinstance(asf_id, str) or not ASF_ID_PATTERN.fullmatch(asf_id): + raise RosterError("roster.json: role entry has an invalid ASF ID") + name = person.get("name") + if not isinstance(name, str) or not name.strip(): + raise RosterError(f"roster.json: member name must be non-empty for {asf_id!r}") + if person.get("initials") != _initials(name): + raise RosterError(f"roster.json: member initials mismatch for {asf_id!r}") + if not isinstance(person.get("profile_url"), str): + raise RosterError(f"roster.json: member profile URL must be a string for {asf_id!r}") + if type(person.get("chair")) is not bool: + raise RosterError(f"roster.json: chair must be boolean for {asf_id!r}") + ids = [person["asf_id"] for person in people] + if len(ids) != len(set(ids)) or set(ids) != set(members): + raise RosterError("roster.json: members must match unique source ASF IDs") + if {p["asf_id"] for p in pmc} != set(owners): + raise RosterError("roster.json: PMC must equal owners") + if {p["asf_id"] for p in committers} != set(members) - set(owners): + raise RosterError("roster.json: Committers must equal members minus owners") + chairs = [person for person in people if person.get("chair") is True] + if len(chairs) != 1 or chairs[0].get("asf_id") != chair or pmc[0] != chairs[0]: + raise RosterError("roster.json: unique Chair must be first in PMC") + for role, entries in roles.items(): + tail = entries[1:] if role == "pmc" else entries + actual_order = [(p["name"].casefold(), p["asf_id"].casefold()) for p in tail] + if actual_order != sorted(actual_order): + raise RosterError(f"roster.json: {role} must be sorted by public name and ASF ID casefold") + mappings = _validate_mapping(mapping, set(ids)) + for person in people: + expected, avatar = mappings.get(person["asf_id"]), person.get("avatar") + if expected != person.get("github"): + raise RosterError(f"roster.json: GitHub mapping drift for {person['asf_id']!r}") + if expected: + match = AVATAR_PATH_PATTERN.fullmatch(avatar) if isinstance(avatar, str) else None + if not match: + raise RosterError(f"roster.json: mapped member {person['asf_id']!r} needs a local avatar") + filename = f"{match.group(1)}.webp" + path = AVATAR_DIR / filename + if path.is_symlink(): + raise RosterError(f"roster.json: avatar must not be a symlink {avatar}") + raw = path.read_bytes() + if hashlib.sha256(raw).hexdigest() != match.group(1): + raise RosterError(f"roster.json: invalid avatar {avatar}") + _validate_webp(raw, expected_dimensions=(128, 128)) + if person["profile_url"] != f"https://github.com/{expected['login']}": + raise RosterError(f"roster.json: mapped profile URL mismatch") + elif avatar: + raise RosterError(f"roster.json: unmapped member has an avatar") + elif person["profile_url"] != f"https://people.apache.org/phonebook.html?uid={person['asf_id']}": + raise RosterError(f"roster.json: unmapped profile URL mismatch for {person['asf_id']!r}") + retrieved_at = roster.get("retrieved_at") + if not isinstance(retrieved_at, str): + raise RosterError("roster.json: retrieved_at must be an ISO-8601 UTC string") + try: + retrieved = dt.datetime.fromisoformat(retrieved_at.replace("Z", "+00:00")) + except (KeyError, TypeError, ValueError) as exc: + raise RosterError("roster.json: retrieved_at must be ISO-8601 UTC") from exc + now = dt.datetime.now(dt.timezone.utc) + if retrieved.tzinfo is None or retrieved > now + dt.timedelta(minutes=5): + raise RosterError("roster.json: retrieved_at is in the future or lacks a timezone") + age = now - retrieved + return [f"community roster is {age.days} days old (threshold: {warn_after_days})"] if age > dt.timedelta(days=warn_after_days) else [] + + +class _CommunityLinkParser(html.parser.HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.role_stack: list[str | None] = [] + self.section_order: list[str] = [] + self.links = {"pmc": [], "committers": []} + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if tag == "section": + role = attributes.get("data-community-role") + if role in self.links: + self.section_order.append(role) + self.role_stack.append(role if role in self.links else None) + elif tag == "a" and self.role_stack and self.role_stack[-1]: + href = attributes.get("href") + if href: + self.links[self.role_stack[-1]].append(href) + + def handle_endtag(self, tag: str) -> None: + if tag == "section" and self.role_stack: + self.role_stack.pop() + + +def _rendered_role_links(rendered: str, html_output: bool) -> dict[str, list[str]]: + if html_output: + parser = _CommunityLinkParser() + parser.feed(rendered) + if parser.section_order != ["pmc", "committers"]: + raise RosterError("Community role section order drift") + return parser.links + starts = {} + for role, heading in (("pmc", "PMC"), ("committers", "Committers")): + match = re.search(rf"(?m)^### {heading}\s*$", rendered) + if not match: + return {"pmc": [], "committers": []} + starts[role] = match.end() + if starts["pmc"] >= starts["committers"]: + return {"pmc": [], "committers": []} + segments = { + "pmc": rendered[starts["pmc"] : starts["committers"]], + "committers": rendered[starts["committers"] :], + } + return { + role: re.findall(r"(?m)^-\s+\[[^\]]+\]\(([^)\s]+)\)", segment) + for role, segment in segments.items() + } + + +def validate_rendered_outputs(destination: pathlib.Path) -> None: + expected = { + "community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "community/index.md": ("## Project members", "### PMC", "### Committers"), + "cn/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/_print/community/index.html": ('data-community-role="pmc"', 'data-community-role="committers"'), + "cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"), + } + roster_roles = _read_json(ROSTER_PATH)["roles"] + for relative, markers in expected.items(): + path = destination / relative + if not path.is_file(): + raise RosterError(f"rendered output is missing {relative}") + rendered = path.read_text(encoding="utf-8") + if relative.endswith(".html"): + has_markers = all( + re.search(rf'data-community-role=(?:"{role}"|{role})(?:\s|>)', rendered) + for role in ("pmc", "committers") + ) + else: + has_markers = all(marker in rendered for marker in markers) + if not has_markers: + raise RosterError(f"rendered output {relative} is missing Community markers") + rendered_links = _rendered_role_links(rendered, relative.endswith(".html")) + for role, entries in roster_roles.items(): + expected_links = [person["profile_url"] for person in entries] + if rendered_links[role] != expected_links: + raise RosterError(f"rendered output {relative} has {role} link parity drift") + + +def _atomic_write(path: pathlib.Path, raw: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary_path = pathlib.Path(temporary) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + + +def _unlink(path: pathlib.Path) -> None: + path.unlink() + + +def _copy_candidate(raw: bytes, destination: pathlib.Path) -> None: + with destination.open("xb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + + +def _validate_avatar_blob(name: str, raw: bytes) -> None: + match = re.fullmatch(r"([0-9a-f]{64})\.webp", name) + if not match or hashlib.sha256(raw).hexdigest() != match.group(1): + raise RosterError(f"candidate avatar name/hash mismatch: {name}") + _validate_webp(raw, expected_dimensions=(128, 128)) + + +def _assert_repo_path(path: pathlib.Path, label: str) -> None: + root = ROOT.absolute() + candidate = path.absolute() + try: + relative = candidate.relative_to(root) + except ValueError as exc: + raise RosterError(f"{label} must stay inside the repository") from exc + current = root + if current.is_symlink(): + raise RosterError("repository root must not be a symlink") + for part in relative.parts: + current /= part + if current.is_symlink(): + raise RosterError(f"{label} must not contain symlink path components") + try: + candidate.resolve(strict=False).relative_to(root.resolve(strict=True)) + except (OSError, ValueError) as exc: + raise RosterError(f"{label} resolves outside the repository") from exc + + +def _validate_repo_paths() -> None: + _assert_repo_path(DATA_DIR, "community data directory") + _assert_repo_path(ROSTER_PATH, "community roster") + _assert_repo_path(MAP_PATH, "GitHub mapping") + _assert_repo_path(AVATAR_DIR, "community avatar directory") + + +def _commit_bundle(candidate: dict, candidate_avatars: dict[str, bytes]) -> None: + """Install verified immutable assets, then atomically publish the roster.""" + _validate_repo_paths() + AVATAR_DIR.mkdir(parents=True, exist_ok=True) + referenced = { + pathlib.PurePosixPath(person["avatar"]).name + for role in candidate["roles"].values() + for person in role + if person.get("avatar") + } + existing = {path.name: path for path in AVATAR_DIR.glob("*.webp")} + for name, avatar in sorted(candidate_avatars.items()): + _validate_avatar_blob(name, avatar) + destination = AVATAR_DIR / name + if destination.exists() or destination.is_symlink(): + try: + if destination.is_symlink(): + raise RosterError(f"candidate destination is a symlink: {destination}") + _validate_avatar_blob(name, destination.read_bytes()) + continue + except RosterError: + pass + staged = AVATAR_DIR / f".{name}.candidate" + try: + _copy_candidate(avatar, staged) + os.replace(staged, destination) + finally: + if staged.exists(): + _unlink(staged) + raw = (json.dumps(candidate, indent=2, ensure_ascii=False) + "\n").encode() + # This atomic replace is the commit point. Failures before it leave the + # last-good roster selected; installed content-addressed assets are safe + # unreferenced candidates. + _atomic_write(ROSTER_PATH, raw) + for name in sorted(set(existing) - referenced): + try: + _unlink(AVATAR_DIR / name) + except OSError as exc: + print( + f"::warning file=static/img/community/avatars/{name}::" + f"could not remove unreferenced avatar: {exc}", + file=sys.stderr, + ) + + +def refresh() -> None: + _validate_repo_paths() + DATA_DIR.mkdir(parents=True, exist_ok=True) + source_data = {key: _fetch_json(url) for key, url in SOURCES.items()} + candidate = build_roster(source_data["committee"], source_data["projects"], source_data["people"], _read_json(MAP_PATH)) + work = pathlib.Path(tempfile.mkdtemp(prefix=".community-refresh-", dir=DATA_DIR)) + try: + candidate_avatars = work / "avatars" + _install_avatars(candidate, candidate_avatars) + avatar_bytes = {path.name: path.read_bytes() for path in candidate_avatars.glob("*.webp")} + finally: + # Candidate cleanup is deliberately completed before the checked-in + # bundle changes, so cleanup failure cannot publish a new roster. + shutil.rmtree(work) + _commit_bundle(candidate, avatar_bytes) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("refresh") + validate = commands.add_parser("validate") + validate.add_argument("--warn-after-days", type=int, default=90) + validate.add_argument("--artifact", type=pathlib.Path, help="validate a prebuilt Hugo artifact") + args = parser.parse_args() + try: + if args.command == "refresh": + refresh() + else: + if args.warn_after_days < 0: + raise RosterError("--warn-after-days must be non-negative") + for warning in validate_bundle(args.warn_after_days): + print(f"::warning file=data/community/roster.json::{warning}") + if args.artifact: + validate_rendered_outputs(args.artifact.resolve()) + except (OSError, RosterError, urllib.error.URLError) as exc: + print(f"community roster: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fixtures/community_search_queries.json b/scripts/fixtures/community_search_queries.json new file mode 100644 index 0000000000..a65cfdcb3c --- /dev/null +++ b/scripts/fixtures/community_search_queries.json @@ -0,0 +1,26 @@ +[ + {"locale": "en", "query": "HugeGraph overview", "expected_ref": "/docs/introduction/"}, + {"locale": "en", "query": "server quickstart", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-server/"}, + {"locale": "en", "query": "distributed storage", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-hstore/"}, + {"locale": "en", "query": "placement driver", "expected_ref": "/docs/quickstart/hugegraph/hugegraph-pd/"}, + {"locale": "en", "query": "graph computing", "expected_ref": "/docs/quickstart/computing/hugegraph-computer/"}, + {"locale": "en", "query": "bulk import", "expected_ref": "/docs/quickstart/toolchain/hugegraph-loader/"}, + {"locale": "en", "query": "graph visualization", "expected_ref": "/docs/quickstart/toolchain/hugegraph-hubble/"}, + {"locale": "en", "query": "Java client", "expected_ref": "/docs/clients/"}, + {"locale": "en", "query": "HugeGraph REST API", "expected_ref": "/docs/clients/restful-api/"}, + {"locale": "en", "query": "server config", "expected_ref": "/docs/config/config-guide/"}, + {"locale": "en", "query": "StandardAuthenticator", "expected_ref": "/docs/config/config-authentication/"}, + {"locale": "en", "query": "release artifacts", "expected_ref": "/docs/download/download/"}, + {"locale": "cn", "query": "HugeGraph 介绍", "expected_ref": "/cn/docs/introduction/"}, + {"locale": "cn", "query": "Server 快速开始", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-server/"}, + {"locale": "cn", "query": "分布式存储", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-hstore/"}, + {"locale": "cn", "query": "元数据管理", "expected_ref": "/cn/docs/quickstart/hugegraph/hugegraph-pd/"}, + {"locale": "cn", "query": "图计算", "expected_ref": "/cn/docs/quickstart/computing/hugegraph-computer/"}, + {"locale": "cn", "query": "批量导入", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-loader/"}, + {"locale": "cn", "query": "图可视化", "expected_ref": "/cn/docs/quickstart/toolchain/hugegraph-hubble/"}, + {"locale": "cn", "query": "Java 客户端", "expected_ref": "/cn/docs/clients/"}, + {"locale": "cn", "query": "HugeGraph REST API", "expected_ref": "/cn/docs/clients/restful-api/"}, + {"locale": "cn", "query": "Server 配置", "expected_ref": "/cn/docs/config/config-guide/"}, + {"locale": "cn", "query": "权限配置", "expected_ref": "/cn/docs/config/config-authentication/"}, + {"locale": "cn", "query": "发布包", "expected_ref": "/cn/docs/download/download/"} +] diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py new file mode 100644 index 0000000000..4842a0629d --- /dev/null +++ b/scripts/test_community_roster.py @@ -0,0 +1,785 @@ +import hashlib +import importlib.util +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("community_roster", ROOT / "scripts" / "community_roster.py") +roster = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +SPEC.loader.exec_module(roster) + + +class FakeResponse: + def __init__(self, raw, *, url, content_type, status=200): + self.raw = raw + self.url = url + self.headers = {"Content-Type": content_type} + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def geturl(self): + return self.url + + def read(self, limit=-1): + return self.raw if limit < 0 else self.raw[:limit] + + +class CommunityRosterTests(unittest.TestCase): + def fixture(self): + return ( + {"committees": {"hugegraph": {"chair": {"chair": {"name": "Chair Person"}}, "roster": {"chair": {}, "zeta": {}}}}}, + {"projects": {"hugegraph": {"owners": ["zeta", "chair"], "members": ["other", "zeta", "chair"]}}}, + {"people": {"chair": {"name": "Chair Person"}, "zeta": {"name": "Alpha Owner"}, "other": {"name": "Beta Committer"}}}, + {"schema_version": 1, "mappings": {}}, + ) + + def test_build_roster_derives_roles_and_order(self): + candidate = roster.build_roster(*self.fixture()) + self.assertEqual(["chair", "zeta"], [p["asf_id"] for p in candidate["roles"]["pmc"]]) + self.assertEqual(["other"], [p["asf_id"] for p in candidate["roles"]["committers"]]) + self.assertTrue(candidate["roles"]["pmc"][0]["chair"]) + + def test_same_names_use_asf_id_tiebreaker_across_hash_seeds(self): + program = f""" +import importlib.util, json +spec = importlib.util.spec_from_file_location("community_roster", {str(ROOT / "scripts/community_roster.py")!r}) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +committee = {{"committees": {{"hugegraph": {{"chair": {{"chair": {{}}}}, "roster": {{"chair": {{}}, "zeta": {{}}, "alpha": {{}}}}}}}}}} +projects = {{"projects": {{"hugegraph": {{"owners": ["zeta", "chair", "alpha"], "members": ["zeta", "chair", "alpha"]}}}}}} +people = {{"people": {{"chair": {{"name": "Chair"}}, "zeta": {{"name": "Same Name"}}, "alpha": {{"name": "Same Name"}}}}}} +result = module.build_roster(committee, projects, people, {{"schema_version": 1, "mappings": {{}}}}) +print(json.dumps([person["asf_id"] for person in result["roles"]["pmc"]])) +""" + outputs = [] + for seed in ("1", "777"): + environment = {**os.environ, "PYTHONHASHSEED": seed} + outputs.append(subprocess.check_output([sys.executable, "-c", program], env=environment, text=True)) + self.assertEqual(outputs[0], outputs[1]) + self.assertEqual(["chair", "alpha", "zeta"], json.loads(outputs[0])) + + def test_build_roster_rejects_committee_ldap_drift(self): + committee, projects, people, mapping = self.fixture() + committee["committees"]["hugegraph"]["roster"].pop("zeta") + with self.assertRaisesRegex(roster.RosterError, "disagree"): + roster.build_roster(committee, projects, people, mapping) + + def test_build_roster_rejects_duplicate_ldap_ids(self): + for field in ("owners", "members"): + with self.subTest(field=field): + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"][field].append( + projects["projects"]["hugegraph"][field][0] + ) + with self.assertRaisesRegex( + roster.RosterError, + rf"LDAP project {field} contains duplicate ASF IDs", + ): + roster.build_roster(committee, projects, people, mapping) + + def test_mapping_requires_unique_numeric_ids(self): + mapping = {"schema_version": 1, "mappings": {"one": {"login": "same", "user_id": 1}, "two": {"login": "other", "user_id": 1}}} + with self.assertRaisesRegex(roster.RosterError, "duplicate GitHub user_id"): + roster._validate_mapping(mapping, {"one", "two"}) + + def test_mapping_rejects_invalid_identity_characters(self): + with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"): + roster._validate_mapping( + {"schema_version": 1, "mappings": {"Bad ID": {"login": "valid", "user_id": 1}}}, + {"Bad ID"}, + ) + with self.assertRaisesRegex(roster.RosterError, "invalid GitHub login"): + roster._validate_mapping( + {"schema_version": 1, "mappings": {"valid": {"login": "bad/login", "user_id": 1}}}, + {"valid"}, + ) + + def test_avatar_metadata_is_stripped(self): + vp8x = b"VP8X" + (10).to_bytes(4, "little") + bytes([0x2D]) + b"\0" * 9 + exif = b"EXIF" + (4).to_bytes(4, "little") + b"meta" + iccp = b"ICCP" + (4).to_bytes(4, "little") + b"icc!" + payload = b"WEBP" + vp8x + exif + iccp + raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload + stripped = roster._strip_webp_metadata(raw) + self.assertNotIn(b"EXIF", stripped) + self.assertNotIn(b"ICCP", stripped) + self.assertEqual(0, stripped[20] & 0x2D) + + def test_truncated_vp8x_without_image_bitstream_is_rejected(self): + vp8x = b"VP8X" + (10).to_bytes(4, "little") + b"\0" * 10 + payload = b"WEBP" + vp8x + raw = b"RIFF" + len(payload).to_bytes(4, "little") + payload + with self.assertRaisesRegex(roster.RosterError, "no decodable"): + roster._validate_webp(raw) + + def test_network_response_contracts_are_bounded_and_allowlisted(self): + with self.assertRaisesRegex(roster.RosterError, "not allowlisted"): + roster._read_bounded_response( + FakeResponse(b"{}", url="https://evil.example/data", content_type="application/json"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + with self.assertRaisesRegex(roster.RosterError, "Content-Type"): + roster._read_bounded_response( + FakeResponse(b"{}", url="https://whimsy.apache.org/data", content_type="text/html"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + with self.assertRaisesRegex(roster.RosterError, "exceeds"): + roster._read_bounded_response( + FakeResponse(b"x" * 11, url="https://whimsy.apache.org/data", content_type="application/json"), + expected_hosts={"whimsy.apache.org"}, + content_types={"application/json"}, + limit=10, + kind="JSON source", + ) + + def test_redirect_is_rejected_before_following_disallowed_host(self): + handler = roster._AllowlistedRedirectHandler({"whimsy.apache.org"}, "JSON source") + with self.assertRaisesRegex(roster.RosterError, "not allowlisted"): + handler.redirect_request( + mock.Mock(), + None, + 302, + "Found", + {}, + "http://127.0.0.1/private", + ) + + def test_malformed_json_and_encoder_timeout_are_roster_errors(self): + response = FakeResponse( + b"{bad", + url="https://whimsy.apache.org/public/committee-info.json", + content_type="application/json", + ) + with mock.patch.object(roster, "_open_allowlisted", return_value=response): + with self.assertRaisesRegex(roster.RosterError, "malformed JSON"): + roster._fetch_json(roster.SOURCES["committee"]) + avatar = FakeResponse( + b"not-an-image", + url="https://avatars.githubusercontent.com/u/1?s=128&v=4", + content_type="image/png", + ) + with mock.patch.object(roster, "_open_allowlisted", return_value=avatar), \ + mock.patch.object(roster.shutil, "which", return_value="/fake/cwebp"), \ + mock.patch.object(roster.subprocess, "run", side_effect=subprocess.TimeoutExpired("cwebp", 20)): + with self.assertRaisesRegex(roster.RosterError, "cwebp failed"): + roster._avatar_bytes(1) + + def test_nested_source_schema_errors_are_roster_errors(self): + committee, projects, people, mapping = self.fixture() + projects["projects"] = [] + with self.assertRaisesRegex(roster.RosterError, "projects and committees objects"): + roster.build_roster(committee, projects, people, mapping) + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"]["owners"] = [[]] + with self.assertRaisesRegex(roster.RosterError, "invalid ASF ID"): + roster.build_roster(committee, projects, people, mapping) + with tempfile.TemporaryDirectory(prefix="community-json-root-") as directory: + path = pathlib.Path(directory) / "array.json" + path.write_text("[]") + with self.assertRaisesRegex(roster.RosterError, "JSON root must be an object"): + roster._read_json(path) + + def test_checked_in_bundle_validates(self): + self.assertEqual([], roster.validate_bundle(90)) + + def test_unmapped_profile_must_be_exact_phonebook_url(self): + with tempfile.TemporaryDirectory(prefix="community-profile-test-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(roster.ROSTER_PATH.read_text()) + candidate["roles"]["committers"][0]["profile_url"] = "https://example.invalid/profile" + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(roster.MAP_PATH.read_text()) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "unmapped profile URL mismatch"): + roster.validate_bundle(90) + + def test_chair_values_must_be_strict_booleans(self): + with tempfile.TemporaryDirectory(prefix="community-chair-test-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(roster.ROSTER_PATH.read_text()) + candidate["roles"]["committers"][0]["chair"] = 0 + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(roster.MAP_PATH.read_text()) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "chair must be boolean"): + roster.validate_bundle(90) + + def test_avatar_path_rejects_extra_segments_and_symlinks(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + asf_id = base["roles"]["committers"][0]["asf_id"] + mapping = {"schema_version": 1, "mappings": {asf_id: {"login": "valid-user", "user_id": 1}}} + for avatar in ( + "/img/community/avatars/extra/" + "a" * 64 + ".webp", + "/img/community/avatars/../" + "a" * 64 + ".webp", + ): + with self.subTest(avatar=avatar), tempfile.TemporaryDirectory(prefix="community-avatar-path-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id) + member.update(github=mapping["mappings"][asf_id], avatar=avatar, profile_url="https://github.com/valid-user") + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "needs a local avatar"): + roster.validate_bundle(90) + with tempfile.TemporaryDirectory(prefix="community-avatar-link-") as directory: + root = pathlib.Path(directory) + avatar_dir = root / "avatars" + avatar_dir.mkdir() + raw = b"target" + digest = hashlib.sha256(raw).hexdigest() + target = root / "target.webp" + target.write_bytes(raw) + (avatar_dir / f"{digest}.webp").symlink_to(target) + candidate = json.loads(json.dumps(base)) + member = next(p for p in candidate["roles"]["committers"] if p["asf_id"] == asf_id) + member.update( + github=mapping["mappings"][asf_id], + avatar=f"/img/community/avatars/{digest}.webp", + profile_url="https://github.com/valid-user", + ) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir): + with self.assertRaisesRegex(roster.RosterError, "must not be a symlink"): + roster.validate_bundle(90) + + def test_avatar_directory_parent_symlink_is_rejected(self): + with tempfile.TemporaryDirectory(prefix="community-avatar-parent-") as directory: + root = pathlib.Path(directory) + outside = root / "outside" + outside.mkdir() + avatar_link = root / "static" / "img" / "community" / "avatars" + avatar_link.parent.mkdir(parents=True) + avatar_link.symlink_to(outside, target_is_directory=True) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root / "data" / "community"), \ + mock.patch.object(roster, "ROSTER_PATH", root / "data" / "community" / "roster.json"), \ + mock.patch.object(roster, "MAP_PATH", root / "data" / "community" / "github-map.json"), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_link): + with self.assertRaisesRegex(roster.RosterError, "symlink path components"): + roster._validate_repo_paths() + + def test_member_name_and_initials_must_be_non_empty_and_derived(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + mapping = json.loads(roster.MAP_PATH.read_text()) + for field, value, message in ( + ("name", "", "name must be non-empty"), + ("initials", "", "initials mismatch"), + ): + with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-identity-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + candidate["roles"]["committers"][0][field] = value + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, message): + roster.validate_bundle(90) + + def test_local_roster_schema_errors_are_roster_errors(self): + base = json.loads(roster.ROSTER_PATH.read_text()) + mapping = json.loads(roster.MAP_PATH.read_text()) + mutations = ( + ("asf_id", [], "invalid ASF ID"), + ("name", 123, "name must be non-empty"), + ("retrieved_at", None, "ISO-8601 UTC string"), + ) + for field, value, message in mutations: + with self.subTest(field=field), tempfile.TemporaryDirectory(prefix="community-schema-") as directory: + root = pathlib.Path(directory) + candidate = json.loads(json.dumps(base)) + if field == "retrieved_at": + candidate[field] = value + else: + candidate["roles"]["committers"][0][field] = value + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, message): + roster.validate_bundle(90) + + def test_refresh_validates_paths_before_creating_data_directory(self): + with tempfile.TemporaryDirectory(prefix="community-refresh-path-") as directory: + root = pathlib.Path(directory) + outside = root / "outside" + outside.mkdir() + (root / "data").symlink_to(outside, target_is_directory=True) + data_dir = root / "data" / "community" + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", data_dir), \ + mock.patch.object(roster, "ROSTER_PATH", data_dir / "roster.json"), \ + mock.patch.object(roster, "MAP_PATH", data_dir / "github-map.json"), \ + mock.patch.object(roster, "AVATAR_DIR", root / "static" / "img" / "community" / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "symlink path components"): + roster.refresh() + self.assertFalse((outside / "community").exists()) + + def test_validator_rejects_same_name_out_of_asf_id_order(self): + committee, projects, people, mapping = self.fixture() + committee["committees"]["hugegraph"]["roster"]["alpha"] = {} + projects["projects"]["hugegraph"]["owners"].append("alpha") + projects["projects"]["hugegraph"]["members"].append("alpha") + people["people"]["zeta"]["name"] = "Same Name" + people["people"]["alpha"] = {"name": "Same Name"} + candidate = roster.build_roster(committee, projects, people, mapping) + candidate["roles"]["pmc"][1:] = reversed(candidate["roles"]["pmc"][1:]) + with tempfile.TemporaryDirectory(prefix="community-order-test-") as directory: + root = pathlib.Path(directory) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_text(json.dumps(candidate)) + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"): + with self.assertRaisesRegex(roster.RosterError, "sorted by public name"): + roster.validate_bundle(90) + + def test_fetch_failure_preserves_last_good(self): + original, old_fetch = roster.ROSTER_PATH.read_bytes(), roster._fetch_json + try: + roster._fetch_json = lambda _url: (_ for _ in ()).throw(OSError("network down")) + with self.assertRaises(OSError): + roster.refresh() + finally: + roster._fetch_json = old_fetch + self.assertEqual(original, roster.ROSTER_PATH.read_bytes()) + + def test_refresh_duplicate_ldap_ids_preserves_last_good(self): + for field in ("owners", "members"): + with self.subTest(field=field), tempfile.TemporaryDirectory( + prefix="community-duplicate-test-" + ) as directory: + root = pathlib.Path(directory) + data_dir = root / "data" + roster_path, map_path = data_dir / "roster.json", data_dir / "github-map.json" + data_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + committee, projects, people, mapping = self.fixture() + projects["projects"]["hugegraph"][field].append( + projects["projects"]["hugegraph"][field][0] + ) + sources = { + roster.SOURCES["committee"]: committee, + roster.SOURCES["projects"]: projects, + roster.SOURCES["people"]: people, + } + map_path.write_text(json.dumps(mapping)) + with mock.patch.object(roster, "ROOT", root), \ + mock.patch.object(roster, "DATA_DIR", data_dir), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "AVATAR_DIR", root / "avatars"), \ + mock.patch.object(roster, "_fetch_json", side_effect=sources.__getitem__), \ + mock.patch.object(roster, "_commit_bundle") as commit: + with self.assertRaisesRegex( + roster.RosterError, + rf"LDAP project {field} contains duplicate ASF IDs", + ): + roster.refresh() + commit.assert_not_called() + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_copy_failure_preserves_last_good_bundle(self): + with tempfile.TemporaryDirectory(prefix="community-copy-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + (avatar_dir / "old.webp").write_bytes(b"old") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_copy_candidate", side_effect=OSError("copy failed")): + with self.assertRaisesRegex(OSError, "copy failed"): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes()) + + def test_candidate_cleanup_failure_does_not_publish_roster(self): + with tempfile.TemporaryDirectory(prefix="community-cleanup-test-") as directory: + root = pathlib.Path(directory) + roster_path, map_path = root / "roster.json", root / "github-map.json" + roster_path.write_bytes(b"last-good\n") + map_path.write_text('{"schema_version": 1, "mappings": {}}') + candidate = {"roles": {"pmc": [], "committers": []}} + with mock.patch.object(roster, "DATA_DIR", root), \ + mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "MAP_PATH", map_path), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_fetch_json", return_value={}), \ + mock.patch.object(roster, "build_roster", return_value=candidate), \ + mock.patch.object(roster, "_install_avatars"), \ + mock.patch.object(roster.shutil, "rmtree", side_effect=OSError("cleanup failed")): + with self.assertRaisesRegex(OSError, "cleanup failed"): + roster.refresh() + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_atomic_roster_write_failure_keeps_last_good_selected(self): + with tempfile.TemporaryDirectory(prefix="community-write-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_atomic_write", side_effect=OSError("write failed")): + with self.assertRaisesRegex(OSError, "write failed"): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertEqual(b"last-good\n", roster_path.read_bytes()) + + def test_orphan_unlink_failure_is_a_successful_commit_warning(self): + with tempfile.TemporaryDirectory(prefix="community-unlink-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + (avatar_dir / "old.webp").write_bytes(b"old") + candidate = {"roles": {"pmc": [{"avatar": "/img/community/avatars/new.webp"}], "committers": []}} + real_unlink, failed = roster._unlink, False + + def fail_once(path): + nonlocal failed + if path.name == "old.webp" and not failed: + failed = True + raise OSError("unlink failed") + real_unlink(path) + + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_avatar_blob"), \ + mock.patch.object(roster, "_unlink", side_effect=fail_once): + roster._commit_bundle(candidate, {"new.webp": b"new"}) + self.assertNotEqual(b"last-good\n", roster_path.read_bytes()) + self.assertEqual(b"old", (avatar_dir / "old.webp").read_bytes()) + self.assertEqual(b"new", (avatar_dir / "new.webp").read_bytes()) + + def test_corrupt_existing_candidate_destination_is_replaced(self): + with tempfile.TemporaryDirectory(prefix="community-replace-test-") as directory: + root = pathlib.Path(directory) + roster_path, avatar_dir = root / "roster.json", root / "avatars" + avatar_dir.mkdir() + roster_path.write_bytes(b"last-good\n") + raw = b"new" + name = f"{__import__('hashlib').sha256(raw).hexdigest()}.webp" + destination = avatar_dir / name + destination.write_bytes(b"corrupt") + candidate = {"roles": {"pmc": [{"avatar": f"/img/community/avatars/{name}"}], "committers": []}} + with mock.patch.object(roster, "ROSTER_PATH", roster_path), \ + mock.patch.object(roster, "AVATAR_DIR", avatar_dir), \ + mock.patch.object(roster, "_validate_repo_paths"), \ + mock.patch.object(roster, "_validate_webp"): + roster._commit_bundle(candidate, {name: raw}) + self.assertEqual(raw, destination.read_bytes()) + + +class CommunityContentContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._site = tempfile.TemporaryDirectory(prefix="community-content-site-") + hugo_version = subprocess.check_output(["hugo", "version"], text=True) + if "hugo v0.165.0+extended" not in hugo_version: + raise RuntimeError( + f"Community render contracts require Hugo v0.165.0 Extended: {hugo_version.strip()}" + ) + environment = {**os.environ, "GOPROXY": "off"} + subprocess.run( + ["hugo", "--quiet", "--destination", cls._site.name], + cwd=ROOT, + env=environment, + text=True, + capture_output=True, + check=True, + ) + cls.site = pathlib.Path(cls._site.name) + + @classmethod + def tearDownClass(cls): + cls._site.cleanup() + + def test_search_metadata_covers_fixed_bilingual_entries(self): + entries = [ + "docs/introduction/_index.md", + "docs/quickstart/hugegraph/hugegraph-server.md", + "docs/quickstart/hugegraph/hugegraph-hstore.md", + "docs/quickstart/hugegraph/hugegraph-pd.md", + "docs/quickstart/computing/hugegraph-computer.md", + "docs/quickstart/toolchain/hugegraph-loader.md", + "docs/quickstart/toolchain/hugegraph-hubble.md", + "docs/clients/_index.md", + "docs/clients/restful-api/_index.md", + "docs/config/config-guide.md", + "docs/config/config-authentication.md", + "docs/download/download.md", + ] + for language in ("en", "cn"): + for relative in entries: + text = (ROOT / "content" / language / relative).read_text(encoding="utf-8") + self.assertIn("search_keywords:", text, f"{language}/{relative}") + self.assertIn("search_boost:", text, f"{language}/{relative}") + + def test_docs_roots_respect_core_platform_llmsfull_ownership(self): + versions = { + entry["id"] + for entry in json.loads((ROOT / "versions.json").read_text(encoding="utf-8"))["versions"] + } + core_platform_integrated = {"1.3", "1.0"} <= versions + for language in ("en", "cn"): + text = (ROOT / "content" / language / "docs/_index.md").read_text(encoding="utf-8") + frontmatter = text.split("---", 2)[1] + if core_platform_integrated: + self.assertIn("outputs: [HTML, RSS, print, markdown, LLMSFULL]", frontmatter) + else: + self.assertNotIn("LLMSFULL", frontmatter) + + def test_component_pilots_are_bilingual_and_scoped(self): + for language in ("en", "cn"): + server = (ROOT / "content" / language / "docs/quickstart/hugegraph/hugegraph-server.md").read_text() + config = (ROOT / "content" / language / "docs/config/config-guide.md").read_text() + vertex = (ROOT / "content" / language / "docs/clients/restful-api/vertex.md").read_text() + self.assertIn("{.steps}", server) + self.assertIn('filename="conf/gremlin-server.yaml"', config) + self.assertIn(".full-width", vertex) + self.assertIn("{#vertex-id-strategy", vertex) + + def test_component_pilots_render_in_html_print_and_markdown(self): + for prefix in ("", "cn/"): + outputs = { + "server_html": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.html", + "server_print": self.site / prefix / "_print/docs/quickstart/hugegraph/index.html", + "server_md": self.site / prefix / "docs/quickstart/hugegraph/hugegraph-server/index.md", + "config_html": self.site / prefix / "docs/config/config-guide/index.html", + "config_print": self.site / prefix / "_print/docs/config/index.html", + "config_md": self.site / prefix / "docs/config/config-guide/index.md", + "vertex_html": self.site / prefix / "docs/clients/restful-api/vertex/index.html", + "vertex_print": self.site / prefix / "_print/docs/clients/restful-api/index.html", + "vertex_md": self.site / prefix / "docs/clients/restful-api/vertex/index.md", + } + rendered = {key: path.read_text(encoding="utf-8") for key, path in outputs.items()} + self.assertIn('class="steps"', rendered["server_html"]) + self.assertIn('class="steps"', rendered["server_print"]) + self.assertIn("{.steps}", rendered["server_md"]) + for key in ("config_html", "config_print", "config_md"): + self.assertIn("conf/gremlin-server.yaml", rendered[key]) + self.assertIn('id="vertex-id-strategy"', rendered["vertex_html"]) + self.assertIn('id="vertex-id-strategy"', rendered["vertex_print"]) + self.assertIn("{#vertex-id-strategy .full-width", rendered["vertex_md"]) + + def test_community_markdown_follows_section_order_and_about_is_unchanged(self): + expected = { + "community/index.md": ( + "## Join the Apache HugeGraph community", + "## Get involved", + "## Project members", + "## Learn how the project works", + ), + "cn/community/index.md": ( + "## 加入 Apache HugeGraph 社区", + "## 参与社区", + "## 项目成员", + "## 了解项目运作方式", + ), + } + for relative, markers in expected.items(): + rendered = (self.site / relative).read_text(encoding="utf-8") + expected_title = "# 社区" if relative.startswith("cn/") else "# Community" + self.assertTrue(rendered.startswith(expected_title + "\n")) + self.assertNotIn("td-page-meta__footer", rendered) + positions = [rendered.index(marker) for marker in markers] + self.assertEqual(positions, sorted(positions)) + member_heading = "项目成员" if relative.startswith("cn/") else "Project members" + self.assertRegex(rendered, rf"(?m)^## {member_heading}$") + about = { + "about/index.md": ( + "## One ecosystem for graph data and graph intelligence", + "HugeGraph is an Apache top-level project", + ), + "cn/about/index.md": ( + "## 连接图数据与图智能的一体化生态", + "HugeGraph 是 Apache 顶级项目", + ), + } + for relative, markers in about.items(): + rendered = (self.site / relative).read_text(encoding="utf-8") + self.assertNotIn("Project members", rendered) + for marker in markers: + self.assertIn(marker, rendered) + + def test_explicit_artifact_validator_accepts_prebuilt_site(self): + roster.validate_rendered_outputs(self.site) + result = subprocess.run( + [ + sys.executable, + "scripts/community_roster.py", + "validate", + "--warn-after-days", + "90", + "--artifact", + str(self.site), + ], + cwd=ROOT, + text=True, + capture_output=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_artifact_validator_rejects_swapped_role_sections(self): + outputs = ( + "community/index.html", + "_print/community/index.html", + "community/index.md", + "cn/community/index.html", + "cn/_print/community/index.html", + "cn/community/index.md", + ) + for swapped_relative in ( + "community/index.html", + "_print/community/index.html", + "cn/community/index.html", + "cn/_print/community/index.html", + ): + with self.subTest(output=swapped_relative), tempfile.TemporaryDirectory( + prefix="community-role-output-" + ) as directory: + destination = pathlib.Path(directory) + for relative in outputs: + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.site / relative, target) + path = destination / swapped_relative + rendered = path.read_text(encoding="utf-8") + match = re.search( + r'(]*data-community-role=(?:"pmc"|pmc)[^>]*>.*?)' + r"(\s*)" + r'(]*data-community-role=(?:"committers"|committers)[^>]*>.*?)', + rendered, + flags=re.DOTALL, + ) + self.assertIsNotNone(match) + rendered = ( + rendered[: match.start()] + + match.group(3) + + match.group(2) + + match.group(1) + + rendered[match.end() :] + ) + path.write_text(rendered, encoding="utf-8") + with self.assertRaisesRegex(roster.RosterError, "role section order drift"): + roster.validate_rendered_outputs(destination) + + def test_artifact_validator_rejects_plain_text_profile_urls(self): + with tempfile.TemporaryDirectory(prefix="community-fake-output-") as directory: + destination = pathlib.Path(directory) + roles = json.loads(roster.ROSTER_PATH.read_text())["roles"] + for relative in ( + "community/index.html", + "_print/community/index.html", + "cn/community/index.html", + "cn/_print/community/index.html", + ): + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + '
' + + " ".join(person["profile_url"] for person in roles["pmc"]) + + '
' + + " ".join(person["profile_url"] for person in roles["committers"]) + + "
" + ) + for relative, title in ( + ("community/index.md", "Project members"), + ("cn/community/index.md", "项目成员"), + ): + path = destination / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"## {title}\n\n### PMC\n" + + "\n".join(person["profile_url"] for person in roles["pmc"]) + + "\n\n### Committers\n" + + "\n".join(person["profile_url"] for person in roles["committers"]) + ) + with self.assertRaisesRegex(roster.RosterError, "link parity drift"): + roster.validate_rendered_outputs(destination) + + def test_fixed_metadata_is_present_in_actual_offline_indexes(self): + fixture = json.loads( + (ROOT / "scripts/fixtures/community_search_queries.json").read_text(encoding="utf-8") + ) + self.assertEqual(24, len(fixture)) + self.assertEqual(24, len({(item["locale"], item["query"]) for item in fixture})) + for language in ("en", "cn"): + indexes = list(self.site.glob(f"offline-search-index.{language}.*.json")) + self.assertEqual(1, len(indexes)) + records = {record["ref"]: record for record in json.loads(indexes[0].read_text())} + for item in (entry for entry in fixture if entry["locale"] == language): + ref = item["expected_ref"] + self.assertIn(ref, records) + self.assertTrue(records[ref]["keywords"], ref) + self.assertGreater(records[ref]["boost"], 1, ref) + normalized_query = item["query"].casefold() + searchable = " ".join( + [records[ref]["title"], *records[ref]["keywords"]] + ).casefold() + self.assertIn(normalized_query, searchable, ref) + + +if __name__ == "__main__": + unittest.main() From 774389bbbf5679b55bbcef390c3b520b9a366a9c Mon Sep 17 00:00:00 2001 From: dark Date: Fri, 18 Sep 2026 05:13:19 +0800 Subject: [PATCH 2/3] fix(search): improve bilingual entry summaries - surface Java client intent for client queries\n- surface graph visualization intent for Hubble queries\n- preserve existing titles and boost metadata --- content/cn/docs/clients/_index.md | 1 + content/cn/docs/quickstart/toolchain/hugegraph-hubble.md | 1 + content/en/docs/clients/_index.md | 1 + content/en/docs/quickstart/toolchain/hugegraph-hubble.md | 1 + 4 files changed, 4 insertions(+) diff --git a/content/cn/docs/clients/_index.md b/content/cn/docs/clients/_index.md index 26b2d129ff..5fd06188dc 100644 --- a/content/cn/docs/clients/_index.md +++ b/content/cn/docs/clients/_index.md @@ -1,5 +1,6 @@ --- title: "客户端与 API" +description: "通过 Java 客户端、REST API、Gremlin Console 和其他客户端库连接 HugeGraph。" linkTitle: "客户端与 API" weight: 5 search_keywords: [HugeGraph 客户端, Java 客户端, 客户端库] diff --git a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md index 9b696593a0..313e5173cd 100644 --- a/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md +++ b/content/cn/docs/quickstart/toolchain/hugegraph-hubble.md @@ -1,5 +1,6 @@ --- title: "HugeGraph-Hubble Quick Start" +description: "部署 HugeGraph-Hubble,进行图可视化、元数据管理、数据导入,以及 Gremlin 或 Cypher 查询。" linkTitle: "使用 Hubble 实现图可视化" weight: 1 search_keywords: [HugeGraph Hubble, 图可视化, Web 管理界面] diff --git a/content/en/docs/clients/_index.md b/content/en/docs/clients/_index.md index 76a9ddfb55..3ac8c6fc20 100644 --- a/content/en/docs/clients/_index.md +++ b/content/en/docs/clients/_index.md @@ -1,5 +1,6 @@ --- title: "Clients and APIs" +description: "Connect to HugeGraph with the Java client, REST API, Gremlin Console, and other client libraries." linkTitle: "Clients and APIs" weight: 5 search_keywords: [HugeGraph clients, Java client, client libraries] diff --git a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md index 00df45dfc1..fcac006c69 100644 --- a/content/en/docs/quickstart/toolchain/hugegraph-hubble.md +++ b/content/en/docs/quickstart/toolchain/hugegraph-hubble.md @@ -1,5 +1,6 @@ --- title: "HugeGraph-Hubble Quick Start" +description: "Deploy HugeGraph-Hubble for graph visualization, schema management, data import, and Gremlin or Cypher queries." linkTitle: "Visual with HugeGraph-Hubble" weight: 1 search_keywords: [HugeGraph Hubble, graph visualization, web console] From ac96442692f900929fb3868b0fcf468b085d826e Mon Sep 17 00:00:00 2001 From: dark Date: Fri, 18 Sep 2026 05:20:44 +0800 Subject: [PATCH 3/3] fix(community): accept pinned Hugo build metadata - accept CI Hugo build suffixes for 0.165.0 extended\n- keep the major and extended runtime contract strict\n- unblock current organization workflow validation --- scripts/test_community_roster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py index 4842a0629d..c50c0fcc7f 100644 --- a/scripts/test_community_roster.py +++ b/scripts/test_community_roster.py @@ -536,7 +536,7 @@ class CommunityContentContractTests(unittest.TestCase): def setUpClass(cls): cls._site = tempfile.TemporaryDirectory(prefix="community-content-site-") hugo_version = subprocess.check_output(["hugo", "version"], text=True) - if "hugo v0.165.0+extended" not in hugo_version: + if not hugo_version.startswith("hugo v0.165.0") or "+extended" not in hugo_version: raise RuntimeError( f"Community render contracts require Hugo v0.165.0 Extended: {hugo_version.strip()}" )