实战 | 文件下载、及浏览器加速导致不能下载的问题

1. 版权

本文为原创, 遵循 CC 4.0 BY-SA 版权协议, 转载需注明出处: https://blog.csdn.net/big_cheng/article/details/121565484.
文中代码属于 public domain (无版权).

2. nginx x-accel

为减轻处理压力, 一个较好的方法是程序将下载交由nginx 实际执行, 参考:
https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/

例如:

w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment;filename=xxx")
w.Header().Set("X-Accel-Redirect", "/download/xxx")
w.Header().Set("X-Accel-Limit-Rate", "512000") // 500K/s

在nginx conf 里配置下载目录"download" 的位置. 当下游nginx 收到程序如上设置的header 后, 将接手该文件的下载.

程序在设置header 之前可以根据请求参数进行权限检查, 但不便之处是程序收不到nginx 的处理结果(也就不知道实际下载成功否).

3. 浏览器加速导致的问题

在实践中碰到某些浏览器, 在下载时可以自动启用加速功能, 推测其机制:
- 可能是浏览器内部创建多线程来下载同一文件(Range 分块请求)
- 也可能加速模块在云上(作为下载代理, 可具有缓存功能), 在云上多线程下载后 -> 返给浏览器

加速造成一个问题: 我们系统中下载文件需验证已登录且有权限, 而加速线程发出的请求经测试(一般)不带session cookie, 从而导致下载失败(有时失败若干次后偶尔又会带上cookie).

经简单调查, nginx 默认支持文件的分块(Range) 下载, 以更高效下载且容易续传/出错恢复; 而且据闻一些video 控件在源不支持Range 请求时不能seek, 所以简单地禁止Range 请求可能不是个好办法.

目前大多数程序传递登录信息都是通过cookie session, 虽然这不是http 标准定义的方式. RFC 7235 (HTTP Authentication) 5.1 指向的iana 官方网页注册了Basic/Bearer/Digest/OAuth 等鉴权方式, 没有"session".
经测试, 发现未登录时设置"WWW-Authenticate" header 并返回401 (Unauthorized) 并没有效果.

好在我们系统的下载文件的安全等级不高, 所以想到一个办法: 在浏览器首次请求下载时, 生成一个免登录的下载链接并redirect 回去, 经试验后续请求都会使用这个免登录链接, 这样就可以下载了.
免登录链接 = 原链接 & _dsid=xx.
dsid “download session id”, 在生成免登录链接同时在服务端创建对应的下载会话, 后续带相同dsid 参数的请求使用同一个下载会话. 下载会话的作用:
- 控制有效期(例如5分钟未请求则不再允许使用), 减少安全风险
- 绑定目标下载文件(文件标志存储在下载会话里), 防止例如拿到dsid 后修改原链接的部分指向其他文件

4. DownloadSessionManager

由于下载的并发度不高, 使用 GolangHttpSession-1 数据结构 “概述” 部分描述的数据结构.

import (
	"container/list"
	"crypto/rand"
	"fmt"
	"sync"
	"time"
)

type DownloadSessionManager struct {
	mu *sync.Mutex
	m  map[string]*list.Element // id => {*dlses}
	l  *list.List               // front (active) -> back (inactive).
}

type dlses struct {
	id     string
	expire time.Time // 过期时间
	n      int       // access count, 0 when create
	data   string
}

const default_dl_duration_min = 5 // 下载会话有效期: 5min

func NewDownloadSessionManager() *DownloadSessionManager {
	return &DownloadSessionManager{
		mu: new(sync.Mutex),
		m:  map[string]*list.Element{},
		l:  new(list.List),
	}
}

dlses.data 可用于绑定下载文件. dlses.n 可用于下载计数(因为同一个文件可能有很多Range 下载请求, 但总体只能算一次下载).

创建下载会话:

// Create 新建一个下载会话, 并存储data 数据. 返回会话id. 不能生成id 时panic.
func (dsm *DownloadSessionManager) Create(data string) string {
	dsm.mu.Lock()
	defer dsm.mu.Unlock()

	id := ""
	for i := 0; i < 10; i++ { // (试10次)
		if s := genDlSesId(); dsm.m[s] == nil {
			id = s
			break
		}
	}
	if id == "" {
		panic("cannot create download sesId")
	}

	dsm.m[id] = dsm.l.PushFront(&dlses{
		id:     id,
		expire: time.Now().Add(default_dl_duration_min * time.Minute),
		n:      0,
		data:   data,
	})
	return id
}

func genDlSesId() string {
	// 24位大写, 如"AC58F133FD0E7AAA03A1F5D6"
	b := make([]byte, 12)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return fmt.Sprintf("%X", b)
}

获取下载会话 - 这将在收到免登录下载请求时调用以作验证:

// Get 获取指定下载会话, 返回{access count/int/>=0, data/string}. 如果会话
// 不存在或已过期, 返回nil.
func (dsm *DownloadSessionManager) Get(id string) []interface{} {
	dsm.mu.Lock()
	defer dsm.mu.Unlock()

	dsm.gc() // 同步清理

	if e := dsm.m[id]; e != nil {
		ds := e.Value.(*dlses)
		ds.n++
		ds.expire = time.Now().Add(default_dl_duration_min * time.Minute)
		dsm.l.MoveToFront(e) // => 最活跃
		return []interface{}{ds.n - 1, ds.data}
	} else {
		return nil
	}
}

// gc 清理所有过期下载会话 - 调用者需lock.
func (dsm *DownloadSessionManager) gc() {
	now := time.Now()
	for e := dsm.l.Back(); e != nil; e = dsm.l.Back() {
		ds := e.Value.(*dlses)
		if now.After(ds.expire) { // 过期
			dsm.l.Remove(e)
			delete(dsm.m, ds.id)
		} else {
			break
		}
	}
}

因为下载的并发度不高, 所以简化直接在Get 时做过期处理.

5. 关于下载计数

如前所述, 由于程序转交nginx 下载后无法知道实际的下载结果, 所以无法准确计数. 保守做法可以:
在Get 返回dlses.n == 0 时计数.

本文未分析Range 请求的"Range" 参数.

Stackoverflow 上有一篇使用cookie 侦测实际下载是否开始的文章:
https://stackoverflow.com/questions/1106377/detect-when-browser-receives-file-download
原理是服务端在响应文件内容同时返回一个cookie, 当页面js 能读到这个cookie 时就说明下载已经开始(但是云加速环境是否compatible?). Interesting.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值