From 606c7b6b4ebc84c959e286f2c5b84b590c85e135 Mon Sep 17 00:00:00 2001 From: xfy911 Date: Thu, 4 Jun 2026 18:13:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(http2):=20=E6=B7=BB=E5=8A=A0=20HTTP/2=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=B5=8F=E8=A7=88=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 http2_serve_directory() 函数,生成 HTML 目录列表页面 - 目录无 index.html 时自动返回目录浏览(与 HTTP/1.1 行为一致) - 支持 HTML 转义、文件大小格式化、修改时间显示、上级目录链接 - 支持 HEAD 请求(不发送 body) - 新增 2 项集成测试:HTTPS h2 目录浏览 + h2c 目录浏览 - 54 项集成测试全部通过,35 个单元测试全部通过 --- .cocoon-plan.md | 29 +++--- http2.c | 206 +++++++++++++++++++++++++++++++++++--- tests/integration_test.sh | 24 +++++ 3 files changed, 229 insertions(+), 30 deletions(-) diff --git a/.cocoon-plan.md b/.cocoon-plan.md index aaa1536..c63ff2a 100644 --- a/.cocoon-plan.md +++ b/.cocoon-plan.md @@ -34,8 +34,9 @@ - [x] **配置文件支持** — JSON 配置替代纯命令行 ✅ 2026-06-04 - [x] **Brotli 压缩** — 比 gzip 更高压缩率 ✅ 2026-06-04 - [x] **HTTPS / TLS** — OpenSSL Memory BIO 集成,支持命令行与配置文件启用 ✅ 2026-06-04 -- [x] **HTTP/2** — nghttp2 完整实现(TLS ALPN 协商 + 静态文件服务 + 缓存)✅ 2026-06-04 +- [x] **HTTP/2** — nghttp2 完整实现(TLS ALPN 协商 + 静态文件服务 + 缓存 + 目录浏览)✅ 2026-06-04 - [x] **h2c 升级支持** — 明文 HTTP/2(PRI 魔术字直接连接 + Upgrade: h2c 协商)✅ 2026-06-04 +- [x] **HTTP/2 目录浏览** — 目录无 index.html 时返回目录列表 ✅ 2026-06-04 - [ ] Windows 兼容性 ### Phase 4 — 生态 @@ -46,33 +47,33 @@ ## 当前状态 - 编译通过,零警告(除 coco 子模块的 linker .note.GNU-stack 提示) -- **52 项集成测试全部通过**(新增 4 项 h2c 测试:prior knowledge、Upgrade 协商、404 ×2) +- **54 项集成测试全部通过**(新增 2 项 HTTP/2 目录浏览测试) - **35 个单元测试全部通过**(Unity 框架) - 压测数据:wrk -t4 -c100 -d10s → 16,179 RPS,平均延迟 59.86μs - POST 支持:JSON 和 form-urlencoded 回显,multipart 文件上传,Content-Length 解析,8MB 上限 - **配置文件支持**:JSON 格式,8 个字段,命令行参数可覆盖 - **TLS/HTTPS**:OpenSSL 3.0 Memory BIO + coco 协程集成,自签名证书支持,ALPN 协商 h2/http1.1 -- **HTTP/2**: - - 基于 nghttp2 的完整会话管理(431 行) - - TLS ALPN 自动协商 h2 - - 静态文件服务(GET/HEAD、路径安全、ETag、304 Not Modified) - - HEAD 请求正确处理(无 body) - - 目录自动 fallback 到 index.html - - 路径遍历防护(.. 检查) - - gzip/brotli 压缩支持(HTTP/2 响应中优先 brotli) - - 文件内容通过 nghttp2_data_provider 流式传输 - - 修复 send_callback 返回值 bug(send_all 返回 0 而非已发送字节数) - - 修复 :path 伪头长度解析 bug(4→5)和路径空终止问题 +- **HTTP/2**:完整功能(静态文件服务、目录浏览、缓存协商、压缩、HEAD 请求) +- **h2c**:明文 HTTP/2 支持(prior knowledge + Upgrade 协商) ## 待办池 1. **[中] Windows 兼容性** — 平台抽象层(platform.h / platform.c),sendfile 替代方案,io_uring 替代方案 2. **[中] Doxygen 中文注释** — 所有模块的公共 API 需要完整文档(http2.c 已部分完成) 3. **[低] 检查远程更新内容** — 上游有 new commits,需 review 9041d8d -4. **[低] HTTP/2 目录浏览** — 目前返回 404,可考虑支持目录列表 ## 最近行动记录 +- **2026-06-04: 本轮行动 — HTTP/2 目录浏览支持** + - http2.c: 新增 `http2_serve_directory()` 函数,生成 HTML 目录列表页面 + - http2.c: 在 `http2_serve_static()` 中检测目录无 index.html 时调用目录浏览 + - http2.c: 目录浏览支持 HTML 转义、文件大小格式化、修改时间显示、上级目录链接 + - http2.c: 目录浏览支持 HEAD 请求(不发送 body) + - http2.c: 添加 `#include ` 头文件 + - tests/integration_test.sh: 新增 2 项 HTTP/2 目录浏览集成测试(HTTPS h2 + h2c) + - 编译通过,零警告 + - **54 项集成测试全部通过,35 个单元测试全部通过** + - 推送到 main - **2026-06-04: 本轮行动 — h2c 升级支持(明文 HTTP/2)** - server.c: 添加 h2c 检测逻辑(PRI 魔术字 + Upgrade: h2c 协商) - server.c: `is_h2c_upgrade_request()` 检查 Upgrade 和 Connection 头 diff --git a/http2.c b/http2.c index 37e4b10..4b1e439 100644 --- a/http2.c +++ b/http2.c @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -108,6 +109,8 @@ static int on_data_chunk_recv_callback(nghttp2_session *session, static ssize_t http2_data_source_read_callback(nghttp2_session *session, int32_t stream_id, uint8_t *buf, size_t length, uint32_t *data_flags, nghttp2_data_source *source, void *user_data); +static bool http2_serve_directory(http2_session_t *h2, http2_stream_data_t *stream, + const char *real_path, const char *request_path); static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream); /* ===================== 会话管理 ===================== */ @@ -569,6 +572,177 @@ static ssize_t http2_data_source_read_callback( return (ssize_t)to_send; } +static bool http2_serve_directory(http2_session_t *h2, http2_stream_data_t *stream, + const char *real_path, const char *request_path) { + struct stat st; + if (stat(real_path, &st) != 0 || !S_ISDIR(st.st_mode)) { + return false; + } + + DIR *dir = opendir(real_path); + if (!dir) { + nghttp2_nv hdrs[] = { + {(uint8_t *)":status", (uint8_t *)"403", 7, 3, 0}}; + nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL); + return true; + } + + /* 收集所有目录项 */ + struct dirent *entry; + char *entries[4096]; + int num_entries = 0; + while ((entry = readdir(dir)) != NULL && num_entries < 4096) { + if (entry->d_name[0] == '.') continue; + entries[num_entries] = strdup(entry->d_name); + num_entries++; + } + closedir(dir); + + /* 构建 HTML */ + char *html = (char *)malloc(65536); + if (!html) { + for (int i = 0; i < num_entries; i++) free(entries[i]); + nghttp2_nv hdrs[] = { + {(uint8_t *)":status", (uint8_t *)"500", 7, 3, 0}}; + nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL); + return true; + } + + int n = snprintf(html, 65536, + "\n" + "\n" + "\n" + "Index of %s\n" + "\n" + "\n" + "

Index of %s

\n" + "\n" + "\n", + request_path, request_path); + + /* 添加返回上级链接 */ + if (strcmp(request_path, "/") != 0) { + n += snprintf(html + n, 65536 - n, + "\n"); + } + + /* HTML 转义辅助 */ + auto void html_escape(const char *src, char *dst, size_t dst_size) { + size_t j = 0; + for (size_t i = 0; src[i] && j < dst_size - 1; i++) { + switch (src[i]) { + case '&': + if (j + 5 < dst_size) { memcpy(dst + j, "&", 5); j += 5; } + break; + case '<': + if (j + 4 < dst_size) { memcpy(dst + j, "<", 4); j += 4; } + break; + case '>': + if (j + 4 < dst_size) { memcpy(dst + j, ">", 4); j += 4; } + break; + case '"': + if (j + 6 < dst_size) { memcpy(dst + j, """, 6); j += 6; } + break; + default: + dst[j++] = src[i]; + } + } + dst[j] = '\0'; + } + + /* 添加目录项 */ + for (int i = 0; i < num_entries; i++) { + char full_path[4096]; + snprintf(full_path, sizeof(full_path), "%s/%s", real_path, entries[i]); + + struct stat entry_st; + char size_str[32] = "-"; + char mtime_str[32] = "-"; + + if (stat(full_path, &entry_st) == 0) { + if (S_ISDIR(entry_st.st_mode)) { + strncpy(size_str, "-", sizeof(size_str)); + } else if (entry_st.st_size < 1024) { + snprintf(size_str, sizeof(size_str), "%ld B", (long)entry_st.st_size); + } else if (entry_st.st_size < 1024 * 1024) { + snprintf(size_str, sizeof(size_str), "%.1f KB", entry_st.st_size / 1024.0); + } else if (entry_st.st_size < 1024 * 1024 * 1024) { + snprintf(size_str, sizeof(size_str), "%.1f MB", entry_st.st_size / (1024.0 * 1024)); + } else { + snprintf(size_str, sizeof(size_str), "%.1f GB", entry_st.st_size / (1024.0 * 1024 * 1024)); + } + + struct tm *tm_info = localtime(&entry_st.st_mtime); + if (tm_info) { + strftime(mtime_str, sizeof(mtime_str), "%Y-%m-%d %H:%M", tm_info); + } + } + + char escaped_name[512]; + html_escape(entries[i], escaped_name, sizeof(escaped_name)); + + n += snprintf(html + n, 65536 - n, + "\n", + escaped_name, + S_ISDIR(entry_st.st_mode) ? "/" : "", + escaped_name, + S_ISDIR(entry_st.st_mode) ? "/" : "", + size_str, mtime_str); + + free(entries[i]); + } + + n += snprintf(html + n, 65536 - n, + "
NameSizeModified
../--
%s%s%s%s
\n" + "
\n" + "

Cocoon Server

\n" + "\n"); + + /* 存储响应并发送 */ + stream->response_body = html; + stream->response_len = (size_t)n; + stream->response_sent = 0; + + nghttp2_nv hdrs[8]; + int num_hdrs = 0; + + hdrs[num_hdrs++] = (nghttp2_nv){ + (uint8_t *)":status", (uint8_t *)"200", 7, 3, 0}; + + hdrs[num_hdrs++] = (nghttp2_nv){ + (uint8_t *)"content-type", (uint8_t *)"text/html; charset=utf-8", 12, 24, 0}; + + char content_length_str[32]; + snprintf(content_length_str, sizeof(content_length_str), "%d", n); + hdrs[num_hdrs++] = (nghttp2_nv){ + (uint8_t *)"content-length", (uint8_t *)content_length_str, 14, strlen(content_length_str), 0}; + + hdrs[num_hdrs++] = (nghttp2_nv){ + (uint8_t *)"server", (uint8_t *)"Cocoon/1.0", 6, 10, 0}; + + if (stream->request.method == HTTP_HEAD) { + free(stream->response_body); + stream->response_body = NULL; + stream->response_len = 0; + nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, NULL); + } else { + nghttp2_data_provider provider; + provider.source.ptr = stream; + provider.read_callback = http2_data_source_read_callback; + nghttp2_submit_response(h2->session, stream->stream_id, hdrs, num_hdrs, &provider); + } + + return true; +} + static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream) { if (!h2->root_dir) { nghttp2_nv hdrs[] = { @@ -614,26 +788,26 @@ static void http2_serve_static(http2_session_t *h2, http2_stream_data_t *stream) return; } - /* 目录:尝试 index.html */ + /* 目录:尝试 index.html,否则生成目录列表 */ if (S_ISDIR(st.st_mode)) { char index_path[4096]; size_t real_len = strlen(real_path); - if (real_len + 12 >= sizeof(index_path)) { - nghttp2_nv hdrs[] = { - {(uint8_t *)":status", (uint8_t *)"404", 7, 3, 0}}; - nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL); - return; - } - snprintf(index_path, sizeof(index_path), "%s/index.html", real_path); - struct stat index_st; - if (stat(index_path, &index_st) == 0 && S_ISREG(index_st.st_mode)) { - snprintf(real_path, sizeof(real_path), "%s", index_path); - stat(real_path, &st); + if (real_len + 12 < sizeof(index_path)) { + snprintf(index_path, sizeof(index_path), "%s/index.html", real_path); + struct stat index_st; + if (stat(index_path, &index_st) == 0 && S_ISREG(index_st.st_mode)) { + snprintf(real_path, sizeof(real_path), "%s", index_path); + stat(real_path, &st); + } else { + /* 生成目录浏览页面 */ + if (http2_serve_directory(h2, stream, real_path, stream->request.path)) { + return; + } + } } else { - nghttp2_nv hdrs[] = { - {(uint8_t *)":status", (uint8_t *)"404", 7, 3, 0}}; - nghttp2_submit_response(h2->session, stream->stream_id, hdrs, 1, NULL); - return; + if (http2_serve_directory(h2, stream, real_path, stream->request.path)) { + return; + } } } diff --git a/tests/integration_test.sh b/tests/integration_test.sh index 557be0f..52fb9fd 100755 --- a/tests/integration_test.sh +++ b/tests/integration_test.sh @@ -572,6 +572,18 @@ fi assert_http2_brotli "https://$HOST/index.html" "HTTP/2 HTML brotli" assert_http2_brotli "https://$HOST/style.css" "HTTP/2 CSS brotli" assert_http2_brotli "https://$HOST/app.js" "HTTP/2 JS brotli" + +# HTTP/2 目录浏览测试 +h2_dir_status=$(curl --http2 -k -s -o /dev/null -w "%{http_code}" "https://$HOST/subdir/") +h2_dir_body=$(curl --http2 -k -s "https://$HOST/subdir/") +if [[ "$h2_dir_status" == "200" ]] && echo "$h2_dir_body" | grep -q "page.html"; then + echo " ✓ HTTP/2 目录浏览 — HTTP 200,包含 page.html" + pass +else + echo " ✗ HTTP/2 目录浏览 — 期望 200+page.html, 实际 $h2_dir_status" + fail +fi + # 停止 HTTP/2 服务器,恢复 HTTP 服务器 kill_server sleep 1 @@ -623,6 +635,18 @@ else fail fi +# HTTP/2 目录浏览测试 +# subdir 目录没有 index.html,应返回 200 目录列表 +h2c_dir_status=$(curl --http2-prior-knowledge -s -o /dev/null -w "%{http_code}" "http://$HOST/subdir/") +h2c_dir_body=$(curl --http2-prior-knowledge -s "http://$HOST/subdir/") +if [[ "$h2c_dir_status" == "200" ]] && echo "$h2c_dir_body" | grep -q "page.html"; then + echo " ✓ h2c 目录浏览 — HTTP 200,包含 page.html" + pass +else + echo " ✗ h2c 目录浏览 — 期望 200+page.html, 实际 $h2c_dir_status" + fail +fi + echo "" echo "=== 结果汇总 ===" echo "通过: $PASS"