File size: 4,609 Bytes
929a9dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b91d49e
 
 
 
929a9dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
"""内存结果共用的 HTTP 下载响应,适用于图像字节和 JSON。"""

import hashlib
import secrets
from email.utils import formatdate

from fastapi import Request
from fastapi.responses import FileResponse, Response


class ResultFileResponse(FileResponse):
    """文件响应结束时释放既有下载租约,包括 416 和连接中断路径。"""

    async def __call__(self, scope, receive, send):
        """发送文件并保证后台租约释放动作只执行一次。

        Args:
            scope: ASGI 请求上下文。
            receive: ASGI 请求消息接收函数。
            send: ASGI 响应消息发送函数。
        """
        release = self.background
        self.background = None
        if scope["method"] == "HEAD":
            # Range 只用于 GET;HEAD 应提供完整结果的长度和校验信息。
            scope = {**scope, "headers": [(name, value) for name, value in scope["headers"]
                                         if name.lower() != b"range"]}
        try:
            await super().__call__(scope, receive, send)
        finally:
            # FileResponse 的范围错误会提前返回,不会运行通常的 background。
            if release is not None:
                await release()


def result_bytes_response(request: Request, content: bytes, media_type: str,
                          modified_at: float) -> Response:
    """返回带校验信息、范围读取和 HEAD 支持的不可变结果。

    Args:
        request: 当前 GET 或 HEAD 请求。
        content: 已在任务清理锁内读出的完整结果,发送期间不再依赖文件。
        media_type: 结果的 MIME 类型。
        modified_at: 任务完成时间,作为稳定的 Last-Modified。
    Returns:
        完整结果、206 范围结果或 416 越界响应;HEAD 始终不含响应体。
    """
    size = len(content)
    etag = '"' + hashlib.sha256(content).hexdigest() + '"'
    modified = formatdate(modified_at, usegmt=True)
    headers = {
        "ETag": etag,
        "Last-Modified": modified,
        "Accept-Ranges": "bytes",
        "Cache-Control": "private, max-age=300",
        "Referrer-Policy": "no-referrer",
        "X-Content-Type-Options": "nosniff",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Expose-Headers": (
            "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified"
        ),
    }
    status_code = 200
    range_header = request.headers.get("range")
    # If-Range 不匹配时发送完整的新结果,避免把两个版本拼成一个文件。
    if (request.method == "GET" and range_header
            and request.headers.get("if-range", etag) in {etag, modified}):
        try:
            unit, ranges_text = range_header.split("=", 1)
            ranges = []
            if unit.strip() != "bytes" or len(ranges_text.split(",")) > 100:
                raise ValueError("Unsupported range")
            for part in ranges_text.split(","):
                start_text, end_text = part.strip().split("-", 1)
                if not start_text:
                    suffix = int(end_text)
                    if suffix <= 0:
                        raise ValueError("Empty suffix")
                    start, end = max(0, size - suffix), size - 1
                else:
                    start = int(start_text)
                    end = min(int(end_text), size - 1) if end_text else size - 1
                if start < 0 or start > end or start >= size:
                    raise ValueError("Unsatisfiable range")
                ranges.append((start, end))
        except ValueError:
            headers["Content-Range"] = f"bytes */{size}"
            return Response(status_code=416, headers=headers)
        status_code = 206
        if len(ranges) == 1:
            start, end = ranges[0]
            headers["Content-Range"] = f"bytes {start}-{end}/{size}"
            content = content[start:end + 1]
        else:
            boundary = secrets.token_hex(16)
            content = b"".join(
                (f"--{boundary}\r\nContent-Type: {media_type}\r\n"
                 f"Content-Range: bytes {start}-{end}/{size}\r\n\r\n").encode()
                + content[start:end + 1] + b"\r\n" for start, end in ranges
            ) + f"--{boundary}--\r\n".encode()
            media_type = f"multipart/byteranges; boundary={boundary}"
    headers["Content-Length"] = str(len(content))
    return Response(b"" if request.method == "HEAD" else content,
                    status_code=status_code, media_type=media_type, headers=headers)