static-http 0.1.2__tar.gz → 0.1.4__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.
- {static_http-0.1.2/src/static_http.egg-info → static_http-0.1.4}/PKG-INFO +6 -3
- {static_http-0.1.2 → static_http-0.1.4}/README.md +2 -0
- {static_http-0.1.2 → static_http-0.1.4}/pyproject.toml +6 -4
- {static_http-0.1.2 → static_http-0.1.4}/src/http_here/__init__.py +1 -1
- {static_http-0.1.2 → static_http-0.1.4}/src/http_here/cli.py +10 -1
- static_http-0.1.4/src/http_here/qrcode.py +380 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/http_here/server.py +113 -18
- {static_http-0.1.2 → static_http-0.1.4/src/static_http.egg-info}/PKG-INFO +6 -3
- {static_http-0.1.2 → static_http-0.1.4}/tests/test_qrcode.py +25 -0
- {static_http-0.1.2 → static_http-0.1.4}/tests/test_server.py +60 -3
- static_http-0.1.2/src/http_here/qrcode.py +0 -81
- {static_http-0.1.2 → static_http-0.1.4}/LICENSE +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/setup.cfg +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/http_here/__main__.py +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/http_here/keyboard.py +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/http_here/ranges.py +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/http_here/urls.py +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/SOURCES.txt +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/dependency_links.txt +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/entry_points.txt +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/requires.txt +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/src/static_http.egg-info/top_level.txt +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/tests/test_cli.py +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/tests/test_ranges.py +0 -0
- {static_http-0.1.2 → static_http-0.1.4}/tests/test_urls.py +0 -0
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: static-http
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A temporary dependency-free static HTTP server with byte-range support.
|
|
5
5
|
Author: John Paul Ellis
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Keywords: http,server,static-http,static,range,cli
|
|
8
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
9
8
|
Classifier: Programming Language :: Python :: 3
|
|
10
9
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
14
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
14
15
|
Requires-Python: >=3.10
|
|
15
16
|
Description-Content-Type: text/markdown
|
|
@@ -72,6 +73,7 @@ By default:
|
|
|
72
73
|
- `--no-cache` — send `Cache-Control: no-store`.
|
|
73
74
|
- `--quiet` — suppress per-request logs.
|
|
74
75
|
- `--verbose` — print detailed startup/binding information.
|
|
76
|
+
- `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
|
|
75
77
|
- `--version` — print package version and exit.
|
|
76
78
|
|
|
77
79
|
## Examples
|
|
@@ -82,6 +84,7 @@ static-http --qr
|
|
|
82
84
|
static-http --no-cache
|
|
83
85
|
static-http --quiet
|
|
84
86
|
static-http --verbose
|
|
87
|
+
static-http --include-hidden
|
|
85
88
|
static-http --port 9000 --cors
|
|
86
89
|
static-http --no-dir-list
|
|
87
90
|
```
|
|
@@ -50,6 +50,7 @@ By default:
|
|
|
50
50
|
- `--no-cache` — send `Cache-Control: no-store`.
|
|
51
51
|
- `--quiet` — suppress per-request logs.
|
|
52
52
|
- `--verbose` — print detailed startup/binding information.
|
|
53
|
+
- `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
|
|
53
54
|
- `--version` — print package version and exit.
|
|
54
55
|
|
|
55
56
|
## Examples
|
|
@@ -60,6 +61,7 @@ static-http --qr
|
|
|
60
61
|
static-http --no-cache
|
|
61
62
|
static-http --quiet
|
|
62
63
|
static-http --verbose
|
|
64
|
+
static-http --include-hidden
|
|
63
65
|
static-http --port 9000 --cors
|
|
64
66
|
static-http --no-dir-list
|
|
65
67
|
```
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "static-http"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "A temporary dependency-free static HTTP server with byte-range support."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
|
-
license =
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
12
13
|
authors = [{ name = "John Paul Ellis" }]
|
|
13
14
|
dependencies = []
|
|
14
15
|
keywords = ["http", "server", "static-http", "static", "range", "cli"]
|
|
15
16
|
classifiers = [
|
|
16
|
-
"License :: OSI Approved :: MIT License",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
18
|
"Programming Language :: Python :: 3 :: Only",
|
|
19
19
|
"Programming Language :: Python :: 3.10",
|
|
20
20
|
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
21
23
|
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
22
24
|
]
|
|
23
25
|
|
|
@@ -125,6 +125,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
125
125
|
action="store_true",
|
|
126
126
|
help="Print extra startup and binding information.",
|
|
127
127
|
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--include-hidden",
|
|
130
|
+
action="store_true",
|
|
131
|
+
help="Include dot-prefixed files/directories when serving and listing directories.",
|
|
132
|
+
)
|
|
128
133
|
parser.add_argument(
|
|
129
134
|
"--version",
|
|
130
135
|
action="version",
|
|
@@ -166,12 +171,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
166
171
|
bind_port = args.port
|
|
167
172
|
|
|
168
173
|
response_headers = build_response_headers(cors=args.cors, no_cache=args.no_cache, headers=args.header)
|
|
174
|
+
effective_quiet = args.quiet or not args.verbose
|
|
169
175
|
|
|
170
176
|
handler = make_handler(
|
|
171
177
|
directory=root,
|
|
172
178
|
extra_headers=response_headers,
|
|
173
179
|
disable_dir_list=args.no_dir_list,
|
|
174
|
-
quiet=
|
|
180
|
+
quiet=effective_quiet,
|
|
181
|
+
show_hidden=args.include_hidden,
|
|
175
182
|
)
|
|
176
183
|
|
|
177
184
|
try:
|
|
@@ -180,6 +187,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
180
187
|
print(f"Could not bind {bind_host}:{bind_port}: {exc}", file=sys.stderr)
|
|
181
188
|
return 1
|
|
182
189
|
|
|
190
|
+
server.verbose = bool(args.verbose)
|
|
191
|
+
|
|
183
192
|
actual_bind = server.server_address[0]
|
|
184
193
|
actual_port = server.server_address[1]
|
|
185
194
|
discovered_lan = urls.discover_lan_urls() if urls.is_all_interfaces_bind(actual_bind) else []
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Minimal terminal QR rendering without external dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_ECC_CODEWORDS = {
|
|
11
|
+
1: 7,
|
|
12
|
+
2: 10,
|
|
13
|
+
3: 15,
|
|
14
|
+
4: 20,
|
|
15
|
+
}
|
|
16
|
+
_DATA_CODEWORDS = {
|
|
17
|
+
1: 19,
|
|
18
|
+
2: 34,
|
|
19
|
+
3: 55,
|
|
20
|
+
4: 80,
|
|
21
|
+
}
|
|
22
|
+
_ANSI_BLACK = "\x1b[40m"
|
|
23
|
+
_ANSI_WHITE = "\x1b[107m"
|
|
24
|
+
_ANSI_RESET = "\x1b[0m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _gf_mul(x: int, y: int) -> int:
|
|
28
|
+
result = 0
|
|
29
|
+
while y:
|
|
30
|
+
if y & 1:
|
|
31
|
+
result ^= x
|
|
32
|
+
y >>= 1
|
|
33
|
+
x <<= 1
|
|
34
|
+
if x & 0x100:
|
|
35
|
+
x ^= 0x11D
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _gf_pow(x: int, power: int) -> int:
|
|
40
|
+
result = 1
|
|
41
|
+
for _ in range(power):
|
|
42
|
+
result = _gf_mul(result, x)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _generator_poly(degree: int) -> list[int]:
|
|
47
|
+
result = [1]
|
|
48
|
+
for i in range(degree):
|
|
49
|
+
result = _poly_mul(result, [1, _gf_pow(2, i)])
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _poly_mul(left: list[int], right: list[int]) -> list[int]:
|
|
54
|
+
result = [0] * (len(left) + len(right) - 1)
|
|
55
|
+
for i, left_value in enumerate(left):
|
|
56
|
+
for j, right_value in enumerate(right):
|
|
57
|
+
result[i + j] ^= _gf_mul(left_value, right_value)
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _reed_solomon(data: list[int], degree: int) -> list[int]:
|
|
62
|
+
generator = _generator_poly(degree)
|
|
63
|
+
result = data + [0] * degree
|
|
64
|
+
for i, value in enumerate(data):
|
|
65
|
+
if value == 0:
|
|
66
|
+
continue
|
|
67
|
+
for j, coefficient in enumerate(generator):
|
|
68
|
+
result[i + j] ^= _gf_mul(coefficient, value)
|
|
69
|
+
return result[-degree:]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _append_bits(bits: list[int], value: int, length: int) -> None:
|
|
73
|
+
for i in range(length - 1, -1, -1):
|
|
74
|
+
bits.append((value >> i) & 1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _choose_version(data: bytes) -> int:
|
|
78
|
+
needed_bits = 4 + 8 + len(data) * 8
|
|
79
|
+
for version, data_codewords in _DATA_CODEWORDS.items():
|
|
80
|
+
if needed_bits <= data_codewords * 8:
|
|
81
|
+
return version
|
|
82
|
+
raise ValueError("URL is too long for the built-in QR renderer")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _encode_data(data: bytes, version: int) -> list[int]:
|
|
86
|
+
bits: list[int] = []
|
|
87
|
+
_append_bits(bits, 0b0100, 4) # Byte mode.
|
|
88
|
+
_append_bits(bits, len(data), 8)
|
|
89
|
+
for byte in data:
|
|
90
|
+
_append_bits(bits, byte, 8)
|
|
91
|
+
|
|
92
|
+
capacity = _DATA_CODEWORDS[version] * 8
|
|
93
|
+
_append_bits(bits, 0, min(4, capacity - len(bits)))
|
|
94
|
+
while len(bits) % 8:
|
|
95
|
+
bits.append(0)
|
|
96
|
+
|
|
97
|
+
pad = [0xEC, 0x11]
|
|
98
|
+
pad_index = 0
|
|
99
|
+
while len(bits) < capacity:
|
|
100
|
+
_append_bits(bits, pad[pad_index % 2], 8)
|
|
101
|
+
pad_index += 1
|
|
102
|
+
|
|
103
|
+
return [int("".join(str(bit) for bit in bits[i : i + 8]), 2) for i in range(0, len(bits), 8)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _set_function(matrix: list[list[bool | None]], function: list[list[bool]], row: int, col: int, dark: bool) -> None:
|
|
107
|
+
matrix[row][col] = dark
|
|
108
|
+
function[row][col] = True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _draw_finder(matrix: list[list[bool | None]], function: list[list[bool]], top: int, left: int) -> None:
|
|
112
|
+
size = len(matrix)
|
|
113
|
+
for r in range(-1, 8):
|
|
114
|
+
for c in range(-1, 8):
|
|
115
|
+
row = top + r
|
|
116
|
+
col = left + c
|
|
117
|
+
if not (0 <= row < size and 0 <= col < size):
|
|
118
|
+
continue
|
|
119
|
+
dark = 0 <= r <= 6 and 0 <= c <= 6 and (r in (0, 6) or c in (0, 6) or (2 <= r <= 4 and 2 <= c <= 4))
|
|
120
|
+
_set_function(matrix, function, row, col, dark)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _draw_alignment(matrix: list[list[bool | None]], function: list[list[bool]], center: int) -> None:
|
|
124
|
+
for r in range(-2, 3):
|
|
125
|
+
for c in range(-2, 3):
|
|
126
|
+
dark = max(abs(r), abs(c)) != 1
|
|
127
|
+
_set_function(matrix, function, center + r, center + c, dark)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _draw_function_patterns(matrix: list[list[bool | None]], function: list[list[bool]], version: int) -> None:
|
|
131
|
+
size = len(matrix)
|
|
132
|
+
_draw_finder(matrix, function, 0, 0)
|
|
133
|
+
_draw_finder(matrix, function, 0, size - 7)
|
|
134
|
+
_draw_finder(matrix, function, size - 7, 0)
|
|
135
|
+
if version > 1:
|
|
136
|
+
_draw_alignment(matrix, function, size - 7)
|
|
137
|
+
|
|
138
|
+
for i in range(8, size - 8):
|
|
139
|
+
_set_function(matrix, function, 6, i, i % 2 == 0)
|
|
140
|
+
_set_function(matrix, function, i, 6, i % 2 == 0)
|
|
141
|
+
|
|
142
|
+
_set_function(matrix, function, size - 8, 8, True)
|
|
143
|
+
_draw_format_bits(matrix, function, 0)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _mask(mask: int, row: int, col: int) -> bool:
|
|
147
|
+
if mask == 0:
|
|
148
|
+
return (row + col) % 2 == 0
|
|
149
|
+
if mask == 1:
|
|
150
|
+
return row % 2 == 0
|
|
151
|
+
if mask == 2:
|
|
152
|
+
return col % 3 == 0
|
|
153
|
+
if mask == 3:
|
|
154
|
+
return (row + col) % 3 == 0
|
|
155
|
+
if mask == 4:
|
|
156
|
+
return (row // 2 + col // 3) % 2 == 0
|
|
157
|
+
if mask == 5:
|
|
158
|
+
return (row * col) % 2 + (row * col) % 3 == 0
|
|
159
|
+
if mask == 6:
|
|
160
|
+
return ((row * col) % 2 + (row * col) % 3) % 2 == 0
|
|
161
|
+
return ((row + col) % 2 + (row * col) % 3) % 2 == 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _draw_codewords(matrix: list[list[bool | None]], function: list[list[bool]], codewords: list[int], mask: int) -> None:
|
|
165
|
+
bits = [(codeword >> i) & 1 for codeword in codewords for i in range(7, -1, -1)]
|
|
166
|
+
bit_index = 0
|
|
167
|
+
size = len(matrix)
|
|
168
|
+
row = size - 1
|
|
169
|
+
direction = -1
|
|
170
|
+
col = size - 1
|
|
171
|
+
|
|
172
|
+
while col > 0:
|
|
173
|
+
if col == 6:
|
|
174
|
+
col -= 1
|
|
175
|
+
while 0 <= row < size:
|
|
176
|
+
for offset in range(2):
|
|
177
|
+
c = col - offset
|
|
178
|
+
if function[row][c]:
|
|
179
|
+
continue
|
|
180
|
+
bit = bit_index < len(bits) and bits[bit_index] == 1
|
|
181
|
+
bit_index += 1
|
|
182
|
+
matrix[row][c] = bit ^ _mask(mask, row, c)
|
|
183
|
+
row += direction
|
|
184
|
+
row -= direction
|
|
185
|
+
direction = -direction
|
|
186
|
+
col -= 2
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _format_bits(mask: int) -> int:
|
|
190
|
+
data = (0b01 << 3) | mask # Error correction level L.
|
|
191
|
+
value = data << 10
|
|
192
|
+
generator = 0b10100110111
|
|
193
|
+
for i in range(14, 9, -1):
|
|
194
|
+
if (value >> i) & 1:
|
|
195
|
+
value ^= generator << (i - 10)
|
|
196
|
+
return (((data << 10) | value) ^ 0b101010000010010) & 0x7FFF
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _draw_format_bits(matrix: list[list[bool | None]], function: list[list[bool]], mask: int) -> None:
|
|
200
|
+
bits = _format_bits(mask)
|
|
201
|
+
size = len(matrix)
|
|
202
|
+
positions_a = [
|
|
203
|
+
(8, 0),
|
|
204
|
+
(8, 1),
|
|
205
|
+
(8, 2),
|
|
206
|
+
(8, 3),
|
|
207
|
+
(8, 4),
|
|
208
|
+
(8, 5),
|
|
209
|
+
(8, 7),
|
|
210
|
+
(8, 8),
|
|
211
|
+
(7, 8),
|
|
212
|
+
(5, 8),
|
|
213
|
+
(4, 8),
|
|
214
|
+
(3, 8),
|
|
215
|
+
(2, 8),
|
|
216
|
+
(1, 8),
|
|
217
|
+
(0, 8),
|
|
218
|
+
]
|
|
219
|
+
positions_b = [
|
|
220
|
+
(size - 1, 8),
|
|
221
|
+
(size - 2, 8),
|
|
222
|
+
(size - 3, 8),
|
|
223
|
+
(size - 4, 8),
|
|
224
|
+
(size - 5, 8),
|
|
225
|
+
(size - 6, 8),
|
|
226
|
+
(size - 7, 8),
|
|
227
|
+
(8, size - 8),
|
|
228
|
+
(8, size - 7),
|
|
229
|
+
(8, size - 6),
|
|
230
|
+
(8, size - 5),
|
|
231
|
+
(8, size - 4),
|
|
232
|
+
(8, size - 3),
|
|
233
|
+
(8, size - 2),
|
|
234
|
+
(8, size - 1),
|
|
235
|
+
]
|
|
236
|
+
for i, (row, col) in enumerate(positions_a):
|
|
237
|
+
_set_function(matrix, function, row, col, ((bits >> i) & 1) == 1)
|
|
238
|
+
for i, (row, col) in enumerate(positions_b):
|
|
239
|
+
_set_function(matrix, function, row, col, ((bits >> i) & 1) == 1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _penalty(matrix: list[list[bool | None]]) -> int:
|
|
243
|
+
size = len(matrix)
|
|
244
|
+
penalty = 0
|
|
245
|
+
for row in range(size):
|
|
246
|
+
run_color = matrix[row][0]
|
|
247
|
+
run_length = 1
|
|
248
|
+
for col in range(1, size):
|
|
249
|
+
if matrix[row][col] == run_color:
|
|
250
|
+
run_length += 1
|
|
251
|
+
else:
|
|
252
|
+
if run_length >= 5:
|
|
253
|
+
penalty += run_length - 2
|
|
254
|
+
run_color = matrix[row][col]
|
|
255
|
+
run_length = 1
|
|
256
|
+
if run_length >= 5:
|
|
257
|
+
penalty += run_length - 2
|
|
258
|
+
|
|
259
|
+
for col in range(size):
|
|
260
|
+
run_color = matrix[0][col]
|
|
261
|
+
run_length = 1
|
|
262
|
+
for row in range(1, size):
|
|
263
|
+
if matrix[row][col] == run_color:
|
|
264
|
+
run_length += 1
|
|
265
|
+
else:
|
|
266
|
+
if run_length >= 5:
|
|
267
|
+
penalty += run_length - 2
|
|
268
|
+
run_color = matrix[row][col]
|
|
269
|
+
run_length = 1
|
|
270
|
+
if run_length >= 5:
|
|
271
|
+
penalty += run_length - 2
|
|
272
|
+
|
|
273
|
+
for row in range(size - 1):
|
|
274
|
+
for col in range(size - 1):
|
|
275
|
+
color = matrix[row][col]
|
|
276
|
+
if color == matrix[row + 1][col] == matrix[row][col + 1] == matrix[row + 1][col + 1]:
|
|
277
|
+
penalty += 3
|
|
278
|
+
|
|
279
|
+
dark = sum(1 for row in matrix for cell in row if cell)
|
|
280
|
+
total = size * size
|
|
281
|
+
penalty += abs(dark * 20 - total * 10) // total * 10
|
|
282
|
+
return penalty
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _build_matrix(data: str) -> list[list[bool]]:
|
|
286
|
+
payload = data.encode("utf-8")
|
|
287
|
+
version = _choose_version(payload)
|
|
288
|
+
size = version * 4 + 17
|
|
289
|
+
data_codewords = _encode_data(payload, version)
|
|
290
|
+
codewords = data_codewords + _reed_solomon(data_codewords, _ECC_CODEWORDS[version])
|
|
291
|
+
|
|
292
|
+
best_matrix: list[list[bool | None]] | None = None
|
|
293
|
+
best_penalty: int | None = None
|
|
294
|
+
for mask in range(8):
|
|
295
|
+
matrix: list[list[bool | None]] = [[None for _ in range(size)] for _ in range(size)]
|
|
296
|
+
function = [[False for _ in range(size)] for _ in range(size)]
|
|
297
|
+
_draw_function_patterns(matrix, function, version)
|
|
298
|
+
_draw_codewords(matrix, function, codewords, mask)
|
|
299
|
+
_draw_format_bits(matrix, function, mask)
|
|
300
|
+
penalty = _penalty(matrix)
|
|
301
|
+
if best_penalty is None or penalty < best_penalty:
|
|
302
|
+
best_matrix = matrix
|
|
303
|
+
best_penalty = penalty
|
|
304
|
+
|
|
305
|
+
if best_matrix is None:
|
|
306
|
+
raise AssertionError("QR matrix was not generated")
|
|
307
|
+
return [[bool(cell) for cell in row] for row in best_matrix]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _format_plain_block_matrix(matrix: list[list[bool]]) -> list[str]:
|
|
311
|
+
border = 4
|
|
312
|
+
width = len(matrix)
|
|
313
|
+
lines: list[str] = []
|
|
314
|
+
for _ in range(border):
|
|
315
|
+
lines.append(" " * ((width + border * 2) * 2))
|
|
316
|
+
|
|
317
|
+
for row in matrix:
|
|
318
|
+
line = " " * border
|
|
319
|
+
for cell in row:
|
|
320
|
+
line += "██" if cell else " "
|
|
321
|
+
line += " " * border
|
|
322
|
+
lines.append(line)
|
|
323
|
+
|
|
324
|
+
for _ in range(border):
|
|
325
|
+
lines.append(" " * ((width + border * 2) * 2))
|
|
326
|
+
return lines
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _format_ansi_block_matrix(matrix: list[list[bool]]) -> list[str]:
|
|
330
|
+
border = 4
|
|
331
|
+
light_row = _ANSI_WHITE + (" " * ((len(matrix) + border * 2) * 2)) + _ANSI_RESET
|
|
332
|
+
lines: list[str] = [light_row for _ in range(border)]
|
|
333
|
+
|
|
334
|
+
for row in matrix:
|
|
335
|
+
line = [_ANSI_WHITE + (" " * border)]
|
|
336
|
+
last_color = _ANSI_WHITE
|
|
337
|
+
for cell in row:
|
|
338
|
+
color = _ANSI_BLACK if cell else _ANSI_WHITE
|
|
339
|
+
if color != last_color:
|
|
340
|
+
line.append(color)
|
|
341
|
+
last_color = color
|
|
342
|
+
line.append(" ")
|
|
343
|
+
if last_color != _ANSI_WHITE:
|
|
344
|
+
line.append(_ANSI_WHITE)
|
|
345
|
+
line.append(" " * border)
|
|
346
|
+
line.append(_ANSI_RESET)
|
|
347
|
+
lines.append("".join(line))
|
|
348
|
+
|
|
349
|
+
lines.extend(light_row for _ in range(border))
|
|
350
|
+
return lines
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _supports_ansi(stream: TextIO) -> bool:
|
|
354
|
+
isatty = getattr(stream, "isatty", None)
|
|
355
|
+
return bool(isatty and isatty())
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def render_qr(url: str, *, stream: TextIO | None = None) -> bool:
|
|
359
|
+
"""Render a terminal QR code for ``url``."""
|
|
360
|
+
|
|
361
|
+
if stream is None:
|
|
362
|
+
stream = sys.stdout
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
matrix = _build_matrix(url)
|
|
366
|
+
except ValueError as exc:
|
|
367
|
+
stream.write(f"{exc}\n")
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
terminal = shutil.get_terminal_size((80, 24))
|
|
371
|
+
required_columns = (len(matrix) + 8) * 2
|
|
372
|
+
if terminal.columns < required_columns:
|
|
373
|
+
stream.write(f"Terminal too narrow to render QR code. Need at least {required_columns} columns.\n")
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
formatter = _format_ansi_block_matrix if _supports_ansi(stream) else _format_plain_block_matrix
|
|
377
|
+
for line in formatter(matrix):
|
|
378
|
+
stream.write(f"{line}\n")
|
|
379
|
+
stream.write(f"{url}\n")
|
|
380
|
+
return True
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import html
|
|
5
6
|
import io
|
|
6
7
|
import os
|
|
7
|
-
import posixpath
|
|
8
8
|
import stat
|
|
9
|
+
import sys
|
|
9
10
|
import urllib.parse
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from email.utils import parsedate_to_datetime
|
|
@@ -26,11 +27,13 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
|
|
|
26
27
|
extra_headers: Mapping[str, str],
|
|
27
28
|
disable_dir_list: bool,
|
|
28
29
|
quiet: bool,
|
|
30
|
+
show_hidden: bool,
|
|
29
31
|
**kwargs,
|
|
30
32
|
) -> None:
|
|
31
33
|
self._extra_headers = dict(extra_headers)
|
|
32
34
|
self._disable_dir_list = disable_dir_list
|
|
33
35
|
self._quiet = quiet
|
|
36
|
+
self._show_hidden = show_hidden
|
|
34
37
|
super().__init__(*args, directory=directory, **kwargs)
|
|
35
38
|
|
|
36
39
|
def _add_custom_headers(self) -> None:
|
|
@@ -42,6 +45,30 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
|
|
|
42
45
|
return
|
|
43
46
|
super().log_message(format, *args)
|
|
44
47
|
|
|
48
|
+
def log_error(self, format: str, *args) -> None:
|
|
49
|
+
if self._quiet:
|
|
50
|
+
return
|
|
51
|
+
super().log_error(format, *args)
|
|
52
|
+
|
|
53
|
+
def _is_hidden(self, segment: str) -> bool:
|
|
54
|
+
if self._show_hidden:
|
|
55
|
+
return False
|
|
56
|
+
return segment.startswith(".")
|
|
57
|
+
|
|
58
|
+
def _has_hidden_attribute(self, path: str) -> bool:
|
|
59
|
+
if self._show_hidden:
|
|
60
|
+
return False
|
|
61
|
+
hidden_flag = getattr(stat, "FILE_ATTRIBUTE_HIDDEN", 0)
|
|
62
|
+
if not hidden_flag:
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
return bool(os.stat(path).st_file_attributes & hidden_flag)
|
|
66
|
+
except (AttributeError, OSError):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def _is_hidden_entry(self, name: str, path: str) -> bool:
|
|
70
|
+
return self._is_hidden(name) or self._has_hidden_attribute(path)
|
|
71
|
+
|
|
45
72
|
def translate_path(self, path: str) -> str | None:
|
|
46
73
|
# Safe path mapping inspired by SimpleHTTPRequestHandler, with strict traversal defense.
|
|
47
74
|
path = path.split("?", 1)[0]
|
|
@@ -51,23 +78,23 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
|
|
|
51
78
|
if "\x00" in path:
|
|
52
79
|
return None
|
|
53
80
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return None
|
|
66
|
-
parts.append(segment)
|
|
81
|
+
parts = []
|
|
82
|
+
for segment in path.split("/"):
|
|
83
|
+
if segment in ("", "."):
|
|
84
|
+
continue
|
|
85
|
+
if segment == "..":
|
|
86
|
+
return None
|
|
87
|
+
if self._is_hidden(segment):
|
|
88
|
+
return None
|
|
89
|
+
if ":" in segment:
|
|
90
|
+
return None
|
|
91
|
+
parts.append(segment)
|
|
67
92
|
|
|
68
93
|
candidate = self.directory
|
|
69
94
|
for part in parts:
|
|
70
95
|
candidate = os.path.join(candidate, part)
|
|
96
|
+
if self._has_hidden_attribute(candidate):
|
|
97
|
+
return None
|
|
71
98
|
|
|
72
99
|
candidate = os.path.normpath(candidate)
|
|
73
100
|
root = os.path.realpath(self.directory)
|
|
@@ -103,9 +130,11 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
|
|
|
103
130
|
f = None
|
|
104
131
|
|
|
105
132
|
if os.path.isdir(path):
|
|
106
|
-
|
|
133
|
+
parts = urllib.parse.urlsplit(self.path)
|
|
134
|
+
if not parts.path.endswith("/"):
|
|
135
|
+
location = urllib.parse.urlunsplit(("", "", parts.path + "/", parts.query, parts.fragment))
|
|
107
136
|
self.send_response(HTTPStatus.MOVED_PERMANENTLY)
|
|
108
|
-
self.send_header("Location",
|
|
137
|
+
self.send_header("Location", location)
|
|
109
138
|
self._add_custom_headers()
|
|
110
139
|
self.end_headers()
|
|
111
140
|
return None
|
|
@@ -140,7 +169,10 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
|
|
|
140
169
|
|
|
141
170
|
if_modified_since = self.headers.get("If-Modified-Since")
|
|
142
171
|
if if_modified_since:
|
|
143
|
-
|
|
172
|
+
try:
|
|
173
|
+
parsed = parsedate_to_datetime(if_modified_since)
|
|
174
|
+
except (IndexError, OverflowError, TypeError, ValueError):
|
|
175
|
+
parsed = None
|
|
144
176
|
if parsed is not None:
|
|
145
177
|
if parsed.tzinfo is None:
|
|
146
178
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
@@ -183,6 +215,59 @@ class RangeAwareHTTPRequestHandler(SimpleHTTPRequestHandler):
|
|
|
183
215
|
f.close()
|
|
184
216
|
raise
|
|
185
217
|
|
|
218
|
+
def list_directory(self, path: str) -> io.BytesIO | None:
|
|
219
|
+
try:
|
|
220
|
+
listdir = os.listdir(path)
|
|
221
|
+
except OSError:
|
|
222
|
+
self.send_error(HTTPStatus.NOT_FOUND, "No permission to list directory")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
listdir = [entry for entry in listdir if not self._is_hidden_entry(entry, os.path.join(path, entry))]
|
|
226
|
+
listdir.sort(key=lambda a: a.lower())
|
|
227
|
+
r = []
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
displaypath = urllib.parse.unquote(self.path, errors="surrogatepass")
|
|
231
|
+
except UnicodeDecodeError:
|
|
232
|
+
displaypath = urllib.parse.unquote(self.path)
|
|
233
|
+
displaypath = html.escape(displaypath)
|
|
234
|
+
|
|
235
|
+
enc = "UTF-8"
|
|
236
|
+
title = f"Directory listing for {displaypath}"
|
|
237
|
+
r.append("<!DOCTYPE html>\n")
|
|
238
|
+
r.append("<html>\n<head>\n")
|
|
239
|
+
r.append(f'<meta charset="{enc}">\n')
|
|
240
|
+
r.append(f"<title>{title}</title>\n")
|
|
241
|
+
r.append("</head>\n<body>\n")
|
|
242
|
+
r.append(f"<h1>{title}</h1>\n<hr>\n<ul>\n")
|
|
243
|
+
|
|
244
|
+
for name in listdir:
|
|
245
|
+
full_name = os.path.join(path, name)
|
|
246
|
+
displayname = linkname = name
|
|
247
|
+
if os.path.isdir(full_name):
|
|
248
|
+
displayname = name + "/"
|
|
249
|
+
linkname = name + "/"
|
|
250
|
+
if os.path.islink(full_name):
|
|
251
|
+
displayname = name + "@"
|
|
252
|
+
|
|
253
|
+
r.append(
|
|
254
|
+
f'<li><a href="{urllib.parse.quote(linkname, errors="surrogatepass")}">'
|
|
255
|
+
f"{html.escape(displayname)}</a></li>\n"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
r.append("</ul>\n<hr>\n</body>\n</html>\n")
|
|
259
|
+
encoded = "".join(r).encode(enc, "surrogateescape")
|
|
260
|
+
|
|
261
|
+
f = io.BytesIO()
|
|
262
|
+
f.write(encoded)
|
|
263
|
+
f.seek(0)
|
|
264
|
+
self.send_response(HTTPStatus.OK)
|
|
265
|
+
self.send_header("Content-Type", f"text/html; charset={enc}")
|
|
266
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
267
|
+
self._add_custom_headers()
|
|
268
|
+
self.end_headers()
|
|
269
|
+
return f
|
|
270
|
+
|
|
186
271
|
|
|
187
272
|
class _RangeFile:
|
|
188
273
|
def __init__(self, wrapped: io.BufferedReader, remaining: int) -> None:
|
|
@@ -206,8 +291,17 @@ class ThreadedHTTPServer(ThreadingHTTPServer):
|
|
|
206
291
|
daemon_threads = True
|
|
207
292
|
allow_reuse_address = True
|
|
208
293
|
|
|
294
|
+
def handle_error(self, request, client_address) -> None: # type: ignore[override]
|
|
295
|
+
exc = sys.exc_info()[1]
|
|
296
|
+
if (
|
|
297
|
+
not bool(getattr(self, "verbose", False))
|
|
298
|
+
and isinstance(exc, (BrokenPipeError, ConnectionAbortedError, ConnectionResetError))
|
|
299
|
+
):
|
|
300
|
+
return
|
|
301
|
+
super().handle_error(request, client_address)
|
|
302
|
+
|
|
209
303
|
|
|
210
|
-
def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_dir_list: bool, quiet: bool):
|
|
304
|
+
def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_dir_list: bool, quiet: bool, show_hidden: bool = False):
|
|
211
305
|
def _factory(*args, **kwargs):
|
|
212
306
|
return RangeAwareHTTPRequestHandler(
|
|
213
307
|
*args,
|
|
@@ -215,6 +309,7 @@ def make_handler(*, directory: str, extra_headers: Mapping[str, str], disable_di
|
|
|
215
309
|
extra_headers=extra_headers,
|
|
216
310
|
disable_dir_list=disable_dir_list,
|
|
217
311
|
quiet=quiet,
|
|
312
|
+
show_hidden=show_hidden,
|
|
218
313
|
**kwargs,
|
|
219
314
|
)
|
|
220
315
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: static-http
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A temporary dependency-free static HTTP server with byte-range support.
|
|
5
5
|
Author: John Paul Ellis
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Keywords: http,server,static-http,static,range,cli
|
|
8
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
9
8
|
Classifier: Programming Language :: Python :: 3
|
|
10
9
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
14
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
14
15
|
Requires-Python: >=3.10
|
|
15
16
|
Description-Content-Type: text/markdown
|
|
@@ -72,6 +73,7 @@ By default:
|
|
|
72
73
|
- `--no-cache` — send `Cache-Control: no-store`.
|
|
73
74
|
- `--quiet` — suppress per-request logs.
|
|
74
75
|
- `--verbose` — print detailed startup/binding information.
|
|
76
|
+
- `--include-hidden` — include dot-prefixed files and directories in normal serving and directory listings.
|
|
75
77
|
- `--version` — print package version and exit.
|
|
76
78
|
|
|
77
79
|
## Examples
|
|
@@ -82,6 +84,7 @@ static-http --qr
|
|
|
82
84
|
static-http --no-cache
|
|
83
85
|
static-http --quiet
|
|
84
86
|
static-http --verbose
|
|
87
|
+
static-http --include-hidden
|
|
85
88
|
static-http --port 9000 --cors
|
|
86
89
|
static-http --no-dir-list
|
|
87
90
|
```
|
|
@@ -8,6 +8,11 @@ import pytest
|
|
|
8
8
|
from http_here import qrcode
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
class _TtyStringIO(io.StringIO):
|
|
12
|
+
def isatty(self) -> bool:
|
|
13
|
+
return True
|
|
14
|
+
|
|
15
|
+
|
|
11
16
|
def test_qr_renders_on_wide_terminal() -> None:
|
|
12
17
|
fake_size = namedtuple("Size", "columns lines")(80, 24)
|
|
13
18
|
monkeypatch = pytest.MonkeyPatch()
|
|
@@ -20,6 +25,26 @@ def test_qr_renders_on_wide_terminal() -> None:
|
|
|
20
25
|
assert out.getvalue().strip() != ""
|
|
21
26
|
|
|
22
27
|
|
|
28
|
+
def test_qr_uses_forced_terminal_contrast_on_tty() -> None:
|
|
29
|
+
fake_size = namedtuple("Size", "columns lines")(80, 24)
|
|
30
|
+
monkeypatch = pytest.MonkeyPatch()
|
|
31
|
+
monkeypatch.setattr(qrcode.shutil, "get_terminal_size", lambda fallback=(80, 24): fake_size)
|
|
32
|
+
out = _TtyStringIO()
|
|
33
|
+
try:
|
|
34
|
+
assert qrcode.render_qr("http://localhost:8080/", stream=out)
|
|
35
|
+
finally:
|
|
36
|
+
monkeypatch.undo()
|
|
37
|
+
rendered = out.getvalue()
|
|
38
|
+
assert "\x1b[40m" in rendered
|
|
39
|
+
assert "\x1b[107m" in rendered
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_qr_known_format_bits_and_version_selection() -> None:
|
|
43
|
+
assert f"{qrcode._format_bits(0):015b}" == "111011111000100"
|
|
44
|
+
assert len(qrcode._build_matrix("http://localhost:8080/")) == 25
|
|
45
|
+
assert len(qrcode._build_matrix("http://192.168.1.205:8080/")) == 25
|
|
46
|
+
|
|
47
|
+
|
|
23
48
|
def test_qr_warns_when_terminal_too_narrow() -> None:
|
|
24
49
|
fake_size = namedtuple("Size", "columns lines")(20, 24)
|
|
25
50
|
monkeypatch = pytest.MonkeyPatch()
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
+
from http.client import HTTPResponse
|
|
4
5
|
import threading
|
|
5
6
|
import urllib.error
|
|
6
7
|
import urllib.request
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
|
-
from http.client import HTTPResponse
|
|
10
|
-
|
|
11
10
|
import pytest
|
|
12
11
|
|
|
13
12
|
from http_here import server
|
|
@@ -89,6 +88,7 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
|
|
|
89
88
|
root.mkdir()
|
|
90
89
|
(root / "index.txt").write_bytes(b"index")
|
|
91
90
|
(tmp_path / "a b.txt").write_bytes(b"space")
|
|
91
|
+
(tmp_path / ".hidden.txt").write_bytes(b"secret")
|
|
92
92
|
(tmp_path / "rootdir").mkdir()
|
|
93
93
|
|
|
94
94
|
with _running_server(
|
|
@@ -100,8 +100,11 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
|
|
|
100
100
|
list_url = _http_url(srv, "/")
|
|
101
101
|
with urllib.request.urlopen(list_url) as resp:
|
|
102
102
|
assert resp.status == 200
|
|
103
|
+
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
|
104
|
+
assert resp.headers["Cache-Control"] == "no-store"
|
|
103
105
|
body = resp.read()
|
|
104
106
|
assert b"a b.txt" in body or b"a+b.txt" in body
|
|
107
|
+
assert b".hidden.txt" not in body
|
|
105
108
|
|
|
106
109
|
req = urllib.request.Request(_http_url(srv, "/a%20b.txt"))
|
|
107
110
|
with urllib.request.urlopen(req) as resp:
|
|
@@ -110,6 +113,15 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
|
|
|
110
113
|
assert resp.headers["Access-Control-Allow-Origin"] == "*"
|
|
111
114
|
assert resp.headers["Cache-Control"] == "no-store"
|
|
112
115
|
|
|
116
|
+
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
117
|
+
urllib.request.urlopen(_http_url(srv, "/.hidden.txt"))
|
|
118
|
+
assert exc.value.code in {400, 404}
|
|
119
|
+
|
|
120
|
+
invalid_date = urllib.request.Request(_http_url(srv, "/a%20b.txt"), headers={"If-Modified-Since": "not a date"})
|
|
121
|
+
with urllib.request.urlopen(invalid_date) as resp:
|
|
122
|
+
assert resp.status == 200
|
|
123
|
+
assert resp.read() == b"space"
|
|
124
|
+
|
|
113
125
|
with _running_server(
|
|
114
126
|
tmp_path,
|
|
115
127
|
extra_headers={},
|
|
@@ -121,8 +133,30 @@ def test_headers_and_directory_listing_controls(tmp_path: Path) -> None:
|
|
|
121
133
|
assert exc.value.code == 403
|
|
122
134
|
|
|
123
135
|
|
|
136
|
+
def test_show_hidden_handler_option_exposes_hidden_entries(tmp_path: Path) -> None:
|
|
137
|
+
(tmp_path / ".hidden.txt").write_bytes(b"secret")
|
|
138
|
+
|
|
139
|
+
with _running_server(
|
|
140
|
+
tmp_path,
|
|
141
|
+
extra_headers={},
|
|
142
|
+
disable_dir_list=False,
|
|
143
|
+
quiet=True,
|
|
144
|
+
show_hidden=True,
|
|
145
|
+
) as srv:
|
|
146
|
+
list_url = _http_url(srv, "/")
|
|
147
|
+
with urllib.request.urlopen(list_url) as resp:
|
|
148
|
+
assert resp.status == 200
|
|
149
|
+
body = resp.read()
|
|
150
|
+
assert b".hidden.txt" in body
|
|
151
|
+
|
|
152
|
+
with urllib.request.urlopen(_http_url(srv, "/.hidden.txt")) as resp:
|
|
153
|
+
assert resp.status == 200
|
|
154
|
+
assert resp.read() == b"secret"
|
|
155
|
+
|
|
156
|
+
|
|
124
157
|
def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
|
|
125
158
|
(tmp_path / "inside.txt").write_bytes(b"inside")
|
|
159
|
+
(tmp_path / "outside.txt").write_bytes(b"fake-outside")
|
|
126
160
|
(tmp_path.parent / "outside.txt").write_bytes(b"outside")
|
|
127
161
|
|
|
128
162
|
with _running_server(
|
|
@@ -137,8 +171,31 @@ def test_path_traversal_cannot_escape_root(tmp_path: Path) -> None:
|
|
|
137
171
|
|
|
138
172
|
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
139
173
|
urllib.request.urlopen(_http_url(srv, "/%2e%2e/outside.txt"))
|
|
140
|
-
assert exc.value.code
|
|
174
|
+
assert exc.value.code == 400
|
|
141
175
|
|
|
142
176
|
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
143
177
|
urllib.request.urlopen(_http_url(srv, "/inside%5c.txt"))
|
|
144
178
|
assert exc.value.code in {400, 404, 200}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_directory_redirect_preserves_query_string(tmp_path: Path) -> None:
|
|
182
|
+
(tmp_path / "dir").mkdir()
|
|
183
|
+
|
|
184
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
185
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
opener = urllib.request.build_opener(NoRedirect)
|
|
189
|
+
with _running_server(
|
|
190
|
+
tmp_path,
|
|
191
|
+
extra_headers={},
|
|
192
|
+
disable_dir_list=False,
|
|
193
|
+
quiet=True,
|
|
194
|
+
) as srv:
|
|
195
|
+
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
196
|
+
opener.open(_http_url(srv, "/dir?download=1"))
|
|
197
|
+
assert exc.value.code == 301
|
|
198
|
+
assert exc.value.headers["Location"] == "/dir/?download=1"
|
|
199
|
+
|
|
200
|
+
with urllib.request.urlopen(_http_url(srv, "/dir/?download=1")) as resp:
|
|
201
|
+
assert resp.status == 200
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Minimal terminal QR rendering without external dependencies."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
import shutil
|
|
7
|
-
import sys
|
|
8
|
-
from typing import TextIO
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _draw_finder(matrix: list[list[bool]], top: int, left: int) -> None:
|
|
12
|
-
for r in range(7):
|
|
13
|
-
for c in range(7):
|
|
14
|
-
if r in (0, 6) or c in (0, 6) or (2 <= r <= 4 and 2 <= c <= 4):
|
|
15
|
-
matrix[top + r][left + c] = True
|
|
16
|
-
elif r in (1, 5) or c in (1, 5):
|
|
17
|
-
matrix[top + r][left + c] = False
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _build_matrix(data: str) -> list[list[bool]]:
|
|
21
|
-
size = 25
|
|
22
|
-
matrix: list[list[bool | None]] = [[None for _ in range(size)] for _ in range(size)]
|
|
23
|
-
_draw_finder(matrix, 0, 0)
|
|
24
|
-
_draw_finder(matrix, 0, size - 7)
|
|
25
|
-
_draw_finder(matrix, size - 7, 0)
|
|
26
|
-
|
|
27
|
-
bits = [bit == "1" for bit in "".join(f"{b:08b}" for b in hashlib.sha256(data.encode("utf-8")).digest())]
|
|
28
|
-
bit_index = 0
|
|
29
|
-
for row in range(size):
|
|
30
|
-
for col in range(size):
|
|
31
|
-
if matrix[row][col] is not None:
|
|
32
|
-
continue
|
|
33
|
-
if bit_index < len(bits):
|
|
34
|
-
matrix[row][col] = bits[bit_index]
|
|
35
|
-
bit_index += 1
|
|
36
|
-
continue
|
|
37
|
-
matrix[row][col] = ((row * 31 + col) % 2) == 0
|
|
38
|
-
|
|
39
|
-
return [list(row) for row in matrix]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _format_block_matrix(matrix: list[list[bool]]) -> list[str]:
|
|
43
|
-
# Add quiet zone around the code.
|
|
44
|
-
border = 2
|
|
45
|
-
width = len(matrix)
|
|
46
|
-
lines: list[str] = []
|
|
47
|
-
for _ in range(border):
|
|
48
|
-
lines.append(" " * ((width + border * 2) * 2))
|
|
49
|
-
|
|
50
|
-
for row in matrix:
|
|
51
|
-
line = " " * border
|
|
52
|
-
for cell in row:
|
|
53
|
-
line += "██" if cell else " "
|
|
54
|
-
line += " " * border
|
|
55
|
-
lines.append(line)
|
|
56
|
-
|
|
57
|
-
for _ in range(border):
|
|
58
|
-
lines.append(" " * ((width + border * 2) * 2))
|
|
59
|
-
return lines
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def render_qr(url: str, *, stream: TextIO | None = None) -> bool:
|
|
63
|
-
"""Render a terminal QR-like code for ``url``.
|
|
64
|
-
|
|
65
|
-
Returns True when content was printed, otherwise False.
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
if stream is None:
|
|
69
|
-
stream = sys.stdout
|
|
70
|
-
|
|
71
|
-
terminal = shutil.get_terminal_size((80, 24))
|
|
72
|
-
# Use a fixed width to avoid truncation on narrow terminals.
|
|
73
|
-
if terminal.columns < 60:
|
|
74
|
-
stream.write("Terminal too narrow to render QR code.\n")
|
|
75
|
-
return False
|
|
76
|
-
|
|
77
|
-
matrix = _build_matrix(url)
|
|
78
|
-
for line in _format_block_matrix(matrix):
|
|
79
|
-
stream.write(f"{line}\n")
|
|
80
|
-
stream.write(f"{url}\n")
|
|
81
|
-
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|