diff --git a/internal/conf/const.go b/internal/conf/const.go index b99d8849c..3517c2851 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -191,4 +191,5 @@ const ( PathKey SharingIDKey SkipHookKey + VhostPrefixKey ) diff --git a/internal/db/sharing.go b/internal/db/sharing.go index 9c356440c..beab93fb4 100644 --- a/internal/db/sharing.go +++ b/internal/db/sharing.go @@ -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) } diff --git a/internal/model/sharing.go b/internal/model/sharing.go index c50149448..6df1b1c5b 100644 --- a/internal/model/sharing.go +++ b/internal/model/sharing.go @@ -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 } diff --git a/internal/op/sharing.go b/internal/op/sharing.go index 5be0f8e6a..28ba22b3a 100644 --- a/internal/op/sharing.go +++ b/internal/op/sharing.go @@ -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 { diff --git a/server/common/common.go b/server/common/common.go index a1a9a6319..945adcede 100644 --- a/server/common/common.go +++ b/server/common/common.go @@ -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 +} diff --git a/server/handles/fsread.go b/server/handles/fsread.go index 856f46e54..21ea0389c 100644 --- a/server/handles/fsread.go +++ b/server/handles/fsread.go @@ -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 +} + + diff --git a/server/handles/sharing.go b/server/handles/sharing.go index 6090fcbd9..b73e47508 100644 --- a/server/handles/sharing.go +++ b/server/handles/sharing.go @@ -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, diff --git a/server/middlewares/down.go b/server/middlewares/down.go index d71be00b3..7e7c5dee3 100644 --- a/server/middlewares/down.go +++ b/server/middlewares/down.go @@ -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) diff --git a/server/middlewares/vhost.go b/server/middlewares/vhost.go new file mode 100644 index 000000000..f89d843f1 --- /dev/null +++ b/server/middlewares/vhost.go @@ -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() +} diff --git a/server/router.go b/server/router.go index 8e9068824..8294731a6 100644 --- a/server/router.go +++ b/server/router.go @@ -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")) diff --git a/server/static/render.go b/server/static/render.go new file mode 100644 index 000000000..05f515476 --- /dev/null +++ b/server/static/render.go @@ -0,0 +1,166 @@ +// Package static —— 虚拟主机 Web Hosting 模式下针对 Markdown 的服务端预览渲染。 +// +// 设计原则: +// 1. 不引入任何新的第三方依赖。Markdown 渲染交给浏览器端 marked.js(CDN)。 +// 2. 渲染有 size 上限(默认 5MB),防止从云端读取超大文件造成 OOM。 +// 3. Markdown 原文嵌入到 ,也会因 type 非 JS 而不被执行;同时对 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}} 标签内容(页面文件名,已 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}} + + + + + +

Rendering Markdown...

+ + + + + + + + +` + +// renderMarkdownPreview 把 Markdown 原文包装为完整的 HTML 预览页。 +// 注意:原文必须做 中提前闭合。 +func renderMarkdownPreview(filename string, mdSource []byte) []byte { + // HTML-escape title(防止文件名注入 HTML) + title := htmlEscape(filename) + + // 对 块在 HTML 解析阶段 + // 会按"原始文本"处理,唯一会让它结束的是 ", ">", + `"`, """, + "'", "'", + ) + 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) + } +} \ No newline at end of file diff --git a/server/static/share_pwd.go b/server/static/share_pwd.go new file mode 100644 index 000000000..c5e6028f0 --- /dev/null +++ b/server/static/share_pwd.go @@ -0,0 +1,253 @@ +// Package static —— 虚拟主机访问码(Share Pwd)门禁。 +// +// 当 sharing.Pwd != "" 时,所有通过域名访问 sharing 内容(无论 Web Hosting +// 还是路径重映射模式)都必须先通过密码校验: +// - 提取顺序(择一即可):?pwd= 查询参数 → X-Share-Pwd 请求头 → cookie share_pwd_ +// - 校验通过后,会写一个 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 = ` + + + + +需要访问码 + + + +
+
+

需要访问码

+

该分享已设置访问码,请输入后继续。

+ {{ERROR_BLOCK}} + + + +
+
+ +` + +// 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 = `

访问码错误,请重试。

` + 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() +} diff --git a/server/static/static.go b/server/static/static.go index 29f97ff74..220f42be1 100644 --- a/server/static/static.go +++ b/server/static/static.go @@ -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) @@ -98,9 +118,9 @@ func initIndex(siteConfig SiteConfig) { manifestPath = siteConfig.BasePath + "/manifest.json" } replaceMap := map[string]string{ - "cdn: undefined": fmt.Sprintf("cdn: '%s'", siteConfig.Cdn), - "base_path: undefined": fmt.Sprintf("base_path: '%s'", siteConfig.BasePath), - `href="/manifest.json"`: fmt.Sprintf(`href="%s"`, manifestPath), + "cdn: undefined": fmt.Sprintf("cdn: '%s'", siteConfig.Cdn), + "base_path: undefined": fmt.Sprintf("base_path: '%s'", siteConfig.BasePath), + `href="/manifest.json"`: fmt.Sprintf(`href="%s"`, manifestPath), } conf.RawIndexHtml = replaceStrings(conf.RawIndexHtml, replaceMap) UpdateIndex() @@ -134,10 +154,10 @@ func UpdateIndex() { func ManifestJSON(c *gin.Context) { // Get site configuration to ensure consistent base path handling siteConfig := getSiteConfig() - + // Get site title from settings siteTitle := setting.GetStr(conf.SiteTitle) - + // Get logo from settings, use the first line (light theme logo) logoSetting := setting.GetStr(conf.Logo) logoUrl := strings.Split(logoSetting, "\n")[0] @@ -167,7 +187,7 @@ func ManifestJSON(c *gin.Context) { c.Header("Content-Type", "application/json") c.Header("Cache-Control", "public, max-age=3600") // cache for 1 hour - + if err := json.NewEncoder(c.Writer).Encode(manifest); err != nil { utils.Log.Errorf("Failed to encode manifest.json: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate manifest"}) @@ -181,7 +201,7 @@ func Static(r *gin.RouterGroup, noRoute func(handlers ...gin.HandlerFunc)) { initStatic() initIndex(siteConfig) folders := []string{"assets", "images", "streamer", "static"} - + if conf.Conf.Cdn == "" { utils.Log.Debug("Setting up static file serving...") r.Use(func(c *gin.Context) { @@ -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" + } +} + +