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 +0 -0
- web_edit/__main__.py +4 -0
- web_edit/app.py +171 -0
- web_edit/cli.py +54 -0
- web_edit/files.py +289 -0
- web_edit/security.py +56 -0
- web_edit/static/THIRD_PARTY_LICENSES.txt +696 -0
- web_edit/static/app.css +98 -0
- web_edit/static/app.js +32 -0
- web_edit/static/index.html +65 -0
- web_edit-0.1a0.dist-info/METADATA +85 -0
- web_edit-0.1a0.dist-info/RECORD +16 -0
- web_edit-0.1a0.dist-info/WHEEL +5 -0
- web_edit-0.1a0.dist-info/entry_points.txt +2 -0
- web_edit-0.1a0.dist-info/licenses/LICENSE +201 -0
- web_edit-0.1a0.dist-info/top_level.txt +1 -0
web_edit/__init__.py
ADDED
|
File without changes
|
web_edit/__main__.py
ADDED
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
|