ssserve 0.1.0__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.
- ssserve-0.1.0/PKG-INFO +7 -0
- ssserve-0.1.0/README.md +148 -0
- ssserve-0.1.0/pyproject.toml +24 -0
- ssserve-0.1.0/setup.cfg +4 -0
- ssserve-0.1.0/ssserve/__init__.py +1 -0
- ssserve-0.1.0/ssserve/__main__.py +3 -0
- ssserve-0.1.0/ssserve/cli.py +198 -0
- ssserve-0.1.0/ssserve/config.py +101 -0
- ssserve-0.1.0/ssserve/handler.py +466 -0
- ssserve-0.1.0/ssserve/listing.py +111 -0
- ssserve-0.1.0/ssserve/network.py +96 -0
- ssserve-0.1.0/ssserve.egg-info/PKG-INFO +7 -0
- ssserve-0.1.0/ssserve.egg-info/SOURCES.txt +15 -0
- ssserve-0.1.0/ssserve.egg-info/dependency_links.txt +1 -0
- ssserve-0.1.0/ssserve.egg-info/entry_points.txt +2 -0
- ssserve-0.1.0/ssserve.egg-info/requires.txt +1 -0
- ssserve-0.1.0/ssserve.egg-info/top_level.txt +1 -0
ssserve-0.1.0/PKG-INFO
ADDED
ssserve-0.1.0/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# ssserve
|
|
2
|
+
|
|
3
|
+
Python port of [vercel/serve](https://github.com/vercel/serve) — static file serving and directory listing. (vibecoded)
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install ssserve
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or run directly without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv run --from ssserve ssserve
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
ssserve [path] [options]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Defaults to current directory and port 3000.
|
|
24
|
+
|
|
25
|
+
### Options
|
|
26
|
+
|
|
27
|
+
| Flag | Description |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `-l, --listen URI` | Listen endpoint (default: `tcp://0.0.0.0:3000`, multi-allowed) |
|
|
30
|
+
| `-s, --single` | SPA mode — rewrite 404s to `index.html` |
|
|
31
|
+
| `-C, --cors` | Enable CORS headers |
|
|
32
|
+
| `-u, --no-compression` | Disable gzip compression |
|
|
33
|
+
| `-S, --symlinks` | Resolve symlinks |
|
|
34
|
+
| `-L, --no-request-logging` | Disable request logging |
|
|
35
|
+
| `-c, --config PATH` | Path to `serve.json` |
|
|
36
|
+
| `--no-etag` | Disable ETag (use `Last-Modified`) |
|
|
37
|
+
| `--ssl-cert FILE` | SSL certificate (PEM) |
|
|
38
|
+
| `--ssl-key FILE` | SSL private key (PEM) |
|
|
39
|
+
| `--ssl-pass FILE` | SSL passphrase file |
|
|
40
|
+
| `--no-port-switching` | Don't auto-switch if port is taken |
|
|
41
|
+
| `-d, --debug` | Debug output |
|
|
42
|
+
| `--version` | Show version |
|
|
43
|
+
| `--help` | Show help |
|
|
44
|
+
|
|
45
|
+
### Examples
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Serve current directory on port 3000
|
|
49
|
+
ssserve
|
|
50
|
+
|
|
51
|
+
# Serve specific directory on port 5000 with CORS
|
|
52
|
+
ssserve -l 5000 -C ./my-site
|
|
53
|
+
|
|
54
|
+
# Serve with HTTPS
|
|
55
|
+
ssserve --ssl-cert cert.pem --ssl-key key.pem
|
|
56
|
+
|
|
57
|
+
# SPA mode for React/Vue apps
|
|
58
|
+
ssserve -s dist
|
|
59
|
+
|
|
60
|
+
# Multiple listeners
|
|
61
|
+
ssserve -l tcp://0.0.0.0:3000 -l unix:/tmp/serve.sock
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
Create a `serve.json` file in the served directory:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"public": "_site",
|
|
71
|
+
"cleanUrls": true,
|
|
72
|
+
"rewrites": [
|
|
73
|
+
{ "source": "/api/**", "destination": "/index.html" }
|
|
74
|
+
],
|
|
75
|
+
"redirects": [
|
|
76
|
+
{ "source": "/old", "destination": "/new", "type": 302 }
|
|
77
|
+
],
|
|
78
|
+
"headers": [
|
|
79
|
+
{
|
|
80
|
+
"source": "**/*.@(jpg|png)",
|
|
81
|
+
"headers": [
|
|
82
|
+
{ "key": "Cache-Control", "value": "max-age=7200" }
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"directoryListing": false,
|
|
87
|
+
"trailingSlash": true,
|
|
88
|
+
"etag": true,
|
|
89
|
+
"symlinks": false,
|
|
90
|
+
"renderSingle": false
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Testing
|
|
95
|
+
|
|
96
|
+
E2E tests verify behaviour, performance, and resource consumption against a live server process.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Run all tests
|
|
100
|
+
uv run pytest tests/ -v
|
|
101
|
+
|
|
102
|
+
# Run only e2e tests
|
|
103
|
+
uv run pytest tests/e2e/ -v
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Test layout
|
|
107
|
+
|
|
108
|
+
| File | Tests | What it covers |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `tests/e2e/test_behavior.py` | 47 | File serving, directory listing, clean URLs, redirects, rewrites, CORS, gzip, ETag/304, Range requests, SPA mode, custom headers, symlinks, path traversal |
|
|
111
|
+
| `tests/e2e/test_performance.py` | 8 | Latency (p50/p95/p99), TTFB, gzip vs raw, concurrent throughput — measure-only |
|
|
112
|
+
| `tests/e2e/test_resources.py` | 7 | RSS memory, CPU usage, file descriptor count — measure-only |
|
|
113
|
+
|
|
114
|
+
Dependencies: `pytest`, `psutil` (see `[dependency-groups]` in `pyproject.toml`).
|
|
115
|
+
|
|
116
|
+
Tests run automatically on CI via `.github/workflows/tests.yml`.
|
|
117
|
+
|
|
118
|
+
## Benchmarks
|
|
119
|
+
|
|
120
|
+
Micro-benchmarks using [airspeed velocity](https://asv.readthedocs.io/) track performance of core operations across commits:
|
|
121
|
+
|
|
122
|
+
| Benchmark | What it measures |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `TimeRouteToRegex` | Pattern compilation for route matching |
|
|
125
|
+
| `TimeMatchGlob` | Glob matching and exclusion lists |
|
|
126
|
+
| `TimeParseByteRange` | HTTP Range header parsing |
|
|
127
|
+
| `TimeFormatSize` | File size formatting |
|
|
128
|
+
| `TimeFormatDate` | Date formatting |
|
|
129
|
+
| `TimeParseListen` | Listen URI parsing |
|
|
130
|
+
| `TimeMergeConfig` | Config merging from `serve.json` |
|
|
131
|
+
| `TimeRenderListing` | HTML directory listing rendering (100 files) |
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
# Validate benchmarks
|
|
135
|
+
uv run asv check --python=same
|
|
136
|
+
|
|
137
|
+
# Quick run
|
|
138
|
+
uv run asv run --python=same --quick
|
|
139
|
+
|
|
140
|
+
# Full run across configured pythons
|
|
141
|
+
uv run asv run
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Benchmarks run automatically on CI via `.github/workflows/benchmarks.yml`.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ssserve"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Static file serving and directory listing — Python port of vercel/serve"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = ["click>=8.0"]
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
ssserve = "ssserve.cli:main"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["setuptools>=64"]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools]
|
|
17
|
+
packages = ["ssserve"]
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8",
|
|
22
|
+
"psutil>=6",
|
|
23
|
+
"asv>=0.6.5",
|
|
24
|
+
]
|
ssserve-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
import socketserver
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from http.server import HTTPServer
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from ssserve import __version__
|
|
14
|
+
from ssserve.config import load_config
|
|
15
|
+
from ssserve.handler import ServeHandler
|
|
16
|
+
from ssserve.network import Address, find_free_port, get_lan_ip, parse_listen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _create_server(
|
|
20
|
+
addr: Address,
|
|
21
|
+
handler_class: type,
|
|
22
|
+
ssl_cert: str | None = None,
|
|
23
|
+
ssl_key: str | None = None,
|
|
24
|
+
ssl_pass: str | None = None,
|
|
25
|
+
) -> HTTPServer:
|
|
26
|
+
if addr.scheme == "unix":
|
|
27
|
+
if os.path.exists(addr.path):
|
|
28
|
+
os.unlink(addr.path)
|
|
29
|
+
server = HTTPServer(addr.path, handler_class)
|
|
30
|
+
server.server_address = addr.path
|
|
31
|
+
else:
|
|
32
|
+
host = addr.host or "0.0.0.0"
|
|
33
|
+
server = HTTPServer((host, addr.port), handler_class)
|
|
34
|
+
server.server_address = (host, addr.port)
|
|
35
|
+
|
|
36
|
+
if ssl_cert and ssl_key:
|
|
37
|
+
passphrase = None
|
|
38
|
+
if ssl_pass:
|
|
39
|
+
with open(ssl_pass) as f:
|
|
40
|
+
passphrase = f.read().strip()
|
|
41
|
+
ctx = ssl.SSLContext(ssl.Purpose.CLIENT_AUTH)
|
|
42
|
+
ctx.load_cert_chain(ssl_cert, ssl_key, passphrase if passphrase else None)
|
|
43
|
+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
|
44
|
+
|
|
45
|
+
return server
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _print_startup(
|
|
49
|
+
addr: Address,
|
|
50
|
+
cors: bool,
|
|
51
|
+
ssl_active: bool,
|
|
52
|
+
no_port_switching: bool,
|
|
53
|
+
no_compression: bool,
|
|
54
|
+
port_switched: bool = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
scheme = "https" if ssl_active else "http"
|
|
57
|
+
host = addr.host or "0.0.0.0"
|
|
58
|
+
|
|
59
|
+
click.echo("")
|
|
60
|
+
click.echo(f" ssserve v{__version__}")
|
|
61
|
+
click.echo("")
|
|
62
|
+
|
|
63
|
+
if addr.scheme == "unix":
|
|
64
|
+
click.echo(f" ➜ Local: unix:{addr.path}")
|
|
65
|
+
else:
|
|
66
|
+
local_url = f" ➜ Local: {scheme}://localhost:{addr.port}"
|
|
67
|
+
if port_switched:
|
|
68
|
+
local_url += f" (port {addr.port} was in use, switched)"
|
|
69
|
+
click.echo(local_url)
|
|
70
|
+
|
|
71
|
+
lan_ip = get_lan_ip()
|
|
72
|
+
if lan_ip:
|
|
73
|
+
click.echo(f" ➜ Network: {scheme}://{lan_ip}:{addr.port}")
|
|
74
|
+
|
|
75
|
+
click.echo("")
|
|
76
|
+
|
|
77
|
+
if cors:
|
|
78
|
+
click.echo(" ➜ CORS enabled")
|
|
79
|
+
if ssl_active:
|
|
80
|
+
click.echo(" ➜ SSL enabled")
|
|
81
|
+
if not no_compression:
|
|
82
|
+
click.echo(" ➜ Compression enabled (gzip)")
|
|
83
|
+
|
|
84
|
+
click.echo("")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@click.command()
|
|
88
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=".", required=False)
|
|
89
|
+
@click.option("-l", "--listen", type=str, multiple=True, default=["tcp://0.0.0.0:3000"], help="Listen endpoint")
|
|
90
|
+
@click.option("-s", "--single", is_flag=True, help="Single page application mode (rewrite 404 to index.html)")
|
|
91
|
+
@click.option("-d", "--debug", is_flag=True, help="Show debugging information")
|
|
92
|
+
@click.option("-c", "--config", type=click.Path(exists=True, dir_okay=False), help="Path to serve.json config")
|
|
93
|
+
@click.option("-L", "--no-request-logging", is_flag=True, help="Disable request logging")
|
|
94
|
+
@click.option("-C", "--cors", is_flag=True, help="Enable CORS")
|
|
95
|
+
@click.option("-u", "--no-compression", is_flag=True, help="Disable compression")
|
|
96
|
+
@click.option("--no-etag", is_flag=True, help="Disable ETag (use Last-Modified)")
|
|
97
|
+
@click.option("-S", "--symlinks", is_flag=True, help="Resolve symlinks")
|
|
98
|
+
@click.option("--ssl-cert", type=click.Path(exists=True, dir_okay=False), help="SSL/TLS certificate (PEM)")
|
|
99
|
+
@click.option("--ssl-key", type=click.Path(exists=True, dir_okay=False), help="SSL/TLS private key (PEM)")
|
|
100
|
+
@click.option("--ssl-pass", type=click.Path(exists=True, dir_okay=False), help="SSL/TLS passphrase file")
|
|
101
|
+
@click.option("--no-port-switching", is_flag=True, help="Don't switch to another port when port is taken")
|
|
102
|
+
@click.version_option(version=__version__, prog_name="ssserve")
|
|
103
|
+
def main(
|
|
104
|
+
path: str,
|
|
105
|
+
listen: tuple[str, ...],
|
|
106
|
+
single: bool,
|
|
107
|
+
debug: bool,
|
|
108
|
+
config: str | None,
|
|
109
|
+
no_request_logging: bool,
|
|
110
|
+
cors: bool,
|
|
111
|
+
no_compression: bool,
|
|
112
|
+
no_etag: bool,
|
|
113
|
+
symlinks: bool,
|
|
114
|
+
ssl_cert: str | None,
|
|
115
|
+
ssl_key: str | None,
|
|
116
|
+
ssl_pass: str | None,
|
|
117
|
+
no_port_switching: bool,
|
|
118
|
+
) -> None:
|
|
119
|
+
root_dir = os.path.abspath(path) if path else os.getcwd()
|
|
120
|
+
|
|
121
|
+
if not os.path.isdir(root_dir):
|
|
122
|
+
click.echo(f"Error: {path} is not a directory", err=True)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
cfg = load_config(config, root_dir)
|
|
126
|
+
|
|
127
|
+
if ssl_cert and not ssl_key:
|
|
128
|
+
click.echo("Error: --ssl-key is required when --ssl-cert is provided", err=True)
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
if no_etag:
|
|
132
|
+
cfg.etag = False
|
|
133
|
+
|
|
134
|
+
if symlinks:
|
|
135
|
+
cfg.symlinks = True
|
|
136
|
+
|
|
137
|
+
ServeHandler.config = cfg
|
|
138
|
+
ServeHandler.cors = cors
|
|
139
|
+
ServeHandler.single = single
|
|
140
|
+
ServeHandler.debug = debug
|
|
141
|
+
ServeHandler.logging_enabled = not no_request_logging
|
|
142
|
+
ServeHandler.no_compression = no_compression
|
|
143
|
+
ServeHandler.no_port_switching = no_port_switching
|
|
144
|
+
ServeHandler.root_dir = root_dir
|
|
145
|
+
|
|
146
|
+
listeners = []
|
|
147
|
+
for listen_val in listen:
|
|
148
|
+
addr = parse_listen(listen_val)
|
|
149
|
+
port_switched = False
|
|
150
|
+
|
|
151
|
+
if addr.scheme == "tcp" and not no_port_switching:
|
|
152
|
+
try:
|
|
153
|
+
with socket.create_connection(("localhost", addr.port), timeout=0.5):
|
|
154
|
+
new_port = find_free_port(addr.port + 1)
|
|
155
|
+
click.echo(f" Port {addr.port} is in use, using port {new_port} instead", err=True)
|
|
156
|
+
addr.port = new_port
|
|
157
|
+
port_switched = True
|
|
158
|
+
except (ConnectionRefusedError, OSError, socket.timeout):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
listeners.append((addr, port_switched))
|
|
162
|
+
|
|
163
|
+
ssl_active = ssl_cert is not None and ssl_key is not None
|
|
164
|
+
|
|
165
|
+
if len(listeners) == 1:
|
|
166
|
+
addr, port_switched = listeners[0]
|
|
167
|
+
_print_startup(addr, cors, ssl_active, no_port_switching, no_compression, port_switched)
|
|
168
|
+
server = _create_server(addr, ServeHandler, ssl_cert, ssl_key, ssl_pass)
|
|
169
|
+
try:
|
|
170
|
+
server.serve_forever()
|
|
171
|
+
except KeyboardInterrupt:
|
|
172
|
+
click.echo("\n Shutting down...")
|
|
173
|
+
server.shutdown()
|
|
174
|
+
else:
|
|
175
|
+
servers = []
|
|
176
|
+
for addr, port_switched in listeners:
|
|
177
|
+
_print_startup(addr, cors, ssl_active, no_port_switching, no_compression, port_switched)
|
|
178
|
+
server = _create_server(addr, ServeHandler, ssl_cert, ssl_key, ssl_pass)
|
|
179
|
+
servers.append(server)
|
|
180
|
+
|
|
181
|
+
click.echo(f" Serving {len(servers)} listeners")
|
|
182
|
+
click.echo("")
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
for server in servers:
|
|
186
|
+
import threading
|
|
187
|
+
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
188
|
+
t.start()
|
|
189
|
+
while True:
|
|
190
|
+
time.sleep(3600)
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
click.echo("\n Shutting down...")
|
|
193
|
+
for server in servers:
|
|
194
|
+
server.shutdown()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Rewrite:
|
|
11
|
+
source: str
|
|
12
|
+
destination: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Redirect:
|
|
17
|
+
source: str
|
|
18
|
+
destination: str
|
|
19
|
+
type: int = 301
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class HeaderRule:
|
|
24
|
+
source: str
|
|
25
|
+
headers: list[dict[str, str | None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
public: str | None = None
|
|
31
|
+
clean_urls: bool | list[str] = True
|
|
32
|
+
rewrites: list[Rewrite] = field(default_factory=list)
|
|
33
|
+
redirects: list[Redirect] = field(default_factory=list)
|
|
34
|
+
headers: list[HeaderRule] = field(default_factory=list)
|
|
35
|
+
directory_listing: bool | list[str] = True
|
|
36
|
+
unlisted: list[str] = field(default_factory=lambda: [".DS_Store", ".git"])
|
|
37
|
+
trailing_slash: bool | None = None
|
|
38
|
+
render_single: bool = False
|
|
39
|
+
symlinks: bool = False
|
|
40
|
+
etag: bool = False
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def defaults(cls) -> Config:
|
|
44
|
+
return cls()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def merge_config(base: Config, overrides: dict) -> Config:
|
|
48
|
+
kw = {}
|
|
49
|
+
for key, val in overrides.items():
|
|
50
|
+
match key:
|
|
51
|
+
case "public":
|
|
52
|
+
kw["public"] = str(val) if val else None
|
|
53
|
+
case "cleanUrls":
|
|
54
|
+
if isinstance(val, bool):
|
|
55
|
+
kw["clean_urls"] = val
|
|
56
|
+
elif isinstance(val, list):
|
|
57
|
+
kw["clean_urls"] = val
|
|
58
|
+
case "rewrites":
|
|
59
|
+
kw["rewrites"] = [Rewrite(**r) for r in val]
|
|
60
|
+
case "redirects":
|
|
61
|
+
kw["redirects"] = [Redirect(**r) for r in val]
|
|
62
|
+
case "headers":
|
|
63
|
+
kw["headers"] = [HeaderRule(**h) for h in val]
|
|
64
|
+
case "directoryListing":
|
|
65
|
+
if isinstance(val, bool):
|
|
66
|
+
kw["directory_listing"] = val
|
|
67
|
+
elif isinstance(val, list):
|
|
68
|
+
kw["directory_listing"] = val
|
|
69
|
+
case "unlisted":
|
|
70
|
+
kw["unlisted"] = list(val)
|
|
71
|
+
case "trailingSlash":
|
|
72
|
+
kw["trailing_slash"] = bool(val)
|
|
73
|
+
case "renderSingle":
|
|
74
|
+
kw["render_single"] = bool(val)
|
|
75
|
+
case "symlinks":
|
|
76
|
+
kw["symlinks"] = bool(val)
|
|
77
|
+
case "etag":
|
|
78
|
+
kw["etag"] = bool(val)
|
|
79
|
+
return Config(**kw)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(path: str | None, base_dir: str | None = None) -> Config:
|
|
83
|
+
if path:
|
|
84
|
+
config_path = Path(path)
|
|
85
|
+
else:
|
|
86
|
+
search_dir = base_dir or os.getcwd()
|
|
87
|
+
for name in ("serve.json",):
|
|
88
|
+
p = Path(search_dir) / name
|
|
89
|
+
if p.exists():
|
|
90
|
+
config_path = p
|
|
91
|
+
break
|
|
92
|
+
else:
|
|
93
|
+
return Config.defaults()
|
|
94
|
+
|
|
95
|
+
if not config_path.exists():
|
|
96
|
+
return Config.defaults()
|
|
97
|
+
|
|
98
|
+
with open(config_path) as f:
|
|
99
|
+
data = json.load(f)
|
|
100
|
+
|
|
101
|
+
return merge_config(Config.defaults(), data)
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import hashlib
|
|
5
|
+
import html
|
|
6
|
+
import io
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from email.utils import formatdate, parsedate
|
|
13
|
+
from fnmatch import fnmatch
|
|
14
|
+
from http import HTTPStatus
|
|
15
|
+
from http.server import BaseHTTPRequestHandler
|
|
16
|
+
|
|
17
|
+
from ssserve.config import Config
|
|
18
|
+
from ssserve.listing import render_listing
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _route_to_regex(pattern: str) -> re.Pattern:
|
|
22
|
+
parts = []
|
|
23
|
+
for segment in pattern.split("/"):
|
|
24
|
+
if segment.startswith(":"):
|
|
25
|
+
parts.append(f"(?P<{segment[1:]}>[^/]+)")
|
|
26
|
+
elif "*" in segment:
|
|
27
|
+
parts.append(re.escape(segment).replace(r"\*\*", ".*").replace(r"\*", "[^/]*"))
|
|
28
|
+
else:
|
|
29
|
+
parts.append(re.escape(segment))
|
|
30
|
+
return re.compile(f"^{'/'.join(parts)}$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _apply_segments(template: str, groups: dict[str, str]) -> str:
|
|
34
|
+
result = template
|
|
35
|
+
for key, val in groups.items():
|
|
36
|
+
result = result.replace(f":{key}", val)
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _match_glob(path: str, pattern: str) -> bool:
|
|
41
|
+
if pattern.startswith("!"):
|
|
42
|
+
return not fnmatch(path, pattern[1:])
|
|
43
|
+
return fnmatch(path, pattern)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _match_glob_list(path: str, patterns: list[str]) -> bool:
|
|
47
|
+
result = False
|
|
48
|
+
for p in patterns:
|
|
49
|
+
if p.startswith("!"):
|
|
50
|
+
if fnmatch(path, p[1:]):
|
|
51
|
+
return False
|
|
52
|
+
else:
|
|
53
|
+
if fnmatch(path, p):
|
|
54
|
+
result = True
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_byte_range(range_header: str, file_size: int) -> tuple[int, int] | None:
|
|
59
|
+
if not range_header or not range_header.startswith("bytes="):
|
|
60
|
+
return None
|
|
61
|
+
parts = range_header[6:].split("-", 1)
|
|
62
|
+
if len(parts) != 2:
|
|
63
|
+
return None
|
|
64
|
+
start_str, end_str = parts
|
|
65
|
+
try:
|
|
66
|
+
if start_str == "":
|
|
67
|
+
start = file_size - int(end_str)
|
|
68
|
+
end = file_size - 1
|
|
69
|
+
else:
|
|
70
|
+
start = int(start_str)
|
|
71
|
+
end = int(end_str) if end_str else file_size - 1
|
|
72
|
+
if start < 0 or end >= file_size or start > end:
|
|
73
|
+
return None
|
|
74
|
+
return start, end
|
|
75
|
+
except ValueError:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ServeHandler(BaseHTTPRequestHandler):
|
|
80
|
+
server_version = "ssserve/0.1.0"
|
|
81
|
+
default_request_version = "HTTP/1.1"
|
|
82
|
+
|
|
83
|
+
config: Config = Config.defaults()
|
|
84
|
+
cors: bool = False
|
|
85
|
+
single: bool = False
|
|
86
|
+
debug: bool = False
|
|
87
|
+
logging_enabled: bool = True
|
|
88
|
+
no_compression: bool = False
|
|
89
|
+
no_port_switching: bool = False
|
|
90
|
+
root_dir: str = os.getcwd()
|
|
91
|
+
|
|
92
|
+
def log_message(self, format: str, *args) -> None:
|
|
93
|
+
if self.logging_enabled:
|
|
94
|
+
super().log_message(format, *args)
|
|
95
|
+
|
|
96
|
+
def _log_request(self, status: int, size: int = 0) -> None:
|
|
97
|
+
if not self.logging_enabled:
|
|
98
|
+
return
|
|
99
|
+
self.log_message('"%s %s %s" %d %d', self.command, self.path, self.request_version, status, size)
|
|
100
|
+
|
|
101
|
+
def _send_redirect(self, location: str, status: int = 301) -> None:
|
|
102
|
+
self.send_response(status)
|
|
103
|
+
self.send_header("Location", location)
|
|
104
|
+
self.send_header("Content-Length", "0")
|
|
105
|
+
self._apply_common_headers()
|
|
106
|
+
self.end_headers()
|
|
107
|
+
self._log_request(status)
|
|
108
|
+
|
|
109
|
+
def _send_error_page(self, status: int, message: str = "") -> None:
|
|
110
|
+
status_code = int(status)
|
|
111
|
+
error_page = os.path.join(self.root_dir, f"{status_code}.html")
|
|
112
|
+
content = f"<h1>{status_code} {HTTPStatus(status_code).phrase}</h1>"
|
|
113
|
+
if os.path.isfile(error_page):
|
|
114
|
+
with open(error_page, "rb") as f:
|
|
115
|
+
body = f.read()
|
|
116
|
+
content = body.decode("utf-8", errors="replace")
|
|
117
|
+
else:
|
|
118
|
+
content = f"<!doctype html><html><head><meta charset='utf-8'><title>{status_code}</title><style>body{{font-family:sans-serif;padding:40px;text-align:center}}h1{{font-weight:400;color:#333}}p{{color:#666}}</style></head><body><h1>{status_code}</h1><p>{html.escape(message)}</p></body></html>"
|
|
119
|
+
if status_code == 404:
|
|
120
|
+
content = f"<!doctype html><html><head><meta charset='utf-8'><title>404 Not Found</title><style>body{{font-family:sans-serif;padding:40px;text-align:center}}h1{{font-weight:400;color:#333}}</style></head><body><h1>404</h1><p>Not Found</p></body></html>"
|
|
121
|
+
|
|
122
|
+
body_bytes = content.encode("utf-8")
|
|
123
|
+
self.send_response(status)
|
|
124
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
125
|
+
self.send_header("Content-Length", str(len(body_bytes)))
|
|
126
|
+
self._apply_common_headers()
|
|
127
|
+
self.end_headers()
|
|
128
|
+
self.wfile.write(body_bytes)
|
|
129
|
+
self._log_request(status, len(body_bytes))
|
|
130
|
+
|
|
131
|
+
def _apply_common_headers(self) -> None:
|
|
132
|
+
if self.cors:
|
|
133
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
134
|
+
self.send_header("Access-Control-Allow-Headers", "*")
|
|
135
|
+
self.send_header("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
136
|
+
self.send_header("Access-Control-Allow-Credentials", "true")
|
|
137
|
+
|
|
138
|
+
def _get_mime(self, path: str) -> str:
|
|
139
|
+
mime, _ = mimetypes.guess_type(path)
|
|
140
|
+
return mime or "application/octet-stream"
|
|
141
|
+
|
|
142
|
+
def _normalize_path(self, url_path: str) -> str:
|
|
143
|
+
parsed = urllib.parse.urlsplit(url_path)
|
|
144
|
+
path = urllib.parse.unquote(parsed.path)
|
|
145
|
+
has_trailing_slash = path.endswith("/") and path != "/"
|
|
146
|
+
path = os.path.normpath(path).replace("\\", "/")
|
|
147
|
+
if not path.startswith("/"):
|
|
148
|
+
path = "/" + path
|
|
149
|
+
if has_trailing_slash and not path.endswith("/"):
|
|
150
|
+
path += "/"
|
|
151
|
+
return path
|
|
152
|
+
|
|
153
|
+
def _resolve_path(self, url_path: str) -> str | None:
|
|
154
|
+
normalized = self._normalize_path(url_path)
|
|
155
|
+
if self.config.public:
|
|
156
|
+
base = os.path.join(self.root_dir, self.config.public)
|
|
157
|
+
else:
|
|
158
|
+
base = self.root_dir
|
|
159
|
+
fs_path = os.path.normpath(os.path.join(base, normalized.lstrip("/")))
|
|
160
|
+
|
|
161
|
+
if not fs_path.startswith(os.path.normpath(base)):
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if not self.config.symlinks and os.path.islink(fs_path):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
if not os.path.exists(fs_path):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
return fs_path
|
|
171
|
+
|
|
172
|
+
def _check_clean_urls(self, url_path: str) -> str | None:
|
|
173
|
+
if isinstance(self.config.clean_urls, bool) and not self.config.clean_urls:
|
|
174
|
+
return None
|
|
175
|
+
if url_path.endswith(".html"):
|
|
176
|
+
stripped = url_path[: -len(".html")] if url_path != "/index.html" else "/"
|
|
177
|
+
if stripped == "":
|
|
178
|
+
stripped = "/"
|
|
179
|
+
fs_path = self._resolve_with_clean_urls(stripped)
|
|
180
|
+
if fs_path:
|
|
181
|
+
return stripped
|
|
182
|
+
return None
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def _resolve_with_clean_urls(self, url_path: str) -> str | None:
|
|
186
|
+
fs_path = self._resolve_path(url_path)
|
|
187
|
+
if fs_path:
|
|
188
|
+
return fs_path
|
|
189
|
+
if self.config.clean_urls is not False:
|
|
190
|
+
html_path = url_path.rstrip("/") + ".html"
|
|
191
|
+
if url_path == "/":
|
|
192
|
+
html_path = "/index.html"
|
|
193
|
+
fs_path = self._resolve_path(html_path)
|
|
194
|
+
if fs_path and os.path.isfile(fs_path):
|
|
195
|
+
return fs_path
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def _check_trailing_slash(self, url_path: str) -> str | None:
|
|
199
|
+
if self.config.trailing_slash is None:
|
|
200
|
+
return None
|
|
201
|
+
fs_path = self._resolve_path(url_path)
|
|
202
|
+
if fs_path and os.path.isdir(fs_path):
|
|
203
|
+
if not url_path.endswith("/") and self.config.trailing_slash:
|
|
204
|
+
return url_path + "/"
|
|
205
|
+
if url_path.endswith("/") and not self.config.trailing_slash:
|
|
206
|
+
return url_path.rstrip("/") or "/"
|
|
207
|
+
if fs_path and os.path.isfile(fs_path):
|
|
208
|
+
if url_path.endswith("/") and not self.config.trailing_slash:
|
|
209
|
+
return url_path.rstrip("/") or "/"
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def _check_redirects(self, url_path: str) -> tuple[str, int] | None:
|
|
213
|
+
for rule in self.config.redirects:
|
|
214
|
+
regex = _route_to_regex(rule.source)
|
|
215
|
+
m = regex.match(url_path)
|
|
216
|
+
if m:
|
|
217
|
+
dest = _apply_segments(rule.destination, m.groupdict())
|
|
218
|
+
return dest, rule.type
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def _check_rewrites(self, url_path: str) -> str | None:
|
|
222
|
+
for rule in self.config.rewrites:
|
|
223
|
+
regex = _route_to_regex(rule.source)
|
|
224
|
+
m = regex.match(url_path)
|
|
225
|
+
if m:
|
|
226
|
+
return _apply_segments(rule.destination, m.groupdict())
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def _get_custom_headers(self, url_path: str) -> list[tuple[str, str | None]]:
|
|
230
|
+
result = []
|
|
231
|
+
for rule in self.config.headers:
|
|
232
|
+
if _match_glob(url_path, rule.source):
|
|
233
|
+
for h in rule.headers:
|
|
234
|
+
result.append((h["key"], h.get("value")))
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
def _is_listing_allowed(self, url_path: str) -> bool:
|
|
238
|
+
val = self.config.directory_listing
|
|
239
|
+
if isinstance(val, bool):
|
|
240
|
+
return val
|
|
241
|
+
return _match_glob_list(url_path, val)
|
|
242
|
+
|
|
243
|
+
def _is_unlisted(self, name: str) -> bool:
|
|
244
|
+
return _match_glob_list(name, self.config.unlisted)
|
|
245
|
+
|
|
246
|
+
def _serve_file(self, fs_path: str, url_path: str, byte_range: tuple[int, int] | None = None) -> None:
|
|
247
|
+
try:
|
|
248
|
+
stat = os.stat(fs_path)
|
|
249
|
+
file_size = stat.st_size
|
|
250
|
+
mtime = stat.st_mtime
|
|
251
|
+
except OSError:
|
|
252
|
+
self._send_error_page(500)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
mime = self._get_mime(fs_path)
|
|
256
|
+
etag_val = None
|
|
257
|
+
if self.config.etag:
|
|
258
|
+
hasher = hashlib.sha256()
|
|
259
|
+
try:
|
|
260
|
+
with open(fs_path, "rb") as f:
|
|
261
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
262
|
+
hasher.update(chunk)
|
|
263
|
+
etag_val = f'"{hasher.hexdigest()}"'
|
|
264
|
+
except OSError:
|
|
265
|
+
etag_val = None
|
|
266
|
+
|
|
267
|
+
if byte_range:
|
|
268
|
+
status = HTTPStatus.PARTIAL_CONTENT
|
|
269
|
+
start, end = byte_range
|
|
270
|
+
content_length = end - start + 1
|
|
271
|
+
else:
|
|
272
|
+
status = HTTPStatus.OK
|
|
273
|
+
content_length = file_size
|
|
274
|
+
|
|
275
|
+
if etag_val:
|
|
276
|
+
if_none_match = self.headers.get("If-None-Match")
|
|
277
|
+
if if_none_match and if_none_match.strip('" ') == etag_val.strip('" '):
|
|
278
|
+
self.send_response(HTTPStatus.NOT_MODIFIED)
|
|
279
|
+
self._apply_common_headers()
|
|
280
|
+
if etag_val:
|
|
281
|
+
self.send_header("ETag", etag_val)
|
|
282
|
+
self.end_headers()
|
|
283
|
+
self._log_request(304)
|
|
284
|
+
return
|
|
285
|
+
else:
|
|
286
|
+
ims = self.headers.get("If-Modified-Since")
|
|
287
|
+
if ims:
|
|
288
|
+
try:
|
|
289
|
+
ims_time = time.mktime(parsedate(ims))
|
|
290
|
+
if int(mtime) <= int(ims_time):
|
|
291
|
+
self.send_response(HTTPStatus.NOT_MODIFIED)
|
|
292
|
+
self._apply_common_headers()
|
|
293
|
+
self.end_headers()
|
|
294
|
+
self._log_request(304)
|
|
295
|
+
return
|
|
296
|
+
except (TypeError, OSError):
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
accept_encoding = self.headers.get("Accept-Encoding", "")
|
|
300
|
+
use_gzip = (
|
|
301
|
+
not self.no_compression
|
|
302
|
+
and "gzip" in accept_encoding
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
gzipped_data = None
|
|
306
|
+
if use_gzip and not byte_range:
|
|
307
|
+
try:
|
|
308
|
+
with open(fs_path, "rb") as f:
|
|
309
|
+
raw = f.read()
|
|
310
|
+
gzipped_data = gzip.compress(raw)
|
|
311
|
+
content_length = len(gzipped_data)
|
|
312
|
+
except OSError:
|
|
313
|
+
gzipped_data = None
|
|
314
|
+
else:
|
|
315
|
+
gzipped_data = None
|
|
316
|
+
|
|
317
|
+
self.send_response(status)
|
|
318
|
+
self.send_header("Content-Type", mime)
|
|
319
|
+
if content_length is not None:
|
|
320
|
+
self.send_header("Content-Length", str(content_length))
|
|
321
|
+
if etag_val:
|
|
322
|
+
self.send_header("ETag", etag_val)
|
|
323
|
+
else:
|
|
324
|
+
self.send_header("Last-Modified", formatdate(mtime, usegmt=True))
|
|
325
|
+
if byte_range:
|
|
326
|
+
self.send_header("Content-Range", f"bytes {byte_range[0]}-{byte_range[1]}/{file_size}")
|
|
327
|
+
if not self.config.etag:
|
|
328
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
329
|
+
if gzipped_data is not None:
|
|
330
|
+
self.send_header("Content-Encoding", "gzip")
|
|
331
|
+
|
|
332
|
+
for key, val in self._get_custom_headers(url_path):
|
|
333
|
+
if val is None:
|
|
334
|
+
self.send_header(key, "")
|
|
335
|
+
else:
|
|
336
|
+
self.send_header(key, val)
|
|
337
|
+
|
|
338
|
+
self._apply_common_headers()
|
|
339
|
+
self.end_headers()
|
|
340
|
+
|
|
341
|
+
if self.command == "HEAD":
|
|
342
|
+
self._log_request(status, file_size)
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
if gzipped_data is not None:
|
|
347
|
+
self.wfile.write(gzipped_data)
|
|
348
|
+
elif byte_range:
|
|
349
|
+
with open(fs_path, "rb") as f:
|
|
350
|
+
f.seek(byte_range[0])
|
|
351
|
+
remaining = byte_range[1] - byte_range[0] + 1
|
|
352
|
+
while remaining > 0:
|
|
353
|
+
chunk_size = min(65536, remaining)
|
|
354
|
+
data = f.read(chunk_size)
|
|
355
|
+
if not data:
|
|
356
|
+
break
|
|
357
|
+
self.wfile.write(data)
|
|
358
|
+
remaining -= len(data)
|
|
359
|
+
else:
|
|
360
|
+
with open(fs_path, "rb") as f:
|
|
361
|
+
self.copyfile(f, self.wfile)
|
|
362
|
+
except OSError:
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
self._log_request(status, file_size)
|
|
366
|
+
|
|
367
|
+
def _handle_request(self) -> None:
|
|
368
|
+
url_path = self._normalize_path(self.path)
|
|
369
|
+
|
|
370
|
+
if url_path != self.path:
|
|
371
|
+
self._send_redirect(url_path, 301)
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
cu_result = self._check_clean_urls(url_path)
|
|
375
|
+
if cu_result:
|
|
376
|
+
self._send_redirect(cu_result, 301)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
ts_result = self._check_trailing_slash(url_path)
|
|
380
|
+
if ts_result:
|
|
381
|
+
self._send_redirect(ts_result, 301)
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
redirect = self._check_redirects(url_path)
|
|
385
|
+
if redirect:
|
|
386
|
+
dest, status = redirect
|
|
387
|
+
self._send_redirect(dest, status)
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
rewritten = self._check_rewrites(url_path)
|
|
391
|
+
if rewritten:
|
|
392
|
+
url_path = rewritten
|
|
393
|
+
|
|
394
|
+
fs_path = self._resolve_with_clean_urls(url_path)
|
|
395
|
+
|
|
396
|
+
if fs_path and os.path.isdir(fs_path):
|
|
397
|
+
for index in ("index.html", "index.htm"):
|
|
398
|
+
index_path = os.path.join(fs_path, index)
|
|
399
|
+
if os.path.isfile(index_path):
|
|
400
|
+
self._serve_file(index_path, url_path.rstrip("/") + "/" + index)
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
if self.config.render_single:
|
|
404
|
+
entries = [e for e in os.scandir(fs_path) if not self._is_unlisted(e.name)]
|
|
405
|
+
if len(entries) == 1:
|
|
406
|
+
entry = entries[0]
|
|
407
|
+
file_path = os.path.join(fs_path, entry.name)
|
|
408
|
+
if entry.is_file():
|
|
409
|
+
sub_url = url_path.rstrip("/") + "/" + entry.name
|
|
410
|
+
self._serve_file(file_path, sub_url)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
if self._is_listing_allowed(url_path):
|
|
414
|
+
try:
|
|
415
|
+
entries = sorted(os.scandir(fs_path), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
416
|
+
entries = [e for e in entries if not self._is_unlisted(e.name)]
|
|
417
|
+
if not url_path.endswith("/"):
|
|
418
|
+
self._send_redirect(url_path + "/", 301)
|
|
419
|
+
return
|
|
420
|
+
html = render_listing(url_path, fs_path, entries)
|
|
421
|
+
body = html.encode("utf-8")
|
|
422
|
+
self.send_response(HTTPStatus.OK)
|
|
423
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
424
|
+
self.send_header("Content-Length", str(len(body)))
|
|
425
|
+
self._apply_common_headers()
|
|
426
|
+
self.end_headers()
|
|
427
|
+
if self.command != "HEAD":
|
|
428
|
+
self.wfile.write(body)
|
|
429
|
+
self._log_request(200, len(body))
|
|
430
|
+
return
|
|
431
|
+
except OSError:
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
self._send_error_page(404)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
if fs_path and os.path.isfile(fs_path):
|
|
438
|
+
range_header = self.headers.get("Range", "")
|
|
439
|
+
byte_range = _parse_byte_range(range_header, os.path.getsize(fs_path))
|
|
440
|
+
self._serve_file(fs_path, url_path, byte_range)
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
if self.single:
|
|
444
|
+
index_path = os.path.join(self.root_dir, "index.html")
|
|
445
|
+
if os.path.isfile(index_path):
|
|
446
|
+
self._serve_file(index_path, "/index.html")
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
self._send_error_page(404)
|
|
450
|
+
|
|
451
|
+
def do_GET(self) -> None:
|
|
452
|
+
self._handle_request()
|
|
453
|
+
|
|
454
|
+
def do_HEAD(self) -> None:
|
|
455
|
+
self._handle_request()
|
|
456
|
+
|
|
457
|
+
def do_OPTIONS(self) -> None:
|
|
458
|
+
self.send_response(HTTPStatus.NO_CONTENT)
|
|
459
|
+
self._apply_common_headers()
|
|
460
|
+
self.end_headers()
|
|
461
|
+
|
|
462
|
+
def copyfile(self, source: io.IOBase, output: io.IOBase) -> None:
|
|
463
|
+
buf = source.read(65536)
|
|
464
|
+
while buf:
|
|
465
|
+
output.write(buf)
|
|
466
|
+
buf = source.read(65536)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import math
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_size(size: int) -> str:
|
|
8
|
+
if size == 0:
|
|
9
|
+
return "0 B"
|
|
10
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
11
|
+
i = int(math.floor(math.log(size, 1024))) if size > 0 else 0
|
|
12
|
+
i = min(i, len(units) - 1)
|
|
13
|
+
s = size / (1024**i)
|
|
14
|
+
return f"{s:.1f} {units[i]}" if i > 0 else f"{s:.0f} B"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_date(timestamp: float) -> str:
|
|
18
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(timestamp))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
LISTING_TEMPLATE = """\
|
|
22
|
+
<!doctype html>
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="utf-8">
|
|
26
|
+
<title>Index of {path}</title>
|
|
27
|
+
<style>
|
|
28
|
+
*{{margin:0;padding:0;box-sizing:border-box}}
|
|
29
|
+
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;font-size:14px;color:#333;background:#fafafa;padding:20px}}
|
|
30
|
+
h1{{font-weight:500;font-size:18px;margin-bottom:16px;color:#111}}
|
|
31
|
+
table{{width:100%;border-collapse:collapse}}
|
|
32
|
+
th{{text-align:left;font-weight:500;font-size:12px;text-transform:uppercase;color:#888;padding:8px 12px;border-bottom:2px solid #eaeaea}}
|
|
33
|
+
td{{padding:8px 12px;border-bottom:1px solid #eee}}
|
|
34
|
+
tr:hover td{{background:#f5f5f5}}
|
|
35
|
+
a{{color:#067df7;text-decoration:none}}
|
|
36
|
+
a:hover{{text-decoration:underline}}
|
|
37
|
+
.icon{{width:20px;display:inline-block;text-align:center;margin-right:8px;color:#888}}
|
|
38
|
+
.size{{text-align:right;font-variant-numeric:tabular-nums;color:#666}}
|
|
39
|
+
.date{{color:#888}}
|
|
40
|
+
.folder .icon{{color:#f5a623}}
|
|
41
|
+
.file .icon{{color:#666}}
|
|
42
|
+
.parent{{font-weight:500}}
|
|
43
|
+
.footer{{margin-top:20px;font-size:12px;color:#999;text-align:center}}
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<h1>Index of {path}</h1>
|
|
48
|
+
<table>
|
|
49
|
+
<tr><th>Name</th><th class="size">Size</th><th class="date">Modified</th></tr>
|
|
50
|
+
{parent_row}
|
|
51
|
+
{entries}
|
|
52
|
+
</table>
|
|
53
|
+
<div class="footer">ssserve · Python port of vercel/serve</div>
|
|
54
|
+
</body>
|
|
55
|
+
</html>"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_listing(path: str, base_dir: str, entries: list[os.DirEntry]) -> str:
|
|
59
|
+
normalized = "/" + path.strip("/")
|
|
60
|
+
rows = []
|
|
61
|
+
|
|
62
|
+
if path != "/":
|
|
63
|
+
parent = "/" if normalized == "/" else normalized.rsplit("/", 1)[0] or "/"
|
|
64
|
+
rows.append(
|
|
65
|
+
'<tr class="parent">'
|
|
66
|
+
f'<td><a href="{html.escape(parent)}"><span class="icon">📁</span>..</a></td>'
|
|
67
|
+
'<td class="size">-</td>'
|
|
68
|
+
'<td class="date">-</td>'
|
|
69
|
+
"</tr>"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
for entry in sorted(entries, key=lambda e: (not e.is_dir(), e.name.lower())):
|
|
73
|
+
name = entry.name
|
|
74
|
+
href = normalized.rstrip("/") + "/" + name
|
|
75
|
+
is_dir = entry.is_dir()
|
|
76
|
+
|
|
77
|
+
if is_dir:
|
|
78
|
+
icon = "📁"
|
|
79
|
+
size_display = "-"
|
|
80
|
+
cls = "folder"
|
|
81
|
+
else:
|
|
82
|
+
icon = "📄"
|
|
83
|
+
try:
|
|
84
|
+
stat = entry.stat()
|
|
85
|
+
size_display = format_size(stat.st_size)
|
|
86
|
+
date_display = format_date(stat.st_mtime)
|
|
87
|
+
except OSError:
|
|
88
|
+
size_display = "?"
|
|
89
|
+
date_display = "-"
|
|
90
|
+
cls = "file"
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
stat = entry.stat()
|
|
94
|
+
date_display = format_date(stat.st_mtime)
|
|
95
|
+
except OSError:
|
|
96
|
+
date_display = "-"
|
|
97
|
+
|
|
98
|
+
dir_slash = "/" if is_dir else ""
|
|
99
|
+
rows.append(
|
|
100
|
+
f'<tr class="{cls}">'
|
|
101
|
+
f'<td><a href="{html.escape(href)}"><span class="icon">{icon}</span>{html.escape(name)}{dir_slash}</a></td>'
|
|
102
|
+
f'<td class="size">{size_display}</td>'
|
|
103
|
+
f'<td class="date">{date_display}</td>'
|
|
104
|
+
"</tr>"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return LISTING_TEMPLATE.format(
|
|
108
|
+
path=html.escape(normalized),
|
|
109
|
+
parent_row="\n".join(rows[:1]) if path != "/" else "",
|
|
110
|
+
entries="\n".join(rows[1:] if path != "/" else rows),
|
|
111
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socket
|
|
3
|
+
import struct
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Address:
|
|
9
|
+
scheme: str
|
|
10
|
+
host: str
|
|
11
|
+
port: int
|
|
12
|
+
path: str | None
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
if self.scheme == "unix":
|
|
16
|
+
return f"unix:{self.path}"
|
|
17
|
+
host = self.host or "0.0.0.0"
|
|
18
|
+
if ":" in host:
|
|
19
|
+
host = f"[{host}]"
|
|
20
|
+
return f"{host}:{self.port}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_listen(value: str) -> Address:
|
|
24
|
+
value = value.strip()
|
|
25
|
+
|
|
26
|
+
if value.startswith("unix:"):
|
|
27
|
+
return Address(scheme="unix", host="", port=0, path=value[5:])
|
|
28
|
+
|
|
29
|
+
if value.startswith("tcp://"):
|
|
30
|
+
rest = value[6:]
|
|
31
|
+
if rest.startswith("["):
|
|
32
|
+
bracket_end = rest.find("]")
|
|
33
|
+
if bracket_end == -1:
|
|
34
|
+
raise ValueError(f"Invalid IPv6 address in listen URI: {value}")
|
|
35
|
+
host = rest[1:bracket_end]
|
|
36
|
+
after = rest[bracket_end + 1 :]
|
|
37
|
+
if after.startswith(":"):
|
|
38
|
+
port = int(after[1:])
|
|
39
|
+
else:
|
|
40
|
+
port = 3000
|
|
41
|
+
elif ":" in rest:
|
|
42
|
+
host, port_str = rest.rsplit(":", 1)
|
|
43
|
+
port = int(port_str)
|
|
44
|
+
else:
|
|
45
|
+
host = rest
|
|
46
|
+
port = 3000
|
|
47
|
+
return Address(scheme="tcp", host=host, port=port, path=None)
|
|
48
|
+
|
|
49
|
+
if value.startswith("pipe:"):
|
|
50
|
+
raise ValueError("Windows named pipes are not supported")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
port = int(value)
|
|
54
|
+
return Address(scheme="tcp", host="", port=port, path=None)
|
|
55
|
+
except ValueError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
if ":" in value:
|
|
59
|
+
host, port_str = value.rsplit(":", 1)
|
|
60
|
+
try:
|
|
61
|
+
port = int(port_str)
|
|
62
|
+
except ValueError:
|
|
63
|
+
raise ValueError(f"Invalid listen URI: {value}")
|
|
64
|
+
return Address(scheme="tcp", host=host, port=port, path=None)
|
|
65
|
+
|
|
66
|
+
return Address(scheme="tcp", host=value, port=3000, path=None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def find_free_port(start: int = 3000, max_attempts: int = 100) -> int:
|
|
70
|
+
port = start
|
|
71
|
+
for _ in range(max_attempts):
|
|
72
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
73
|
+
try:
|
|
74
|
+
s.bind(("", port))
|
|
75
|
+
return port
|
|
76
|
+
except OSError:
|
|
77
|
+
port += 1
|
|
78
|
+
raise RuntimeError("Could not find a free port")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_lan_ip() -> str | None:
|
|
82
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
83
|
+
try:
|
|
84
|
+
s.connect(("10.255.255.255", 1))
|
|
85
|
+
ip = s.getsockname()[0]
|
|
86
|
+
return ip
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
finally:
|
|
90
|
+
s.close()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
hostname = socket.gethostname()
|
|
94
|
+
return socket.gethostbyname(hostname)
|
|
95
|
+
except Exception:
|
|
96
|
+
return None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ssserve/__init__.py
|
|
4
|
+
ssserve/__main__.py
|
|
5
|
+
ssserve/cli.py
|
|
6
|
+
ssserve/config.py
|
|
7
|
+
ssserve/handler.py
|
|
8
|
+
ssserve/listing.py
|
|
9
|
+
ssserve/network.py
|
|
10
|
+
ssserve.egg-info/PKG-INFO
|
|
11
|
+
ssserve.egg-info/SOURCES.txt
|
|
12
|
+
ssserve.egg-info/dependency_links.txt
|
|
13
|
+
ssserve.egg-info/entry_points.txt
|
|
14
|
+
ssserve.egg-info/requires.txt
|
|
15
|
+
ssserve.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
click>=8.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ssserve
|