tigrcorn-static 0.3.16.dev12__tar.gz → 0.3.17.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/PKG-INFO +4 -4
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/pyproject.toml +4 -4
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/__init__.py +34 -0
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/app.py +92 -0
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/models.py +45 -0
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/mounting.py +99 -0
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/representations.py +167 -0
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/responses.py +189 -0
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/security.py +228 -0
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static.egg-info/PKG-INFO +4 -4
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static.egg-info/SOURCES.txt +17 -0
- tigrcorn_static-0.3.17.dev1/src/tigrcorn_static.egg-info/requires.txt +3 -0
- tigrcorn_static-0.3.16.dev12/src/tigrcorn_static/static.py +0 -557
- tigrcorn_static-0.3.16.dev12/src/tigrcorn_static.egg-info/SOURCES.txt +0 -11
- tigrcorn_static-0.3.16.dev12/src/tigrcorn_static.egg-info/requires.txt +0 -3
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/LICENSE +0 -0
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/README.md +0 -0
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/setup.cfg +0 -0
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static/__init__.py +0 -0
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static/py.typed +0 -0
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static.egg-info/dependency_links.txt +0 -0
- {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tigrcorn-static
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.17.dev1
|
|
4
4
|
Summary: Static file origin, file-send, validators, ranges, and cache-aware HTTP responses for the Tigrcorn ASGI server.
|
|
5
5
|
Author-email: Jacob Stewart <jacob@swarmauri.com>
|
|
6
6
|
License: Apache License
|
|
@@ -186,9 +186,9 @@ Classifier: Typing :: Typed
|
|
|
186
186
|
Requires-Python: <3.15,>=3.10
|
|
187
187
|
Description-Content-Type: text/markdown
|
|
188
188
|
License-File: LICENSE
|
|
189
|
-
Requires-Dist: tigrcorn-core==0.3.
|
|
190
|
-
Requires-Dist: tigrcorn-asgi==0.3.
|
|
191
|
-
Requires-Dist: tigrcorn-http==0.3.
|
|
189
|
+
Requires-Dist: tigrcorn-core==0.3.17.dev1
|
|
190
|
+
Requires-Dist: tigrcorn-asgi==0.3.17.dev1
|
|
191
|
+
Requires-Dist: tigrcorn-http==0.3.17.dev1
|
|
192
192
|
Dynamic: license-file
|
|
193
193
|
|
|
194
194
|
<div align="center">
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tigrcorn-static"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.17.dev1"
|
|
8
8
|
description = "Static file origin, file-send, validators, ranges, and cache-aware HTTP responses for the Tigrcorn ASGI server."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10,<3.15"
|
|
@@ -29,9 +29,9 @@ classifiers = [
|
|
|
29
29
|
"Typing :: Typed",
|
|
30
30
|
]
|
|
31
31
|
dependencies = [
|
|
32
|
-
"tigrcorn-core==0.3.
|
|
33
|
-
"tigrcorn-asgi==0.3.
|
|
34
|
-
"tigrcorn-http==0.3.
|
|
32
|
+
"tigrcorn-core==0.3.17.dev1",
|
|
33
|
+
"tigrcorn-asgi==0.3.17.dev1",
|
|
34
|
+
"tigrcorn-http==0.3.17.dev1",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[tool.setuptools]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .app import StaticFilesApp
|
|
4
|
+
from .mounting import mount_static_app, normalize_static_route
|
|
5
|
+
from .security import (
|
|
6
|
+
StaticSecurityCertificationError,
|
|
7
|
+
StaticSecurityPolicy,
|
|
8
|
+
certify_static_delivery_security,
|
|
9
|
+
static_alt_svc_headers,
|
|
10
|
+
static_cache_headers,
|
|
11
|
+
static_delivery_certification_artifact,
|
|
12
|
+
static_security_policy,
|
|
13
|
+
validate_static_early_hints,
|
|
14
|
+
validate_static_path,
|
|
15
|
+
validate_static_range_request,
|
|
16
|
+
validate_static_resolved_path,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"StaticFilesApp",
|
|
21
|
+
"StaticSecurityCertificationError",
|
|
22
|
+
"StaticSecurityPolicy",
|
|
23
|
+
"certify_static_delivery_security",
|
|
24
|
+
"mount_static_app",
|
|
25
|
+
"normalize_static_route",
|
|
26
|
+
"static_alt_svc_headers",
|
|
27
|
+
"static_cache_headers",
|
|
28
|
+
"static_delivery_certification_artifact",
|
|
29
|
+
"static_security_policy",
|
|
30
|
+
"validate_static_early_hints",
|
|
31
|
+
"validate_static_path",
|
|
32
|
+
"validate_static_range_request",
|
|
33
|
+
"validate_static_resolved_path",
|
|
34
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from .representations import StaticRepresentationMixin
|
|
8
|
+
from .responses import StaticResponseMixin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StaticFilesApp(StaticResponseMixin, StaticRepresentationMixin):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
directory: str | Path,
|
|
15
|
+
*,
|
|
16
|
+
index_file: str | None = "index.html",
|
|
17
|
+
dir_to_file: bool = True,
|
|
18
|
+
expires: int | None = None,
|
|
19
|
+
default_headers: Iterable[tuple[bytes, bytes] | tuple[str, str]] = (),
|
|
20
|
+
apply_content_coding: bool = True,
|
|
21
|
+
content_coding_policy: str = "allowlist",
|
|
22
|
+
content_codings: Iterable[str] = ("br", "gzip", "deflate"),
|
|
23
|
+
use_precompressed_sidecars: bool = True,
|
|
24
|
+
precompressed_codings: Iterable[str] = ("br", "gzip"),
|
|
25
|
+
) -> None:
|
|
26
|
+
self.directory = Path(directory).resolve()
|
|
27
|
+
self.index_file = index_file
|
|
28
|
+
self.dir_to_file = bool(dir_to_file)
|
|
29
|
+
self.expires = None if expires is None else int(expires)
|
|
30
|
+
self.default_headers = [
|
|
31
|
+
(
|
|
32
|
+
name if isinstance(name, bytes) else str(name).encode("latin1"),
|
|
33
|
+
value if isinstance(value, bytes) else str(value).encode("latin1"),
|
|
34
|
+
)
|
|
35
|
+
for name, value in default_headers
|
|
36
|
+
]
|
|
37
|
+
self.apply_content_coding = apply_content_coding
|
|
38
|
+
self.content_coding_policy = str(content_coding_policy)
|
|
39
|
+
self.content_codings = tuple(str(coding) for coding in content_codings)
|
|
40
|
+
self.use_precompressed_sidecars = bool(use_precompressed_sidecars)
|
|
41
|
+
self.precompressed_codings = tuple(str(coding).lower() for coding in precompressed_codings)
|
|
42
|
+
self._etag_cache: dict[tuple[str, int, int], bytes] = {}
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _supports_file_response(scope: dict) -> bool:
|
|
46
|
+
extensions = scope.get("extensions") or {}
|
|
47
|
+
return bool(extensions.get("tigrcorn.http.response.file"))
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _supports_pathsend(scope: dict) -> bool:
|
|
51
|
+
extensions = scope.get("extensions") or {}
|
|
52
|
+
return "http.response.pathsend" in extensions
|
|
53
|
+
|
|
54
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
55
|
+
if scope["type"] != "http":
|
|
56
|
+
raise RuntimeError("StaticFilesApp only supports HTTP scopes")
|
|
57
|
+
method = str(scope.get("method", "GET")).upper()
|
|
58
|
+
if method not in {"GET", "HEAD"}:
|
|
59
|
+
await send(
|
|
60
|
+
{
|
|
61
|
+
"type": "http.response.start",
|
|
62
|
+
"status": 405,
|
|
63
|
+
"headers": [(b"allow", b"GET, HEAD"), (b"content-type", b"text/plain; charset=utf-8")],
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
await send({"type": "http.response.body", "body": b"method not allowed"})
|
|
67
|
+
return
|
|
68
|
+
request_headers = [(bytes(name).lower(), bytes(value)) for name, value in scope.get("headers", [])]
|
|
69
|
+
supports_file_response = self._supports_file_response(scope)
|
|
70
|
+
supports_pathsend = self._supports_pathsend(scope)
|
|
71
|
+
response = await self._response_for_path(
|
|
72
|
+
method,
|
|
73
|
+
scope.get("path", "/"),
|
|
74
|
+
request_headers,
|
|
75
|
+
supports_streaming_response=supports_file_response or supports_pathsend,
|
|
76
|
+
)
|
|
77
|
+
await send({"type": "http.response.start", "status": response.status, "headers": response.headers})
|
|
78
|
+
if response.preprocessed:
|
|
79
|
+
pathsend_segment = self._pathsend_segment(response) if supports_pathsend else None
|
|
80
|
+
if pathsend_segment is not None:
|
|
81
|
+
await send({"type": "http.response.pathsend", "path": os.fspath(pathsend_segment.path)})
|
|
82
|
+
return
|
|
83
|
+
if supports_file_response and response.segments:
|
|
84
|
+
await send(
|
|
85
|
+
{
|
|
86
|
+
"type": "tigrcorn.http.response.file",
|
|
87
|
+
"segments": self._serialize_segments(response.segments),
|
|
88
|
+
"more_body": False,
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
await send({"type": "http.response.body", "body": response.body})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tigrcorn_asgi.send import FileBodySegment, MemoryBodySegment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HeaderList = list[tuple[bytes, bytes]]
|
|
11
|
+
PRECOMPRESSED_SIDECAR_SUFFIXES: dict[str, str] = {"br": ".br", "gzip": ".gz"}
|
|
12
|
+
BUFFERED_DYNAMIC_CODING_MAX_BYTES = 256 * 1024
|
|
13
|
+
MAX_ETAG_CACHE_ENTRIES = 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class StaticFileResponse:
|
|
18
|
+
status: int
|
|
19
|
+
headers: HeaderList
|
|
20
|
+
body: bytes = b""
|
|
21
|
+
segments: tuple[MemoryBodySegment | FileBodySegment, ...] = ()
|
|
22
|
+
preprocessed: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class StaticPathDecision:
|
|
27
|
+
accepted: bool
|
|
28
|
+
normalized_path: str | None
|
|
29
|
+
reason: str
|
|
30
|
+
|
|
31
|
+
def as_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"accepted": self.accepted,
|
|
34
|
+
"normalized_path": self.normalized_path,
|
|
35
|
+
"reason": self.reason,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class SelectedRepresentation:
|
|
41
|
+
path: Path
|
|
42
|
+
content_encoding: str | None
|
|
43
|
+
mtime: float
|
|
44
|
+
size: int
|
|
45
|
+
mtime_ns: int
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from tigrcorn_core.types import ASGIApp
|
|
7
|
+
from tigrcorn_core.utils.proxy import strip_root_path
|
|
8
|
+
|
|
9
|
+
from .app import StaticFilesApp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _not_found_app(scope, receive, send) -> None:
|
|
13
|
+
if scope["type"] == "lifespan":
|
|
14
|
+
while True:
|
|
15
|
+
message = await receive()
|
|
16
|
+
if message["type"] == "lifespan.startup":
|
|
17
|
+
await send({"type": "lifespan.startup.complete"})
|
|
18
|
+
elif message["type"] == "lifespan.shutdown":
|
|
19
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
20
|
+
return
|
|
21
|
+
if scope["type"] == "websocket":
|
|
22
|
+
await send({"type": "websocket.close", "code": 1000})
|
|
23
|
+
return
|
|
24
|
+
if scope["type"] != "http":
|
|
25
|
+
raise RuntimeError(f"unsupported scope type for static fallback: {scope['type']!r}")
|
|
26
|
+
if scope.get("method", "GET").upper() not in {"GET", "HEAD"}:
|
|
27
|
+
await send(
|
|
28
|
+
{
|
|
29
|
+
"type": "http.response.start",
|
|
30
|
+
"status": 405,
|
|
31
|
+
"headers": [(b"allow", b"GET, HEAD"), (b"content-type", b"text/plain; charset=utf-8")],
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
await send({"type": "http.response.body", "body": b"method not allowed"})
|
|
35
|
+
return
|
|
36
|
+
await send({"type": "http.response.start", "status": 404, "headers": [(b"content-type", b"text/plain; charset=utf-8")]})
|
|
37
|
+
await send({"type": "http.response.body", "body": b"not found"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_static_route(route: str | None) -> str:
|
|
41
|
+
if not route:
|
|
42
|
+
return "/"
|
|
43
|
+
return ("/" + str(route).lstrip("/")).rstrip("/") or "/"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _route_matches(route: str, path: str) -> bool:
|
|
47
|
+
if route == "/":
|
|
48
|
+
return True
|
|
49
|
+
return path == route or path.startswith(route + "/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def mount_static_app(
|
|
53
|
+
app: ASGIApp | None,
|
|
54
|
+
*,
|
|
55
|
+
route: str,
|
|
56
|
+
directory: str | Path,
|
|
57
|
+
dir_to_file: bool = True,
|
|
58
|
+
index_file: str | None = "index.html",
|
|
59
|
+
expires: int | None = None,
|
|
60
|
+
apply_content_coding: bool = True,
|
|
61
|
+
content_coding_policy: str = "allowlist",
|
|
62
|
+
content_codings: Iterable[str] = ("br", "gzip", "deflate"),
|
|
63
|
+
use_precompressed_sidecars: bool = True,
|
|
64
|
+
precompressed_codings: Iterable[str] = ("br", "gzip"),
|
|
65
|
+
) -> ASGIApp:
|
|
66
|
+
static_app = StaticFilesApp(
|
|
67
|
+
directory,
|
|
68
|
+
index_file=index_file,
|
|
69
|
+
dir_to_file=dir_to_file,
|
|
70
|
+
expires=expires,
|
|
71
|
+
apply_content_coding=apply_content_coding,
|
|
72
|
+
content_coding_policy=content_coding_policy,
|
|
73
|
+
content_codings=content_codings,
|
|
74
|
+
use_precompressed_sidecars=use_precompressed_sidecars,
|
|
75
|
+
precompressed_codings=precompressed_codings,
|
|
76
|
+
)
|
|
77
|
+
fallback = app or _not_found_app
|
|
78
|
+
normalized_route = normalize_static_route(route)
|
|
79
|
+
|
|
80
|
+
async def wrapped(scope, receive, send) -> None:
|
|
81
|
+
if scope["type"] != "http":
|
|
82
|
+
await fallback(scope, receive, send)
|
|
83
|
+
return
|
|
84
|
+
path = str(scope.get("path") or "/")
|
|
85
|
+
if not _route_matches(normalized_route, path):
|
|
86
|
+
await fallback(scope, receive, send)
|
|
87
|
+
return
|
|
88
|
+
raw_path = bytes(scope.get("raw_path") or path.encode("latin1"))
|
|
89
|
+
mounted_path, mounted_raw_path = strip_root_path(path, raw_path, normalized_route)
|
|
90
|
+
mounted_scope = dict(scope)
|
|
91
|
+
mounted_scope["path"] = mounted_path
|
|
92
|
+
mounted_scope["raw_path"] = mounted_raw_path
|
|
93
|
+
if normalized_route != "/":
|
|
94
|
+
existing_root = str(scope.get("root_path") or "")
|
|
95
|
+
combined_root = (existing_root.rstrip("/") + normalized_route).rstrip("/") or "/"
|
|
96
|
+
mounted_scope["root_path"] = combined_root
|
|
97
|
+
await static_app(mounted_scope, receive, send)
|
|
98
|
+
|
|
99
|
+
return wrapped
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import mimetypes
|
|
5
|
+
import time
|
|
6
|
+
from email.utils import formatdate
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
|
|
10
|
+
from tigrcorn_asgi.send import FileBodySegment, iter_response_body_segments
|
|
11
|
+
from tigrcorn_core.utils.headers import append_if_missing, get_header
|
|
12
|
+
|
|
13
|
+
from .models import (
|
|
14
|
+
HeaderList,
|
|
15
|
+
MAX_ETAG_CACHE_ENTRIES,
|
|
16
|
+
PRECOMPRESSED_SIDECAR_SUFFIXES,
|
|
17
|
+
SelectedRepresentation,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StaticRepresentationMixin:
|
|
22
|
+
def _resolve_candidate(self, path: str) -> Path | None:
|
|
23
|
+
decoded = unquote(path or "/")
|
|
24
|
+
if "\x00" in decoded:
|
|
25
|
+
return None
|
|
26
|
+
parts: list[str] = []
|
|
27
|
+
for part in PurePosixPath(decoded).parts:
|
|
28
|
+
if part in {"", "/", "."}:
|
|
29
|
+
continue
|
|
30
|
+
if part == "..":
|
|
31
|
+
return None
|
|
32
|
+
if "\\" in part:
|
|
33
|
+
return None
|
|
34
|
+
parts.append(part)
|
|
35
|
+
candidate = self.directory.joinpath(*parts).resolve()
|
|
36
|
+
try:
|
|
37
|
+
candidate.relative_to(self.directory)
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
if candidate.is_dir():
|
|
41
|
+
if not self.dir_to_file or not self.index_file:
|
|
42
|
+
return None
|
|
43
|
+
candidate = candidate / self.index_file
|
|
44
|
+
try:
|
|
45
|
+
candidate.relative_to(self.directory)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
return candidate
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _parse_qvalue(raw: str) -> float:
|
|
52
|
+
try:
|
|
53
|
+
value = float(raw)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return 0.0
|
|
56
|
+
if value < 0.0:
|
|
57
|
+
return 0.0
|
|
58
|
+
if value > 1.0:
|
|
59
|
+
return 1.0
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
def _preferred_precompressed_codings(
|
|
63
|
+
self,
|
|
64
|
+
request_headers: list[tuple[bytes, bytes]],
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
header_value = get_header(request_headers, b"accept-encoding")
|
|
67
|
+
if header_value is None:
|
|
68
|
+
return []
|
|
69
|
+
wildcard_q: float | None = None
|
|
70
|
+
coding_q: dict[str, float] = {}
|
|
71
|
+
order: dict[str, int] = {}
|
|
72
|
+
for index, part in enumerate(header_value.decode("ascii", "ignore").split(",")):
|
|
73
|
+
token = part.strip()
|
|
74
|
+
if not token:
|
|
75
|
+
continue
|
|
76
|
+
name, *params = [piece.strip() for piece in token.split(";")]
|
|
77
|
+
lower = name.lower()
|
|
78
|
+
q = 1.0
|
|
79
|
+
for param in params:
|
|
80
|
+
if "=" not in param:
|
|
81
|
+
continue
|
|
82
|
+
key, value = param.split("=", 1)
|
|
83
|
+
if key.strip().lower() == "q":
|
|
84
|
+
q = self._parse_qvalue(value.strip())
|
|
85
|
+
if lower == "*":
|
|
86
|
+
wildcard_q = q
|
|
87
|
+
else:
|
|
88
|
+
coding_q[lower] = q
|
|
89
|
+
order.setdefault(lower, index)
|
|
90
|
+
|
|
91
|
+
ranked: list[tuple[float, int, str]] = []
|
|
92
|
+
for index, coding in enumerate(self.precompressed_codings):
|
|
93
|
+
q = coding_q.get(coding)
|
|
94
|
+
if q is None:
|
|
95
|
+
q = wildcard_q if wildcard_q is not None else 0.0
|
|
96
|
+
if q <= 0.0:
|
|
97
|
+
continue
|
|
98
|
+
ranked.append((-q, order.get(coding, 1000 + index), coding))
|
|
99
|
+
ranked.sort()
|
|
100
|
+
return [coding for _neg_q, _order, coding in ranked]
|
|
101
|
+
|
|
102
|
+
def _select_representation(
|
|
103
|
+
self,
|
|
104
|
+
candidate: Path,
|
|
105
|
+
request_headers: list[tuple[bytes, bytes]],
|
|
106
|
+
) -> SelectedRepresentation:
|
|
107
|
+
origin_stat = candidate.stat()
|
|
108
|
+
if not self.apply_content_coding or not self.use_precompressed_sidecars:
|
|
109
|
+
return SelectedRepresentation(candidate, None, origin_stat.st_mtime, origin_stat.st_size, origin_stat.st_mtime_ns)
|
|
110
|
+
if get_header(request_headers, b"range") is not None:
|
|
111
|
+
return SelectedRepresentation(candidate, None, origin_stat.st_mtime, origin_stat.st_size, origin_stat.st_mtime_ns)
|
|
112
|
+
for coding in self._preferred_precompressed_codings(request_headers):
|
|
113
|
+
suffix = PRECOMPRESSED_SIDECAR_SUFFIXES.get(coding)
|
|
114
|
+
if suffix is None:
|
|
115
|
+
continue
|
|
116
|
+
sidecar = candidate.with_name(candidate.name + suffix)
|
|
117
|
+
if not sidecar.exists() or not sidecar.is_file():
|
|
118
|
+
continue
|
|
119
|
+
sidecar_stat = sidecar.stat()
|
|
120
|
+
return SelectedRepresentation(
|
|
121
|
+
sidecar,
|
|
122
|
+
coding,
|
|
123
|
+
max(origin_stat.st_mtime, sidecar_stat.st_mtime),
|
|
124
|
+
sidecar_stat.st_size,
|
|
125
|
+
max(origin_stat.st_mtime_ns, sidecar_stat.st_mtime_ns),
|
|
126
|
+
)
|
|
127
|
+
return SelectedRepresentation(candidate, None, origin_stat.st_mtime, origin_stat.st_size, origin_stat.st_mtime_ns)
|
|
128
|
+
|
|
129
|
+
async def _representation_etag(self, representation: SelectedRepresentation) -> bytes:
|
|
130
|
+
cache_key = (str(representation.path), representation.size, representation.mtime_ns)
|
|
131
|
+
cached = self._etag_cache.get(cache_key)
|
|
132
|
+
if cached is not None:
|
|
133
|
+
return cached
|
|
134
|
+
digest = hashlib.blake2s(digest_size=16)
|
|
135
|
+
segment = FileBodySegment(str(representation.path), 0, representation.size)
|
|
136
|
+
async for chunk in iter_response_body_segments((segment,)):
|
|
137
|
+
digest.update(chunk)
|
|
138
|
+
value = b'"' + digest.hexdigest().encode("ascii") + b'"'
|
|
139
|
+
self._etag_cache[cache_key] = value
|
|
140
|
+
if len(self._etag_cache) > MAX_ETAG_CACHE_ENTRIES:
|
|
141
|
+
self._etag_cache.pop(next(iter(self._etag_cache)))
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
def _base_headers(
|
|
145
|
+
self,
|
|
146
|
+
candidate: Path,
|
|
147
|
+
representation: SelectedRepresentation,
|
|
148
|
+
*,
|
|
149
|
+
etag: bytes,
|
|
150
|
+
) -> HeaderList:
|
|
151
|
+
content_type = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
|
|
152
|
+
headers: HeaderList = [
|
|
153
|
+
(b"content-type", content_type.encode("latin1")),
|
|
154
|
+
(b"last-modified", formatdate(representation.mtime, usegmt=True).encode("ascii")),
|
|
155
|
+
(b"etag", etag),
|
|
156
|
+
*self.default_headers,
|
|
157
|
+
]
|
|
158
|
+
if representation.content_encoding is not None:
|
|
159
|
+
headers.append((b"content-encoding", representation.content_encoding.encode("ascii")))
|
|
160
|
+
append_if_missing(headers, b"vary", b"accept-encoding")
|
|
161
|
+
if self.expires is not None:
|
|
162
|
+
if self.expires <= 0:
|
|
163
|
+
headers.append((b"cache-control", b"no-store"))
|
|
164
|
+
else:
|
|
165
|
+
headers.append((b"cache-control", f"public, max-age={self.expires}".encode("ascii")))
|
|
166
|
+
headers.append((b"expires", formatdate(time.time() + self.expires, usegmt=True).encode("ascii")))
|
|
167
|
+
return headers
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tigrcorn_asgi.send import FileBodySegment, MemoryBodySegment, materialize_response_body_segments
|
|
7
|
+
from tigrcorn_core.utils.headers import get_header
|
|
8
|
+
from tigrcorn_http.conditional import apply_conditional_request
|
|
9
|
+
from tigrcorn_http.entity import apply_response_entity_semantics, finalize_response_content_length
|
|
10
|
+
from tigrcorn_http.range import FileRangePlan, plan_file_byte_ranges
|
|
11
|
+
from tigrcorn_protocols.http1.serializer import response_allows_body
|
|
12
|
+
|
|
13
|
+
from .models import BUFFERED_DYNAMIC_CODING_MAX_BYTES, StaticFileResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StaticResponseMixin:
|
|
17
|
+
async def _buffered_dynamic_coding_response(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
method: str,
|
|
21
|
+
request_headers: list[tuple[bytes, bytes]],
|
|
22
|
+
candidate: Path,
|
|
23
|
+
representation,
|
|
24
|
+
) -> StaticFileResponse:
|
|
25
|
+
segment = FileBodySegment(str(representation.path), 0, representation.size)
|
|
26
|
+
body = await materialize_response_body_segments((segment,))
|
|
27
|
+
headers = self._base_headers(candidate, representation, etag=await self._representation_etag(representation))
|
|
28
|
+
processed = apply_response_entity_semantics(
|
|
29
|
+
method=method,
|
|
30
|
+
request_headers=request_headers,
|
|
31
|
+
response_headers=headers,
|
|
32
|
+
body=body,
|
|
33
|
+
status=200,
|
|
34
|
+
apply_content_coding=True,
|
|
35
|
+
content_coding_policy=self.content_coding_policy,
|
|
36
|
+
supported_codings=self.content_codings,
|
|
37
|
+
generate_etag=False,
|
|
38
|
+
)
|
|
39
|
+
segments = (MemoryBodySegment(processed.body),) if processed.body else ()
|
|
40
|
+
return StaticFileResponse(
|
|
41
|
+
status=processed.status,
|
|
42
|
+
headers=processed.headers,
|
|
43
|
+
body=processed.body,
|
|
44
|
+
segments=segments,
|
|
45
|
+
preprocessed=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _multipart_segments(
|
|
50
|
+
*,
|
|
51
|
+
path: Path,
|
|
52
|
+
plan: FileRangePlan,
|
|
53
|
+
total_length: int,
|
|
54
|
+
source_content_type: bytes | None,
|
|
55
|
+
) -> tuple[MemoryBodySegment | FileBodySegment, ...]:
|
|
56
|
+
assert plan.boundary is not None
|
|
57
|
+
segments: list[MemoryBodySegment | FileBodySegment] = []
|
|
58
|
+
for item in plan.parts:
|
|
59
|
+
lines = [b"--" + plan.boundary]
|
|
60
|
+
if source_content_type is not None:
|
|
61
|
+
lines.append(b"Content-Type: " + source_content_type)
|
|
62
|
+
lines.append(b"Content-Range: bytes " + f"{item.start}-{item.end}/{total_length}".encode("ascii"))
|
|
63
|
+
segments.append(MemoryBodySegment(b"\r\n".join(lines) + b"\r\n\r\n"))
|
|
64
|
+
segments.append(FileBodySegment(str(path), item.start, item.end - item.start + 1))
|
|
65
|
+
segments.append(MemoryBodySegment(b"\r\n"))
|
|
66
|
+
segments.append(MemoryBodySegment(b"--" + plan.boundary + b"--\r\n"))
|
|
67
|
+
return tuple(segments)
|
|
68
|
+
|
|
69
|
+
async def _static_file_plan(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
method: str,
|
|
73
|
+
request_headers: list[tuple[bytes, bytes]],
|
|
74
|
+
candidate: Path,
|
|
75
|
+
representation,
|
|
76
|
+
supports_file_response: bool,
|
|
77
|
+
) -> StaticFileResponse:
|
|
78
|
+
etag = await self._representation_etag(representation)
|
|
79
|
+
headers = self._base_headers(candidate, representation, etag=etag)
|
|
80
|
+
conditional = apply_conditional_request(
|
|
81
|
+
method=method.upper(),
|
|
82
|
+
request_headers=request_headers,
|
|
83
|
+
response_headers=headers,
|
|
84
|
+
body=b"",
|
|
85
|
+
status=200,
|
|
86
|
+
)
|
|
87
|
+
if conditional.not_modified or conditional.precondition_failed:
|
|
88
|
+
processed = apply_response_entity_semantics(
|
|
89
|
+
method=method,
|
|
90
|
+
request_headers=request_headers,
|
|
91
|
+
response_headers=conditional.headers,
|
|
92
|
+
body=conditional.body,
|
|
93
|
+
status=conditional.status,
|
|
94
|
+
apply_content_coding=False,
|
|
95
|
+
generate_etag=False,
|
|
96
|
+
)
|
|
97
|
+
segments = (MemoryBodySegment(processed.body),) if processed.body else ()
|
|
98
|
+
return StaticFileResponse(processed.status, processed.headers, processed.body, segments, True)
|
|
99
|
+
|
|
100
|
+
plan = plan_file_byte_ranges(
|
|
101
|
+
method=method,
|
|
102
|
+
request_headers=request_headers,
|
|
103
|
+
response_headers=conditional.headers,
|
|
104
|
+
resource_length=representation.size,
|
|
105
|
+
status=conditional.status,
|
|
106
|
+
)
|
|
107
|
+
headers = finalize_response_content_length(
|
|
108
|
+
method=method.upper(),
|
|
109
|
+
status=plan.status,
|
|
110
|
+
headers=plan.headers,
|
|
111
|
+
body_length=plan.body_length,
|
|
112
|
+
)
|
|
113
|
+
segments: tuple[MemoryBodySegment | FileBodySegment, ...]
|
|
114
|
+
if method.upper() == "HEAD" or not response_allows_body(plan.status) or plan.unsatisfied:
|
|
115
|
+
segments = ()
|
|
116
|
+
elif plan.applied and len(plan.parts) > 1:
|
|
117
|
+
source_content_type = mimetypes.guess_type(str(candidate))[0]
|
|
118
|
+
segments = self._multipart_segments(
|
|
119
|
+
path=representation.path,
|
|
120
|
+
plan=plan,
|
|
121
|
+
total_length=representation.size,
|
|
122
|
+
source_content_type=None if source_content_type is None else source_content_type.encode("latin1"),
|
|
123
|
+
)
|
|
124
|
+
elif plan.applied and len(plan.parts) == 1:
|
|
125
|
+
item = plan.parts[0]
|
|
126
|
+
segments = (FileBodySegment(str(representation.path), item.start, item.end - item.start + 1),)
|
|
127
|
+
else:
|
|
128
|
+
segments = (FileBodySegment(str(representation.path), 0, representation.size),)
|
|
129
|
+
|
|
130
|
+
body = await materialize_response_body_segments(segments) if (segments and not supports_file_response) else b""
|
|
131
|
+
return StaticFileResponse(plan.status, headers, body, segments, True)
|
|
132
|
+
|
|
133
|
+
async def _response_for_path(
|
|
134
|
+
self,
|
|
135
|
+
method: str,
|
|
136
|
+
path: str,
|
|
137
|
+
request_headers: list[tuple[bytes, bytes]],
|
|
138
|
+
*,
|
|
139
|
+
supports_streaming_response: bool,
|
|
140
|
+
) -> StaticFileResponse:
|
|
141
|
+
candidate = self._resolve_candidate(path)
|
|
142
|
+
if candidate is None or not candidate.exists() or not candidate.is_file():
|
|
143
|
+
return StaticFileResponse(404, [(b"content-type", b"text/plain; charset=utf-8")], b"not found")
|
|
144
|
+
representation = self._select_representation(candidate, request_headers)
|
|
145
|
+
if (
|
|
146
|
+
representation.content_encoding is None
|
|
147
|
+
and self.apply_content_coding
|
|
148
|
+
and get_header(request_headers, b"accept-encoding") is not None
|
|
149
|
+
and get_header(request_headers, b"range") is None
|
|
150
|
+
and representation.size <= BUFFERED_DYNAMIC_CODING_MAX_BYTES
|
|
151
|
+
):
|
|
152
|
+
return await self._buffered_dynamic_coding_response(
|
|
153
|
+
method=method,
|
|
154
|
+
request_headers=request_headers,
|
|
155
|
+
candidate=candidate,
|
|
156
|
+
representation=representation,
|
|
157
|
+
)
|
|
158
|
+
return await self._static_file_plan(
|
|
159
|
+
method=method,
|
|
160
|
+
request_headers=request_headers,
|
|
161
|
+
candidate=candidate,
|
|
162
|
+
representation=representation,
|
|
163
|
+
supports_file_response=supports_streaming_response,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _pathsend_segment(response: StaticFileResponse) -> FileBodySegment | None:
|
|
168
|
+
if not response.segments or len(response.segments) != 1:
|
|
169
|
+
return None
|
|
170
|
+
segment = response.segments[0]
|
|
171
|
+
if not isinstance(segment, FileBodySegment) or segment.offset != 0:
|
|
172
|
+
return None
|
|
173
|
+
if segment.count is None:
|
|
174
|
+
return segment
|
|
175
|
+
try:
|
|
176
|
+
size = Path(segment.path).stat().st_size
|
|
177
|
+
except FileNotFoundError:
|
|
178
|
+
return None
|
|
179
|
+
return segment if segment.count == size else None
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _serialize_segments(segments: tuple[MemoryBodySegment | FileBodySegment, ...]) -> list[dict]:
|
|
183
|
+
serialized: list[dict] = []
|
|
184
|
+
for segment in segments:
|
|
185
|
+
if isinstance(segment, MemoryBodySegment):
|
|
186
|
+
serialized.append({"type": "memory", "body": segment.data})
|
|
187
|
+
else:
|
|
188
|
+
serialized.append({"type": "file", "path": segment.path, "offset": segment.offset, "count": segment.count})
|
|
189
|
+
return serialized
|