neev 0.1.0__py3-none-any.whl
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.
- neev/__init__.py +6 -0
- neev/auth.py +274 -0
- neev/cli.py +309 -0
- neev/config.py +52 -0
- neev/fs.py +177 -0
- neev/html.py +235 -0
- neev/html_entries.py +298 -0
- neev/html_icons.py +239 -0
- neev/html_login.py +212 -0
- neev/html_markdown.py +152 -0
- neev/html_markdown_assets.py +226 -0
- neev/html_nav.py +115 -0
- neev/html_page_template.py +265 -0
- neev/html_preview.py +209 -0
- neev/html_upload.py +292 -0
- neev/html_upload_js.py +137 -0
- neev/log.py +37 -0
- neev/py.typed +0 -0
- neev/server.py +270 -0
- neev/server_assets.py +74 -0
- neev/server_auth.py +120 -0
- neev/server_core.py +224 -0
- neev/server_preview.py +89 -0
- neev/server_upload.py +111 -0
- neev/server_utils.py +19 -0
- neev/server_zip.py +95 -0
- neev/static/alpine.min.js +5 -0
- neev/static/neev.css +2 -0
- neev/toml_config.py +76 -0
- neev/upload.py +223 -0
- neev/upload_multipart.py +175 -0
- neev/url_utils.py +84 -0
- neev/zip.py +246 -0
- neev-0.1.0.dist-info/METADATA +595 -0
- neev-0.1.0.dist-info/RECORD +37 -0
- neev-0.1.0.dist-info/WHEEL +4 -0
- neev-0.1.0.dist-info/entry_points.txt +3 -0
neev/__init__.py
ADDED
neev/auth.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Authentication and session management for neev."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hmac
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Sessions older than this are considered expired and will be pruned.
|
|
14
|
+
TOKEN_TTL = 86400 # 24 hours in seconds
|
|
15
|
+
|
|
16
|
+
COOKIE_NAME = "neev_session"
|
|
17
|
+
|
|
18
|
+
# Run opportunistic pruning every Nth validate() call.
|
|
19
|
+
_VALIDATE_PRUNE_INTERVAL = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# -- Credential validation ---------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_basic_auth(
|
|
26
|
+
authorization_header: str | None,
|
|
27
|
+
expected_username: str,
|
|
28
|
+
expected_password: str,
|
|
29
|
+
) -> bool:
|
|
30
|
+
"""Validate a Basic Auth Authorization header against expected credentials.
|
|
31
|
+
|
|
32
|
+
Uses ``hmac.compare_digest()`` for constant-time comparison to prevent
|
|
33
|
+
timing attacks.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
authorization_header: The raw ``Authorization`` header value, or
|
|
37
|
+
``None`` if the header was not sent.
|
|
38
|
+
expected_username: The username to match against.
|
|
39
|
+
expected_password: The password to match against.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
``True`` if credentials match, ``False`` otherwise.
|
|
43
|
+
"""
|
|
44
|
+
if not authorization_header:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
if not authorization_header.startswith("Basic "):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
encoded = authorization_header[len("Basic ") :]
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
decoded = base64.b64decode(encoded).decode("utf-8")
|
|
54
|
+
except (ValueError, UnicodeDecodeError):
|
|
55
|
+
logger.debug("Failed to decode Basic Auth credentials")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
if ":" not in decoded:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
username, password = decoded.split(":", maxsplit=1)
|
|
62
|
+
return check_credentials(username, password, expected_username, expected_password)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_credentials(
|
|
66
|
+
username: str,
|
|
67
|
+
password: str,
|
|
68
|
+
expected_username: str,
|
|
69
|
+
expected_password: str,
|
|
70
|
+
) -> bool:
|
|
71
|
+
"""Validate plaintext credentials against expected values.
|
|
72
|
+
|
|
73
|
+
Uses ``hmac.compare_digest()`` for constant-time comparison.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
username: The submitted username.
|
|
77
|
+
password: The submitted password.
|
|
78
|
+
expected_username: The username to match against.
|
|
79
|
+
expected_password: The password to match against.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
``True`` if both match, ``False`` otherwise.
|
|
83
|
+
"""
|
|
84
|
+
username_match = hmac.compare_digest(
|
|
85
|
+
username.encode("utf-8"), expected_username.encode("utf-8")
|
|
86
|
+
)
|
|
87
|
+
password_match = hmac.compare_digest(
|
|
88
|
+
password.encode("utf-8"), expected_password.encode("utf-8")
|
|
89
|
+
)
|
|
90
|
+
return username_match and password_match
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# -- Session management ------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SessionStore:
|
|
97
|
+
"""In-memory session token store with TTL-based expiry.
|
|
98
|
+
|
|
99
|
+
Each token is stored alongside its creation timestamp. Expired tokens are
|
|
100
|
+
pruned on every ``create()`` call. ``validate()`` also rejects tokens that
|
|
101
|
+
have exceeded ``TOKEN_TTL`` without waiting for the next prune sweep.
|
|
102
|
+
|
|
103
|
+
Tokens are generated with ``secrets.token_urlsafe()`` for cryptographic
|
|
104
|
+
randomness.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
"""Initialize an empty session store."""
|
|
109
|
+
self._lock = threading.Lock()
|
|
110
|
+
# Maps token → monotonic creation timestamp.
|
|
111
|
+
self._tokens: dict[str, float] = {}
|
|
112
|
+
self._validate_count = 0
|
|
113
|
+
|
|
114
|
+
def create(self) -> str:
|
|
115
|
+
"""Create a new session token, pruning expired tokens first.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A cryptographically random URL-safe token.
|
|
119
|
+
"""
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._prune()
|
|
122
|
+
token = secrets.token_urlsafe(32)
|
|
123
|
+
self._tokens[token] = time.monotonic()
|
|
124
|
+
return token
|
|
125
|
+
|
|
126
|
+
def validate(self, token: str) -> bool:
|
|
127
|
+
"""Check whether a session token is valid and unexpired.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
token: The token to validate.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
``True`` if the token exists and has not exceeded ``TOKEN_TTL``.
|
|
134
|
+
"""
|
|
135
|
+
with self._lock:
|
|
136
|
+
self._validate_count += 1
|
|
137
|
+
if self._validate_count % _VALIDATE_PRUNE_INTERVAL == 0:
|
|
138
|
+
self._prune()
|
|
139
|
+
created_at = self._tokens.get(token)
|
|
140
|
+
if created_at is None:
|
|
141
|
+
return False
|
|
142
|
+
return (time.monotonic() - created_at) < TOKEN_TTL
|
|
143
|
+
|
|
144
|
+
def invalidate(self, token: str) -> None:
|
|
145
|
+
"""Remove a session token.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
token: The token to invalidate.
|
|
149
|
+
"""
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._tokens.pop(token, None)
|
|
152
|
+
self._prune()
|
|
153
|
+
|
|
154
|
+
def _prune(self) -> None:
|
|
155
|
+
"""Remove all tokens that have exceeded ``TOKEN_TTL``."""
|
|
156
|
+
now = time.monotonic()
|
|
157
|
+
expired = [t for t, ts in self._tokens.items() if (now - ts) >= TOKEN_TTL]
|
|
158
|
+
for token in expired:
|
|
159
|
+
del self._tokens[token]
|
|
160
|
+
if expired:
|
|
161
|
+
logger.debug("Pruned %d expired session token(s)", len(expired))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# -- Login rate limiting -----------------------------------------------------
|
|
165
|
+
|
|
166
|
+
# After this many consecutive failures, start blocking.
|
|
167
|
+
MAX_LOGIN_ATTEMPTS = 5
|
|
168
|
+
|
|
169
|
+
# Initial cooldown in seconds; doubles on each subsequent failure.
|
|
170
|
+
BASE_COOLDOWN = 30
|
|
171
|
+
|
|
172
|
+
# Never block longer than this.
|
|
173
|
+
MAX_COOLDOWN = 300
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class LoginRateLimiter:
|
|
177
|
+
"""Per-IP login rate limiter with exponential backoff.
|
|
178
|
+
|
|
179
|
+
Tracks consecutive failed login attempts per client IP. After
|
|
180
|
+
``MAX_LOGIN_ATTEMPTS`` failures, further attempts are blocked for a
|
|
181
|
+
cooldown that doubles with each additional failure, up to
|
|
182
|
+
``MAX_COOLDOWN`` seconds.
|
|
183
|
+
|
|
184
|
+
Thread-safe — uses the same locking pattern as ``SessionStore``.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self) -> None:
|
|
188
|
+
"""Initialize an empty rate limiter."""
|
|
189
|
+
self._lock = threading.Lock()
|
|
190
|
+
# Maps IP → (consecutive_failures, last_failure_monotonic).
|
|
191
|
+
self._attempts: dict[str, tuple[int, float]] = {}
|
|
192
|
+
|
|
193
|
+
def is_blocked(self, ip: str) -> bool:
|
|
194
|
+
"""Check whether an IP is currently in a cooldown period.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
ip: The client IP address.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
``True`` if the IP must wait before retrying.
|
|
201
|
+
"""
|
|
202
|
+
with self._lock:
|
|
203
|
+
record = self._attempts.get(ip)
|
|
204
|
+
if record is None:
|
|
205
|
+
return False
|
|
206
|
+
failures, last_failure = record
|
|
207
|
+
if failures < MAX_LOGIN_ATTEMPTS:
|
|
208
|
+
return False
|
|
209
|
+
cooldown = min(
|
|
210
|
+
BASE_COOLDOWN * 2 ** (failures - MAX_LOGIN_ATTEMPTS),
|
|
211
|
+
MAX_COOLDOWN,
|
|
212
|
+
)
|
|
213
|
+
return (time.monotonic() - last_failure) < cooldown
|
|
214
|
+
|
|
215
|
+
def record_failure(self, ip: str) -> None:
|
|
216
|
+
"""Record a failed login attempt for an IP.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
ip: The client IP address.
|
|
220
|
+
"""
|
|
221
|
+
now = time.monotonic()
|
|
222
|
+
with self._lock:
|
|
223
|
+
self._prune(now)
|
|
224
|
+
record = self._attempts.get(ip)
|
|
225
|
+
failures = (record[0] + 1) if record else 1
|
|
226
|
+
self._attempts[ip] = (failures, now)
|
|
227
|
+
logger.warning("Failed login attempt %d from %s", failures, ip)
|
|
228
|
+
|
|
229
|
+
def _prune(self, now: float) -> None:
|
|
230
|
+
"""Drop entries whose cooldown has fully elapsed.
|
|
231
|
+
|
|
232
|
+
Caller must hold ``self._lock``.
|
|
233
|
+
"""
|
|
234
|
+
expired = [ip for ip, rec in self._attempts.items() if (now - rec[1]) >= MAX_COOLDOWN]
|
|
235
|
+
for ip in expired:
|
|
236
|
+
del self._attempts[ip]
|
|
237
|
+
if expired:
|
|
238
|
+
logger.debug("Pruned %d stale rate-limit entries", len(expired))
|
|
239
|
+
|
|
240
|
+
def record_success(self, ip: str) -> None:
|
|
241
|
+
"""Clear the failure record for an IP after a successful login.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
ip: The client IP address.
|
|
245
|
+
"""
|
|
246
|
+
with self._lock:
|
|
247
|
+
self._attempts.pop(ip, None)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# -- Cookie helpers ----------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_cookie(cookie_header: str | None, name: str) -> str | None:
|
|
254
|
+
"""Extract a named value from a Cookie header.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
cookie_header: The raw ``Cookie`` header value, or ``None``.
|
|
258
|
+
name: The cookie name to look for.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The cookie value, or ``None`` if not found.
|
|
262
|
+
"""
|
|
263
|
+
if not cookie_header:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
for raw_pair in cookie_header.split(";"):
|
|
267
|
+
stripped = raw_pair.strip()
|
|
268
|
+
if "=" not in stripped:
|
|
269
|
+
continue
|
|
270
|
+
key, value = stripped.split("=", maxsplit=1)
|
|
271
|
+
if key.strip() == name:
|
|
272
|
+
return value.strip()
|
|
273
|
+
|
|
274
|
+
return None
|
neev/cli.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""CLI argument parsing and entry point for neev."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from neev.config import Config
|
|
10
|
+
from neev.log import ansi_styled
|
|
11
|
+
from neev.server import run_server
|
|
12
|
+
from neev.toml_config import load_toml, merge_toml_into_args
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Real defaults live here (not on the parser). The parser uses ``None`` as a
|
|
16
|
+
# sentinel so CLI-vs-TOML precedence can be resolved unambiguously: if an
|
|
17
|
+
# attribute is ``None`` after parsing, the user did not pass that flag, and
|
|
18
|
+
# TOML (or these defaults) may fill it in.
|
|
19
|
+
_DEFAULTS = {
|
|
20
|
+
"host": "127.0.0.1",
|
|
21
|
+
"port": 8000,
|
|
22
|
+
"show_hidden": False,
|
|
23
|
+
"enable_zip_download": False,
|
|
24
|
+
"max_zip_size": 100,
|
|
25
|
+
"enable_upload": False,
|
|
26
|
+
"read_only": False,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _print_error(message: str) -> None:
|
|
31
|
+
"""Print a red error message to stderr.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
message: The error description (without ``error:`` prefix).
|
|
35
|
+
"""
|
|
36
|
+
if sys.stderr.isatty():
|
|
37
|
+
print(f"\033[31merror:\033[0m {message}", file=sys.stderr)
|
|
38
|
+
else:
|
|
39
|
+
print(f"error: {message}", file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _on(label: str) -> str:
|
|
43
|
+
"""Format an enabled feature label in green.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
label: The enabled-state text (e.g. ``"enabled"``).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Green-styled text.
|
|
50
|
+
"""
|
|
51
|
+
return ansi_styled(label, "32", stream=sys.stdout)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _off(label: str) -> str:
|
|
55
|
+
"""Format a disabled feature label in dim gray.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
label: The disabled-state text (e.g. ``"disabled"``).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dim-styled text.
|
|
62
|
+
"""
|
|
63
|
+
return ansi_styled(label, "2", stream=sys.stdout)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# -- Parsing and validation ------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_auth(auth_string: str) -> tuple[str, str]:
|
|
70
|
+
"""Split an ``user:pass`` string into username and password.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
auth_string: Credentials in ``user:pass`` format.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A ``(username, password)`` tuple.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
SystemExit: If the string does not contain exactly one colon.
|
|
80
|
+
"""
|
|
81
|
+
if ":" not in auth_string:
|
|
82
|
+
_print_error(f"invalid auth format '{auth_string}' — expected 'user:pass'")
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
username, password = auth_string.split(":", maxsplit=1)
|
|
85
|
+
return username, password
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_auth(args: argparse.Namespace) -> tuple[str | None, str | None]:
|
|
89
|
+
"""Resolve auth credentials from CLI flag or environment variable.
|
|
90
|
+
|
|
91
|
+
``--auth`` takes precedence over the ``NEEV_AUTH`` environment variable.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
args: Parsed CLI arguments.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A ``(username, password)`` tuple, or ``(None, None)`` if no auth is configured.
|
|
98
|
+
"""
|
|
99
|
+
auth_string = args.auth or os.environ.get("NEEV_AUTH")
|
|
100
|
+
if not auth_string:
|
|
101
|
+
return None, None
|
|
102
|
+
return _parse_auth(auth_string)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_directory(directory: Path) -> Path:
|
|
106
|
+
"""Resolve and validate the served directory.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
directory: Path provided by the user (may be relative).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The resolved absolute path.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
SystemExit: If the directory does not exist or is not a directory.
|
|
116
|
+
"""
|
|
117
|
+
resolved = directory.resolve()
|
|
118
|
+
if not resolved.exists():
|
|
119
|
+
_print_error(f"directory '{directory}' does not exist")
|
|
120
|
+
raise SystemExit(1)
|
|
121
|
+
if not resolved.is_dir():
|
|
122
|
+
_print_error(f"'{directory}' is not a directory")
|
|
123
|
+
raise SystemExit(1)
|
|
124
|
+
return resolved
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _validate_port(value: str) -> int:
|
|
128
|
+
"""Validate and convert a port string to an integer.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
value: The raw string from argparse.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The port number as an integer.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
argparse.ArgumentTypeError: If the value is not a valid port (1-65535).
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
port = int(value)
|
|
141
|
+
except ValueError:
|
|
142
|
+
msg = f"'{value}' is not a valid port number"
|
|
143
|
+
raise argparse.ArgumentTypeError(msg) from None
|
|
144
|
+
if port < 1 or port > 65535:
|
|
145
|
+
msg = f"port must be between 1 and 65535, got {port}"
|
|
146
|
+
raise argparse.ArgumentTypeError(msg)
|
|
147
|
+
return port
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
151
|
+
"""Build the argparse parser with all neev CLI flags.
|
|
152
|
+
|
|
153
|
+
All optional flags default to ``None`` so a caller can distinguish "not
|
|
154
|
+
passed" from "passed with a value that happens to match the default".
|
|
155
|
+
Real defaults are applied in :func:`build_config` after TOML merging.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
A configured ``ArgumentParser``.
|
|
159
|
+
"""
|
|
160
|
+
parser = argparse.ArgumentParser(
|
|
161
|
+
prog="neev",
|
|
162
|
+
description="Serve a local directory over HTTP with auth, file browsing, and downloads.",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
"directory",
|
|
166
|
+
nargs="?",
|
|
167
|
+
default=".",
|
|
168
|
+
type=Path,
|
|
169
|
+
help="directory to serve (default: current directory)",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--host",
|
|
173
|
+
default=None,
|
|
174
|
+
help="bind address (default: 127.0.0.1)",
|
|
175
|
+
)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--port",
|
|
178
|
+
"-p",
|
|
179
|
+
default=None,
|
|
180
|
+
type=_validate_port,
|
|
181
|
+
help="bind port (default: 8000)",
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--auth",
|
|
185
|
+
default=None,
|
|
186
|
+
help="credentials as 'user:pass' (or set NEEV_AUTH env var)",
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--show-hidden",
|
|
190
|
+
action=argparse.BooleanOptionalAction,
|
|
191
|
+
default=None,
|
|
192
|
+
help="show dotfiles, dotdirs, and neev.toml in listings",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"--enable-zip-download",
|
|
196
|
+
action=argparse.BooleanOptionalAction,
|
|
197
|
+
default=None,
|
|
198
|
+
help="allow ZIP downloads of folders",
|
|
199
|
+
)
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--max-zip-size",
|
|
202
|
+
default=None,
|
|
203
|
+
type=int,
|
|
204
|
+
help="maximum ZIP archive size in MB (default: 100)",
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--enable-upload",
|
|
208
|
+
action=argparse.BooleanOptionalAction,
|
|
209
|
+
default=None,
|
|
210
|
+
help="allow file uploads",
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"--read-only",
|
|
214
|
+
action=argparse.BooleanOptionalAction,
|
|
215
|
+
default=None,
|
|
216
|
+
help="disable all write operations (overrides --enable-upload)",
|
|
217
|
+
)
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
"--banner",
|
|
220
|
+
default=None,
|
|
221
|
+
help="message to display at the top of directory listings",
|
|
222
|
+
)
|
|
223
|
+
return parser
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _print_startup_banner(config: Config) -> None:
|
|
227
|
+
"""Print a human-readable summary of the resolved configuration.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
config: The resolved server configuration.
|
|
231
|
+
"""
|
|
232
|
+
url = ansi_styled(f"http://{config.host}:{config.port}", "1;36", stream=sys.stdout)
|
|
233
|
+
directory = ansi_styled(str(config.directory), "1", stream=sys.stdout)
|
|
234
|
+
serving = ansi_styled("Serving", "1;36", stream=sys.stdout)
|
|
235
|
+
|
|
236
|
+
auth_status = _on(f"enabled (user: {config.username})") if config.username else _off("disabled")
|
|
237
|
+
upload_status = _on("enabled") if config.enable_upload else _off("disabled")
|
|
238
|
+
zip_status = _on("enabled") if config.enable_zip_download else _off("disabled")
|
|
239
|
+
hidden_status = _on("visible") if config.show_hidden else _off("hidden")
|
|
240
|
+
|
|
241
|
+
print(f"{serving} {directory}")
|
|
242
|
+
print(f" {url}")
|
|
243
|
+
print()
|
|
244
|
+
print(f" auth: {auth_status}")
|
|
245
|
+
print(f" uploads: {upload_status}")
|
|
246
|
+
print(f" zip downloads: {zip_status}")
|
|
247
|
+
print(f" hidden files: {hidden_status}")
|
|
248
|
+
if config.banner:
|
|
249
|
+
print(f" banner: {_on(config.banner)}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _resolve(args: argparse.Namespace, attr: str) -> Any:
|
|
253
|
+
"""Return ``args.<attr>`` if set, otherwise the registered default."""
|
|
254
|
+
value = getattr(args, attr)
|
|
255
|
+
if value is None:
|
|
256
|
+
return _DEFAULTS[attr]
|
|
257
|
+
return value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_config(args: argparse.Namespace, directory: Path) -> Config:
|
|
261
|
+
"""Resolve and validate parsed CLI arguments into a ``Config``.
|
|
262
|
+
|
|
263
|
+
Applies real defaults to any attribute still set to ``None`` after TOML
|
|
264
|
+
merging, handles auth resolution (flag vs env var), and enforces
|
|
265
|
+
``--read-only``.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
args: Parsed CLI arguments (post-TOML-merge).
|
|
269
|
+
directory: The validated, resolved directory to serve.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
A frozen ``Config`` instance ready for use by the server.
|
|
273
|
+
"""
|
|
274
|
+
username, password = _resolve_auth(args)
|
|
275
|
+
|
|
276
|
+
max_zip_size = _resolve(args, "max_zip_size")
|
|
277
|
+
if max_zip_size < 1:
|
|
278
|
+
_print_error("--max-zip-size must be at least 1 MB")
|
|
279
|
+
raise SystemExit(1)
|
|
280
|
+
|
|
281
|
+
enable_upload = _resolve(args, "enable_upload")
|
|
282
|
+
if _resolve(args, "read_only"):
|
|
283
|
+
enable_upload = False
|
|
284
|
+
|
|
285
|
+
return Config(
|
|
286
|
+
directory=directory,
|
|
287
|
+
host=_resolve(args, "host"),
|
|
288
|
+
port=_resolve(args, "port"),
|
|
289
|
+
username=username,
|
|
290
|
+
password=password,
|
|
291
|
+
show_hidden=_resolve(args, "show_hidden"),
|
|
292
|
+
enable_zip_download=_resolve(args, "enable_zip_download"),
|
|
293
|
+
max_zip_size=max_zip_size * 1024 * 1024,
|
|
294
|
+
enable_upload=enable_upload,
|
|
295
|
+
banner=args.banner,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def main() -> None:
|
|
300
|
+
"""Entry point for the neev CLI."""
|
|
301
|
+
parser = _build_parser()
|
|
302
|
+
args = parser.parse_args()
|
|
303
|
+
directory = _validate_directory(args.directory)
|
|
304
|
+
toml_data = load_toml(directory)
|
|
305
|
+
if toml_data:
|
|
306
|
+
merge_toml_into_args(args, toml_data)
|
|
307
|
+
config = build_config(args, directory)
|
|
308
|
+
_print_startup_banner(config)
|
|
309
|
+
run_server(config)
|
neev/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Configuration dataclass for neev."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Config:
|
|
10
|
+
"""Immutable configuration resolved from CLI arguments and environment.
|
|
11
|
+
|
|
12
|
+
All settings are finalized at parse time. For example, ``--read-only``
|
|
13
|
+
forces ``enable_upload`` to ``False`` before this object is created, so
|
|
14
|
+
consumers never need to check a separate read-only flag.
|
|
15
|
+
|
|
16
|
+
``__post_init__`` normalizes ``directory`` to its real path (resolving
|
|
17
|
+
symlinks) and pre-computes ``auth_enabled`` so these are done once at
|
|
18
|
+
startup, not per-request.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
directory: Absolute, realpath-resolved path to the served directory.
|
|
22
|
+
host: Network address to bind the server to.
|
|
23
|
+
port: TCP port to listen on.
|
|
24
|
+
username: HTTP Basic Auth username, or ``None`` if auth is disabled.
|
|
25
|
+
password: HTTP Basic Auth password, or ``None`` if auth is disabled.
|
|
26
|
+
show_hidden: Whether dotfiles and dotdirs appear in listings.
|
|
27
|
+
enable_zip_download: Whether on-the-fly ZIP downloads of folders are allowed.
|
|
28
|
+
max_zip_size: Maximum size in bytes for generated ZIP archives.
|
|
29
|
+
enable_upload: Whether file uploads are accepted.
|
|
30
|
+
auth_enabled: Whether HTTP Basic Auth is active (computed at init).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
directory: Path
|
|
34
|
+
host: str
|
|
35
|
+
port: int
|
|
36
|
+
username: str | None
|
|
37
|
+
password: str | None
|
|
38
|
+
show_hidden: bool
|
|
39
|
+
enable_zip_download: bool
|
|
40
|
+
max_zip_size: int
|
|
41
|
+
enable_upload: bool
|
|
42
|
+
banner: str | None = None
|
|
43
|
+
auth_enabled: bool = False
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
"""Resolve directory realpath and compute auth_enabled once."""
|
|
47
|
+
object.__setattr__(self, "directory", Path(os.path.realpath(self.directory)))
|
|
48
|
+
object.__setattr__(
|
|
49
|
+
self,
|
|
50
|
+
"auth_enabled",
|
|
51
|
+
self.username is not None and self.password is not None,
|
|
52
|
+
)
|