package updownctrl import ( "encoding/base64" "fmt" "io" "net/http" "net/url" "path/filepath" "strconv" "strings" "sync" "time" "91porn-server/app/appg" "91porn-server/app/service/m3u8ticket" "91porn-server/app/service/updownloadser" "91porn-server/common" "91porn-server/common/constant/redisconst" "91porn-server/common/hevcpull" "91porn-server/common/log" "91porn-server/common/m3u8" "91porn-server/common/stderr" "91porn-server/models/commod" "91porn-server/models/v/sourcemod" "91porn-server/models/v/usermod" "github.com/gin-gonic/gin" ) const ( Retries = 3 //重试3次 transcodeM3u8RoutePrefix = "/api/app/vid/transcode/m3u8/" transcodeSigningOriginHost = "hevc-pull.invalid" // ctxM3u8TicketRequired 标记当前 m3u8 路由需要做 H5 防盗链票据校验。 // 仅 App H5 播放路由挂载 RequireM3u8Ticket;官网/分享等自有鉴权路由不挂载,避免误伤。 ctxM3u8TicketRequired = "m3u8_ticket_required" ) // RequireM3u8Ticket 是一个标记中间件:挂到某条 m3u8 路由后,DownloadM3u8H5 会对其启用票据校验。 // 未挂载的路由保持旧逻辑(不验票),从而把防盗链范围精确限定在 App H5 播放地址上。 func RequireM3u8Ticket(c *gin.Context) { c.Set(ctxM3u8TicketRequired, true) } var interval = []int64{5, 5, 10, 15} //通知时间间隔 // Upload doc // @Summary 文件管理 - 表单上传文件 // @Description 表单上传文件 // @Tags uploaddown // @Accept mpfd,json // @Produce json,html // @Param upload formData file true "文件" // @Param id formData string true "文件ID" // @Success 200 {string} json "{"msg": "success" "data":{"coverImg":"xxxxxxxxxx.ext"}}" // @Failure 400 {string} json "{"msg": "操作失败"}" // @Router /vid/upload [post] func Upload(c *gin.Context) { headers, err := c.FormFile("upload") if err != nil { log.Warn("request multipart wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } id := c.PostForm("id") if id == "" { common.ServeJSON(c, stderr.ErrUploadError, "id is required") return } f, err := headers.Open() if err != nil { log.Warn("headers Open file wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } byteData, err := io.ReadAll(f) f.Close() if err != nil { log.Warn("request multipart wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } fileData := base64.StdEncoding.EncodeToString(byteData) ext := strings.TrimLeft(filepath.Ext(headers.Filename), ".") resp, err := updownloadser.SendVidCover2FS(id, ext, fileData) if err != nil { log.Warn("file upload wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } if resp.Code == http.StatusOK { common.ServeJSON(c, stderr.Success, resp.Data) return } common.ServeJSON(c, stderr.ErrUploadError, "") } // UploadStatic doc // @Summary 文件管理 - 表单上传文件 // @Description 表单上传文件,上传静态文件到AWS 上传独立文件 // @Tags uploaddown // @Accept mpfd,json // @Produce json,html // @Param upload formData file true "文件" // @Success 200 {string} json "{"msg": "success" "data":{"coverImg":"xxxxxxxxxx.ext"}}" // @Failure 400 {string} json "{"msg": "操作失败"}" // @Router /vid/uploadStatic [post] func UploadStatic(c *gin.Context) { headers, err := c.FormFile("upload") if err != nil { log.Warn("request multipart wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } f, err := headers.Open() if err != nil { log.Warn("headers Open file wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } byteData, err := io.ReadAll(f) f.Close() if err != nil { log.Warn("request multipart wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } fileData := base64.StdEncoding.EncodeToString(byteData) resp, err := updownloadser.SendImageToFS(headers.Filename, fileData) if err != nil { log.Warn("file upload wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } if resp.Code == http.StatusOK { common.ServeJSON(c, stderr.Success, gin.H{"coverImg": resp.Data.FileName}) return } common.ServeJSON(c, stderr.ErrUploadError, "") } // UploadDotStream doc // @Summary 文件管理 - 流式断点续传文件 // @Description 流式断点续传 // @Tags uploaddown // @Accept mpfd,json // @Produce json,html // @Param ID header string true "文件ID 文件MD5做ID" // @Param POS header string true "第几片视频" // @Param TotalPos header string true "视频总片数" // @Success 200 {string} json "{"msg": "success" "data":{"id":"5d8a2af58747044ca077f358","videoUri":"xxxxxxx.m3u8"} }" // @Failure 400 {string} json "{"msg": "fail"}" // @Router /vid/uploadDotStream [post] func UploadDotStream(c *gin.Context) { var ( id, pos, totalPos string ) var cnt int var resp commod.Resp var httpErr error data := c.Request.Body id = c.GetHeader("ID") pos = c.GetHeader("POS") totalPos = c.GetHeader("TotalPos") if id == "" || pos == "" || totalPos == "" { common.ServeJSON(c, stderr.ErrParamError, "upload args error") return } posint, _ := strconv.ParseInt(pos, 10, 32) total, _ := strconv.ParseInt(totalPos, 10, 32) byteData, err := io.ReadAll(data) data.Close() if err != nil { log.Warn("request multipart wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } fileData := base64.StdEncoding.EncodeToString(byteData) for cnt < Retries { resp, httpErr = updownloadser.SendFile2FS(id, fileData, posint, total) if httpErr == nil { break } time.Sleep(time.Duration(interval[cnt]) * time.Second) log.Warn("retry to upload file to file-server", log.Any("重试次数", cnt), log.E(httpErr)) cnt++ } if httpErr != nil { log.Warn("file upload wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, nil) return } fmt.Println(resp) if resp.Code == http.StatusOK { common.ServeJSON(c, stderr.Success, resp.Data) return } common.ServeJSON(c, stderr.ErrUploadError, "") } // UploadDotJson doc // @Summary 文件管理 - API断点续传文件 // @Description API断点续传 // @Tags uploaddown // @Accept mpfd,json // @Produce json,html // @Param id formData string true "taskId 文件MD5做ID" // @Param pos formData integer true "第几片视频" // @Param totalPos formData integer true "视频总片数" // @Param data formData string true "数据内容" // @Success 200 {string} json "{"msg": "success" "data":{"id":"5d8a2af58747044ca077f358","videoUri":"xxxxxxx.m3u8"} }" // @Failure 400 {string} json "{"msg": "fail"}" // @Router /vid/uploadDotJson [post] func UploadDotJson(c *gin.Context) { uid, err := common.GetUID(c) if err == nil && uid > 0 { //判断该用户是否被禁止上传视频 user, err := usermod.FindUserByUID(uid) if err != nil || (user != nil && user.ForbidUpload) { common.ServeJSON(c, stderr.ForbidUploadVideo, "") return } } var cnt int var resp commod.Resp var httpErr error var args struct { ID string `form:"id" json:"id" binding:"required"` //taskId POS int64 `form:"pos" json:"pos" binding:"required"` //分片序号 TotalPos int64 `form:"totalPos" json:"totalPos" binding:"required"` //总分片数 Data string `form:"data" json:"data" binding:"required"` //分片内容 } if err = c.ShouldBind(&args); err != nil { common.ServeJSON(c, stderr.ErrParamError, err) return } for cnt < Retries { resp, httpErr = updownloadser.SendFile2FS(args.ID, args.Data, args.POS, args.TotalPos) if httpErr == nil { break } time.Sleep(time.Duration(interval[cnt]) * time.Second) log.Warn("retry to upload file to file-server", log.Any("重试次数", cnt), log.E(httpErr)) cnt++ } if httpErr != nil { log.Warn("file upload wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, nil) return } if resp.Code == http.StatusOK { common.ServeJSON(c, stderr.Success, resp.Data) return } common.ServeJSON(c, stderr.ErrUploadError, nil) } // Download doc // @Summary 文件管理 - 下载文件接口 // @Description 下载文件 // @Tags 正式 // @Accept mpfd,json // @Produce json,html // @Success 200 {string} json "{"msg": "操作成功"}" // @Failure 400 {string} json "{"msg": "操作失败"}" // @Router /vid/m3u8/:source [get] func DownloadM3u8(c *gin.Context) { source := c.Param("source") if source == "" { common.ServeJSON(c, stderr.ErrParamError, "") return } //cdn有值,则代表前端选线使用 cdn := c.Query("c") // 该接口不做严格校验:老明文链接原样、新带票链接解密还原真实 path,都能播放。 source = m3u8ticket.StripTicket(source) ext := filepath.Ext(source) if ext != ".m3u8" { common.ServeJSON(c, stderr.ErrMimeType, "") return } fileName := filepath.Base(source) c.Writer.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) cdns := sourcemod.GetCdnURL() if len(cdns) <= 0 { common.ServeJSON(c, stderr.Failure, "") return } if cdn == "" { //cdn为空时,则前端为老版本,没有选线 //切记,后台配置第一个域名为当前系统常用cdn域名(eg:松鼠云) cdn = cdns[0].Url //去掉首尾反斜杠(/)、空格 cdn = strings.Trim(cdn, "/ ") } byteBuff, err := m3u8.GetAPPM3u8(source, fileName, ext, cdn, updownloadser.FsIO) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": err.Error()}) return } if byteBuff == nil { log.Warn("can't create m3u8 file", log.Any("source", source)) c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": stderr.CodeEmptyData.Msg()}) return } c.Writer.Header().Add("Content-Length", strconv.Itoa(byteBuff.Len())) c.Data(200, "application/octet-stream", byteBuff.Bytes()) } // UploadStaticBatch doc // @Summary 文件管理 - 表单上传文件 批量上传 // @Description 表单上传文件,上传静态文件到文件服务器 用于独立文件上传 // @Tags uploaddown // @Accept mpfd,json // @Produce json,html // @Param upload[] formData file true "文件" // @Success 200 {string} json "{"msg": "success" "data":{"coverImg":"xxxxxxxxxx.ext"}}" // @Failure 400 {string} json "{"msg": "操作失败"}" // @Router /vid/uploadStatic/batch [post] func UploadStaticBatch(c *gin.Context) { form, err := c.MultipartForm() if err != nil { log.Warn("request multipart wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } files := form.File["upload[]"] batch := make([]*updownloadser.FileInfo, len(files)) for i, f := range files { fi, err := f.Open() if err != nil { log.Warn("headers Open file wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } byteData, err := io.ReadAll(fi) fi.Close() if err != nil { log.Warn("request multipart wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } fileData := base64.StdEncoding.EncodeToString(byteData) batch[i] = &updownloadser.FileInfo{ FileName: &f.Filename, FileData: &fileData, } } resp, err := updownloadser.SendImageToFSBatch(updownloadser.InfoBatch{Batch: batch}) if err != nil { log.Warn("file upload wrong ", log.E(err)) common.ServeJSON(c, stderr.ErrUploadError, err.Error()) return } common.ServeJSON(c, stderr.Success, gin.H{"filePath": resp.Data.GetFileNames(), "success": resp.Data.Count()}) } // DownloadM3u8H5 doc // @Summary 文件管理 - 下载文件接口H5 // @Description 下载文件 // @Tags 正式 // @Accept mpfd,json // @Produce json,html // @Success 200 {string} json "{"msg": "操作成功"}" // @Failure 400 {string} json "{"msg": "操作失败"}" // @Router /vid//h5/m3u8/:source [get] func DownloadM3u8H5(c *gin.Context) { // 统计请求来源(Referer/Origin):进程内无锁累加,后台定时批量刷回 Redis ZSet 计数。 collectM3u8H5Referer(c) source := c.Param("source") if source == "" { common.ServeJSON(c, stderr.ErrParamError, "") return } //cdn有值,则代表前端选线使用 cdn := c.Query("c") // 防盗链:开启票据后校验,校验失败改下发广告兜底 m3u8,阻断盗链。 // 带票地址是加密单段 token(无 .m3u8 后缀),故先验票解出真实 path,再判断后缀与取文件名。 source = verifyH5M3u8Ticket(c, source) ext := filepath.Ext(source) if ext != ".m3u8" { common.ServeJSON(c, stderr.ErrMimeType, "") return } fileName := filepath.Base(source) c.Writer.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) cdns := sourcemod.GetCdnURL() if len(cdns) <= 0 { common.ServeJSON(c, stderr.Failure, "") return } if cdn == "" { //cdn为空时,则前端为老版本,没有选线 //切记,后台配置第一个域名为当前系统常用cdn域名(eg:松鼠云) cdn = cdns[0].Url //去掉首尾反斜杠(/)、空格 cdn = strings.Trim(cdn, "/ ") } byteBuff, err := m3u8.GetAPPM3u8(source, fileName, ext, cdn, updownloadser.FsIO) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": err.Error()}) return } if byteBuff == nil { c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": stderr.CodeEmptyData.Msg()}) return } c.Writer.Header().Add("Content-Length", strconv.Itoa(byteBuff.Len())) c.Data(200, "application/octet-stream", byteBuff.Bytes()) } // m3u8 请求来源计数(按 origin):进程内用 map[origin]次数 聚合,再由后台定时批量刷回 Redis ZSet。 // 每个请求都计入(不去重,去重会丢失次数),把高频接口"每请求一次 Redis 写"降为"每周期每来源一次"。 // ZSet:member=origin,score=该来源累计请求次数 —— ZScore 查单个来源,ZRevRangeWithScores 看 Top。 var ( m3u8RefererMu sync.Mutex m3u8RefererCounts = make(map[string]int64) // key=origin,value=该来源累计请求次数 m3u8RefererFlushOnce sync.Once ) // m3u8RefererFlushInterval 为聚合计数刷回 Redis 的周期;越短则崩溃丢失窗口越小、Redis 写越频繁。 const m3u8RefererFlushInterval = 5 * time.Second // m3u8RefererResetHour 为来源累计计数每日清零的整点(time.Local,已在启动时设为 Asia/Shanghai): // 每天该点该 ZSet 过期失效、从零重新累计。改此值即可调整清零时刻。 const m3u8RefererResetHour = 5 // collectM3u8H5Referer 给当前请求来源(origin)的计数 +1:每个 origin 在 map 里各占一个计数。 // 加锁只护一次 map 自增,不碰 Redis;计数由后台定时批量刷回 Redis(见 startM3u8RefererFlusher)。 func collectM3u8H5Referer(c *gin.Context) { origin := refererOrigin(c) if origin == "" { return } m3u8RefererFlushOnce.Do(startM3u8RefererFlusher) // 首个请求到来时惰性启动后台刷新协程 m3u8RefererMu.Lock() m3u8RefererCounts[origin]++ // 每个 origin 各自累加 m3u8RefererMu.Unlock() } // startM3u8RefererFlusher 启动后台协程,按固定周期把聚合计数批量刷回 Redis。 func startM3u8RefererFlusher() { common.Go(func() { ticker := time.NewTicker(m3u8RefererFlushInterval) defer ticker.Stop() for range ticker.C { FlushM3u8RefererStats() } }) } // FlushM3u8RefererStats 换出当前按 origin 聚合的计数,逐个 ZIncrBy 刷回 Redis,供定时器与进程退出兜底调用。 // 锁内只换出快照(不含 Redis IO),换出后本地表即清空(空闲来源自然淘汰);刷回失败的计数并回本地表、下个周期重试。 func FlushM3u8RefererStats() { if appg.Redis == nil { return } m3u8RefererMu.Lock() if len(m3u8RefererCounts) == 0 { m3u8RefererMu.Unlock() return } snapshot := m3u8RefererCounts m3u8RefererCounts = make(map[string]int64) m3u8RefererMu.Unlock() var failed map[string]int64 for origin, cnt := range snapshot { if _, err := appg.Redis.ZIncrBy(redisconst.M3u8H5RefererSet, float64(cnt), origin); err != nil { if failed == nil { failed = make(map[string]int64) } failed[origin] += cnt } } // 每天凌晨 m3u8RefererResetHour 点整体清零:每次刷回都把过期续到下一个清零点,到点 Redis 删除该 key, // 下次刷回自然重建、从零累计。用 EXPIREAT(绝对时间点)而非相对 TTL,故进程重启/无请求空窗期也照常按点失效。 _, _ = appg.Redis.ExpireKeAt(redisconst.M3u8H5RefererSet, nextM3u8RefererResetAt()) if len(failed) > 0 { m3u8RefererMu.Lock() for origin, cnt := range failed { m3u8RefererCounts[origin] += cnt } m3u8RefererMu.Unlock() log.Warn("flush m3u8 referer stats partially failed", log.Any("failedSources", len(failed))) } } // nextM3u8RefererResetAt 返回下一个每日清零时刻(今天 m3u8RefererResetHour 点未过则用今天,已过则用明天), // 供刷回时给累计 ZSet 设 EXPIREAT。基于 time.Now()(time.Local=Asia/Shanghai),即北京时间。 func nextM3u8RefererResetAt() time.Time { now := time.Now() reset := time.Date(now.Year(), now.Month(), now.Day(), m3u8RefererResetHour, 0, 0, 0, now.Location()) if !now.Before(reset) { // 已到/过今天清零点,则顺延到明天 reset = reset.AddDate(0, 0, 1) } return reset } // refererOrigin 提取请求来源站点并归一到 scheme://host:优先 Referer,缺省或解析失败时回退 Origin。 // 解析不出 host(非法 URL / Origin 为 "null" 等)返回空串丢弃,避免任意串塞进永不过期的 ZSet 撑爆内存。 func refererOrigin(c *gin.Context) string { if o := normalizeOrigin(c.GetHeader("Referer")); o != "" { return o } return normalizeOrigin(c.GetHeader("Origin")) } // normalizeOrigin 把来源头归一到 scheme://host;空串或解析不出 host 一律返回空串。 func normalizeOrigin(raw string) string { if raw == "" { return "" } if u, err := url.Parse(raw); err == nil && u.Host != "" { return u.Scheme + "://" + u.Host } return "" } // verifyH5M3u8Ticket 校验 H5 m3u8 播放防盗链票据。 // 未配置密钥时原样返回 source(保持旧逻辑);开启后票据非法/过期/IP 不符则返回广告兜底 source, // 并打上 no-store 避免中间层把兜底 playlist 当正片缓存。 func verifyH5M3u8Ticket(c *gin.Context, source string) string { if !m3u8ticket.Enabled() { return source } // 仅 h5/m3u8 这类显式标记 RequireM3u8Ticket 的路由做严格校验;其余路由(h5 light/官网/分享等)只把 // 带票地址解密还原成真实 path,不校验,保证新老链接都能播放。 if !c.GetBool(ctxM3u8TicketRequired) { return m3u8ticket.StripTicket(source) } ip := common.GetIP(c) ua := "" if u, uaErr := common.GetUA(c); uaErr == nil { ua = u.UserAgent } // 带票地址形如 /{version}/{token}.m3u8,真实 path 加密在 token 里。 realSource, info, ok := m3u8ticket.VerifyPath(source, ip, ua) if ok { // 若上游 Auth 已解析出登录用户,则要求与票据签发用户一致,进一步绑定到本人。 if uid := common.TryGetUID(c); uid > 0 && uid != info.UserID { ok = false } } if !ok { log.Warn("DownloadM3u8H5 ticket invalid", log.Any("source", source), log.Any("ip", ip), ) c.Header("Cache-Control", "private, no-store") return m3u8ticket.FallbackPath } return realSource } // DownloadTranscodeM3u8 仅供 H.265 云转码服务拉取源播放列表。 // URL 必须由 SKD 使用共享密钥签名,签名同时绑定资源路径和过期时间。 func DownloadTranscodeM3u8(c *gin.Context) { c.Header("Cache-Control", "no-store") secret := "" if appg.Conf != nil { secret = appg.Conf.Hevc.PullSecret } if err := hevcpull.VerifyURL(c.Request.URL, secret, time.Now()); err != nil { log.Warn("DownloadTranscodeM3u8 rejected", log.Any("path", c.Request.URL.Path), log.E(err), ) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "code": http.StatusUnauthorized, "msg": "invalid or expired transcode pull signature", }) return } expiresUnix, err := strconv.ParseInt(c.Query(hevcpull.ExpiresParam), 10, 64) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "code": http.StatusUnauthorized, "msg": "invalid or expired transcode pull signature", }) return } expiresAt := time.Unix(expiresUnix, 0).UTC() // Downstream playback helpers and generic error logs do not need the // bearer query after verification; remove it before any further handling. c.Request.URL.RawQuery = "" c.Request.RequestURI = c.Request.URL.RequestURI() source := strings.TrimLeft(c.Param("source"), "/") normalizedSource, err := hevcpull.NormalizeSource(source) if err != nil || normalizedSource != source { log.Warn("DownloadTranscodeM3u8 rejected non-canonical source", log.Any("path", c.Request.URL.Path), ) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "code": http.StatusBadRequest, "msg": "invalid transcode pull source", }) return } fileName := filepath.Base(normalizedSource) if filepath.Ext(normalizedSource) != ".m3u8" { common.ServeJSON(c, stderr.ErrMimeType, "") return } cdns := sourcemod.GetCdnURL() if len(cdns) <= 0 { common.ServeJSON(c, stderr.Failure, "") return } cdn := strings.Trim(cdns[0].Url, "/ ") byteBuff, err := m3u8.GetAPPM3u8( transcodePlaybackSource(normalizedSource), fileName, ".m3u8", cdn, updownloadser.FsIO, ) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": err.Error()}) return } byteBuff, err = m3u8.RewriteMasterPlaylist(byteBuff.Bytes(), func(childURI string) (string, error) { return signedTranscodeChildPlaylistURI(normalizedSource, childURI, secret, expiresAt) }) if err != nil { log.Warn("DownloadTranscodeM3u8 rewrite master failed", log.Any("source", normalizedSource), log.E(err), ) c.JSON(http.StatusBadRequest, gin.H{"data": "", "msg": "invalid transcode master playlist"}) return } c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) c.Header("Content-Length", strconv.Itoa(byteBuff.Len())) c.Data(http.StatusOK, "application/octet-stream", byteBuff.Bytes()) } func transcodePlaybackSource(normalizedSource string) string { return "/" + strings.TrimLeft(normalizedSource, "/") } func signedTranscodeChildPlaylistURI(parentSource, childURI, secret string, expiresAt time.Time) (string, error) { childSource, err := hevcpull.ResolveChildSource(parentSource, childURI) if err != nil { return "", err } unsigned := (&url.URL{ Scheme: "https", Host: transcodeSigningOriginHost, Path: transcodeM3u8RoutePrefix + childSource, }).String() signed, err := hevcpull.SignURL(unsigned, secret, expiresAt) if err != nil { return "", err } parsed, err := url.Parse(signed) if err != nil { return "", err } // Root-relative output keeps the trusted host of the originally signed // master URL and cannot be influenced by a forwarded Host header. return parsed.RequestURI(), nil }