Compare commits

...

17 Commits

Author SHA1 Message Date
Pikachu Ren 3bfd422b4a Merge branch 'main' into dev-vhost 2026-07-06 18:04:07 +08:00
Pikachu Ren c8b0f5ebe7 Merge branch 'main' into dev-vhost 2026-05-26 14:13:03 +08:00
pikachuim da588ba5f2 fix(vhost): fix vhost function error 2026-05-26 14:12:21 +08:00
pikachuim 4ae2d1f9a8 fix(vhost): fix vhost function error 2026-05-26 13:03:29 +08:00
pikachuim 379958b5ef fix(vhost): remove useless files 2026-05-26 12:37:57 +08:00
pikachuim 8b3ef7c658 Merge branch 'main' into dev-vhost
# Conflicts:
#	server/middlewares/down.go
2026-05-26 11:05:03 +08:00
pikachuim a5f92bddaa Merge branch 'dev-vhost' of https://github.com/OpenListTeam/OpenList into dev-vhost 2026-05-14 12:14:35 +08:00
pikachuim ae00551b4e feat(func): merge vhost_db to sharing_db 2026-05-14 12:13:32 +08:00
Pikachu Ren bedf0a9447 Merge branch 'main' into dev-vhost
Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-04-08 14:19:08 +08:00
Pikachu Ren 8489950a94 Merge branch 'main' into dev-vhost 2026-03-25 17:36:06 +08:00
Copilot d825769779 [WIP] Add support for virtual host functionality (#2212)
* 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>
2026-03-09 21:47:18 +08:00
Pikachu Ren 166af74388 Update server/middlewares/virtual_host.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-03-09 21:34:48 +08:00
Pikachu Ren a3b10298a1 Merge branch 'main' into dev-vhost 2026-03-09 21:33:45 +08:00
Pikachu Ren 0b54be27a7 Update internal/db/virtual_host.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-03-09 15:20:42 +08:00
Pikachu Ren 38f761f1b1 Update internal/op/virtual_host.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Pikachu Ren <40362270+PIKACHUIM@users.noreply.github.com>
2026-03-09 15:19:23 +08:00
pikachuim 8a878fa98d feat(func): support virtual host 2026-03-09 14:49:05 +08:00
pikachuim 12bd5a048d add(func): spuuort virtual host 2026-03-09 13:02:28 +08:00
13 changed files with 1061 additions and 13 deletions
+1
View File
@@ -191,4 +191,5 @@ const (
PathKey
SharingIDKey
SkipHookKey
VhostPrefixKey
)
+24
View File
@@ -14,6 +14,16 @@ func GetSharingById(id string) (*model.SharingDB, error) {
return &s, nil
}
// GetSharingByDomain 根据绑定的域名查询 sharing 记录(用于虚拟主机能力)。
// 仅当 sharing.Domain 字段精确匹配时返回;调用方需自行判断 Disabled / Expires / Files 等有效性。
func GetSharingByDomain(domain string) (*model.SharingDB, error) {
var s model.SharingDB
if err := db.Where("domain = ?", domain).First(&s).Error; err != nil {
return nil, errors.Wrapf(err, "failed get sharing by domain")
}
return &s, nil
}
func GetSharings(pageIndex, pageSize int) (sharings []model.SharingDB, count int64, err error) {
sharingDB := db.Model(&model.SharingDB{})
if err := sharingDB.Count(&count).Error; err != nil {
@@ -38,6 +48,13 @@ func GetSharingsByCreatorId(creator uint, pageIndex, pageSize int) (sharings []m
}
func CreateSharing(s *model.SharingDB) (string, error) {
// domain 非空时做唯一性提前校验
if s.Domain != "" {
var exist model.SharingDB
if err := db.Where("domain = ?", s.Domain).First(&exist).Error; err == nil {
return "", errors.New("domain already used")
}
}
if s.ID == "" {
id := random.String(8)
for len(id) < 12 {
@@ -61,6 +78,13 @@ func CreateSharing(s *model.SharingDB) (string, error) {
}
func UpdateSharing(s *model.SharingDB) error {
// domain 非空时校验唯一性(排除自身)
if s.Domain != "" {
var exist model.SharingDB
if err := db.Where("domain = ? AND id <> ?", s.Domain, s.ID).First(&exist).Error; err == nil {
return errors.New("domain already used")
}
}
return errors.WithStack(db.Save(s).Error)
}
+21
View File
@@ -14,6 +14,11 @@ type SharingDB struct {
Remark string `json:"remark"`
Readme string `json:"readme" gorm:"type:text"`
Header string `json:"header" gorm:"type:text"`
// Domain 绑定的域名,可为空;非空时该条记录额外作为虚拟主机参与 Host 匹配(与旧 VirtualHost.Domain 等价)。
// 唯一性由应用层在 Create/Update 时校验,避免空字符串在 MySQL 下触发 uniqueIndex 冲突。
Domain string `json:"domain" gorm:"index"`
// WebHosting 仅在 Domain 非空时有效;为 true 时启用 Web 托管模式(直接响应文件内容),为 false 时仅做路径重映射。
WebHosting bool `json:"web_hosting"`
Sort
}
@@ -42,6 +47,22 @@ func (s *Sharing) Valid() bool {
return true
}
// ValidForVhost 虚拟主机场景的有效性检查。
// 与 Valid() 的区别:不检查 Creator.CanShare(),因为 Web Hosting / 路径重映射
// 是服务端功能,不依赖创建者的分享权限位。
func (s *Sharing) ValidForVhost() bool {
if s.Disabled {
return false
}
if len(s.Files) == 0 {
return false
}
if s.Expires != nil && !s.Expires.IsZero() && s.Expires.Before(time.Now()) {
return false
}
return true
}
func (s *Sharing) Verify(pwd string) bool {
return s.Pwd == "" || s.Pwd == pwd
}
+94 -5
View File
@@ -4,6 +4,7 @@ import (
"fmt"
stdpath "path"
"strings"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/db"
"github.com/OpenListTeam/OpenList/v4/internal/model"
@@ -12,6 +13,7 @@ import (
"github.com/OpenListTeam/go-cache"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
func makeJoined(sdb []model.SharingDB) []model.Sharing {
@@ -42,6 +44,11 @@ func makeJoined(sdb []model.SharingDB) []model.Sharing {
var sharingCache = cache.NewMemCache(cache.WithShards[*model.Sharing](8))
var sharingG singleflight.Group[*model.Sharing]
// domainSharingCache 按虚拟主机 domain 作为 key 缓存对应的 *model.Sharing。
// 允许缓存为 nil 以实现"负缓存"防止穿透。
var domainSharingCache = cache.NewMemCache(cache.WithShards[*model.Sharing](2))
var domainSharingG singleflight.Group[*model.Sharing]
func GetSharingById(id string, refresh ...bool) (*model.Sharing, error) {
if !utils.IsBool(refresh...) {
if sharing, ok := sharingCache.Get(id); ok {
@@ -71,6 +78,66 @@ func GetSharingById(id string, refresh ...bool) (*model.Sharing, error) {
return sharing, err
}
// GetSharingByDomain 根据 domain 获取可用的虚拟主机 sharing(带缓存)。
// 仅当 sharing.Domain 非空、Disabled=false、Files 非空、Expires 未过期时才视为有效。
// 如果在 DB 中未找到,会负缓存 5 分钟,避免反复穿透 DB。
func GetSharingByDomain(domain string) (*model.Sharing, error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, errors.New("empty domain")
}
if s, ok := domainSharingCache.Get(domain); ok {
if s == nil {
log.Debugf("[Sharing] domain cache hit (nil) for %q", domain)
return nil, errors.New("sharing not found by domain")
}
log.Debugf("[Sharing] domain cache hit for %q id=%s", domain, s.ID)
if !s.ValidForVhost() {
return nil, errors.New("sharing not valid")
}
return s, nil
}
sharing, err, _ := domainSharingG.Do(domain, func() (*model.Sharing, error) {
sdb, err := db.GetSharingByDomain(domain)
if err != nil {
if errors.Is(errors.Cause(err), gorm.ErrRecordNotFound) {
log.Debugf("[Sharing] domain=%q not found in db, caching nil", domain)
domainSharingCache.Set(domain, nil, cache.WithEx[*model.Sharing](time.Minute*5))
return nil, errors.New("sharing not found by domain")
}
return nil, errors.WithMessagef(err, "failed get sharing by domain [%s]", domain)
}
// 虚拟主机场景不需要 creator,跳过 creator 查询以避免 CanShare 校验阻断 Web Hosting
var files []string
if err = utils.Json.UnmarshalFromString(sdb.FilesRaw, &files); err != nil {
files = make([]string, 0)
}
s := &model.Sharing{
SharingDB: sdb,
Files: files,
Creator: nil, // 虚拟主机匹配不依赖 creator 权限
}
domainSharingCache.Set(domain, s, cache.WithEx[*model.Sharing](time.Hour))
return s, nil
})
if err != nil {
return nil, err
}
if sharing == nil || !sharing.ValidForVhost() {
return nil, errors.New("sharing not valid for domain")
}
return sharing, nil
}
// invalidateDomainCache 在创建/更新/删除记录时调用,同时传入新/旧 domain 以使两者都失效。
func invalidateDomainCache(domains ...string) {
for _, d := range domains {
if d != "" {
domainSharingCache.Del(d)
}
}
}
func GetSharings(pageIndex, pageSize int) ([]model.Sharing, int64, error) {
s, cnt, err := db.GetSharings(pageIndex, pageSize)
if err != nil {
@@ -118,7 +185,11 @@ func CreateSharing(sharing *model.Sharing) (id string, err error) {
if err != nil {
return "", errors.WithStack(err)
}
return db.CreateSharing(sharing.SharingDB)
id, err = db.CreateSharing(sharing.SharingDB)
if err == nil {
invalidateDomainCache(sharing.Domain)
}
return id, err
}
func UpdateSharing(sharing *model.Sharing, skipMarshal ...bool) (err error) {
@@ -129,8 +200,17 @@ func UpdateSharing(sharing *model.Sharing, skipMarshal ...bool) (err error) {
return errors.WithStack(err)
}
}
sharingCache.Del(sharing.ID)
return db.UpdateSharing(sharing.SharingDB)
// 读取旧记录以便同时失效旧 domain 缓存
var oldDomain string
if old, e := db.GetSharingById(sharing.ID); e == nil {
oldDomain = old.Domain
}
err = db.UpdateSharing(sharing.SharingDB)
if err == nil {
sharingCache.Del(sharing.ID)
invalidateDomainCache(oldDomain, sharing.Domain)
}
return err
}
func UpdateSharingId(sharing *model.Sharing, newId string) error {
@@ -143,8 +223,17 @@ func UpdateSharingId(sharing *model.Sharing, newId string) error {
}
func DeleteSharing(sid string) error {
sharingCache.Del(sid)
return db.DeleteSharingById(sid)
// 先读取 domain 用于失效缓存
var oldDomain string
if old, e := db.GetSharingById(sid); e == nil {
oldDomain = old.Domain
}
err := db.DeleteSharingById(sid)
if err == nil {
sharingCache.Del(sid)
invalidateDomainCache(oldDomain)
}
return err
}
func DeleteSharingsByCreatorId(creatorId uint) error {
+12
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"html"
stdnet "net"
"net/http"
"strings"
@@ -155,3 +156,14 @@ func ContentWithValues(ctx context.Context, keyAndValue ...any) context.Context
}
return ctx
}
// StripHostPort 从 Host 头中去掉端口部分,返回纯域名。
// 支持 IPv4、IPv6([::1]:port)及无端口的裸域名/IP。
func StripHostPort(host string) string {
h, _, err := stdnet.SplitHostPort(host)
if err != nil {
// 无端口,原样返回
return host
}
return h
}
+69 -1
View File
@@ -69,6 +69,8 @@ func FsListSplit(c *gin.Context) {
SharingList(c, &req)
return
}
// 虚拟主机路径重映射:根据 Host 头匹配虚拟主机规则,将请求路径映射到实际路径
req.Path = applyVhostPathMapping(c, req.Path)
user := c.Request.Context().Value(conf.UserKey).(*model.User)
if user.IsGuest() && user.Disabled {
common.ErrorStrResp(c, "Guest user is disabled, login please", 401)
@@ -272,6 +274,11 @@ func FsGetSplit(c *gin.Context) {
SharingGet(c, &req)
return
}
// 虚拟主机路径重映射:根据 Host 头匹配虚拟主机规则,将请求路径映射到实际路径
// 同时将 vhost.Path 前缀存入 context,供 FsGet 生成 /p/ 链接时去掉前缀
var vhostPrefix string
req.Path, vhostPrefix = applyVhostPathMappingWithPrefix(c, req.Path)
common.GinAppendValues(c, conf.VhostPrefixKey, vhostPrefix)
user := c.Request.Context().Value(conf.UserKey).(*model.User)
if user.IsGuest() && user.Disabled {
common.ErrorStrResp(c, "Guest user is disabled, login please", 401)
@@ -319,12 +326,14 @@ func FsGet(c *gin.Context, req *FsGetReq, user *model.User) {
rawURL = common.GenerateDownProxyURL(storage.GetStorage(), reqPath)
if rawURL == "" {
query := ""
// 生成 /p/ 链接时,去掉 vhost 路径前缀,保持前端看到的路径一致
downPath := stripVhostPrefix(c, reqPath)
if isEncrypt(meta, reqPath) || setting.GetBool(conf.SignAll) {
query = "?sign=" + sign.Sign(reqPath)
}
rawURL = fmt.Sprintf("%s/p%s%s",
common.GetApiUrl(c),
utils.EncodePath(reqPath, true),
utils.EncodePath(downPath, true),
query)
}
} else {
@@ -427,3 +436,62 @@ func FsOther(c *gin.Context) {
}
common.SuccessResp(c, res)
}
// applyVhostPathMapping 根据请求的 Host 头匹配虚拟主机规则,将请求路径映射到实际路径。
func applyVhostPathMapping(c *gin.Context, reqPath string) string {
mapped, _ := applyVhostPathMappingWithPrefix(c, reqPath)
return mapped
}
// applyVhostPathMappingWithPrefix 根据请求的 Host 头匹配 sharing 中带 Domain 的虚拟主机记录,
// 将请求路径映射到 sharing.Files[0] 之下,同时返回该路径前缀(用于生成下载链接时去掉前缀)。
// 例如:sharing.Files[0]="/123pan/Downloads",reqPath="/",则返回 ("/123pan/Downloads", "/123pan/Downloads")
// 例如:sharing.Files[0]="/123pan/Downloads",reqPath="/subdir",则返回 ("/123pan/Downloads/subdir", "/123pan/Downloads")
// 如果没有匹配的虚拟主机规则,则返回 (原始路径, "")
func applyVhostPathMappingWithPrefix(c *gin.Context, reqPath string) (string, string) {
rawHost := c.Request.Host
domain := common.StripHostPort(rawHost)
if domain == "" {
return reqPath, ""
}
sharing, err := op.GetSharingByDomain(domain)
if err != nil || sharing == nil {
return reqPath, ""
}
if sharing.WebHosting {
// Web 托管模式不做 API 路径重映射
return reqPath, ""
}
if len(sharing.Files) == 0 {
return reqPath, ""
}
root := sharing.Files[0]
// Map request path into the sharing root and verify it does not escape via traversal.
// stdpath.Join calls Clean internally, which collapses ".." segments, so we only need
// to confirm the result still lives under root.
mapped := stdpath.Join(root, reqPath)
if !strings.HasPrefix(mapped, strings.TrimRight(root, "/")+"/") && mapped != root {
utils.Log.Warnf("[VirtualHost] path traversal rejected for API remapping: domain=%q reqPath=%q", domain, reqPath)
return reqPath, ""
}
utils.Log.Debugf("[VirtualHost] API path remapping: domain=%q reqPath=%q -> mappedPath=%q", domain, reqPath, mapped)
return mapped, root
}
// stripVhostPrefix 从 gin context 中取出 vhost 路径前缀,并从 path 中去掉该前缀。
// 用于生成 /p/ 下载链接时,将真实路径还原为前端看到的路径。
func stripVhostPrefix(c *gin.Context, path string) string {
prefix, ok := c.Request.Context().Value(conf.VhostPrefixKey).(string)
if !ok || prefix == "" {
return path
}
if strings.HasPrefix(path, prefix+"/") {
return path[len(prefix):]
}
if path == prefix {
return "/"
}
return path
}
+52
View File
@@ -413,6 +413,8 @@ type UpdateSharingReq struct {
Remark string `json:"remark"`
Readme string `json:"readme"`
Header string `json:"header"`
Domain string `json:"domain"`
WebHosting bool `json:"web_hosting"`
model.Sort
CreatorName string `json:"creator"`
Accessed int `json:"accessed"`
@@ -432,6 +434,32 @@ func validateSharingID(id string) error {
return nil
}
// validDomainRe 校验域名格式:仅允许字母、数字、连字符、点号,且不以点/连字符开头结尾。
var validDomainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`)
// normalizeDomain 对域名做归一化处理:去空白、转小写、去端口。
// 返回归一化后的域名和可能的错误。
func normalizeDomain(domain string) (string, error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return "", nil
}
// 去掉可能误填的端口号
if idx := strings.LastIndex(domain, ":"); idx > 0 {
// 排除 IPv6 裸地址(含 [ 的情况)
if !strings.Contains(domain, "[") {
domain = domain[:idx]
}
}
if len(domain) > 253 {
return "", errors.New("domain must be at most 253 characters")
}
if !validDomainRe.MatchString(domain) {
return "", errors.New("invalid domain format: only lowercase letters, numbers, hyphens and dots are allowed")
}
return domain, nil
}
func UpdateSharing(c *gin.Context) {
var req UpdateSharingReq
if err := c.ShouldBind(&req); err != nil {
@@ -474,6 +502,16 @@ func UpdateSharing(c *gin.Context) {
if reqUser.IsAdmin() && req.CreatorName == "" {
user = s.Creator
}
// 域名归一化与校验
normalizedDomain, domErr := normalizeDomain(req.Domain)
if domErr != nil {
common.ErrorResp(c, domErr, 400)
return
}
if req.WebHosting && normalizedDomain == "" {
common.ErrorStrResp(c, "web_hosting requires a valid domain", 400)
return
}
s.Files = req.Files
s.Expires = req.Expires
s.Pwd = req.Pwd
@@ -484,6 +522,8 @@ func UpdateSharing(c *gin.Context) {
s.Header = req.Header
s.Readme = req.Readme
s.Remark = req.Remark
s.Domain = normalizedDomain
s.WebHosting = req.WebHosting
s.Creator = user
if req.NewID != "" && req.NewID != req.ID {
if !reqUser.CanCustomizeShareID() {
@@ -550,6 +590,16 @@ func CreateSharing(c *gin.Context) {
return
}
}
// 域名归一化与校验
normalizedDomain, domErr := normalizeDomain(req.Domain)
if domErr != nil {
common.ErrorResp(c, domErr, 400)
return
}
if req.WebHosting && normalizedDomain == "" {
common.ErrorStrResp(c, "web_hosting requires a valid domain", 400)
return
}
s := &model.Sharing{
SharingDB: &model.SharingDB{
ID: req.ID,
@@ -562,6 +612,8 @@ func CreateSharing(c *gin.Context) {
Remark: req.Remark,
Readme: req.Readme,
Header: req.Header,
Domain: normalizedDomain,
WebHosting: req.WebHosting,
},
Files: req.Files,
Creator: user,
+36
View File
@@ -1,6 +1,7 @@
package middlewares
import (
stdpath "path"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
@@ -17,10 +18,45 @@ import (
func PathParse(c *gin.Context) {
rawPath := parsePath(c.Param("path"))
// 虚拟主机路径重映射:根据 Host 头匹配虚拟主机规则,将请求路径映射到实际路径
// 例如:vhost.Path="/123pan/Downloads",rawPath="/tests.html" -> "/123pan/Downloads/tests.html"
rawPath = applyDownVhostPathMapping(c, rawPath)
common.GinAppendValues(c, conf.PathKey, rawPath)
c.Next()
}
// applyDownVhostPathMapping 根据请求的 Host 头匹配 sharing 中带 Domain 的虚拟主机记录,
// 将下载/预览路由的路径映射到虚拟主机配置的实际路径(取 sharing.Files[0])。
// 仅在 sharing 有效(未禁用、未过期、Files 非空)且非 Web 托管模式时生效。
func applyDownVhostPathMapping(c *gin.Context, reqPath string) string {
rawHost := c.Request.Host
domain := common.StripHostPort(rawHost)
if domain == "" {
return reqPath
}
sharing, err := op.GetSharingByDomain(domain)
if err != nil || sharing == nil {
return reqPath
}
if sharing.WebHosting {
// Web 托管模式不做下载路径重映射
return reqPath
}
if len(sharing.Files) == 0 {
return reqPath
}
root := sharing.Files[0]
// 路径重映射:将 reqPath 拼接到 root 后面,并校验不逃逸出 root
mapped := stdpath.Join(root, reqPath)
if !strings.HasPrefix(mapped, strings.TrimRight(root, "/")+"/") && mapped != root {
utils.Log.Warnf("[VirtualHost] path traversal rejected for down: domain=%q reqPath=%q", domain, reqPath)
return reqPath
}
utils.Log.Debugf("[VirtualHost] down path remapping: domain=%q reqPath=%q -> mappedPath=%q", domain, reqPath, mapped)
return mapped
}
func Down(verifyFunc func(string, string) error) func(c *gin.Context) {
return func(c *gin.Context) {
rawPath := c.Request.Context().Value(conf.PathKey).(string)
+58
View File
@@ -0,0 +1,58 @@
package middlewares
import (
"net/http"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
)
// vhostBlockedPrefixes 是虚拟主机域名下被阻止的路由前缀列表。
// 这些路由属于管理/内部功能,不应通过 vhost 域名暴露。
var vhostBlockedPrefixes = []string{
"/api/admin/",
"/dav/",
"/s3/",
}
// VhostRouteGuard 虚拟主机路由守卫中间件。
// 当请求的 Host 头匹配到一个有效的虚拟主机 sharing 时,
// 阻止对管理类路由(/api/admin/、/dav/、/s3/)的访问,返回 404。
// 对于 WebHosting=true 的域名,额外阻止 /api/ 下除 /api/public/ 和 /api/fs/ 之外的路由。
func VhostRouteGuard(c *gin.Context) {
rawHost := c.Request.Host
domain := common.StripHostPort(rawHost)
if domain == "" {
c.Next()
return
}
sharing, err := op.GetSharingByDomain(domain)
if err != nil || sharing == nil {
// 非 vhost 域名,放行
c.Next()
return
}
path := c.Request.URL.Path
// 通用阻止:管理类路由
for _, prefix := range vhostBlockedPrefixes {
if strings.HasPrefix(path, prefix) {
c.AbortWithStatus(http.StatusNotFound)
return
}
}
// WebHosting 模式下额外限制:仅允许 /api/public/、/api/fs/ 和非 /api/ 路由
if sharing.WebHosting && strings.HasPrefix(path, "/api/") {
if !strings.HasPrefix(path, "/api/public/") && !strings.HasPrefix(path, "/api/fs/") {
c.AbortWithStatus(http.StatusNotFound)
return
}
}
c.Next()
}
+2
View File
@@ -39,6 +39,8 @@ func Init(e *gin.Engine) {
if conf.Conf.MaxConnections > 0 {
g.Use(middlewares.MaxAllowed(conf.Conf.MaxConnections))
}
// 虚拟主机路由限制:webhost 域名下阻止管理类路由(/api/admin/、/dav/、/s3/)
g.Use(middlewares.VhostRouteGuard)
WebDav(g.Group("/dav"))
S3(g.Group("/s3"))
MCP(g)
+166
View File
@@ -0,0 +1,166 @@
// Package static —— 虚拟主机 Web Hosting 模式下针对 Markdown 的服务端预览渲染。
//
// 设计原则:
// 1. 不引入任何新的第三方依赖。Markdown 渲染交给浏览器端 marked.js(CDN)。
// 2. 渲染有 size 上限(默认 5MB),防止从云端读取超大文件造成 OOM。
// 3. Markdown 原文嵌入到 <script type="text/markdown"> 中传给前端,避免 XSS:
// 即使内容含 <script>、</script>,也会因 type 非 JS 而不被执行;同时对 </script
// 做 <\/ 转义防止 script 标签被切断。
//
// 关于 .mhtml:Chrome 在网络场景下,响应头为 multipart/related 且无 Content-Disposition: attachment
// 时,会调用内置 MHTML 渲染器原生预览。无需服务端解析。
package static
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/http_range"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// renderMaxBytes 单个被渲染文件允许的最大字节数。
// 超过此值视为非预期文件,回退为原始下载(不再尝试服务端解析)。
const renderMaxBytes int64 = 5 * 1024 * 1024
// readLinkAll 通过 Link 把整个文件读入内存,受 maxBytes 限制。
// 适用于 Markdown 这种小文本文件场景。
func readLinkAll(ctx context.Context, link *model.Link, declaredSize int64, maxBytes int64) ([]byte, error) {
size := link.ContentLength
if size <= 0 {
size = declaredSize
}
if size > 0 && size > maxBytes {
return nil, fmt.Errorf("file too large for render: size=%d max=%d", size, maxBytes)
}
rr, err := stream.GetRangeReaderFromLink(size, link)
if err != nil {
return nil, fmt.Errorf("get range reader: %w", err)
}
// 当 size 未知时使用全量范围(Length=-1 由底层处理)
rng := http_range.Range{Start: 0, Length: -1}
if size > 0 {
rng = http_range.Range{Start: 0, Length: size}
}
rc, err := rr.RangeRead(ctx, rng)
if err != nil {
return nil, fmt.Errorf("range read: %w", err)
}
defer rc.Close()
// 用 LimitReader 兜底防止 size 申报为 0 但内容超大
limited := io.LimitReader(rc, maxBytes+1)
buf, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("read all: %w", err)
}
if int64(len(buf)) > maxBytes {
return nil, fmt.Errorf("file exceeds max bytes during read: max=%d", maxBytes)
}
return buf, nil
}
// markdownPreviewTpl 是 Markdown 全屏预览的 HTML 模板。
// 使用 marked.js + highlight.js + DOMPurify 在浏览器端渲染,样式参考 GitHub Markdown CSS。
// 占位符:
//
// {{TITLE}} <title> 标签内容(页面文件名,已 HTML 转义)
// {{MD_BODY}} 原始 Markdown 文本(已对 </ 做 <\/ 转义防止 script 标签提前闭合)
const markdownPreviewTpl = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{TITLE}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github.min.css" crossorigin="anonymous">
<style>
html,body{margin:0;padding:0;background:#fff}
.markdown-body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:32px 45px}
@media (max-width:767px){.markdown-body{padding:16px}}
.md-loading{padding:32px 45px;color:#888;font-family:system-ui,sans-serif}
</style>
</head>
<body>
<article class="markdown-body" id="md-target"><p class="md-loading">Rendering Markdown...</p></article>
<script id="md-source" type="text/markdown">{{MD_BODY}}</script>
<script src="https://cdn.jsdelivr.net/npm/marked@11/marked.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/marked-highlight@2/lib/index.umd.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/core.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/common.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js" crossorigin="anonymous"></script>
<script>
(function(){
try{
var src = document.getElementById('md-source').textContent || '';
if (window.marked && window.markedHighlight && window.hljs) {
window.marked.use(window.markedHighlight.markedHighlight({
langPrefix: 'hljs language-',
highlight: function(code, lang){
try { return (lang && hljs.getLanguage(lang)) ? hljs.highlight(code,{language:lang}).value : hljs.highlightAuto(code).value; }
catch(_) { return code; }
}
}));
}
var html = (window.marked ? window.marked.parse(src) : '').toString();
if (window.DOMPurify) { html = window.DOMPurify.sanitize(html); }
document.getElementById('md-target').innerHTML = html;
}catch(e){
document.getElementById('md-target').textContent = String(e);
}
})();
</script>
</body>
</html>`
// renderMarkdownPreview 把 Markdown 原文包装为完整的 HTML 预览页。
// 注意:原文必须做 </ 转义,避免在 <script type=text/markdown> 中提前闭合。
func renderMarkdownPreview(filename string, mdSource []byte) []byte {
// HTML-escape title(防止文件名注入 HTML)
title := htmlEscape(filename)
// 对 </ 做转义。<script type="text/markdown"> 块在 HTML 解析阶段
// 会按"原始文本"处理,唯一会让它结束的是 </script 这种序列(不区分大小写)。
// 替换为 <\/,浏览器仍会按原字符显示,但不会触发 script 闭合。
body := bytes.ReplaceAll(mdSource, []byte("</"), []byte(`<\/`))
out := strings.NewReplacer(
"{{TITLE}}", title,
"{{MD_BODY}}", string(body),
).Replace(markdownPreviewTpl)
return []byte(out)
}
// htmlEscape 简单的 HTML 转义,仅处理在文本/属性场景需要的几个字符。
func htmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
)
return r.Replace(s)
}
// writeRenderedHTML 把渲染后的 HTML 作为 200 响应写出。
func writeRenderedHTML(w http.ResponseWriter, html []byte) {
h := w.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Content-Disposition", "inline")
// Markdown 内容不常变化,允许浏览器缓存但每次需验证
h.Set("Cache-Control", "no-cache, must-revalidate")
h.Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(html); err != nil {
utils.Log.Debugf("[VirtualHost] writeRenderedHTML: %v", err)
}
}
+253
View File
@@ -0,0 +1,253 @@
// Package static —— 虚拟主机访问码(Share Pwd)门禁。
//
// 当 sharing.Pwd != "" 时,所有通过域名访问 sharing 内容(无论 Web Hosting
// 还是路径重映射模式)都必须先通过密码校验:
// - 提取顺序(择一即可):?pwd= 查询参数 → X-Share-Pwd 请求头 → cookie share_pwd_<id>
// - 校验通过后,会写一个 HttpOnly cookie(Path=/,SameSite=Lax),后续访问免输入;
// - 校验未通过:GET 请求返回内嵌的密码输入页(401),POST application/x-www-form-urlencoded
// 的 ?pwd 字段会被识别为提交(302 回原 URL)。
//
// 安全细节:
// - 密码比较使用 crypto/subtle.ConstantTimeCompare 防止时序侧信道;
// - cookie 名按 sharing.ID 隔离,避免不同分享串扰;
// - cookie 的 Secure 属性在 HTTPS 请求下自动开启。
package static
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/gin-gonic/gin"
)
// sharePwdCookieName 返回当前 sharing 对应的 cookie 名。
// 名字按 ID 隔离避免一个浏览器同时访问多个不同分享时互相串扰。
func sharePwdCookieName(sharingID string) string {
// 仅保留字母数字与下划线,确保 cookie name 合法
var b strings.Builder
b.Grow(len("share_pwd_") + len(sharingID))
b.WriteString("share_pwd_")
for i := 0; i < len(sharingID); i++ {
c := sharingID[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
b.WriteByte(c)
default:
b.WriteByte('_')
}
}
return b.String()
}
// sharePwdToken 用于生成存储在 cookie 中的令牌(而非明文密码)。
// 令牌 = HMAC-SHA256(sharingID + ":" + pwd, salt),即使 cookie 泄露也无法反推原始密码。
const sharePwdHMACSalt = "openlist-vhost-share-pwd-v1"
func sharePwdToken(sharingID, pwd string) string {
mac := hmac.New(sha256.New, []byte(sharePwdHMACSalt))
mac.Write([]byte(sharingID + ":" + pwd))
return hex.EncodeToString(mac.Sum(nil))
}
// extractSharePwd 按优先级从请求中提取访问码(明文)。
// 注意:不从 cookie 中提取,因为 cookie 存储的是 HMAC token 而非明文密码,
// cookie 验证在 verifySharePwd 中单独处理。
func extractSharePwd(c *gin.Context, sharingID string) string {
if v := c.Query("pwd"); v != "" {
return v
}
if v := c.GetHeader("X-Share-Pwd"); v != "" {
return v
}
return ""
}
// constantTimeEqual 时序安全字符串比较。
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// verifySharePwd 判断当前请求是否通过 sharing 访问码校验。
// 若 sharing.Pwd 为空(未设置访问码),永远通过。
func verifySharePwd(c *gin.Context, sharing *model.Sharing) bool {
if sharing == nil || sharing.SharingDB == nil || sharing.Pwd == "" {
return true
}
got := extractSharePwd(c, sharing.ID)
if got == "" {
// 尝试从 cookie 中验证令牌(非明文密码)
if token, err := c.Cookie(sharePwdCookieName(sharing.ID)); err == nil && token != "" {
expected := sharePwdToken(sharing.ID, sharing.Pwd)
if subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1 {
return true
}
}
return false
}
return constantTimeEqual(got, sharing.Pwd)
}
// setSharePwdCookie 把校验通过的令牌(哈希)写入 cookie,便于后续请求免输入。
// 不存储明文密码,避免本机 cookie 数据库泄露风险。
func setSharePwdCookie(c *gin.Context, sharingID, pwd string) {
secure := c.Request.TLS != nil ||
strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
token := sharePwdToken(sharingID, pwd)
cookie := &http.Cookie{
Name: sharePwdCookieName(sharingID),
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
// 不设 MaxAge:作为会话 cookie,浏览器关闭后失效,降低长期暴露风险
}
http.SetCookie(c.Writer, cookie)
}
// handleSharePwdGate 在虚拟主机请求进入 sharing 处理前完成密码门禁。
// 返回 true 表示已放行,调用方继续后续处理;
// 返回 false 表示当前请求已被该函数完整处理(密码页/重定向/方法不允许),调用方应直接 return。
func handleSharePwdGate(c *gin.Context, sharing *model.Sharing) bool {
if sharing == nil || sharing.SharingDB == nil || sharing.Pwd == "" {
return true
}
// POST application/x-www-form-urlencoded:识别为表单提交
if c.Request.Method == http.MethodPost {
ct := c.GetHeader("Content-Type")
if i := strings.Index(ct, ";"); i >= 0 {
ct = ct[:i]
}
if strings.EqualFold(strings.TrimSpace(ct), "application/x-www-form-urlencoded") {
pwd := c.PostForm("pwd")
if pwd != "" && constantTimeEqual(pwd, sharing.Pwd) {
setSharePwdCookie(c, sharing.ID, pwd)
// 提交成功:302 回当前路径(去掉 ?pwd=)
redirect := c.Request.URL.Path
if redirect == "" {
redirect = "/"
}
c.Redirect(http.StatusFound, redirect)
return false
}
// 密码错误:返回错误页
writeSharePwdPage(c, sharing.ID, true)
return false
}
// 其他 POST:405
c.Status(http.StatusMethodNotAllowed)
return false
}
// GET / HEAD:先看是否已携带正确凭据
if verifySharePwd(c, sharing) {
// 若是通过 ?pwd= 提交的,顺手写入 cookie 并清掉 query 中的 pwd 重定向,
// 否则刷新或转发链接会一直暴露密码。
if pwd := c.Query("pwd"); pwd != "" {
setSharePwdCookie(c, sharing.ID, pwd)
cleaned := stripPwdQuery(c.Request.URL.RawQuery)
target := c.Request.URL.Path
if cleaned != "" {
target += "?" + cleaned
}
c.Redirect(http.StatusFound, target)
return false
}
return true
}
// 未通过:返回密码输入页
writeSharePwdPage(c, sharing.ID, false)
return false
}
// stripPwdQuery 从 raw query 中剔除 pwd 参数(大小写不敏感),保留其他参数原序。
func stripPwdQuery(raw string) string {
if raw == "" {
return ""
}
parts := strings.Split(raw, "&")
out := parts[:0]
for _, p := range parts {
if p == "" {
continue
}
key := p
if i := strings.Index(p, "="); i >= 0 {
key = p[:i]
}
if strings.EqualFold(key, "pwd") {
continue
}
out = append(out, p)
}
return strings.Join(out, "&")
}
// sharePwdPageTpl 是访问码输入页的内嵌模板。
// 占位符:
//
// {{ERROR_BLOCK}} 当密码错误时插入的提示块(HTML 片段,无需转义)
const sharePwdPageTpl = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>需要访问码</title>
<style>
html,body{height:100%;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif;background:#f5f7fa;color:#222}
.wrap{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}
.card{width:100%;max-width:380px;background:#fff;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);padding:32px 28px}
h1{margin:0 0 8px;font-size:20px}
p.sub{margin:0 0 20px;color:#666;font-size:14px}
label{display:block;margin-bottom:6px;font-size:13px;color:#444}
input[type=password]{width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid #d0d7de;border-radius:8px;font-size:15px;outline:none;transition:border-color .15s}
input[type=password]:focus{border-color:#1f6feb;box-shadow:0 0 0 3px rgba(31,111,235,.15)}
button{margin-top:16px;width:100%;padding:10px 16px;background:#1f6feb;color:#fff;border:0;border-radius:8px;font-size:15px;cursor:pointer;transition:background .15s}
button:hover{background:#1a5fd0}
.err{margin:0 0 16px;padding:10px 12px;background:#ffeef0;border:1px solid #ffd0d4;border-radius:8px;color:#a40e26;font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<form class="card" method="post" action="" enctype="application/x-www-form-urlencoded" autocomplete="off">
<h1>需要访问码</h1>
<p class="sub">该分享已设置访问码,请输入后继续。</p>
{{ERROR_BLOCK}}
<label for="pwd">访问码</label>
<input id="pwd" name="pwd" type="password" autofocus required>
<button type="submit">进入</button>
</form>
</div>
</body>
</html>`
// writeSharePwdPage 把密码输入页写出。wrong=true 时附带"密码错误"提示块。
// 状态码:未输入时 401(请求需要凭据),密码错误时 403(已提供但无效)。
func writeSharePwdPage(c *gin.Context, sharingID string, wrong bool) {
_ = sharingID // 当前模板表单 action="" 会回到当前 URL,不需要在页面里嵌入 ID
errBlock := ""
status := http.StatusUnauthorized
if wrong {
errBlock = `<p class="err">访问码错误,请重试。</p>`
status = http.StatusForbidden
}
html := strings.Replace(sharePwdPageTpl, "{{ERROR_BLOCK}}", errBlock, 1)
h := c.Writer.Header()
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-store")
h.Set("X-Robots-Tag", "noindex")
c.Status(status)
if _, err := c.Writer.WriteString(html); err != nil {
utils.Log.Debugf("[VirtualHost] writeSharePwdPage: %v", err)
}
c.Writer.Flush()
}
+273 -7
View File
@@ -5,19 +5,39 @@ import (
"errors"
"fmt"
"io"
"io/fs"
iofs "io/fs"
"net/http"
"os"
stdpath "path"
"strings"
"github.com/OpenListTeam/OpenList/v4/drivers/base"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
internalfs "github.com/OpenListTeam/OpenList/v4/internal/fs"
"github.com/OpenListTeam/OpenList/v4/internal/model"
"github.com/OpenListTeam/OpenList/v4/internal/op"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/public"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
)
// vhostInternalUser 是虚拟主机内部使用的系统用户。
// Web Hosting 模式下,文件访问不依赖真实用户身份,但 context 中必须有非 nil 的 User
// 以避免下游 hook/中间件对 nil 指针解引用 panic。
// 该用户 BasePath="/" 且 Permission 全开,实际访问范围由 handleWebHosting 的
// HasPrefix 沙箱校验保证安全。
var vhostInternalUser = &model.User{
ID: 0,
Username: "_vhost_internal",
Role: model.GUEST,
Permission: 0x7FFF, // 所有权限位开启(读、WebDAV 读等)
BasePath: "/",
Disabled: false,
}
type ManifestIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
@@ -32,12 +52,12 @@ type Manifest struct {
Icons []ManifestIcon `json:"icons"`
}
var static fs.FS
var static iofs.FS
func initStatic() {
utils.Log.Debug("Initializing static file system...")
if conf.Conf.DistDir == "" {
dist, err := fs.Sub(public.Public, "dist")
dist, err := iofs.Sub(public.Public, "dist")
if err != nil {
utils.Log.Fatalf("failed to read dist dir: %v", err)
}
@@ -76,7 +96,7 @@ func initIndex(siteConfig SiteConfig) {
utils.Log.Debug("Reading index.html from static files system...")
indexFile, err := static.Open("index.html")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if errors.Is(err, iofs.ErrNotExist) {
utils.Log.Fatalf("index.html not exist, you may forget to put dist of frontend to public/dist")
}
utils.Log.Fatalf("failed to read index.html: %v", err)
@@ -192,7 +212,7 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
})
for _, folder := range folders {
sub, err := fs.Sub(static, folder)
sub, err := iofs.Sub(static, folder)
if err != nil {
utils.Log.Fatalf("can't find folder: %s", folder)
}
@@ -210,7 +230,46 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
utils.Log.Debug("Setting up catch-all route...")
noRoute(func(c *gin.Context) {
// virtualHostHandler 处理虚拟主机 Web 托管,以及默认的前端 SPA 路由
virtualHostHandler := func(c *gin.Context) {
// 直接从 Host 头解析域名,检查是否匹配 sharing 中的虚拟主机记录
rawHost := c.Request.Host
domain := common.StripHostPort(rawHost)
if domain != "" {
sharing, err := op.GetSharingByDomain(domain)
if err == nil && sharing != nil && len(sharing.Files) > 0 {
utils.Log.Debugf("[VirtualHost] domain=%q matched sharing id=%s web_hosting=%v path=%q",
domain, sharing.ID, sharing.WebHosting, c.Request.URL.Path)
// 访问码门禁:sharing.Pwd 非空时,未通过校验的请求会被门禁函数
// 直接处理(密码输入页 / 提交表单 / 重定向),调用方需立即返回。
if !handleSharePwdGate(c, sharing) {
return
}
if sharing.WebHosting {
// Web 托管模式:直接返回文件内容
// 注入 vhostInternalUser 到 context,确保下游 hook 不会因 nil user panic,
// 同时绕过 guest Disabled 限制,作为系统级内部访问处理。
// 实际访问范围由 handleWebHosting 的 HasPrefix 沙箱校验保证。
common.GinAppendValues(c, conf.UserKey, vhostInternalUser)
handleWebHosting(c, sharing)
return
} else {
// 路径重映射模式(伪静态):保持地址栏不变,直接返回 SPA HTML
// 后端 API(fs/list、fs/get、/d/、/p/)已根据 Host 头自动将路径重映射
// 到 sharing.Files[0] 之下,前端正常请求即可看到分享目录内容。
// 注意:此处不注入 user——浏览器加载 SPA 后发起的 /api/fs/list 是新请求,
// 会经过 auth 中间件正常填入 guest/登录用户。
c.Header("Content-Type", "text/html")
c.Status(http.StatusOK)
_, _ = c.Writer.WriteString(conf.IndexHtml)
c.Writer.Flush()
c.Writer.WriteHeaderNow()
return
}
}
}
if c.Request.Method != "GET" && c.Request.Method != "POST" {
c.Status(405)
return
@@ -224,5 +283,212 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) {
}
c.Writer.Flush()
c.Writer.WriteHeaderNow()
})
}
// 显式注册根路径路由,确保 GET / 能被正确处理
// gin 的 NoRoute 不会触发已注册路由前缀下的 GET /
r.GET("/", virtualHostHandler)
r.POST("/", virtualHostHandler)
// NoRoute 处理其他所有未匹配路径(如 /@manage、/d/... 等 SPA 路由)
noRoute(virtualHostHandler)
}
// indexCandidates 是 Web Hosting 模式下,访问目录时按优先级查找的索引文件名列表。
// 命中第一个存在的文件即返回;全部不存在则返回 404。
var indexCandidates = []string{
"index.html",
"index.htm",
"index.mhtml",
"index.md",
"default.htm",
"default.html",
"default.mhtml",
"default.md",
"README.html",
"README.htm",
"README.mhtml",
"README.md",
"readme.html",
"readme.htm",
"readme.mhtml",
"readme.md",
}
// handleWebHosting 处理虚拟主机(sharing)的 Web 托管请求。
// 行为:
// 1. 请求路径若指向某个具体文件(非目录),直接返回该文件内容;
// 2. 请求路径指向目录或文件不存在时,按 indexCandidates 顺序查找索引文件;
// 3. 全部未命中时返回 404。
func handleWebHosting(c *gin.Context, sharing *model.Sharing) {
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
utils.Log.Debugf("[VirtualHost] skip: method=%s not allowed for web hosting", c.Request.Method)
c.Status(http.StatusMethodNotAllowed)
return
}
if len(sharing.Files) == 0 {
utils.Log.Debugf("[VirtualHost] skip: sharing has no files")
c.Status(http.StatusNotFound)
return
}
root := sharing.Files[0]
reqPath := c.Request.URL.Path
// stdpath.Join 内部 Clean,会消除 .. 但仍可能逃出 root,故再做 HasPrefix 校验
filePath := stdpath.Join(root, reqPath)
if !strings.HasPrefix(filePath, strings.TrimRight(root, "/")+"/") && filePath != root {
utils.Log.Warnf("[VirtualHost] path traversal rejected: root=%q reqPath=%q", root, reqPath)
c.Status(http.StatusBadRequest)
return
}
utils.Log.Debugf("[VirtualHost] handleWebHosting: reqPath=%q -> filePath=%q", reqPath, filePath)
// 1) 直接命中文件
if obj, err := internalfs.Get(c.Request.Context(), filePath, &internalfs.GetArgs{NoLog: true}); err == nil && !obj.IsDir() {
utils.Log.Debugf("[VirtualHost] serving file: %q", filePath)
serveWebHostingFile(c, filePath, obj.GetName())
return
}
// 2) 目录或文件不存在:按优先级匹配索引文件
for _, name := range indexCandidates {
candidate := stdpath.Join(filePath, name)
if obj, err := internalfs.Get(c.Request.Context(), candidate, &internalfs.GetArgs{NoLog: true}); err == nil && !obj.IsDir() {
utils.Log.Debugf("[VirtualHost] serving index candidate: %q", candidate)
serveWebHostingFile(c, candidate, obj.GetName())
return
}
}
// 3) 全部未命中
utils.Log.Debugf("[VirtualHost] no index candidate matched for reqPath=%q under root=%q", reqPath, root)
c.Status(http.StatusNotFound)
}
// serveWebHostingFile 通过代理方式直接返回文件内容
func serveWebHostingFile(c *gin.Context, filePath, filename string) {
link, file, err := internalfs.Link(c.Request.Context(), filePath, model.LinkArgs{
IP: c.ClientIP(),
Header: c.Request.Header,
})
if err != nil {
utils.Log.Errorf("web hosting: failed to get link for %s: %v", filePath, err)
c.Status(http.StatusInternalServerError)
return
}
defer link.Close()
// 根据文件扩展名确定正确的 Content-Type
ext := strings.ToLower(stdpath.Ext(filename))
contentType := mimeTypeByExt(ext)
// .md 走服务端模板渲染(浏览器端 marked.js 渲染)。
// 读失败或文件过大时回退为原始内容代理,不中断请求。
if ext == ".md" {
if data, rerr := readLinkAll(c.Request.Context(), link, file.GetSize(), renderMaxBytes); rerr == nil {
html := renderMarkdownPreview(filename, data)
writeRenderedHTML(c.Writer, html)
return
} else {
utils.Log.Warnf("web hosting: markdown render fallback for %s: %v", filePath, rerr)
}
}
// 注意:不要修改 link.Header!
// link.Header 是请求上游存储时附加的请求头(如 Referer、Authorization 等),
// 写入 Content-Type/Content-Disposition 会污染上游请求并触发签名失败/403。
//
// 正确的做法是在响应阶段通过 forceContentTypeWriter 强制覆盖响应头,
// 同时在 ProxyIgnoreHeaders 之外通过响应头 set 直接生效。
wrapped := &forceContentTypeWriter{
ResponseWriter: c.Writer,
contentType: contentType,
contentDisp: "inline",
}
// 使用通用代理函数处理文件传输
if err := common.Proxy(wrapped, c.Request, link, file); err != nil {
utils.Log.Errorf("web hosting: proxy error for %s: %v", filePath, err)
}
}
// forceContentTypeWriter 包装 http.ResponseWriter,
// 在 WriteHeader 时强制覆盖 Content-Type 和 Content-Disposition,
// 确保 HTML 等文件以正确类型返回而不是被浏览器下载
type forceContentTypeWriter struct {
http.ResponseWriter
contentType string
contentDisp string
}
func (w *forceContentTypeWriter) WriteHeader(statusCode int) {
// 上游可能返回非 2xx(如 OSS 签名异常的 403);这种情况不要把异常响应包装成 200,
// 也不要给浏览器一个声称是 HTML 但内容是 OSS XML 错误的响应。
// 直接透传上游状态码,但仍覆盖 Content-Type / Content-Disposition 防止下载/乱解析。
h := w.ResponseWriter.Header()
if statusCode >= 200 && statusCode < 300 {
h.Set("Content-Type", w.contentType)
h.Set("Content-Disposition", w.contentDisp)
// 安全头:防止 MIME 嘲探、限制嵌入、控制引用来源
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
// HTML 类不缓存,静态资源类允许缓存
if strings.HasPrefix(w.contentType, "text/html") {
h.Set("Cache-Control", "no-cache, must-revalidate")
} else {
h.Set("Cache-Control", "public, max-age=86400")
}
}
w.ResponseWriter.WriteHeader(statusCode)
}
// mimeTypeByExt 根据文件扩展名返回 MIME 类型
func mimeTypeByExt(ext string) string {
switch ext {
case ".html", ".htm":
return "text/html; charset=utf-8"
case ".mhtml", ".mht":
// MHTML(Web 归档)。Chrome / Edge 看到 multipart/related 且无 attachment 时会
// 调用内置的 MhtmlPageLoader 原生预览,无需服务端拆包。
// 注意:boundary 参数是 MHTML 文件内嵌的,响应头 Content-Type 上可以不携带;
// Chrome 会从响应 body 头部重新解析。
return "multipart/related"
case ".md":
// .md 在 Web Hosting 场景下会在 serveWebHostingFile 提前走服务端渲染。
// 这里仅作为回退路径(读失败 / 超大)提供合理的 Content-Type,
// 让浏览器直接显示源文本而不是下载。
return "text/markdown; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js", ".mjs":
return "application/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
case ".xml":
return "application/xml; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".ico":
return "image/x-icon"
case ".woff":
return "font/woff"
case ".woff2":
return "font/woff2"
case ".ttf":
return "font/ttf"
case ".txt":
return "text/plain; charset=utf-8"
default:
return "application/octet-stream"
}
}