web-edit 0.1a0__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.
web_edit/__init__.py ADDED
File without changes
web_edit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
web_edit/app.py ADDED
@@ -0,0 +1,171 @@
1
+ """Starlette application factory used by the CLI and embedders."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from starlette.applications import Starlette
7
+ from starlette.concurrency import run_in_threadpool
8
+ from starlette.datastructures import UploadFile
9
+ from starlette.responses import FileResponse, JSONResponse
10
+ from starlette.routing import Mount, Route
11
+ from starlette.staticfiles import StaticFiles
12
+
13
+ from .files import IMAGE_LIMIT, TEXT_LIMIT, FileError, FileStore
14
+ from .security import allowed_hosts_for, is_allowed_host
15
+
16
+ STATIC = Path(__file__).parent / "static"
17
+
18
+
19
+ class BrowserProtection:
20
+ """Check hosts against DNS rebinding and require a non-simple write header."""
21
+
22
+ def __init__(self, app, allowed_hosts):
23
+ self.app = app
24
+ self.allowed_hosts = allowed_hosts
25
+
26
+ async def __call__(self, scope, receive, send):
27
+ if scope["type"] == "http":
28
+ if not is_allowed_host(scope["headers"], self.allowed_hosts):
29
+ await JSONResponse(
30
+ {
31
+ "error": "Host not allowed. Configure --allowed-host to allow an additional hostname or IP address."
32
+ },
33
+ status_code=400,
34
+ headers={"Cache-Control": "no-store"},
35
+ )(scope, receive, send)
36
+ return
37
+ headers = dict(scope["headers"])
38
+ if (
39
+ scope["method"] not in {"GET", "HEAD", "OPTIONS"}
40
+ and headers.get(b"x-web-edit") != b"1"
41
+ ):
42
+ await JSONResponse(
43
+ {"error": "Missing X-Web-Edit header."}, status_code=403
44
+ )(scope, receive, send)
45
+ return
46
+
47
+ async def secure_send(message):
48
+ if message["type"] == "http.response.start":
49
+ message.setdefault("headers", []).extend(
50
+ [
51
+ (b"x-content-type-options", b"nosniff"),
52
+ (b"cache-control", b"no-store"),
53
+ (b"referrer-policy", b"no-referrer"),
54
+ ]
55
+ )
56
+ await send(message)
57
+
58
+ await self.app(scope, receive, secure_send)
59
+ else:
60
+ await self.app(scope, receive, send)
61
+
62
+
63
+ async def handle_error(request, error):
64
+ if isinstance(error, FileError):
65
+ return JSONResponse(
66
+ {"error": str(error), **error.details}, status_code=error.status
67
+ )
68
+ if isinstance(error, FileNotFoundError):
69
+ return JSONResponse({"error": "File or folder not found."}, status_code=404)
70
+ if isinstance(error, PermissionError):
71
+ return JSONResponse(
72
+ {"error": "Permission denied for this file or folder."}, status_code=403
73
+ )
74
+ return JSONResponse(
75
+ {"error": "The filesystem operation could not be completed."}, status_code=400
76
+ )
77
+
78
+
79
+ def create_app(directory=".", *, host="127.0.0.1", allowed_hosts=()):
80
+ trusted_hosts = allowed_hosts_for(host, allowed_hosts)
81
+ store = FileStore(directory)
82
+
83
+ def index(request):
84
+ return FileResponse(
85
+ STATIC / "index.html",
86
+ headers={
87
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
88
+ },
89
+ )
90
+
91
+ def browse(request):
92
+ return JSONResponse(store.browse(request.query_params.get("path", "")))
93
+
94
+ def read_text(request):
95
+ return JSONResponse(store.text(request.query_params.get("path", "")))
96
+
97
+ async def save_text(request):
98
+ # Bound JSON before decoding, allowing for JSON's escaped representation.
99
+ chunks = bytearray()
100
+ async for chunk in request.stream():
101
+ chunks.extend(chunk)
102
+ if len(chunks) > TEXT_LIMIT * 6 + 8192:
103
+ raise FileError("Text editing is limited to 10 MiB.", 413)
104
+ try:
105
+ body = json.loads(chunks)
106
+ except (ValueError, UnicodeDecodeError):
107
+ raise FileError("Invalid JSON.")
108
+ if not isinstance(body, dict) or "path" not in body or "content" not in body:
109
+ raise FileError("Provide path and content.")
110
+ result = await run_in_threadpool(
111
+ store.save_text, body["path"], body["content"], body.get("version")
112
+ )
113
+ return JSONResponse(result)
114
+
115
+ async def upload(request):
116
+ async with request.form(max_files=1, max_fields=2) as form:
117
+ file = form.get("file")
118
+ relative = form.get("path")
119
+ expected = form.get("version")
120
+ if not isinstance(file, UploadFile) or not isinstance(relative, str):
121
+ raise FileError("Provide a file and destination path.")
122
+ if expected is not None and not isinstance(expected, str):
123
+ raise FileError("Invalid version.")
124
+ result = await run_in_threadpool(store.write, relative, file.file, expected)
125
+ return JSONResponse(result, status_code=201)
126
+
127
+ def download(request):
128
+ path = store.regular_file(request.query_params.get("path", ""))
129
+ return FileResponse(
130
+ path, filename=path.name, media_type="application/octet-stream"
131
+ )
132
+
133
+ def delete(request):
134
+ return JSONResponse(
135
+ store.delete(
136
+ request.query_params.get("path", ""),
137
+ request.query_params.get("kind"),
138
+ request.query_params.get("version"),
139
+ )
140
+ )
141
+
142
+ def image(request):
143
+ path = store.regular_file(request.query_params.get("path", ""))
144
+ mime = store.image_type(path)
145
+ if not mime or path.stat().st_size >= IMAGE_LIMIT:
146
+ raise FileError(
147
+ "Image previews are available for images smaller than 2 MB.", 415
148
+ )
149
+ return FileResponse(
150
+ path,
151
+ media_type=mime,
152
+ headers={"Content-Security-Policy": "sandbox; default-src 'none'"},
153
+ )
154
+
155
+ app = Starlette(
156
+ routes=[
157
+ Route("/", index),
158
+ Route("/api/browse", browse),
159
+ Route("/api/text", read_text, methods=["GET"]),
160
+ Route("/api/text", save_text, methods=["PUT"]),
161
+ Route("/api/upload", upload, methods=["POST"]),
162
+ Route("/api/download", download),
163
+ Route("/api/file", delete, methods=["DELETE"]),
164
+ Route("/api/image", image),
165
+ Mount("/static", StaticFiles(directory=STATIC), name="static"),
166
+ ],
167
+ exception_handlers={FileError: handle_error, OSError: handle_error},
168
+ )
169
+ app.add_middleware(BrowserProtection, allowed_hosts=trusted_hosts)
170
+ app.state.store = store
171
+ return app
web_edit/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+ import uvicorn
5
+
6
+ from .app import create_app
7
+ from .security import normalize_host
8
+
9
+
10
+ def validate_host(ctx, param, value):
11
+ try:
12
+ if param.multiple:
13
+ return tuple(normalize_host(host) for host in value)
14
+ return normalize_host(value)
15
+ except ValueError as error:
16
+ raise click.BadParameter(str(error), ctx=ctx, param=param) from error
17
+
18
+
19
+ @click.command()
20
+ @click.version_option()
21
+ @click.argument(
22
+ "directory",
23
+ default=".",
24
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
25
+ )
26
+ @click.option(
27
+ "--host",
28
+ "-h",
29
+ default="127.0.0.1",
30
+ show_default=True,
31
+ callback=validate_host,
32
+ help="Address to listen on.",
33
+ )
34
+ @click.option(
35
+ "--allowed-host",
36
+ "-a",
37
+ "allowed_hosts",
38
+ multiple=True,
39
+ callback=validate_host,
40
+ metavar="HOSTNAME",
41
+ help="Additional hostname or IP to accept. Repeat for multiple hosts. Required for remote access when listening on 0.0.0.0 or ::.",
42
+ )
43
+ @click.option(
44
+ "--port", "-p", default=9000, type=click.IntRange(1, 65535), show_default=True
45
+ )
46
+ def cli(directory, host, port, allowed_hosts):
47
+ "Browse and edit files in DIRECTORY (defaults to the current folder)."
48
+ url_host = f"[{host}]" if ":" in host else host
49
+ click.echo(f"Serving {directory.resolve()} at http://{url_host}:{port}")
50
+ uvicorn.run(
51
+ create_app(directory, host=host, allowed_hosts=allowed_hosts),
52
+ host=host,
53
+ port=port,
54
+ )
web_edit/files.py ADDED
@@ -0,0 +1,289 @@
1
+ """Filesystem operations, confined to one root directory."""
2
+
3
+ import hashlib
4
+ import mimetypes
5
+ import os
6
+ import re
7
+ import shutil
8
+ import stat
9
+ import tempfile
10
+ import threading
11
+ from collections import Counter
12
+ from difflib import SequenceMatcher
13
+ from pathlib import Path, PurePosixPath
14
+
15
+ IMAGE_LIMIT = 2_000_000
16
+ TEXT_LIMIT = 10 * 1024 * 1024
17
+ IMAGE_TYPES = {
18
+ "image/png",
19
+ "image/jpeg",
20
+ "image/gif",
21
+ "image/webp",
22
+ "image/avif",
23
+ "image/bmp",
24
+ "image/x-icon",
25
+ "image/vnd.microsoft.icon",
26
+ "image/svg+xml",
27
+ }
28
+
29
+
30
+ class FileError(Exception):
31
+ def __init__(self, message, status=400, **details):
32
+ super().__init__(message)
33
+ self.status = status
34
+ self.details = details
35
+
36
+
37
+ def version(path):
38
+ # Content hashes ignore metadata-only changes from sync tools like Dropbox.
39
+ with path.open("rb") as source:
40
+ return hashlib.file_digest(source, "sha256").hexdigest()
41
+
42
+
43
+ def delete_version(info):
44
+ """Identify the item shown in a listing without reading file contents."""
45
+ value = f"{info.st_dev}:{info.st_ino}:{info.st_mtime_ns}:{info.st_size}:{stat.S_IFMT(info.st_mode)}"
46
+ return hashlib.sha256(value.encode()).hexdigest()
47
+
48
+
49
+ def decode_text(data):
50
+ try:
51
+ text = data.decode("utf-8-sig")
52
+ except UnicodeDecodeError:
53
+ raise FileError(
54
+ "This file is not UTF-8 text. You can download it instead.", 415
55
+ )
56
+ if re.search(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", text):
57
+ raise FileError(
58
+ "This looks like a binary file. You can download it instead.", 415
59
+ )
60
+ return text
61
+
62
+
63
+ def preserve_endings(original, edited):
64
+ """Keep the endings of unchanged lines, including files with mixed endings."""
65
+ old_lines = re.split(r"\r\n|\r|\n", original)
66
+ old_endings = re.findall(r"\r\n|\r|\n", original)
67
+ new_lines = re.split(r"\r\n|\r|\n", edited)
68
+ default = Counter(old_endings).most_common(1)[0][0] if old_endings else "\n"
69
+ endings = [default] * (len(new_lines) - 1)
70
+ # SequenceMatcher's autojunk heuristic handles repetitive documents.
71
+ for match in SequenceMatcher(None, old_lines, new_lines).get_matching_blocks():
72
+ for offset in range(match.size):
73
+ old_index, new_index = match.a + offset, match.b + offset
74
+ if old_index < len(old_endings) and new_index < len(endings):
75
+ endings[new_index] = old_endings[old_index]
76
+ return "".join(
77
+ line + (endings[i] if i < len(endings) else "")
78
+ for i, line in enumerate(new_lines)
79
+ )
80
+
81
+
82
+ class FileStore:
83
+ def __init__(self, root):
84
+ self.root = Path(root).resolve(strict=True)
85
+ if not self.root.is_dir():
86
+ raise ValueError("The root must be a directory")
87
+ self.lock = threading.RLock()
88
+
89
+ def path(self, relative, *, file=False):
90
+ if not isinstance(relative, str) or "\x00" in relative or "\\" in relative:
91
+ raise FileError("Invalid path.")
92
+ try:
93
+ relative.encode("utf-8")
94
+ except UnicodeEncodeError:
95
+ raise FileError("Invalid path.")
96
+ parts = PurePosixPath(relative)
97
+ if parts.is_absolute() or ".." in parts.parts:
98
+ raise FileError("Paths must stay inside the root folder.", 403)
99
+ if file and (not parts.parts or relative.endswith("/")):
100
+ raise FileError("Choose a filename.")
101
+ result = self.root
102
+ for part in parts.parts:
103
+ result = result / part
104
+ if result.is_symlink():
105
+ raise FileError("Symbolic links are not followed.", 403)
106
+ if not result.resolve().is_relative_to(self.root):
107
+ raise FileError("Paths must stay inside the root folder.", 403)
108
+ return result
109
+
110
+ def regular_file(self, relative):
111
+ path = self.path(relative, file=True)
112
+ if not stat.S_ISREG(path.stat().st_mode):
113
+ raise FileError("This is not a regular file.")
114
+ return path
115
+
116
+ def image_type(self, path):
117
+ mime = mimetypes.guess_type(path.name)[0]
118
+ return mime if mime in IMAGE_TYPES else None
119
+
120
+ def browse(self, relative):
121
+ folder = self.path(relative)
122
+ if not folder.is_dir():
123
+ if not folder.exists():
124
+ raise FileNotFoundError()
125
+ raise FileError("This is not a folder.")
126
+ entries = []
127
+ for child in folder.iterdir():
128
+ if child.name.startswith(".web-edit-"):
129
+ continue
130
+ try:
131
+ info = child.lstat()
132
+ except FileNotFoundError:
133
+ continue
134
+ kind = "directory" if stat.S_ISDIR(info.st_mode) else "file"
135
+ if stat.S_ISLNK(info.st_mode):
136
+ kind = "symlink"
137
+ elif not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
138
+ kind = "special"
139
+ entries.append(
140
+ {
141
+ "name": child.name,
142
+ "path": child.relative_to(self.root).as_posix(),
143
+ "kind": kind,
144
+ "size": info.st_size,
145
+ "modified": info.st_mtime,
146
+ "delete_version": delete_version(info),
147
+ "preview": kind == "file"
148
+ and info.st_size < IMAGE_LIMIT
149
+ and bool(self.image_type(child)),
150
+ }
151
+ )
152
+ entries.sort(
153
+ key=lambda item: (
154
+ item["kind"] != "directory",
155
+ item["name"].casefold(),
156
+ item["name"],
157
+ )
158
+ )
159
+ return {
160
+ "path": ""
161
+ if folder == self.root
162
+ else folder.relative_to(self.root).as_posix(),
163
+ "root_name": self.root.name or "/",
164
+ "entries": entries,
165
+ }
166
+
167
+ def delete(self, relative, kind, expected):
168
+ with self.lock:
169
+ target = self.path(relative)
170
+ if target == self.root:
171
+ raise FileError("The root folder cannot be deleted.", 403)
172
+ if kind not in {"file", "directory"} or not expected:
173
+ raise FileError(
174
+ "Provide the item kind and version from the folder listing."
175
+ )
176
+ info = target.lstat()
177
+ actual_kind = "directory" if stat.S_ISDIR(info.st_mode) else "file"
178
+ if not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
179
+ raise FileError("Only regular files and folders can be deleted.", 403)
180
+ if kind != actual_kind or expected != delete_version(info):
181
+ raise FileError(
182
+ "This item changed since the folder was loaded. Refresh before deleting it.",
183
+ 409,
184
+ )
185
+ if kind == "directory":
186
+ # rmtree removes child symlinks themselves, never their targets.
187
+ shutil.rmtree(target)
188
+ else:
189
+ target.unlink()
190
+ return {"path": relative, "deleted": True}
191
+
192
+ def text(self, relative):
193
+ with self.lock:
194
+ path = self.regular_file(relative)
195
+ before = version(path)
196
+ if path.stat().st_size > TEXT_LIMIT:
197
+ raise FileError(
198
+ "Text editing is limited to 10 MiB. You can download this file.",
199
+ 413,
200
+ )
201
+ data = path.read_bytes()
202
+ if version(path) != before:
203
+ raise FileError(
204
+ "The file changed while opening it. Please try again.", 409
205
+ )
206
+ return {"path": relative, "content": decode_text(data), "version": before}
207
+
208
+ def check_destination(self, relative, expected):
209
+ path = self.path(relative, file=True)
210
+ if path.exists():
211
+ if not path.is_file():
212
+ raise FileError(
213
+ "A folder or special file already exists at that path.", 409
214
+ )
215
+ current = version(path)
216
+ if expected != current:
217
+ raise FileError(
218
+ "This file already exists or has changed on disk.",
219
+ 409,
220
+ version=current,
221
+ exists=True,
222
+ )
223
+ elif expected is not None:
224
+ raise FileError(
225
+ "The file was removed or moved on disk. Save your work to a new path.",
226
+ 409,
227
+ )
228
+ return path
229
+
230
+ def write(self, relative, source, expected=None):
231
+ with self.lock:
232
+ target = self.check_destination(relative, expected)
233
+ target.parent.mkdir(parents=True, exist_ok=True)
234
+ self.path(relative, file=True)
235
+ temporary = None
236
+ try:
237
+ with tempfile.NamedTemporaryFile(
238
+ prefix=".web-edit-", dir=target.parent, delete=False
239
+ ) as output:
240
+ temporary = Path(output.name)
241
+ shutil.copyfileobj(source, output, length=1024 * 1024)
242
+ output.flush()
243
+ os.fsync(output.fileno())
244
+ self.check_destination(relative, expected)
245
+ if expected is None:
246
+ # link is an atomic create-if-absent; never silently replace a file.
247
+ try:
248
+ os.link(temporary, target)
249
+ except FileExistsError:
250
+ raise FileError(
251
+ "A file appeared at this path during the upload. Please retry.",
252
+ 409,
253
+ )
254
+ else:
255
+ os.chmod(temporary, stat.S_IMODE(target.stat().st_mode))
256
+ os.replace(temporary, target)
257
+ finally:
258
+ if temporary is not None:
259
+ temporary.unlink(missing_ok=True)
260
+ return {"path": relative, "version": version(target)}
261
+
262
+ def save_text(self, relative, content, expected):
263
+ from io import BytesIO
264
+
265
+ if not isinstance(content, str) or (
266
+ expected is not None and not isinstance(expected, str)
267
+ ):
268
+ raise FileError(
269
+ "Content must be text and version must be a string or null."
270
+ )
271
+ try:
272
+ encoded = content.encode("utf-8")
273
+ except UnicodeEncodeError:
274
+ raise FileError("Content must be valid UTF-8 text.")
275
+ if len(encoded) > TEXT_LIMIT:
276
+ raise FileError("Text editing is limited to 10 MiB.", 413)
277
+ decode_text(encoded)
278
+ with self.lock:
279
+ path = self.check_destination(relative, expected)
280
+ if expected is not None:
281
+ if path.stat().st_size > TEXT_LIMIT:
282
+ raise FileError("Text editing is limited to 10 MiB.", 413)
283
+ original = path.read_bytes()
284
+ encoded = preserve_endings(decode_text(original), content).encode(
285
+ "utf-8"
286
+ )
287
+ if original.startswith(b"\xef\xbb\xbf"):
288
+ encoded = b"\xef\xbb\xbf" + encoded
289
+ return self.write(relative, BytesIO(encoded), expected)
web_edit/security.py ADDED
@@ -0,0 +1,56 @@
1
+ """Exact host matching for requests to the unauthenticated file server."""
2
+
3
+ import ipaddress
4
+ import re
5
+
6
+ LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
7
+ UNSPECIFIED_HOSTS = {"0.0.0.0", "::"}
8
+ HOST_HEADER = re.compile(r"(?:\[([0-9a-fA-F:.]+)\]|([a-zA-Z0-9.-]+))(?::([0-9]{1,5}))?")
9
+ DNS_LABEL = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
10
+
11
+
12
+ def normalize_host(value):
13
+ """Accept an IP or exact DNS name, without a scheme, port, or wildcard."""
14
+ try:
15
+ return str(ipaddress.ip_address(value))
16
+ except ValueError:
17
+ pass
18
+ try:
19
+ name = value.encode("idna").decode("ascii").lower().removesuffix(".")
20
+ except UnicodeError:
21
+ name = ""
22
+ if (
23
+ not name
24
+ or len(name) > 253
25
+ or not all(DNS_LABEL.fullmatch(label) for label in name.split("."))
26
+ ):
27
+ raise ValueError(
28
+ "Use an IP address or exact hostname, without a scheme, port, path, or wildcard."
29
+ )
30
+ return name
31
+
32
+
33
+ def allowed_hosts_for(host, extra_hosts=()):
34
+ hosts = LOCAL_HOSTS | {normalize_host(name) for name in extra_hosts}
35
+ bound_host = normalize_host(host)
36
+ # Listening on every interface must not mean trusting every Host header.
37
+ if bound_host not in UNSPECIFIED_HOSTS:
38
+ hosts.add(bound_host)
39
+ return frozenset(hosts)
40
+
41
+
42
+ def is_allowed_host(headers, allowed_hosts):
43
+ values = [value for name, value in headers if name.lower() == b"host"]
44
+ if len(values) != 1:
45
+ return False
46
+ try:
47
+ match = HOST_HEADER.fullmatch(values[0].decode("ascii"))
48
+ if match is None:
49
+ return False
50
+ ipv6, hostname, port = match.groups()
51
+ if port is not None and not 1 <= int(port) <= 65535:
52
+ return False
53
+ host = str(ipaddress.IPv6Address(ipv6)) if ipv6 else normalize_host(hostname)
54
+ except (ValueError, UnicodeError):
55
+ return False
56
+ return host in allowed_hosts