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:
parent
a93e9f32f5
commit
606c7b6b4e
@ -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 <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
206
http2.c
@ -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, "&", 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,
|
||||
"<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user