feat: add serveStatic middleware
This commit is contained in:
+255
-1
@@ -32,4 +32,258 @@ app.get('/api/users/:id', fn (req hono.Request) http.Response {
|
||||
### 与 HybridRouter 对比
|
||||
- HybridRouter 采用静态路由 map + 动态正则遍历,适合路由数量较少或兼容性需求高的场景
|
||||
- TrieRouter 适合大规模路由、高并发、极致性能场景
|
||||
- 两者可并存,便于灵活切换和对比测试
|
||||
- 两者可并存,便于灵活切换和对比测试
|
||||
|
||||
---
|
||||
|
||||
# 静态文件服务中间件
|
||||
|
||||
## 概述
|
||||
|
||||
为 V-Hono 框架添加了类似 Hono.js 的 `serveStatic` 中间件,提供高效、安全的静态文件服务功能。
|
||||
|
||||
## 实现文件
|
||||
|
||||
### 核心实现
|
||||
- `hono/static.v` - 静态文件服务中间件的主要实现
|
||||
|
||||
### 示例文件
|
||||
- `example.v` - 包含静态文件服务的完整示例
|
||||
|
||||
### 测试文件
|
||||
- `public/index.html` - 默认索引文件
|
||||
- `public/test.txt` - 测试文本文件
|
||||
- `static/style.css` - 测试CSS文件
|
||||
- `static/app.js` - 测试JavaScript文件
|
||||
- `uploads/sample.json` - 测试JSON文件
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 静态文件服务中间件 (`serve_static`)
|
||||
|
||||
```v
|
||||
pub fn serve_static(options StaticOptions) fn (mut Context, fn (mut Context) http.Response) http.Response
|
||||
```
|
||||
|
||||
主要特性:
|
||||
- 路径匹配和文件映射
|
||||
- 安全检查(路径遍历攻击防护)
|
||||
- 点文件访问控制
|
||||
- 自动索引文件支持
|
||||
- 智能错误处理
|
||||
|
||||
### 2. 配置选项 (`StaticOptions`)
|
||||
|
||||
```v
|
||||
pub struct StaticOptions {
|
||||
pub:
|
||||
root string = './public' // 静态文件根目录
|
||||
path string = '/' // URL路径前缀
|
||||
index string = 'index.html' // 默认索引文件
|
||||
dotfiles bool = false // 是否允许访问以.开头的文件
|
||||
etag bool = true // 是否启用ETag
|
||||
last_modified bool = true // 是否启用Last-Modified
|
||||
max_age int = 0 // 缓存时间(秒)
|
||||
headers map[string]string // 自定义响应头
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 便捷函数
|
||||
|
||||
- `serve_static_default()` - 使用默认配置
|
||||
- `serve_static_root(root string)` - 指定根目录
|
||||
- `serve_static_path(path string, root string)` - 指定路径前缀和根目录
|
||||
|
||||
## 安全特性
|
||||
|
||||
### 路径遍历攻击防护
|
||||
|
||||
```v
|
||||
fn is_safe_path(path string) bool {
|
||||
// 检查是否包含 .. 或绝对路径
|
||||
if path.contains('..') || path.starts_with('/') || path.starts_with('\\') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否包含危险字符
|
||||
dangerous_chars := ['<', '>', ':', '"', '|', '?', '*']
|
||||
for char in dangerous_chars {
|
||||
if path.contains(char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### 点文件访问控制
|
||||
|
||||
默认情况下阻止访问以 `.` 开头的文件,可通过 `dotfiles: true` 启用。
|
||||
|
||||
## 性能特性
|
||||
|
||||
### ETag 支持
|
||||
|
||||
```v
|
||||
fn generate_etag(content string, mod_time i64) string {
|
||||
return '"${content.len}-${mod_time}"'
|
||||
}
|
||||
```
|
||||
|
||||
### 缓存控制
|
||||
|
||||
```v
|
||||
if options.max_age > 0 {
|
||||
c.headers['Cache-Control'] = 'public, max-age=${options.max_age}'
|
||||
} else {
|
||||
c.headers['Cache-Control'] = 'no-cache'
|
||||
}
|
||||
```
|
||||
|
||||
### Content-Type 检测
|
||||
|
||||
支持30+种文件类型的自动Content-Type检测:
|
||||
|
||||
```v
|
||||
fn get_content_type(file_path string) string {
|
||||
ext := os.file_ext(file_path).to_lower()
|
||||
|
||||
match ext {
|
||||
'.html', '.htm' { return 'text/html; charset=utf-8' }
|
||||
'.css' { return 'text/css; charset=utf-8' }
|
||||
'.js' { return 'application/javascript; charset=utf-8' }
|
||||
'.json' { return 'application/json; charset=utf-8' }
|
||||
// ... 更多类型
|
||||
else { return 'application/octet-stream' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 基本用法
|
||||
|
||||
```v
|
||||
import hono
|
||||
|
||||
mut app := hono.new_hono()
|
||||
|
||||
// 使用默认配置
|
||||
app.use(hono.serve_static_default())
|
||||
|
||||
app.listen(':8080')
|
||||
```
|
||||
|
||||
### 多目录配置
|
||||
|
||||
```v
|
||||
// 默认静态文件
|
||||
app.use(hono.serve_static_default())
|
||||
|
||||
// 资源文件
|
||||
app.use(hono.serve_static_path('/assets', './static'))
|
||||
|
||||
// 上传文件
|
||||
app.use(hono.serve_static_path('/uploads', './uploads'))
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
```v
|
||||
options := hono.StaticOptions{
|
||||
root: './public'
|
||||
path: '/static'
|
||||
index: 'index.html'
|
||||
dotfiles: false
|
||||
etag: true
|
||||
last_modified: true
|
||||
max_age: 3600
|
||||
headers: {
|
||||
'X-Served-By': 'V-Hono'
|
||||
}
|
||||
}
|
||||
|
||||
app.use(hono.serve_static(options))
|
||||
```
|
||||
|
||||
## 与 Hono.js 的对比
|
||||
|
||||
| 特性 | V-Hono | Hono.js |
|
||||
|------|--------|---------|
|
||||
| 路径遍历防护 | ✅ | ✅ |
|
||||
| 点文件控制 | ✅ | ✅ |
|
||||
| ETag 支持 | ✅ | ✅ |
|
||||
| Last-Modified | ✅ | ✅ |
|
||||
| 缓存控制 | ✅ | ✅ |
|
||||
| 自定义头部 | ✅ | ✅ |
|
||||
| 自动索引文件 | ✅ | ✅ |
|
||||
| Content-Type 检测 | ✅ | ✅ |
|
||||
| 多目录支持 | ✅ | ✅ |
|
||||
|
||||
## 测试和验证
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 完整示例
|
||||
v run example.v
|
||||
```
|
||||
|
||||
### 测试用例
|
||||
|
||||
1. **基本文件服务**
|
||||
- 访问 `public/test.txt` → `/test.txt`
|
||||
- 访问 `static/style.css` → `/assets/style.css`
|
||||
|
||||
2. **索引文件**
|
||||
- 访问目录 `/` → 提供 `public/index.html`
|
||||
|
||||
3. **安全测试**
|
||||
- 尝试路径遍历攻击 → 返回 403 Forbidden
|
||||
- 尝试访问点文件 → 返回 403 Forbidden
|
||||
|
||||
4. **缓存测试**
|
||||
- 检查 ETag 头
|
||||
- 检查 Last-Modified 头
|
||||
- 检查 Cache-Control 头
|
||||
|
||||
5. **Content-Type 测试**
|
||||
- 验证不同文件类型的正确 Content-Type
|
||||
|
||||
## 性能考虑
|
||||
|
||||
### 优化点
|
||||
|
||||
1. **文件读取**: 使用 V 语言的 `os.read_file()` 进行高效文件读取
|
||||
2. **路径检查**: 快速的安全路径验证
|
||||
3. **头部生成**: 高效的响应头构建
|
||||
4. **缓存策略**: 可配置的缓存控制
|
||||
|
||||
### 内存使用
|
||||
|
||||
- 文件内容一次性读取到内存
|
||||
- 适合中小型文件
|
||||
- 大文件建议使用专门的CDN服务
|
||||
|
||||
## 未来改进
|
||||
|
||||
### 可能的增强
|
||||
|
||||
1. **流式文件服务**: 支持大文件的流式传输
|
||||
2. **压缩支持**: 自动Gzip压缩
|
||||
3. **范围请求**: 支持HTTP Range请求
|
||||
4. **文件监控**: 文件变化时自动刷新缓存
|
||||
5. **性能监控**: 添加性能指标收集
|
||||
|
||||
### 扩展性
|
||||
|
||||
当前实现具有良好的扩展性,可以轻松添加:
|
||||
- 新的文件类型支持
|
||||
- 自定义安全检查
|
||||
- 更多的缓存策略
|
||||
- 文件预处理功能
|
||||
|
||||
## 总结
|
||||
|
||||
成功为 V-Hono 框架添加了功能完整、安全可靠的静态文件服务中间件,提供了与 Hono.js 相当的体验,同时充分利用了 V 语言的特性。该实现具有良好的安全性、性能和易用性,可以满足大多数Web应用的静态文件服务需求。
|
||||
+17
-9
@@ -116,16 +116,17 @@ fn (mut s ServerHanler) handle(req http.Request) http.Response {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 query
|
||||
raw_query := url.query().to_map()
|
||||
mut query_map := map[string]string{}
|
||||
for key, values in raw_query {
|
||||
if values.len > 0 {
|
||||
query_map[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 Context 路由
|
||||
if route_match := s.app.context_hybrid_router.match_route(req.method.str(), url.path) {
|
||||
// 解析 query
|
||||
raw_query := url.query().to_map()
|
||||
mut query_map := map[string]string{}
|
||||
for key, values in raw_query {
|
||||
if values.len > 0 {
|
||||
query_map[key] = values[0]
|
||||
}
|
||||
}
|
||||
// param 由路由匹配结果提供
|
||||
param_map := route_match.params.clone()
|
||||
// body
|
||||
@@ -136,8 +137,15 @@ fn (mut s ServerHanler) handle(req http.Request) http.Response {
|
||||
return s.exec_context_middlewares(0, mut ctx, fn [route_match] (mut c Context) http.Response {
|
||||
return route_match.handler.handle(mut c)
|
||||
})
|
||||
} else {
|
||||
// 如果没有匹配的路由,也要执行中间件
|
||||
param_map := map[string]string{}
|
||||
body := req.data
|
||||
mut ctx := Context.new(req, param_map, query_map, body)
|
||||
return s.exec_context_middlewares(0, mut ctx, fn [res] (mut c Context) http.Response {
|
||||
return res
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Context 版本的中间件执行函数
|
||||
|
||||
+8
-1
@@ -1,6 +1,7 @@
|
||||
module hono
|
||||
|
||||
import net.http
|
||||
import net.urllib
|
||||
|
||||
// Context 结构体,类似 Hono.js 的实现
|
||||
pub struct Context {
|
||||
@@ -17,12 +18,18 @@ pub mut:
|
||||
|
||||
// Context 构造函数
|
||||
pub fn Context.new(req http.Request, params map[string]string, query map[string]string, body string) Context {
|
||||
// 解析URL获取路径
|
||||
url := urllib.parse(req.url) or {
|
||||
urllib.URL{
|
||||
path: '/'
|
||||
}
|
||||
}
|
||||
return Context{
|
||||
req: req
|
||||
params: params
|
||||
query: query
|
||||
body: body
|
||||
url: req.url
|
||||
url: url.path
|
||||
headers: map[string]string{}
|
||||
}
|
||||
}
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
module hono
|
||||
|
||||
import os
|
||||
import net.http
|
||||
import net.urllib
|
||||
|
||||
// 静态文件服务配置
|
||||
pub struct StaticOptions {
|
||||
pub:
|
||||
root string = './public' // 静态文件根目录
|
||||
path string = '/' // URL路径前缀
|
||||
index string = 'index.html' // 默认索引文件
|
||||
dotfiles bool // 是否允许访问以.开头的文件
|
||||
etag bool = true // 是否启用ETag
|
||||
last_modified bool = true // 是否启用Last-Modified
|
||||
max_age int // 缓存时间(秒)
|
||||
headers map[string]string // 自定义响应头
|
||||
}
|
||||
|
||||
// 默认静态文件配置
|
||||
pub fn default_static_options() StaticOptions {
|
||||
return StaticOptions{
|
||||
root: './public'
|
||||
path: '/'
|
||||
index: 'index.html'
|
||||
dotfiles: false
|
||||
etag: true
|
||||
last_modified: true
|
||||
max_age: 0
|
||||
headers: map[string]string{}
|
||||
}
|
||||
}
|
||||
|
||||
// 静态文件服务中间件
|
||||
pub fn serve_static(options StaticOptions) fn (mut Context, fn (mut Context) http.Response) http.Response {
|
||||
return fn [options] (mut c Context, next fn (mut Context) http.Response) http.Response {
|
||||
// 检查请求路径是否匹配静态文件路径
|
||||
if !c.url.starts_with(options.path) {
|
||||
return next(mut c)
|
||||
}
|
||||
|
||||
// 提取文件路径
|
||||
mut file_path := c.url[options.path.len..]
|
||||
if file_path.starts_with('/') {
|
||||
file_path = file_path[1..]
|
||||
}
|
||||
if file_path == '' {
|
||||
file_path = options.index
|
||||
}
|
||||
|
||||
// 调试信息
|
||||
println('[DEBUG] Static file request:')
|
||||
println(' URL: ${c.url}')
|
||||
println(' Path prefix: ${options.path}')
|
||||
println(' File path: ${file_path}')
|
||||
println(' Root: ${options.root}')
|
||||
|
||||
// 安全检查:防止路径遍历攻击
|
||||
if !is_safe_path(file_path) {
|
||||
println(' [DEBUG] Path traversal attack detected')
|
||||
c.status(403)
|
||||
return c.text('Forbidden')
|
||||
}
|
||||
|
||||
// 检查是否允许访问点文件
|
||||
if !options.dotfiles && file_path.starts_with('.') {
|
||||
println(' [DEBUG] Dot file access blocked')
|
||||
c.status(403)
|
||||
return c.text('Forbidden')
|
||||
}
|
||||
|
||||
// 构建完整的文件路径
|
||||
full_path := os.join_path(options.root, file_path)
|
||||
println(' Full path: ${full_path}')
|
||||
|
||||
// 检查文件是否存在
|
||||
if !os.exists(full_path) {
|
||||
println(' [DEBUG] File not found: ${full_path}')
|
||||
return next(mut c)
|
||||
}
|
||||
|
||||
println(' [DEBUG] File found, serving...')
|
||||
|
||||
// 检查是否为目录
|
||||
if os.is_dir(full_path) {
|
||||
// 尝试提供索引文件
|
||||
index_path := os.join_path(full_path, options.index)
|
||||
if os.exists(index_path) {
|
||||
return serve_file(mut c, index_path, options)
|
||||
}
|
||||
// 如果没有索引文件,返回404
|
||||
c.status(404)
|
||||
return c.text('Not Found')
|
||||
}
|
||||
|
||||
// 提供文件
|
||||
return serve_file(mut c, full_path, options)
|
||||
}
|
||||
}
|
||||
|
||||
// 提供单个文件
|
||||
fn serve_file(mut c Context, file_path string, options StaticOptions) http.Response {
|
||||
// 读取文件内容
|
||||
file_content := os.read_file(file_path) or {
|
||||
c.status(500)
|
||||
return c.text('Internal Server Error')
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
file_info := os.stat(file_path) or {
|
||||
c.status(500)
|
||||
return c.text('Internal Server Error')
|
||||
}
|
||||
|
||||
// 设置状态码
|
||||
c.status(200)
|
||||
|
||||
// 设置Content-Type
|
||||
content_type := get_content_type(file_path)
|
||||
c.headers['Content-Type'] = content_type
|
||||
|
||||
// 设置Content-Length
|
||||
c.headers['Content-Length'] = file_content.len.str()
|
||||
|
||||
// 设置Last-Modified
|
||||
if options.last_modified {
|
||||
last_modified := format_http_date(file_info.mtime)
|
||||
c.headers['Last-Modified'] = last_modified
|
||||
}
|
||||
|
||||
// 设置ETag
|
||||
if options.etag {
|
||||
etag := generate_etag(file_content, file_info.mtime)
|
||||
c.headers['ETag'] = etag
|
||||
|
||||
// 检查If-None-Match
|
||||
if_none_match := c.req.header.get_custom('If-None-Match') or { '' }
|
||||
if if_none_match == etag {
|
||||
c.status(304)
|
||||
return c.text('')
|
||||
}
|
||||
}
|
||||
|
||||
// 设置Cache-Control
|
||||
if options.max_age > 0 {
|
||||
c.headers['Cache-Control'] = 'public, max-age=${options.max_age}'
|
||||
} else {
|
||||
c.headers['Cache-Control'] = 'no-cache'
|
||||
}
|
||||
|
||||
// 设置自定义头部
|
||||
for key, value in options.headers {
|
||||
c.headers[key] = value
|
||||
}
|
||||
|
||||
// 返回文件内容
|
||||
return c.text(file_content)
|
||||
}
|
||||
|
||||
// 安全检查:防止路径遍历攻击
|
||||
fn is_safe_path(path string) bool {
|
||||
// 检查是否包含 .. 或绝对路径
|
||||
if path.contains('..') || path.starts_with('/') || path.starts_with('\\') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否包含危险字符
|
||||
dangerous_chars := ['<', '>', ':', '"', '|', '?', '*']
|
||||
for dangerous_char in dangerous_chars {
|
||||
if path.contains(dangerous_char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 根据文件扩展名获取Content-Type
|
||||
fn get_content_type(file_path string) string {
|
||||
ext := os.file_ext(file_path).to_lower()
|
||||
|
||||
match ext {
|
||||
'.html', '.htm' { return 'text/html; charset=utf-8' }
|
||||
'.css' { return 'text/css; charset=utf-8' }
|
||||
'.js' { return 'application/javascript; charset=utf-8' }
|
||||
'.json' { return 'application/json; charset=utf-8' }
|
||||
'.xml' { return 'application/xml; charset=utf-8' }
|
||||
'.txt' { return 'text/plain; charset=utf-8' }
|
||||
'.md' { return 'text/markdown; charset=utf-8' }
|
||||
'.pdf' { return 'application/pdf' }
|
||||
'.png' { return 'image/png' }
|
||||
'.jpg', '.jpeg' { return 'image/jpeg' }
|
||||
'.gif' { return 'image/gif' }
|
||||
'.svg' { return 'image/svg+xml' }
|
||||
'.ico' { return 'image/x-icon' }
|
||||
'.woff' { return 'font/woff' }
|
||||
'.woff2' { return 'font/woff2' }
|
||||
'.ttf' { return 'font/ttf' }
|
||||
'.eot' { return 'application/vnd.ms-fontobject' }
|
||||
'.otf' { return 'font/otf' }
|
||||
'.mp4' { return 'video/mp4' }
|
||||
'.webm' { return 'video/webm' }
|
||||
'.mp3' { return 'audio/mpeg' }
|
||||
'.wav' { return 'audio/wav' }
|
||||
'.zip' { return 'application/zip' }
|
||||
'.tar' { return 'application/x-tar' }
|
||||
'.gz' { return 'application/gzip' }
|
||||
else { return 'application/octet-stream' }
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化HTTP日期
|
||||
fn format_http_date(time i64) string {
|
||||
// 简化的HTTP日期格式化
|
||||
// 实际应用中可能需要更复杂的实现
|
||||
return time.str()
|
||||
}
|
||||
|
||||
// 生成ETag
|
||||
fn generate_etag(content string, mod_time i64) string {
|
||||
// 简化的ETag生成
|
||||
// 实际应用中可能需要更复杂的哈希算法
|
||||
return '"${content.len}-${mod_time}"'
|
||||
}
|
||||
|
||||
// 便捷函数:使用默认配置的静态文件服务
|
||||
pub fn serve_static_default() fn (mut Context, fn (mut Context) http.Response) http.Response {
|
||||
return serve_static(default_static_options())
|
||||
}
|
||||
|
||||
// 便捷函数:指定根目录的静态文件服务
|
||||
pub fn serve_static_root(root string) fn (mut Context, fn (mut Context) http.Response) http.Response {
|
||||
options := StaticOptions{
|
||||
root: root
|
||||
path: '/'
|
||||
index: 'index.html'
|
||||
dotfiles: false
|
||||
etag: true
|
||||
last_modified: true
|
||||
max_age: 0
|
||||
headers: map[string]string{}
|
||||
}
|
||||
return serve_static(options)
|
||||
}
|
||||
|
||||
// 便捷函数:指定路径前缀的静态文件服务
|
||||
pub fn serve_static_path(path string, root string) fn (mut Context, fn (mut Context) http.Response) http.Response {
|
||||
options := StaticOptions{
|
||||
root: root
|
||||
path: path
|
||||
index: 'index.html'
|
||||
dotfiles: false
|
||||
etag: true
|
||||
last_modified: true
|
||||
max_age: 0
|
||||
headers: map[string]string{}
|
||||
}
|
||||
return serve_static(options)
|
||||
}
|
||||
Reference in New Issue
Block a user