Skip to content

feat(func): support virtual host#2202

Open
PIKACHUIM wants to merge 7 commits intomainfrom
dev-vhost
Open

feat(func): support virtual host#2202
PIKACHUIM wants to merge 7 commits intomainfrom
dev-vhost

Conversation

@PIKACHUIM
Copy link
Member

Description / 描述

实现虚拟主机(Virtual Host)功能的完整支持,包含两种工作模式:

模式一:路径重映射(Enabled,Web Hosting 关闭)

将指定域名的访问路径透明映射到后端真实路径,实现"伪静态"效果:

  • 访问 http://example.com/,地址栏保持不变,面包屑显示 🏠Home/
  • 后端 API(/api/fs/list/api/fs/get)自动将请求路径映射到 vhost.Path(如 /123pan/Downloads
  • 下载链接(/p//d/)自动去掉 vhost 路径前缀,保持前端路径一致性,避免路径重复叠加

模式二:Web Hosting(Enabled + Web Hosting 开启)

将指定域名作为静态网站托管,直接返回 index.html 等静态文件内容:

  • 通过 forceContentTypeWriter 包装器强制覆盖响应头中的 Content-Type,确保 HTML 文件在浏览器中正确渲染而非触发下载
  • 在处理前注入 guest 用户到请求 context,解决文件访问时的 401 权限问题

Motivation and Context / 背景

原有虚拟主机功能存在以下问题:

  1. 无限重定向:通过 302 跳转实现路径映射,导致 too many redirects 错误
  2. 地址栏变化:使用 history.replaceState 注入脚本修改地址栏,不符合"伪静态"语义
  3. Web Hosting 文件被下载:代理上游响应头中的 Content-Type 覆盖了正确的 MIME 类型,导致 index.html 被当作 application/octet-stream 下载
  4. 401 权限错误:Web Hosting 模式下调用 internalfs.Get/Link 时,context 中缺少用户信息
  5. 下载链接路径重复:前端拿到的是真实路径(含 vhost 前缀),生成的 /p/ 链接经过中间件再次映射后路径翻倍

How Has This Been Tested? / 测试

  • 配置虚拟主机绑定域名 localhost,映射路径 /123pan/Downloads
  • 访问 http://localhost:5244/,验证:
    • 地址栏保持 http://localhost:5244/ 不变 ✅
    • 面包屑显示 🏠Home/
    • 文件列表正确显示 /123pan/Downloads 的内容 ✅
    • 点击子目录,地址栏变为 /subdir,文件列表正确显示 /123pan/Downloads/subdir 的内容 ✅
    • 下载按钮链接为 /p/filename,不含 vhost 路径前缀 ✅
  • 开启 Web Hosting,上传 index.html 到映射路径:
    • 访问域名,浏览器直接渲染 HTML 页面,不触发下载 ✅
    • 无 401 权限错误 ✅

Checklist / 检查清单

  • I have read the CONTRIBUTING document.
    我已阅读 CONTRIBUTING 文档。
  • I have formatted my code with go fmt or prettier.
    我已使用 go fmtprettier 格式化提交的代码。
  • I have added appropriate labels to this PR (or mentioned needed labels in the description if lacking permissions).
    我已为此 PR 添加了适当的标签(如无权限或需要的标签不存在,请在描述中说明,管理员将后续处理)。
  • I have requested review from relevant code authors using the "Request review" feature when applicable.
    我已在适当情况下使用"Request review"功能请求相关代码作者进行审查。
  • I have updated the repository accordingly (If it's needed).
    我已相应更新了相关仓库(若适用)。

@PIKACHUIM PIKACHUIM self-assigned this Mar 9, 2026
@PIKACHUIM PIKACHUIM requested review from KirCute, jyxjjj and xrgzs and removed request for xrgzs March 9, 2026 06:53
@PIKACHUIM PIKACHUIM linked an issue Mar 9, 2026 that may be closed by this pull request
8 tasks
@PIKACHUIM PIKACHUIM linked an issue Mar 9, 2026 that may be closed by this pull request
8 tasks
@PIKACHUIM PIKACHUIM requested a review from xrgzs March 9, 2026 06:57
@jyxjjj jyxjjj requested a review from Copilot March 9, 2026 07:07
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +296 to +303
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() {
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +469 to +475
}
// 路径重映射:将 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
}

Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
// 路径重映射:将 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
}

Copilot uses AI. Check for mistakes.
Comment on lines +492 to +499
// stripHostPortForVhost 去掉 host 中的端口号,返回纯域名
func stripHostPortForVhost(host string) string {
if idx := strings.LastIndex(host, ":"); idx != -1 {
if !strings.Contains(host, "[") {
return host[:idx]
}
}
return host
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +432 to +440
// 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
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +51 to +58
// stripDownHostPort 去掉 host 中的端口号,返回纯域名
func stripDownHostPort(host string) string {
if idx := strings.LastIndex(host, ":"); idx != -1 {
if !strings.Contains(host, "[") {
return host[:idx]
}
}
return host
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +232 to +236
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 {
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

PIKACHUIM and others added 2 commits March 9, 2026 15:19
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 outdated.

PIKACHUIM and others added 2 commits March 9, 2026 21:33
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
@PIKACHUIM
Copy link
Member Author

@copilot open a new pull request to apply changes based on the comments in this thread

Copy link
Contributor

Copilot AI commented Mar 9, 2026

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

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +466 to +472
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)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +28
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 {
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +233
if domain != "" {
vhost, err := op.GetVirtualHostByDomain(domain)
if err != nil {
utils.Log.Infof("[VirtualHost] domain=%q not found in db: %v", domain, err)
} else {
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
* 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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +46 to +61
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)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +221 to +226
// 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",
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +390 to +393
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)
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +499 to +506
// 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
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@KirCute
Copy link
Member

KirCute commented Mar 10, 2026

这个功能是不是相当于一种另类的分享,做成分享的子功能会不会好点

@PIKACHUIM
Copy link
Member Author

这个功能是不是相当于一种另类的分享,做成分享的子功能会不会好点

我认为不是,定位的是类似于Lucky或者Netpanel的网站托管
绑定域名,返回特定目录的文件列表或者文件
比如绑定:www.example.com -> 绑定到/path/path/
别人使用此域名访问,直接访问/path/path/

进一步,如果启用了静态页面托管,会直接返回/path/path/index.html

@OpenListTeam OpenListTeam deleted a comment from Copilot AI Mar 10, 2026
@KirCute
Copy link
Member

KirCute commented Mar 10, 2026

我认为不是,定位的是类似于Lucky或者Netpanel的网站托管 绑定域名,返回特定目录的文件列表或者文件 比如绑定:www.example.com -> 绑定到/path/path/ 别人使用此域名访问,直接访问/path/path/

进一步,如果启用了静态页面托管,会直接返回/path/path/index.html

我知道这个功能是网站托管,我理解的是这两个功能都是面向不特定人群,提供某个路径的公开下载服务,只不过一个访问的是/@s开头的路径,一个访问的是新域名,一个访问后得到文件列表,一个访问后直接以inline方式下载文件。那我觉得这个功能完全可以把

  • 绑定www.example.com/path/path

改设计为

  • 分享/path/path,给这个分享绑定域名www.example.com

这样这个功能就只用处理路由和响应头相关的一些问题,也不用加一张表以及与之配套的CRUD,还可以充分的利用分享的限时、限次数访问功能,由于二者权限方面的模型及其相近,也不容易写出权限方面的bug,真有这方面bug修一个等于修了俩

@PIKACHUIM
Copy link
Member Author

我认为不是,定位的是类似于Lucky或者Netpanel的网站托管 绑定域名,返回特定目录的文件列表或者文件 比如绑定:www.example.com -> 绑定到/path/path/ 别人使用此域名访问,直接访问/path/path/
进一步,如果启用了静态页面托管,会直接返回/path/path/index.html

我知道这个功能是网站托管,我理解的是这两个功能都是面向不特定人群,提供某个路径的公开下载服务,只不过一个访问的是/@s开头的路径,一个访问的是新域名,一个访问后得到文件列表,一个访问后直接以inline方式下载文件。那我觉得这个功能完全可以把

  • 绑定www.example.com/path/path

改设计为

  • 分享/path/path,给这个分享绑定域名www.example.com

这样这个功能就只用处理路由和响应头相关的一些问题,也不用加一张表以及与之配套的CRUD,还可以充分的利用分享的限时、限次数访问功能,由于二者权限方面的模型及其相近,也不容易写出权限方面的bug,真有这方面bug修一个等于修了俩

嗯,我来看下能不能合并一下

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 虚拟主机 & Web 托管

4 participants