package vision import ( "net/url" "path/filepath" "strings" ) // uploadFilePrefix 是本服务提供已存上传的静态路由前缀,与网关 // internal/services/gateway/upload.go 的 uploadFilePrefix 一致。 // 它位于 /api/v1/ 之下,因此属于版本化客户端契约。 const uploadFilePrefix = "/api/v1/files/" // uploadNameBytes 是上传文件主名的字节数(16 字节 = 32 位十六进制), // 与网关 randomUploadName 一致。 const uploadNameBytes = 16 // localUploadPath 判断 rawURL 是否指向本服务的已存上传,若是则返回它在 // uploadsDir 下的本地路径。命中时直接读盘:比绕一圈 HTTP 回自己更快, // 也不需要网络可达(自部署时同源 URL 用的是局域网地址,服务端自己 // 未必路由得到,云 provider 则必然取不到)。 // // 认可的形状严格为 :///api/v1/files/,且 // 必须通过 validUploadName。查询串、片段、多余路径段、任何分隔符或 // 点号段都在触碰文件系统之前被拒绝;最后的包含性检查是 // 第二道防线,即使名字校验被改弱,路径也不得逃出 uploadsDir。 // // 注意 host 不参与判断:按网关设计,128 位随机文件名本身就是能力 // (静态上传路由刻意不认证),因此指向别处的同名 URL 也只解析到 // 本机同名上传,不会替调用方去取第三方内容。 func localUploadPath(rawURL, uploadsDir string) (string, bool) { if strings.TrimSpace(uploadsDir) == "" { return "", false } u, err := url.Parse(rawURL) if err != nil { return "", false } if u.Scheme != "http" && u.Scheme != "https" { return "", false } if u.Host == "" || u.RawQuery != "" || u.Fragment != "" { return "", false } name, ok := strings.CutPrefix(u.Path, uploadFilePrefix) if !ok || !validUploadName(name) { return "", false } path := filepath.Join(uploadsDir, name) if filepath.Dir(path) != filepath.Clean(uploadsDir) { return "", false } return path, true } // validUploadName 报告 name 是否恰好是 32 位小写十六进制主名加上白名单 // 内的图片扩展名。它与网关静态路由的路径穿越防护同口径 // (upload.go 的 validUploadName):分隔符、点、双点及任何其他形态都在 // 触碰文件系统之前被拒绝。 // // 这里不复用网关那份实现是为了不让本包依赖 HTTP 服务层 // (runtime → services 是反向依赖);两处规则必须一起改。 func validUploadName(name string) bool { if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, "/\\") { return false } stem, ext, ok := strings.Cut(name, ".") if !ok || len(stem) != uploadNameBytes*2 || ext == "" { return false } for _, c := range stem { if !isLowerHex(c) { return false } } return knownUploadExt(ext) } // knownUploadExt 报告 ext(不含点)是否是白名单内的图片扩展名,与网关 // uploadExtForMIME 的可写侧一致:只含光栅图。 func knownUploadExt(ext string) bool { _, ok := knownRasterMIME(mimeByExt("upload." + strings.ToLower(ext))) return ok } // isLowerHex 报告 c 是否是 ASCII 小写十六进制数字。 func isLowerHex(c rune) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') }