feat(http2): 添加 HTTP/2 目录浏览支持

- 新增 http2_serve_directory() 函数,生成 HTML 目录列表页面
- 目录无 index.html 时自动返回目录浏览(与 HTTP/1.1 行为一致)
- 支持 HTML 转义、文件大小格式化、修改时间显示、上级目录链接
- 支持 HEAD 请求(不发送 body)
- 新增 2 项集成测试:HTTPS h2 目录浏览 + h2c 目录浏览
- 54 项集成测试全部通过,35 个单元测试全部通过
This commit is contained in:
xfy911 2026-06-04 18:13:48 +08:00
parent a93e9f32f5
commit 606c7b6b4e
3 changed files with 229 additions and 30 deletions

View File

@ -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/2PRI 魔术字直接连接 + 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 返回值 bugsend_all 返回 0 而非已发送字节数)
- 修复 :path 伪头长度解析 bug4→5和路径空终止问题
- **HTTP/2**完整功能静态文件服务、目录浏览、缓存协商、压缩、HEAD 请求)
- **h2c**:明文 HTTP/2 支持prior knowledge + Upgrade 协商)
## 待办池
1. **[中] Windows 兼容性** — 平台抽象层platform.h / platform.csendfile 替代方案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 <dirent.h>` 头文件
- 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 头

206
http2.c
View File

@ -19,6 +19,7 @@
#include <fcntl.h>
#include <sys/stat.h>
#include <time.h>
#include <dirent.h>
#include <zlib.h>
#include <brotli/encode.h>
@ -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,
"<!DOCTYPE html>\n"
"<html><head>\n"
"<meta charset=\"utf-8\">\n"
"<title>Index of %s</title>\n"
"<style>"
"body{font-family:system-ui,-apple-system,sans-serif;max-width:800px;margin:40px auto;padding:0 20px}"
"h1{border-bottom:1px solid #ddd;padding-bottom:10px}"
"table{width:100%%;border-collapse:collapse}"
"th{text-align:left;padding:8px;border-bottom:2px solid #ddd}"
"td{padding:8px;border-bottom:1px solid #eee}"
"a{text-decoration:none;color:#0066cc}"
"a:hover{text-decoration:underline}"
"</style>\n"
"</head><body>\n"
"<h1>Index of %s</h1>\n"
"<table>\n"
"<tr><th>Name</th><th>Size</th><th>Modified</th></tr>\n",
request_path, request_path);
/* 添加返回上级链接 */
if (strcmp(request_path, "/") != 0) {
n += snprintf(html + n, 65536 - n,
"<tr><td><a href=\"../\">../</a></td><td>-</td><td>-</td></tr>\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, "&amp;", 5); j += 5; }
break;
case '<':
if (j + 4 < dst_size) { memcpy(dst + j, "&lt;", 4); j += 4; }
break;
case '>':
if (j + 4 < dst_size) { memcpy(dst + j, "&gt;", 4); j += 4; }
break;
case '"':
if (j + 6 < dst_size) { memcpy(dst + j, "&quot;", 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,
"<tr><td><a href=\"%s%s\">%s%s</a></td><td>%s</td><td>%s</td></tr>\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,
"</table>\n"
"<hr>\n"
"<p><em>Cocoon Server</em></p>\n"
"</body></html>\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;
}
}
}

View File

@ -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"