Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds full Virtual Host (vhost) support, enabling domain-based path remapping for the SPA and an optional “Web Hosting” mode that serves static site files (e.g., index.html) directly from a configured backend path.
Changes:
- Add vhost CRUD APIs and persistence (model/db/op + admin routes).
- Implement vhost-aware path remapping for FS APIs and download/preview routes, including stripping vhost prefixes when generating
/p/...links. - Add vhost-aware static catch-all handling, including a Web Hosting file-serving path with forced
Content-Type/Content-Disposition.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| server/static/static.go | Adds vhost-aware catch-all handler and Web Hosting file proxying with header overrides. |
| server/router.go | Adds /api/admin/vhost/* routes for vhost management. |
| server/middlewares/virtual_host.go | Introduces a vhost context middleware (currently not wired). |
| server/middlewares/down.go | Applies vhost path remapping for /d//p style routes. |
| server/handles/virtual_host.go | Adds admin handlers for listing/getting/creating/updating/deleting vhosts. |
| server/handles/fsread.go | Applies vhost path remapping to fs/list and fs/get, and strips vhost prefix when generating /p links. |
| internal/op/virtual_host.go | Adds cached vhost lookup and CRUD wrappers with cache invalidation. |
| internal/model/virtual_host.go | Adds VirtualHost model. |
| internal/db/virtual_host.go | Adds DB layer CRUD for vhosts. |
| internal/db/db.go | Adds VirtualHost to AutoMigrate. |
| internal/conf/const.go | Adds context keys for vhost and vhost-prefix propagation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| reqPath := c.Request.URL.Path | ||
| // 将请求路径映射到虚拟主机的根目录 | ||
| filePath := stdpath.Join(vhost.Path, reqPath) | ||
| utils.Log.Infof("[VirtualHost] handleWebHosting: reqPath=%q -> filePath=%q", reqPath, filePath) | ||
|
|
||
| // 尝试获取文件 | ||
| obj, err := internalfs.Get(c.Request.Context(), filePath, &internalfs.GetArgs{NoLog: true}) | ||
| if err == nil && !obj.IsDir() { |
There was a problem hiding this comment.
handleWebHosting uses path.Join(vhost.Path, reqPath) where reqPath comes directly from c.Request.URL.Path. A request like /../etc/passwd will be cleaned by path.Join and can escape the virtual host root (vhost.Path), enabling path traversal against internalfs.Get/Link. Sanitize reqPath as a relative path and reject any cleaned result that starts with .. before joining it to vhost.Path.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| } | ||
| // 路径重映射:将 reqPath 拼接到 vhost.Path 后面 | ||
| mapped := stdpath.Join(vhost.Path, reqPath) | ||
| utils.Log.Debugf("[VirtualHost] API path remapping: domain=%q reqPath=%q -> mappedPath=%q", domain, reqPath, mapped) | ||
| return mapped, vhost.Path | ||
| } | ||
|
|
There was a problem hiding this comment.
applyVhostPathMappingWithPrefix uses path.Join(vhost.Path, reqPath), which cleans the input and can remove .. segments before user.JoinPath runs. This can bypass utils.JoinBasePath's relative-path detection (it checks for .. in the original request path) and potentially allow traversal outside the user's base path. Preserve the original .. check (e.g. by using utils.JoinBasePath-style validation for reqPath) before joining, or return an error on relative paths.
| } | |
| // 路径重映射:将 reqPath 拼接到 vhost.Path 后面 | |
| mapped := stdpath.Join(vhost.Path, reqPath) | |
| utils.Log.Debugf("[VirtualHost] API path remapping: domain=%q reqPath=%q -> mappedPath=%q", domain, reqPath, mapped) | |
| return mapped, vhost.Path | |
| } | |
| } | |
| // 安全检查:在进行路径拼接之前,确保请求路径中没有 ".." 段,避免通过 path.Join 清理后绕过上层的相对路径检测。 | |
| if !isSafeVhostReqPath(reqPath) { | |
| utils.Log.Warnf("[VirtualHost] Suspicious path detected, skip vhost remapping: domain=%q reqPath=%q", domain, reqPath) | |
| // 返回原始路径并不携带 vhost 前缀,让后续的基础路径校验逻辑基于原始路径继续处理。 | |
| return reqPath, "" | |
| } | |
| // 路径重映射:将 reqPath 拼接到 vhost.Path 后面 | |
| mapped := stdpath.Join(vhost.Path, reqPath) | |
| utils.Log.Debugf("[VirtualHost] API path remapping: domain=%q reqPath=%q -> mappedPath=%q", domain, reqPath, mapped) | |
| return mapped, vhost.Path | |
| } | |
| // isSafeVhostReqPath 检查虚拟主机请求路径中是否包含 ".." 段,以防止目录遍历。 | |
| // 仅当某个路径段恰好为 ".." 时视为不安全,例如 "/a/../b"。 | |
| func isSafeVhostReqPath(p string) bool { | |
| if p == "" { | |
| return true | |
| } | |
| for _, seg := range strings.Split(p, "/") { | |
| if seg == ".." { | |
| return false | |
| } | |
| } | |
| return true | |
| } |
server/handles/fsread.go
Outdated
| // stripHostPortForVhost 去掉 host 中的端口号,返回纯域名 | ||
| func stripHostPortForVhost(host string) string { | ||
| if idx := strings.LastIndex(host, ":"); idx != -1 { | ||
| if !strings.Contains(host, "[") { | ||
| return host[:idx] | ||
| } | ||
| } | ||
| return host |
There was a problem hiding this comment.
stripHostPortForVhost has the same IPv6-with-port parsing issue as other host stripping helpers (e.g. [::1]:5244 won’t be normalized). Using net.SplitHostPort (with fallback) would make vhost matching work consistently for IPv6 listeners as well.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
server/static/static.go
Outdated
| // stripHostPort 去掉 host 中的端口号,返回纯域名 | ||
| func stripHostPort(host string) string { | ||
| if idx := strings.LastIndex(host, ":"); idx != -1 { | ||
| // 确保不是 IPv6 地址(IPv6 地址用 [] 包裹) | ||
| if !strings.Contains(host, "[") { | ||
| return host[:idx] | ||
| } | ||
| } | ||
| return host |
There was a problem hiding this comment.
stripHostPort does not correctly handle bracketed IPv6 hosts with ports (e.g. [::1]:5244): it leaves the port in place, so virtual host matching will fail. Consider using net.SplitHostPort (with a fallback when there is no port) to robustly parse both IPv4/IPv6 host headers.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
server/middlewares/down.go
Outdated
| // stripDownHostPort 去掉 host 中的端口号,返回纯域名 | ||
| func stripDownHostPort(host string) string { | ||
| if idx := strings.LastIndex(host, ":"); idx != -1 { | ||
| if !strings.Contains(host, "[") { | ||
| return host[:idx] | ||
| } | ||
| } | ||
| return host |
There was a problem hiding this comment.
stripDownHostPort will not strip the port for bracketed IPv6 hosts (e.g. [::1]:5244), causing vhost lookups to miss on IPv6. Prefer a shared helper based on net.SplitHostPort to handle both IPv4/IPv6 reliably.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
server/static/static.go
Outdated
| utils.Log.Infof("[VirtualHost] domain=%q not found in db: %v", domain, err) | ||
| } else { | ||
| utils.Log.Infof("[VirtualHost] domain=%q matched vhost: id=%d enabled=%v web_hosting=%v path=%q", | ||
| domain, vhost.ID, vhost.Enabled, vhost.WebHosting, vhost.Path) | ||
| if vhost.Enabled && vhost.WebHosting { |
There was a problem hiding this comment.
This section doesn’t appear to be gofmt-formatted (indentation inside the else branch is inconsistent), which makes the control flow harder to review. Please run gofmt on this file (or at least this block) so braces/blocks are clearly aligned.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as outdated.
This comment was marked as outdated.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
|
@copilot open a new pull request to apply changes based on the comments in this thread |
|
@PIKACHUIM I've opened a new pull request, #2212, to work on those changes. Once the pull request is ready, I'll request review from you. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if !vhost.Enabled || vhost.WebHosting { | ||
| // 未启用,或者是 Web 托管模式(Web 托管不做路径重映射) | ||
| return reqPath, "" | ||
| } | ||
| // 路径重映射:将 reqPath 拼接到 vhost.Path 后面 | ||
| mapped := stdpath.Join(vhost.Path, reqPath) | ||
| utils.Log.Debugf("[VirtualHost] API path remapping: domain=%q reqPath=%q -> mappedPath=%q", domain, reqPath, mapped) |
There was a problem hiding this comment.
applyVhostPathMappingWithPrefix joins vhost.Path with the raw reqPath using path.Join, which cleans away .. segments. This can bypass the utils.JoinBasePath relative-path protection later (because the .. is removed before user.JoinPath runs) and allows escaping the virtual host root. Validate reqPath as a relative path (reject any path containing .. after cleaning) or reuse utils.JoinBasePath(vhost.Path, reqPath) so traversal attempts are rejected before joining.
| func GetVirtualHostByDomain(domain string) (*model.VirtualHost, error) { | ||
| if v, ok := vhostCache.Get(domain); ok { | ||
| if v == nil { | ||
| utils.Log.Debugf("[VirtualHost] cache hit (nil) for domain=%q", domain) | ||
| return nil, errors.New("virtual host not found") | ||
| } | ||
| utils.Log.Debugf("[VirtualHost] cache hit for domain=%q id=%d", domain, v.ID) | ||
| return v, nil | ||
| } | ||
| utils.Log.Debugf("[VirtualHost] cache miss for domain=%q, querying db...", domain) | ||
| v, err := db.GetVirtualHostByDomain(domain) | ||
| if err != nil { |
There was a problem hiding this comment.
Domain matching/caching is currently case-sensitive (vhostCache.Get(domain) / DB query use the raw Host value). DNS hostnames are case-insensitive, so requests like Example.com vs example.com may miss the cache/DB record depending on how the domain was stored. Normalize domains (e.g., strings.ToLower) consistently in GetVirtualHostByDomain and when creating/updating VirtualHost.Domain.
| if domain != "" { | ||
| vhost, err := op.GetVirtualHostByDomain(domain) | ||
| if err != nil { | ||
| utils.Log.Infof("[VirtualHost] domain=%q not found in db: %v", domain, err) | ||
| } else { |
There was a problem hiding this comment.
GetVirtualHostByDomain can return real DB/system errors (not only "not found"), but this log message always says "not found in db". That makes operational debugging harder because actual failures (DB down, query error) will be misclassified. Consider checking for the specific not-found error and logging other errors at Errorf with an accurate message.
* Initial plan * Fix vhost security and code quality issues from review feedback Co-authored-by: PIKACHUIM <40362270+PIKACHUIM@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: PIKACHUIM <40362270+PIKACHUIM@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func CreateVirtualHost(v *model.VirtualHost) error { | ||
| v.Path = utils.FixAndCleanPath(v.Path) | ||
| vhostCache.Del(v.Domain) | ||
| return db.CreateVirtualHost(v) | ||
| } | ||
|
|
||
| func UpdateVirtualHost(v *model.VirtualHost) error { | ||
| v.Path = utils.FixAndCleanPath(v.Path) | ||
| old, err := db.GetVirtualHostById(v.ID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| // 如果域名变更,清除旧域名缓存 | ||
| vhostCache.Del(old.Domain) | ||
| vhostCache.Del(v.Domain) | ||
| return db.UpdateVirtualHost(v) |
There was a problem hiding this comment.
Virtual host lookup and caching use the domain string verbatim (case-sensitive), and Create/UpdateVirtualHost don’t normalize v.Domain. Since hostnames are case-insensitive (and user input may include whitespace or a trailing dot), lookups can miss and the unique constraint may allow duplicates that differ only by case. Consider normalizing Domain on write (e.g., strings.TrimSpace + strings.ToLower + optional trailing-dot trim) and applying the same normalization to the parsed request host before calling GetVirtualHostByDomain.
| // virtualHostHandler 处理虚拟主机 Web 托管,以及默认的前端 SPA 路由 | ||
| virtualHostHandler := func(c *gin.Context) { | ||
| // 直接从 Host 头解析域名,检查是否匹配虚拟主机的 Web 托管请求 | ||
| rawHost := c.Request.Host | ||
| domain := stripHostPort(rawHost) | ||
| utils.Log.Debugf("[VirtualHost] handler triggered: method=%s path=%s host=%q domain=%q", |
There was a problem hiding this comment.
In Web Hosting mode, virtualHostHandler is only registered for / and NoRoute. Requests to explicitly registered static prefixes (e.g. /assets/, /images/, /static/, /streamer/) will bypass this handler and still serve the embedded frontend assets, which likely breaks hosting a real site that has paths under those prefixes. Consider moving the vhost web-hosting check into a higher-priority middleware (before r.StaticFS routes) or registering a catch-all that runs before those static routes when vhost.WebHosting is enabled for the request host.
| func (w *forceContentTypeWriter) WriteHeader(statusCode int) { | ||
| w.ResponseWriter.Header().Set("Content-Type", w.contentType) | ||
| w.ResponseWriter.Header().Set("Content-Disposition", w.contentDisp) | ||
| w.ResponseWriter.WriteHeader(statusCode) |
There was a problem hiding this comment.
forceContentTypeWriter.WriteHeader unconditionally overwrites Content-Type. When common.Proxy goes through the range-serving path (internal/net.ServeHTTP), the handler may set Content-Type: multipart/byteranges; boundary=... for multi-range requests; this wrapper will clobber that header and can break clients that request multiple ranges. Adjust the wrapper to only force Content-Type when it’s empty or an undesirable upstream default (e.g. application/octet-stream), and avoid overriding when it’s already set to multipart/byteranges (or other deliberately set types).
| // stripHostPortForVhost removes the port from a host string (supports IPv4, IPv6, and bracketed IPv6). | ||
| func stripHostPortForVhost(host string) string { | ||
| h, _, err := net.SplitHostPort(host) | ||
| if err != nil { | ||
| // No port present; return host as-is | ||
| return host | ||
| } | ||
| return h |
There was a problem hiding this comment.
There are now three near-identical helpers for stripping the port from Host (stripHostPort, stripDownHostPort, stripHostPortForVhost). This duplication makes it easy for them to drift (e.g. future normalization tweaks). Consider centralizing this logic in a shared helper (e.g. under server/common or pkg/utils) and reusing it across static/handles/middlewares.
|
这个功能是不是相当于一种另类的分享,做成分享的子功能会不会好点 |
我认为不是,定位的是类似于Lucky或者Netpanel的网站托管 进一步,如果启用了静态页面托管,会直接返回/path/path/index.html |
我知道这个功能是网站托管,我理解的是这两个功能都是面向不特定人群,提供某个路径的公开下载服务,只不过一个访问的是
改设计为
这样这个功能就只用处理路由和响应头相关的一些问题,也不用加一张表以及与之配套的CRUD,还可以充分的利用分享的限时、限次数访问功能,由于二者权限方面的模型及其相近,也不容易写出权限方面的bug,真有这方面bug修一个等于修了俩 |
嗯,我来看下能不能合并一下 |
Description / 描述
实现虚拟主机(Virtual Host)功能的完整支持,包含两种工作模式:
模式一:路径重映射(Enabled,Web Hosting 关闭)
将指定域名的访问路径透明映射到后端真实路径,实现"伪静态"效果:
http://example.com/,地址栏保持不变,面包屑显示🏠Home//api/fs/list、/api/fs/get)自动将请求路径映射到vhost.Path(如/123pan/Downloads)/p/、/d/)自动去掉 vhost 路径前缀,保持前端路径一致性,避免路径重复叠加模式二:Web Hosting(Enabled + Web Hosting 开启)
将指定域名作为静态网站托管,直接返回
index.html等静态文件内容:forceContentTypeWriter包装器强制覆盖响应头中的Content-Type,确保 HTML 文件在浏览器中正确渲染而非触发下载Motivation and Context / 背景
原有虚拟主机功能存在以下问题:
too many redirects错误history.replaceState注入脚本修改地址栏,不符合"伪静态"语义Content-Type覆盖了正确的 MIME 类型,导致index.html被当作application/octet-stream下载internalfs.Get/Link时,context 中缺少用户信息/p/链接经过中间件再次映射后路径翻倍How Has This Been Tested? / 测试
localhost,映射路径/123pan/Downloadshttp://localhost:5244/,验证:http://localhost:5244/不变 ✅🏠Home/✅/123pan/Downloads的内容 ✅/subdir,文件列表正确显示/123pan/Downloads/subdir的内容 ✅/p/filename,不含 vhost 路径前缀 ✅index.html到映射路径:Checklist / 检查清单
我已阅读 CONTRIBUTING 文档。
go fmtor prettier.我已使用
go fmt或 prettier 格式化提交的代码。我已为此 PR 添加了适当的标签(如无权限或需要的标签不存在,请在描述中说明,管理员将后续处理)。
我已在适当情况下使用"Request review"功能请求相关代码作者进行审查。
我已相应更新了相关仓库(若适用)。