Go實現文件下載的流式轉發

1 背景

在文件下載場景中,用戶點擊一個鏈接,就把文件下載到本地。這其中有幾個安全問題:

1.1 請求方式問題

獲取文件一般是GET請求,而頁面請求有同步和異步兩種方式:

  • 同步請求:由瀏覽器直接發起,此時請求的下載鏈接就相當於是一個靜態資源,一個a鏈接或者iframe,它佔用的是瀏覽器的內存;
  • 異步請求:一般通過ajax發起,請求出去後必須等待數據返回之後,把數據加載到內存中,再在內存中對數據進行處理,它佔用的是前端服務的內存。

我們知道,當文件比較小的時候,下載時間會比較短,內存使用量較小,採用同步或者異步請求,都不是大問題。但是當文件特別大的時候,如果是異步請求,就會需要等待很久,而且內存太大;而同步請求可以通過瀏覽器自身的斷點續傳功能,直到下載完成。

所以在文件下載這個場景中,我們一般採用的是瀏覽器內置的同步GET請求。此時,下載鏈接已經固定,不能對其進行修改,增減參數操作。

1.2 登錄態問題

當這個下載鏈接所請求的接口,如果沒有登錄態校驗,則以爲着用戶可以在不登錄的情況下,直接下載文件,這會導致安全漏洞;這意味着文件下載接口必須存在登錄態校驗,瀏覽器的請求,必須帶上登錄態信息。一般情況下,這種登錄態可通過cookie,session,token來實現。

1.3 用戶身份和權限問題

當1.1問題解決後,假設B用戶是登錄態,拿着A用戶的鏈接來進行下載,這就存在用戶身份安全漏洞;這意味着文件下載接口必須能帶上用戶身份信息,且能帶上一些業務信息,以便於身份和權限校驗。

綜合以上三點,我們可以得出:

  • 這個接口的url是提前生成好的,在到達頁面之前就已經帶上所需要的所有參數和信息,而不能經由前端再進行修改;
  • 這個接口必須能完成登錄態、用戶身份、權限校驗功能;
  • 這個接口必須支持大文件的處理,不能直接把文件一次性讀進內存,然後再從內存中一次性吐給前端,這樣會把內存打掛;

2 接口設計

在這個設計中,我們默認所要下載的文件已經生成好,並存放在一個文件存儲服務中的,只需要通過一個路徑或者服務url就能獲取到文件。

這裏我們看到請求會進行一個流式轉發,也就是把請求直接進行轉發到File System,讓File System直接去進行響應;而不是自己去從File System讀文件到內存,然後再把內存的文件流進行返回。也就相當於Nginx的反向代理。

3 代碼實現

我們用go實現了這個功能,這裏展示一部分核心代碼:

package api

import (
	"fmt"
	"github.com/gin-gonic/gin"
	"net/http"
	"net/http/httputil"
	"net/url"
	"strings"
	"log"

    "some/project/service"
)

type DownloadRequest struct {
	UserID            uint64 `form:"userId"`     // 用戶ID,必傳
	BusinessID        uint64 `form:"BusinessID"` // 業務ID,必傳,用戶獲取業務信息,進行業務權限校驗
}

// Download 處理下載請求
func Download(c *gin.Context) {
	req := DownloadRequest{}
	if err := c.ShouldBindQuery(&req); err != nil {
		AbortWithWriteErrorResponse(c, err)
		return
	}

	// 此處的ctx.UserID是經過上游登錄態校驗後,把用戶身份信息塞入到ctx中的,與url上的UserID進行校驗
    if req.UserID != ctx.UserID {
        AbortWithWriteErrorResponse(c, errors.New("invalid user"))
		return
    }
    
    // 參數校驗
	err := validateDownloadRequest(req)
	if err != nil {
		AbortWithWriteErrorResponse(c, err)
		return
	}
    
    // 通過業務參數,生成存在file system中的路徑或url
	downloadUrl, err := service.GetDownloadUrl(c, req)
	log.Logger.Debug("GetDownloadUrl downloadUrl:%s, err:%+v", downloadUrl, err)
	if err != nil {
		AbortWithWriteErrorResponse(c, err)
		return
	}

	if downloadUrl == "" {
		AbortWithWriteErrorResponse(c, errors.New("GenerateDownloadUrlFailed"))
		return
	}
    
    // 開始準備流量轉發
	target, _ := url.Parse(downloadUrl)
	log.Logger.Debug("Download target: %+v", target)
    
    // 生成反向代理器
	proxy := httputil.ReverseProxy{
        // 對request做修改
		Director: func(request *http.Request) {
			request.URL = target
            // 通過實踐發現,光有上面這步有時候還不夠,需要下面兩步再強制設置一下Host
			request.Host = target.Host
			request.Header.Set("Host", target.Host)
			// 爲了避免file system返回304導致頁面不進行下載動作,這裏刪除這2個header,強制file system返回200
			request.Header.Del("If-Modified-Since")
			request.Header.Del("If-None-Match")
			log.Logger.Debug("proxy Director request: %+v", request)
		},

        // 對response做修改
        ModifyResponse: func(response *http.Response) error {
		    filePath := response.Request.URL.Path
		    filePathList := strings.Split(filePath, "/")
		    fileName := filePathList[len(filePathList)-1]
		    response.Header.Set("Content-Disposition", fmt.Sprintf("attachment;filename=%s", fileName))
		    log.Logger.Debug("proxy ModifyResponse response: %+v", response)
		    return nil
	    }
	}
    
    // 執行轉發
	proxy.ServeHTTP(c.Writer, c.Request)
	c.Abort()
}

// validateDownloadRequest 參數校驗
func validateDownloadRequest(req DownloadRequest) error {
	return nil
}

這裏我們默認是通過url的方式向文件系統請求文件。

核心方法是 httputil.ReverseProxy 和 proxy.ServerHTTP。特別需要注意的是:爲了適應文件下載場景,對 request 和 response 做的相應修改。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章