uploadserver-preview 0.1.13__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.
- uploadserver_preview/__init__.py +1027 -0
- uploadserver_preview/__main__.py +3 -0
- uploadserver_preview/assets/app.css +1078 -0
- uploadserver_preview/assets/diff2html.core.min.js +1 -0
- uploadserver_preview/assets/diff2html.min.css +1 -0
- uploadserver_preview/assets/explorer.js +280 -0
- uploadserver_preview/assets/git.js +233 -0
- uploadserver_preview/assets/hljs-dockerfile.min.js +8 -0
- uploadserver_preview/assets/hljs-github-dark.min.css +10 -0
- uploadserver_preview/assets/hljs-github.min.css +10 -0
- uploadserver_preview/assets/hljs.min.js +1244 -0
- uploadserver_preview/assets/json-viewer.js +28 -0
- uploadserver_preview/assets/marked.umd.js +79 -0
- uploadserver_preview/assets/mobile.js +148 -0
- uploadserver_preview/assets/papaparse.min.js +7 -0
- uploadserver_preview/assets/purify.min.js +3 -0
- uploadserver_preview/assets/themes.css +178 -0
- uploadserver_preview/assets/upload.js +232 -0
- uploadserver_preview/assets/viewer.js +481 -0
- uploadserver_preview/gitinfo.py +203 -0
- uploadserver_preview-0.1.13.dist-info/METADATA +155 -0
- uploadserver_preview-0.1.13.dist-info/RECORD +26 -0
- uploadserver_preview-0.1.13.dist-info/WHEEL +5 -0
- uploadserver_preview-0.1.13.dist-info/entry_points.txt +2 -0
- uploadserver_preview-0.1.13.dist-info/licenses/LICENSE +35 -0
- uploadserver_preview-0.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
"""uploadserver-preview
|
|
2
|
+
|
|
3
|
+
A thin extension of the `uploadserver` package that adds an in-browser previewer
|
|
4
|
+
for developer files: Markdown, JSON, YAML/TOML/INI, source code (syntax
|
|
5
|
+
highlighted), CSV/TSV tables, unified diffs, and images.
|
|
6
|
+
|
|
7
|
+
Rendering happens entirely client-side with vendored JavaScript libraries (no
|
|
8
|
+
build step, no CDN, no network) so it works on an isolated LAN. Uploads and every
|
|
9
|
+
other `uploadserver` feature (auth, TLS, --directory, --theme, ...) are unchanged.
|
|
10
|
+
|
|
11
|
+
The extension works by monkeypatching `uploadserver.SimpleHTTPRequestHandler`
|
|
12
|
+
with a subclass before calling `uploadserver.serve_forever()`. `serve_forever`
|
|
13
|
+
resolves that name from the module namespace at call time, so the subclass is
|
|
14
|
+
used for every request.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import http
|
|
18
|
+
import http.server
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import mimetypes
|
|
22
|
+
import os
|
|
23
|
+
import pathlib
|
|
24
|
+
import posixpath
|
|
25
|
+
import urllib.parse
|
|
26
|
+
|
|
27
|
+
import uploadserver
|
|
28
|
+
|
|
29
|
+
from . import gitinfo
|
|
30
|
+
|
|
31
|
+
__all__ = ["PreviewHTTPRequestHandler", "main", "serve"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _package_version():
|
|
35
|
+
"""The installed package version, for the subtle footer chip. Best-effort."""
|
|
36
|
+
try:
|
|
37
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return version("uploadserver-preview")
|
|
41
|
+
except PackageNotFoundError:
|
|
42
|
+
return "dev"
|
|
43
|
+
except Exception:
|
|
44
|
+
return "dev"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
VERSION = _package_version()
|
|
48
|
+
|
|
49
|
+
ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
|
50
|
+
ASSET_ROUTE = "/__preview_asset__/"
|
|
51
|
+
VIEW_ROUTE = "/__view__"
|
|
52
|
+
INDEX_ROUTE = "/__index__"
|
|
53
|
+
GIT_ROUTE = "/__git__"
|
|
54
|
+
DIFF_ROUTE = "/__diff__"
|
|
55
|
+
|
|
56
|
+
# Files we are willing to serve from the assets directory (basename whitelist).
|
|
57
|
+
_ASSET_FILES = frozenset(
|
|
58
|
+
f for f in os.listdir(ASSETS_DIR) if os.path.isfile(os.path.join(ASSETS_DIR, f))
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _compute_asset_version():
|
|
63
|
+
"""A short fingerprint of the bundled assets (size + mtime of each file).
|
|
64
|
+
|
|
65
|
+
Appended to asset URLs as ?v=... so a long browser cache never serves a
|
|
66
|
+
stale stylesheet/script after the assets change: a change flips the token,
|
|
67
|
+
which flips the URL, which forces a refetch. Computed once at import.
|
|
68
|
+
"""
|
|
69
|
+
import hashlib
|
|
70
|
+
|
|
71
|
+
h = hashlib.sha1()
|
|
72
|
+
for f in sorted(_ASSET_FILES):
|
|
73
|
+
try:
|
|
74
|
+
st = os.stat(os.path.join(ASSETS_DIR, f))
|
|
75
|
+
h.update(("%s:%d:%d;" % (f, st.st_mtime_ns, st.st_size)).encode("utf-8"))
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
return h.hexdigest()[:10]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
ASSET_VERSION = _compute_asset_version()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _asset_url(name):
|
|
85
|
+
"""URL for a bundled asset, cache-busted by the current asset fingerprint."""
|
|
86
|
+
return "%s%s?v=%s" % (ASSET_ROUTE, name, ASSET_VERSION)
|
|
87
|
+
|
|
88
|
+
# Same-origin only. No inline scripts (all JS is external), so script-src stays
|
|
89
|
+
# 'self'. 'unsafe-inline' is allowed for styles because the JSON web component and
|
|
90
|
+
# the highlight/diff themes inject inline styles.
|
|
91
|
+
CSP = (
|
|
92
|
+
"default-src 'self'; "
|
|
93
|
+
"img-src 'self' data: blob:; "
|
|
94
|
+
"style-src 'self' 'unsafe-inline'; "
|
|
95
|
+
"script-src 'self'; "
|
|
96
|
+
"font-src 'self'; "
|
|
97
|
+
"connect-src 'self'; "
|
|
98
|
+
"object-src 'none'; "
|
|
99
|
+
"base-uri 'none'; "
|
|
100
|
+
"form-action 'self'"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# The <script defer> load order matters: hljs before its extra language, and
|
|
104
|
+
# diff2html core after hljs is present as a global.
|
|
105
|
+
_SCRIPTS = (
|
|
106
|
+
"hljs.min.js",
|
|
107
|
+
"hljs-dockerfile.min.js",
|
|
108
|
+
"marked.umd.js",
|
|
109
|
+
"purify.min.js",
|
|
110
|
+
"papaparse.min.js",
|
|
111
|
+
"diff2html.core.min.js",
|
|
112
|
+
"json-viewer.js",
|
|
113
|
+
"viewer.js",
|
|
114
|
+
"git.js",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Extensions that open in the rich viewer. Everything else in a listing is a
|
|
118
|
+
# plain download link. Mirrors (loosely) the client's KIND table.
|
|
119
|
+
_TEXT_EXT = {
|
|
120
|
+
"md", "markdown", "mdown", "mkd", "json", "geojson", "ipynb", "yml", "yaml",
|
|
121
|
+
"toml", "ini", "cfg", "conf", "csv", "tsv", "diff", "patch", "html", "htm",
|
|
122
|
+
"xhtml", "xml", "py", "pyw", "js", "mjs", "cjs", "jsx", "ts", "tsx", "sh",
|
|
123
|
+
"bash", "zsh", "rs", "go", "java", "kt", "c", "h", "cpp", "cc", "cxx", "hpp",
|
|
124
|
+
"hh", "rb", "php", "lua", "sql", "css", "scss", "less", "swift", "r", "pl",
|
|
125
|
+
"txt", "text", "log",
|
|
126
|
+
}
|
|
127
|
+
_IMAGE_EXT = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif", "svg"}
|
|
128
|
+
_DIFF_EXT = {"diff", "patch"}
|
|
129
|
+
_DATA_EXT = {"json", "geojson", "ipynb", "yml", "yaml", "toml", "ini", "cfg", "conf", "csv", "tsv", "xml"}
|
|
130
|
+
_DOC_EXT = {"md", "markdown", "mdown", "mkd", "txt", "text", "log", "html", "htm", "xhtml"}
|
|
131
|
+
_NO_EXT_NAMES = {"dockerfile", "makefile", ".gitignore", ".gitattributes", ".env", "readme", "license"}
|
|
132
|
+
|
|
133
|
+
PREVIEWABLE = _TEXT_EXT | _IMAGE_EXT
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_DIR_GLYPH = "▸" # ▸
|
|
137
|
+
_FILE_GLYPH = "◆" # ◆
|
|
138
|
+
_MARKUP_EXT = {"html", "htm", "xhtml", "xml"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _kind_glyph(name, is_dir):
|
|
142
|
+
"""Return (css_class, glyph_char) for the coloured file-kind glyph.
|
|
143
|
+
|
|
144
|
+
The class picks the glyph colour from the theme tokens (--g-*); the char is
|
|
145
|
+
a folder caret for directories and a diamond for files.
|
|
146
|
+
"""
|
|
147
|
+
if is_dir:
|
|
148
|
+
return "dir", _DIR_GLYPH
|
|
149
|
+
lower = name.lower()
|
|
150
|
+
ext = lower.rsplit(".", 1)[-1] if "." in lower else ""
|
|
151
|
+
if lower in _NO_EXT_NAMES or ext == "":
|
|
152
|
+
if lower in ("readme",):
|
|
153
|
+
return "doc", _FILE_GLYPH
|
|
154
|
+
return "code", _FILE_GLYPH
|
|
155
|
+
if ext in _IMAGE_EXT:
|
|
156
|
+
return "img", _FILE_GLYPH
|
|
157
|
+
if ext in _DIFF_EXT:
|
|
158
|
+
return "diff", _FILE_GLYPH
|
|
159
|
+
if ext in _MARKUP_EXT:
|
|
160
|
+
return "html", _FILE_GLYPH
|
|
161
|
+
if ext in _DATA_EXT:
|
|
162
|
+
return "data", _FILE_GLYPH
|
|
163
|
+
if ext in _DOC_EXT:
|
|
164
|
+
return "doc", _FILE_GLYPH
|
|
165
|
+
if ext in _TEXT_EXT:
|
|
166
|
+
return "code", _FILE_GLYPH
|
|
167
|
+
return "bin", _FILE_GLYPH
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_previewable(name):
|
|
171
|
+
lower = name.lower()
|
|
172
|
+
if lower in _NO_EXT_NAMES:
|
|
173
|
+
return True
|
|
174
|
+
ext = lower.rsplit(".", 1)[-1] if "." in lower else ""
|
|
175
|
+
return ext in PREVIEWABLE
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _list_entries(fs_path, names):
|
|
179
|
+
"""Sort a directory's names: dirs first, then files, case-insensitive.
|
|
180
|
+
|
|
181
|
+
Returns a list of (name, is_dir, full_fs_path, hidden) tuples. `hidden`
|
|
182
|
+
marks dotfiles and gitignored entries — the tree ships them with an
|
|
183
|
+
is-hidden class that CSS hides by default (the sidebar toggle reveals them).
|
|
184
|
+
"""
|
|
185
|
+
ignored = gitinfo.ignored_names(fs_path, names)
|
|
186
|
+
entries = []
|
|
187
|
+
for name in names:
|
|
188
|
+
full = os.path.join(fs_path, name)
|
|
189
|
+
hidden = name.startswith(".") or name in ignored
|
|
190
|
+
entries.append((name, os.path.isdir(full), full, hidden))
|
|
191
|
+
entries.sort(key=lambda e: (not e[1], e[0].lower()))
|
|
192
|
+
return entries
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _entry_json(url_path, name, is_dir, full, hidden):
|
|
196
|
+
"""Describe one listing entry as JSON-serialisable data for the explorer.
|
|
197
|
+
|
|
198
|
+
`url_path` is the (unquoted) URL of the containing directory, ending in "/".
|
|
199
|
+
Mirrors the fields the client's row builder in explorer.js expects.
|
|
200
|
+
"""
|
|
201
|
+
quoted = urllib.parse.quote(name)
|
|
202
|
+
glyph_cls, glyph_ch = _kind_glyph(name, is_dir)
|
|
203
|
+
d = {"name": name, "is_dir": is_dir, "glyph_cls": glyph_cls, "glyph": glyph_ch,
|
|
204
|
+
"hidden": hidden}
|
|
205
|
+
if is_dir:
|
|
206
|
+
d["path"] = url_path + quoted + "/"
|
|
207
|
+
else:
|
|
208
|
+
try:
|
|
209
|
+
d["size_h"] = _human_size(os.path.getsize(full))
|
|
210
|
+
except OSError:
|
|
211
|
+
d["size_h"] = ""
|
|
212
|
+
abs_url = url_path + quoted
|
|
213
|
+
d["raw"] = abs_url
|
|
214
|
+
# Previewable files navigate to their own URL path (the server answers a
|
|
215
|
+
# browser navigation there with the explorer shell — see _serve_file_shell).
|
|
216
|
+
d["view"] = abs_url if _is_previewable(name) else None
|
|
217
|
+
return d
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _dir_index(handler, url_path):
|
|
221
|
+
"""Return the JSON index dict for the directory at `url_path` (ends in "/").
|
|
222
|
+
|
|
223
|
+
Raises OSError if the directory can't be listed.
|
|
224
|
+
"""
|
|
225
|
+
# translate_path unquotes its argument, so hand it a quoted path (url_path is
|
|
226
|
+
# already unquoted) to avoid corrupting names containing '%'.
|
|
227
|
+
fs_path = handler.translate_path(urllib.parse.quote(url_path))
|
|
228
|
+
names = os.listdir(fs_path)
|
|
229
|
+
entries = _list_entries(fs_path, names)
|
|
230
|
+
return {
|
|
231
|
+
"path": url_path,
|
|
232
|
+
"entries": [_entry_json(url_path, n, d, f, h) for (n, d, f, h) in entries],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _git_root():
|
|
237
|
+
"""The served directory (absolute) — the containment boundary for git paths."""
|
|
238
|
+
return os.path.realpath(getattr(uploadserver.args, "directory", os.getcwd()))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _resolve_git_dir(handler, url_path):
|
|
242
|
+
"""The filesystem directory git should run in for a browsed `url_path`.
|
|
243
|
+
|
|
244
|
+
Returns an absolute path inside the served root, or None if the request
|
|
245
|
+
points outside it (symlink/.. escapes). Files resolve to their parent.
|
|
246
|
+
"""
|
|
247
|
+
root = _git_root()
|
|
248
|
+
# translate_path unquotes its argument; hand it a quoted path (see _dir_index)
|
|
249
|
+
real = os.path.realpath(handler.translate_path(urllib.parse.quote(url_path)))
|
|
250
|
+
if real != root and not real.startswith(root + os.sep):
|
|
251
|
+
return None
|
|
252
|
+
return real if os.path.isdir(real) else os.path.dirname(real)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _chip_html(fs_dir):
|
|
256
|
+
"""The context chip: the git branch when browsing a repo, else "local".
|
|
257
|
+
|
|
258
|
+
Server-rendered so it is right without JS; git.js refreshes the label (and
|
|
259
|
+
keeps it in sync with the compare picker) once /__git__ answers.
|
|
260
|
+
"""
|
|
261
|
+
label = gitinfo.head_label(fs_dir)
|
|
262
|
+
if label:
|
|
263
|
+
text = "⎇ %s" % _html_escape(label) # ⎇ branch
|
|
264
|
+
else:
|
|
265
|
+
text = "local"
|
|
266
|
+
return (
|
|
267
|
+
'<span class="chip"><span class="led" aria-hidden="true"></span>'
|
|
268
|
+
'<span id="git-chip">%s</span></span>' % text
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# Inline SVG icons (stroke = currentColor, so they follow the text colour).
|
|
273
|
+
# Used by the topbar controls and the sidebar's hidden-files eye toggle.
|
|
274
|
+
_ICON_PATHS = {
|
|
275
|
+
"eye": '<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/>'
|
|
276
|
+
'<circle cx="12" cy="12" r="3"/>',
|
|
277
|
+
"eye-off": '<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/>'
|
|
278
|
+
'<circle cx="12" cy="12" r="3"/><line x1="4" y1="21" x2="20" y2="3"/>',
|
|
279
|
+
"doc": '<rect x="5" y="3" width="14" height="18" rx="2"/>'
|
|
280
|
+
'<line x1="9" y1="8" x2="15" y2="8"/><line x1="9" y1="12" x2="15" y2="12"/>'
|
|
281
|
+
'<line x1="9" y1="16" x2="13" y2="16"/>',
|
|
282
|
+
"code": '<polyline points="8 6 3 12 8 18"/><polyline points="16 6 21 12 16 18"/>',
|
|
283
|
+
"diff": '<line x1="5" y1="7" x2="11" y2="7"/><line x1="8" y1="4" x2="8" y2="10"/>'
|
|
284
|
+
'<line x1="13" y1="17" x2="19" y2="17"/>',
|
|
285
|
+
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>'
|
|
286
|
+
'<polyline points="7 10 12 15 17 10"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
|
287
|
+
"external": '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'
|
|
288
|
+
'<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _icon(name, cls="ico"):
|
|
293
|
+
return (
|
|
294
|
+
'<svg class="%s" viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
|
295
|
+
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
|
|
296
|
+
'aria-hidden="true">%s</svg>' % (cls, _ICON_PATHS[name])
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _pane_controls_html(raw_hidden=False):
|
|
301
|
+
"""The topbar controls shared by the explorer shell and the standalone
|
|
302
|
+
viewer: kind badge, meta line, the Rendered/Raw/Diff toggle, and the
|
|
303
|
+
open/download links. Toggle buttons and links carry an icon plus a text
|
|
304
|
+
label; narrow screens keep only the icon (see app.css).
|
|
305
|
+
"""
|
|
306
|
+
return (
|
|
307
|
+
'<span class="badge" id="kind"></span>\n'
|
|
308
|
+
'<span class="meta" id="meta"></span>\n'
|
|
309
|
+
'<div class="segmented" id="viewtoggle" role="group" aria-label="View mode" hidden>\n'
|
|
310
|
+
'<button id="btn-rendered" type="button" aria-pressed="true" title="Rendered">'
|
|
311
|
+
'%(i_doc)s<span class="seg-lbl">Rendered</span></button>\n'
|
|
312
|
+
'<button id="btn-raw" type="button" aria-pressed="false" title="Raw">'
|
|
313
|
+
'%(i_code)s<span class="seg-lbl">Raw</span></button>\n'
|
|
314
|
+
'<button id="btn-diff" type="button" aria-pressed="false" title="Diff" hidden>'
|
|
315
|
+
'%(i_diff)s<span class="seg-lbl">Diff</span></button>\n'
|
|
316
|
+
'</div>\n'
|
|
317
|
+
'<a class="raw" id="openlink" href="#" hidden '
|
|
318
|
+
'title="Open the live page in a new tab (runs scripts, no sandbox)">'
|
|
319
|
+
'%(i_ext)s<span class="lbl">open</span></a>\n'
|
|
320
|
+
'<a class="raw" id="rawlink" href="#"%(hid)s download title="Download the raw file">'
|
|
321
|
+
'%(i_dl)s<span class="lbl">download</span></a>'
|
|
322
|
+
% {
|
|
323
|
+
"i_doc": _icon("doc", "ico seg-ico"),
|
|
324
|
+
"i_code": _icon("code", "ico seg-ico"),
|
|
325
|
+
"i_diff": _icon("diff", "ico seg-ico"),
|
|
326
|
+
"i_ext": _icon("external"),
|
|
327
|
+
"i_dl": _icon("download"),
|
|
328
|
+
"hid": " hidden" if raw_hidden else "",
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _hljs_css_links(theme):
|
|
334
|
+
"""The highlight.js theme stylesheet link(s) for the given --theme."""
|
|
335
|
+
if theme == "light":
|
|
336
|
+
return '<link rel="stylesheet" href="%s">' % _asset_url("hljs-github.min.css")
|
|
337
|
+
if theme == "dark":
|
|
338
|
+
return '<link rel="stylesheet" href="%s">' % _asset_url("hljs-github-dark.min.css")
|
|
339
|
+
# auto — follow the OS preference
|
|
340
|
+
return (
|
|
341
|
+
'<link rel="stylesheet" href="%s" media="(prefers-color-scheme: light)">\n'
|
|
342
|
+
'<link rel="stylesheet" href="%s" media="(prefers-color-scheme: dark)">'
|
|
343
|
+
% (_asset_url("hljs-github.min.css"), _asset_url("hljs-github-dark.min.css"))
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _head_links(theme):
|
|
348
|
+
"""The full <link> set shared by the viewer and explorer pages."""
|
|
349
|
+
return "\n".join(
|
|
350
|
+
[
|
|
351
|
+
'<link rel="stylesheet" href="%s">' % _asset_url("themes.css"),
|
|
352
|
+
'<link rel="stylesheet" href="%s">' % _asset_url("app.css"),
|
|
353
|
+
'<link rel="stylesheet" href="%s">' % _asset_url("diff2html.min.css"),
|
|
354
|
+
_hljs_css_links(theme),
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _script_tags(scripts):
|
|
360
|
+
return "\n".join(
|
|
361
|
+
'<script defer src="%s"></script>' % _asset_url(s) for s in scripts
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def get_viewer_page(theme, git_dir=None):
|
|
366
|
+
"""The static viewer shell. The path is read client-side from ?path=.
|
|
367
|
+
|
|
368
|
+
`git_dir` (the viewed file's directory, when resolvable) seeds the branch
|
|
369
|
+
chip server-side.
|
|
370
|
+
"""
|
|
371
|
+
color_scheme = uploadserver.COLOR_SCHEME.get(theme, "light dark")
|
|
372
|
+
head_links = _head_links(theme)
|
|
373
|
+
scripts = _script_tags(_SCRIPTS)
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
"""<!DOCTYPE html>
|
|
377
|
+
<html lang="en" data-theme="%(theme)s">
|
|
378
|
+
<head>
|
|
379
|
+
<meta charset="utf-8">
|
|
380
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
381
|
+
<meta name="color-scheme" content="%(color_scheme)s">
|
|
382
|
+
<title>Preview</title>
|
|
383
|
+
%(head_links)s
|
|
384
|
+
</head>
|
|
385
|
+
<body>
|
|
386
|
+
<div class="app">
|
|
387
|
+
<header class="topbar">
|
|
388
|
+
<a class="back" id="backlink" href="/" title="Back to folder" aria-label="Back to folder">←</a>
|
|
389
|
+
<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"></nav>
|
|
390
|
+
<span class="spacer"></span>
|
|
391
|
+
%(controls)s
|
|
392
|
+
%(chip)s
|
|
393
|
+
</header>
|
|
394
|
+
<main id="content" class="content"><div class="loading">Loading…</div></main>
|
|
395
|
+
</div>
|
|
396
|
+
%(scripts)s
|
|
397
|
+
</body>
|
|
398
|
+
</html>"""
|
|
399
|
+
% {
|
|
400
|
+
"theme": theme,
|
|
401
|
+
"color_scheme": color_scheme,
|
|
402
|
+
"head_links": head_links,
|
|
403
|
+
"controls": _pane_controls_html(),
|
|
404
|
+
"chip": _chip_html(git_dir or _git_root()),
|
|
405
|
+
"scripts": scripts,
|
|
406
|
+
}
|
|
407
|
+
).encode("utf-8")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _tree_row_html(url_path, name, is_dir, full, hidden, depth):
|
|
411
|
+
"""One row of the explorer file-tree. Mirrors rowHtml() in explorer.js.
|
|
412
|
+
|
|
413
|
+
Directories render a twist/caret button and a lazy-loaded `.tchildren`
|
|
414
|
+
container; files render a name link (previewable ones carry data-view so the
|
|
415
|
+
shell loads them into the content pane in place).
|
|
416
|
+
"""
|
|
417
|
+
esc = _html_escape
|
|
418
|
+
glyph_cls, glyph_ch = _kind_glyph(name, is_dir)
|
|
419
|
+
quoted = urllib.parse.quote(name)
|
|
420
|
+
style = ' style="--depth:%d"' % depth
|
|
421
|
+
hid = " is-hidden" if hidden else ""
|
|
422
|
+
if is_dir:
|
|
423
|
+
dpath = url_path + quoted + "/"
|
|
424
|
+
return (
|
|
425
|
+
'<div class="trow isdir%s" data-path="%s"%s>'
|
|
426
|
+
'<button class="twist glyph %s" type="button" aria-expanded="false" '
|
|
427
|
+
'aria-label="Toggle %s">%s</button>'
|
|
428
|
+
'<span class="tname">%s</span>'
|
|
429
|
+
'</div><div class="tchildren" hidden></div>'
|
|
430
|
+
% (hid, esc(dpath), style, glyph_cls, esc(name), glyph_ch, esc(name))
|
|
431
|
+
)
|
|
432
|
+
try:
|
|
433
|
+
size_h = _human_size(os.path.getsize(full))
|
|
434
|
+
except OSError:
|
|
435
|
+
size_h = ""
|
|
436
|
+
abs_url = url_path + quoted
|
|
437
|
+
if _is_previewable(name):
|
|
438
|
+
anchor = '<a class="tname" href="%s" data-view="%s">%s</a>' % (
|
|
439
|
+
esc(abs_url), esc(abs_url), esc(name)
|
|
440
|
+
)
|
|
441
|
+
else:
|
|
442
|
+
anchor = '<a class="tname" href="%s" download>%s</a>' % (esc(quoted), esc(name))
|
|
443
|
+
return (
|
|
444
|
+
'<div class="trow isfile%s"%s>'
|
|
445
|
+
'<span class="twist-spacer" aria-hidden="true"></span>'
|
|
446
|
+
'<span class="glyph %s" aria-hidden="true">%s</span>'
|
|
447
|
+
'%s<span class="tsize">%s</span>'
|
|
448
|
+
"</div>"
|
|
449
|
+
% (hid, style, glyph_cls, glyph_ch, anchor, size_h)
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _breadcrumbs_html(url_path):
|
|
454
|
+
"""Breadcrumb trail for the directory at `url_path` (unquoted, ends in '/').
|
|
455
|
+
|
|
456
|
+
Deep paths collapse: everything above the direct parent folds into one
|
|
457
|
+
"../.." link (to the grandparent, full path in the tooltip), so the bar
|
|
458
|
+
stays short at any depth. Mirrors buildCrumbs() in viewer.js.
|
|
459
|
+
"""
|
|
460
|
+
esc = _html_escape
|
|
461
|
+
segs = [s for s in url_path.strip("/").split("/") if s]
|
|
462
|
+
acc = ""
|
|
463
|
+
if len(segs) > 2:
|
|
464
|
+
above = segs[:-2]
|
|
465
|
+
acc = "/" + "/".join(urllib.parse.quote(s) for s in above)
|
|
466
|
+
parts = ['<a class="up" href="%s/" title="%s/">../..</a>' % (acc, esc("/".join(above)))]
|
|
467
|
+
shown = segs[-2:]
|
|
468
|
+
else:
|
|
469
|
+
parts = ['<a href="/" title="server root">~</a>']
|
|
470
|
+
shown = segs
|
|
471
|
+
for seg in shown:
|
|
472
|
+
acc += "/" + urllib.parse.quote(seg)
|
|
473
|
+
parts.append('<span class="sep">/</span>')
|
|
474
|
+
parts.append('<a href="%s/">%s</a>' % (acc, esc(seg)))
|
|
475
|
+
# mark the last crumb as current
|
|
476
|
+
if shown:
|
|
477
|
+
parts[-1] = '<span class="here">%s</span>' % esc(shown[-1])
|
|
478
|
+
return "".join(parts)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _render_shell(handler, fs_path, names, url_path=None):
|
|
482
|
+
"""The unified explorer shell: a persistent file-tree beside a content pane.
|
|
483
|
+
|
|
484
|
+
The tree for the current directory is rendered server-side (so it works
|
|
485
|
+
without JS); explorer.js then takes over — expanding folders and loading
|
|
486
|
+
files into the pane via fetch(), without a full navigation. `url_path`
|
|
487
|
+
(unquoted) overrides the directory the shell is for — used when a file URL
|
|
488
|
+
gets the shell of its parent directory (see _serve_file_shell).
|
|
489
|
+
"""
|
|
490
|
+
theme = getattr(uploadserver.args, "theme", "auto")
|
|
491
|
+
if url_path is None:
|
|
492
|
+
url_path = urllib.parse.unquote(handler.path.split("?", 1)[0])
|
|
493
|
+
if not url_path.endswith("/"):
|
|
494
|
+
url_path += "/"
|
|
495
|
+
|
|
496
|
+
entries = _list_entries(fs_path, names)
|
|
497
|
+
esc = _html_escape
|
|
498
|
+
|
|
499
|
+
crumbs = _breadcrumbs_html(url_path)
|
|
500
|
+
rows = "".join(_tree_row_html(url_path, n, d, f, h, 0) for (n, d, f, h) in entries)
|
|
501
|
+
if not rows:
|
|
502
|
+
rows = '<div class="empty">This folder is empty.</div>'
|
|
503
|
+
|
|
504
|
+
n = len(entries)
|
|
505
|
+
n_hidden = sum(1 for e in entries if e[3])
|
|
506
|
+
footer = "%d item%s" % (n, "" if n == 1 else "s")
|
|
507
|
+
if n_hidden:
|
|
508
|
+
footer += " · %d hidden" % n_hidden
|
|
509
|
+
|
|
510
|
+
scripts = _script_tags(_SCRIPTS + ("explorer.js", "upload.js", "mobile.js"))
|
|
511
|
+
|
|
512
|
+
return """<!DOCTYPE html>
|
|
513
|
+
<html lang="en" data-theme="%(theme)s">
|
|
514
|
+
<head>
|
|
515
|
+
<meta charset="utf-8">
|
|
516
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
517
|
+
<meta name="color-scheme" content="%(color_scheme)s">
|
|
518
|
+
<title>%(title)s</title>
|
|
519
|
+
%(head_links)s
|
|
520
|
+
</head>
|
|
521
|
+
<body>
|
|
522
|
+
<div class="app app--shell">
|
|
523
|
+
<div class="shell">
|
|
524
|
+
<aside class="sidebar" id="nav-sheet">
|
|
525
|
+
<div class="sheet-grab" id="sheet-grab" aria-hidden="true"><span class="grabber"></span></div>
|
|
526
|
+
<div class="side-head">
|
|
527
|
+
<span class="exp-label">Explorer</span>
|
|
528
|
+
<button class="hid-toggle" id="hid-toggle" type="button" aria-pressed="false"
|
|
529
|
+
title="Show hidden & git-ignored files">%(eye)s%(eye_off)s</button>
|
|
530
|
+
<a class="btn btn-accent btn-sm" id="upload-open" href="/upload">↑ Upload</a>
|
|
531
|
+
</div>
|
|
532
|
+
<div class="tree hide-hidden" id="tree" data-cwd="%(cwd)s">
|
|
533
|
+
<div class="tree-inner">
|
|
534
|
+
%(rows)s
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="exp-foot">
|
|
538
|
+
<span id="exp-foot">%(footer)s</span>
|
|
539
|
+
%(chip)s
|
|
540
|
+
<span class="exp-version" title="uploadserver-preview %(version)s">v%(version)s</span>
|
|
541
|
+
</div>
|
|
542
|
+
</aside>
|
|
543
|
+
<section class="pane">
|
|
544
|
+
<header class="topbar">
|
|
545
|
+
<button class="nav-toggle" id="nav-toggle" type="button" aria-label="Toggle file navigator" aria-controls="nav-sheet" aria-expanded="false">☰</button>
|
|
546
|
+
<nav class="crumbs" id="crumbs" aria-label="Breadcrumb">%(crumbs)s</nav>
|
|
547
|
+
<div class="gitbar" id="gitbar" hidden>
|
|
548
|
+
<span class="git-glyph" aria-hidden="true">⎇</span>
|
|
549
|
+
<span class="git-compare"><span class="git-label">Comparing</span>
|
|
550
|
+
<select class="git-base" id="git-base" aria-label="Compare base"></select>
|
|
551
|
+
<span class="git-arrow" aria-hidden="true">←</span>
|
|
552
|
+
<span class="git-branch" id="git-branch"></span>
|
|
553
|
+
</span>
|
|
554
|
+
<span class="git-counts" id="git-counts" title="Lines added / removed vs the compare base"></span>
|
|
555
|
+
</div>
|
|
556
|
+
<span class="spacer"></span>
|
|
557
|
+
%(controls)s
|
|
558
|
+
</header>
|
|
559
|
+
<main id="content" class="content"><div class="empty-pane">Select a file to preview.</div></main>
|
|
560
|
+
</section>
|
|
561
|
+
</div>
|
|
562
|
+
<div class="nav-backdrop" id="nav-backdrop"></div>
|
|
563
|
+
<button class="nav-peek" id="nav-peek" type="button" aria-label="Show file navigator" aria-controls="nav-sheet"><span class="peek-grip" aria-hidden="true"></span></button>
|
|
564
|
+
<div class="drop-overlay" id="drop-overlay" hidden aria-hidden="true">
|
|
565
|
+
<div class="drop-overlay-inner"><span class="do-glyph" aria-hidden="true">↑</span>Drop files to upload</div>
|
|
566
|
+
</div>
|
|
567
|
+
<div class="modal-backdrop" id="upload-modal" hidden>
|
|
568
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="upload-title">
|
|
569
|
+
<div class="modal-head">
|
|
570
|
+
<h2 class="modal-title" id="upload-title">Upload files</h2>
|
|
571
|
+
<button class="modal-close" id="upload-close" type="button" aria-label="Close">×</button>
|
|
572
|
+
</div>
|
|
573
|
+
<p class="modal-dest">Uploading to <span class="dest-path" id="upload-dest">~</span></p>
|
|
574
|
+
<div class="dropzone" id="upload-drop">
|
|
575
|
+
<span class="dz-glyph" aria-hidden="true">↑</span>
|
|
576
|
+
<p class="dz-text">Drag & drop files here</p>
|
|
577
|
+
<p class="dz-sub">or <button type="button" class="linkbtn" id="upload-browse">browse…</button></p>
|
|
578
|
+
</div>
|
|
579
|
+
<ul class="filelist" id="upload-filelist"></ul>
|
|
580
|
+
<div class="modal-foot">
|
|
581
|
+
<span class="upload-status" id="upload-status" role="status" aria-live="polite"></span>
|
|
582
|
+
<span class="spacer"></span>
|
|
583
|
+
<button class="btn" id="upload-cancel" type="button">Cancel</button>
|
|
584
|
+
<button class="btn btn-accent" id="upload-confirm" type="button" disabled>Upload</button>
|
|
585
|
+
</div>
|
|
586
|
+
<input type="file" id="upload-input" multiple hidden>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
%(scripts)s
|
|
591
|
+
</body>
|
|
592
|
+
</html>""" % {
|
|
593
|
+
"theme": theme,
|
|
594
|
+
"color_scheme": uploadserver.COLOR_SCHEME.get(theme, "light dark"),
|
|
595
|
+
"title": esc(url_path),
|
|
596
|
+
"head_links": _head_links(theme),
|
|
597
|
+
"controls": _pane_controls_html(raw_hidden=True),
|
|
598
|
+
"eye": _icon("eye", "ico eye-open"),
|
|
599
|
+
"eye_off": _icon("eye-off", "ico eye-closed"),
|
|
600
|
+
"chip": _chip_html(fs_path),
|
|
601
|
+
"crumbs": crumbs,
|
|
602
|
+
"cwd": esc(url_path),
|
|
603
|
+
"rows": rows,
|
|
604
|
+
"footer": footer,
|
|
605
|
+
"version": esc(VERSION),
|
|
606
|
+
"scripts": scripts,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _human_size(n):
|
|
611
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
612
|
+
i = 0
|
|
613
|
+
size = float(n)
|
|
614
|
+
while size >= 1024 and i < len(units) - 1:
|
|
615
|
+
size /= 1024.0
|
|
616
|
+
i += 1
|
|
617
|
+
if i == 0:
|
|
618
|
+
return "%d B" % n
|
|
619
|
+
return ("%.1f %s" if size < 10 else "%.0f %s") % (size, units[i])
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _html_escape(s):
|
|
623
|
+
return (
|
|
624
|
+
s.replace("&", "&")
|
|
625
|
+
.replace("<", "<")
|
|
626
|
+
.replace(">", ">")
|
|
627
|
+
.replace('"', """)
|
|
628
|
+
.replace("'", "'")
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _resolve_upload_dir(handler, dir_param):
|
|
633
|
+
"""Resolve the upload destination for `?dir=<url-path>` to a filesystem path.
|
|
634
|
+
|
|
635
|
+
The stock uploadserver drops every file into the served root; this lets the
|
|
636
|
+
explorer upload into the folder the user last interacted with. `dir_param`
|
|
637
|
+
is a URL path (e.g. "/sub/dir/"); we translate it and confirm it stays
|
|
638
|
+
inside the served root (defends against symlink/`..` escapes) and is an
|
|
639
|
+
existing directory. Returns the absolute fs path, or None if invalid.
|
|
640
|
+
"""
|
|
641
|
+
root = os.path.realpath(getattr(uploadserver.args, "directory", os.getcwd()))
|
|
642
|
+
url_path = urllib.parse.unquote(dir_param or "/")
|
|
643
|
+
if not url_path.startswith("/"):
|
|
644
|
+
url_path = "/" + url_path
|
|
645
|
+
# translate_path unquotes its argument and already collapses '..'; hand it a
|
|
646
|
+
# quoted path so names containing '%' survive.
|
|
647
|
+
fs_path = handler.translate_path(urllib.parse.quote(url_path))
|
|
648
|
+
real = os.path.realpath(fs_path)
|
|
649
|
+
if real != root and not real.startswith(root + os.sep):
|
|
650
|
+
return None
|
|
651
|
+
if not os.path.isdir(real):
|
|
652
|
+
return None
|
|
653
|
+
return real
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _receive_upload(handler, dest_dir):
|
|
657
|
+
"""Receive a multipart upload into `dest_dir` (an already-validated fs path).
|
|
658
|
+
|
|
659
|
+
Mirrors uploadserver.receive_upload but writes into an arbitrary directory
|
|
660
|
+
rather than the fixed served root. Reuses uploadserver's PersistentFieldStorage
|
|
661
|
+
(which streams large parts to temp files) and auto_rename/--allow-replace
|
|
662
|
+
conflict handling. Returns an (HTTPStatus, message) tuple.
|
|
663
|
+
"""
|
|
664
|
+
result = (http.HTTPStatus.INTERNAL_SERVER_ERROR, "Server error")
|
|
665
|
+
name_conflict = False
|
|
666
|
+
|
|
667
|
+
form = uploadserver.PersistentFieldStorage(
|
|
668
|
+
fp=handler.rfile, headers=handler.headers, environ={"REQUEST_METHOD": "POST"}
|
|
669
|
+
)
|
|
670
|
+
if "files" not in form:
|
|
671
|
+
return (http.HTTPStatus.BAD_REQUEST, 'Field "files" not found')
|
|
672
|
+
|
|
673
|
+
fields = form["files"]
|
|
674
|
+
if not isinstance(fields, list):
|
|
675
|
+
fields = [fields]
|
|
676
|
+
if not all(field.file and field.filename for field in fields):
|
|
677
|
+
return (http.HTTPStatus.BAD_REQUEST, "No files selected")
|
|
678
|
+
|
|
679
|
+
allow_replace = getattr(uploadserver.args, "allow_replace", False)
|
|
680
|
+
for field in fields:
|
|
681
|
+
filename = pathlib.Path(field.filename).name if (field.file and field.filename) else None
|
|
682
|
+
if not filename:
|
|
683
|
+
continue
|
|
684
|
+
destination = pathlib.Path(dest_dir) / filename
|
|
685
|
+
if os.path.exists(destination):
|
|
686
|
+
if allow_replace and os.path.isfile(destination):
|
|
687
|
+
os.remove(destination)
|
|
688
|
+
else:
|
|
689
|
+
destination = uploadserver.auto_rename(destination)
|
|
690
|
+
name_conflict = True
|
|
691
|
+
# Large parts are spilled to a NamedTemporaryFile we can rename into
|
|
692
|
+
# place; small ones stay in an in-memory buffer we copy out.
|
|
693
|
+
if hasattr(field.file, "name"):
|
|
694
|
+
source = field.file.name
|
|
695
|
+
field.file.close()
|
|
696
|
+
os.rename(source, destination)
|
|
697
|
+
else:
|
|
698
|
+
with open(destination, "wb") as f:
|
|
699
|
+
f.write(field.file.read())
|
|
700
|
+
handler.log_message('[Uploaded] "%s" --> %s', filename, destination)
|
|
701
|
+
result = (
|
|
702
|
+
http.HTTPStatus.NO_CONTENT,
|
|
703
|
+
"Some filename(s) changed due to name conflict" if name_conflict else "Files accepted",
|
|
704
|
+
)
|
|
705
|
+
return result
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class PreviewHTTPRequestHandler(uploadserver.SimpleHTTPRequestHandler):
|
|
709
|
+
"""uploadserver's handler plus preview routes and a richer directory listing."""
|
|
710
|
+
|
|
711
|
+
server_version = "uploadserver-preview"
|
|
712
|
+
|
|
713
|
+
def do_GET(self):
|
|
714
|
+
# Enforce auth exactly like uploadserver does (idempotent if it passes).
|
|
715
|
+
if not uploadserver.check_http_authentication(self):
|
|
716
|
+
return
|
|
717
|
+
if self._route_preview():
|
|
718
|
+
return
|
|
719
|
+
if self._serve_file_shell():
|
|
720
|
+
return
|
|
721
|
+
# /upload, raw files, redirects, and directory listings fall through to
|
|
722
|
+
# uploadserver (which re-checks auth, harmlessly).
|
|
723
|
+
super().do_GET()
|
|
724
|
+
|
|
725
|
+
def do_HEAD(self):
|
|
726
|
+
if not uploadserver.check_http_authentication(self):
|
|
727
|
+
return
|
|
728
|
+
if self._route_preview(): # our senders omit the body for HEAD
|
|
729
|
+
return
|
|
730
|
+
super().do_HEAD()
|
|
731
|
+
|
|
732
|
+
def do_POST(self):
|
|
733
|
+
# /upload accepts an optional ?dir=<url-path> so the explorer can drop
|
|
734
|
+
# files into the folder the user is looking at, not just the served root.
|
|
735
|
+
if not uploadserver.check_http_authentication(self):
|
|
736
|
+
return
|
|
737
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
738
|
+
if parsed.path == "/upload":
|
|
739
|
+
dir_param = (urllib.parse.parse_qs(parsed.query).get("dir") or [None])[0]
|
|
740
|
+
dest = _resolve_upload_dir(self, dir_param)
|
|
741
|
+
if dest is None:
|
|
742
|
+
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid upload directory")
|
|
743
|
+
return
|
|
744
|
+
status, message = _receive_upload(self, dest)
|
|
745
|
+
if status < http.HTTPStatus.BAD_REQUEST:
|
|
746
|
+
self.send_response(status, message)
|
|
747
|
+
self.end_headers()
|
|
748
|
+
else:
|
|
749
|
+
self.send_error(status, message)
|
|
750
|
+
return
|
|
751
|
+
super().do_POST()
|
|
752
|
+
|
|
753
|
+
def do_PUT(self):
|
|
754
|
+
self.do_POST()
|
|
755
|
+
|
|
756
|
+
def _route_preview(self):
|
|
757
|
+
"""Handle preview-specific routes. Returns True if the request was served."""
|
|
758
|
+
route = urllib.parse.urlsplit(self.path).path
|
|
759
|
+
if route == VIEW_ROUTE:
|
|
760
|
+
# seed the branch chip from the viewed file's directory
|
|
761
|
+
query = urllib.parse.urlsplit(self.path).query
|
|
762
|
+
raw = (urllib.parse.parse_qs(query).get("path") or [""])[0]
|
|
763
|
+
git_dir = _resolve_git_dir(self, urllib.parse.unquote(raw)) if raw else None
|
|
764
|
+
self._send_bytes(
|
|
765
|
+
get_viewer_page(getattr(uploadserver.args, "theme", "auto"), git_dir),
|
|
766
|
+
"text/html; charset=utf-8",
|
|
767
|
+
cache=False,
|
|
768
|
+
csp=True,
|
|
769
|
+
)
|
|
770
|
+
return True
|
|
771
|
+
if route == INDEX_ROUTE:
|
|
772
|
+
self._serve_index()
|
|
773
|
+
return True
|
|
774
|
+
if route == GIT_ROUTE:
|
|
775
|
+
self._serve_git()
|
|
776
|
+
return True
|
|
777
|
+
if route == DIFF_ROUTE:
|
|
778
|
+
self._serve_diff()
|
|
779
|
+
return True
|
|
780
|
+
if route.startswith(ASSET_ROUTE):
|
|
781
|
+
self._serve_asset(route[len(ASSET_ROUTE):])
|
|
782
|
+
return True
|
|
783
|
+
return False
|
|
784
|
+
|
|
785
|
+
# Our own directory listing. Because we fully override this, uploadserver's
|
|
786
|
+
# copyfile/flush_headers injection never runs.
|
|
787
|
+
def list_directory(self, path):
|
|
788
|
+
try:
|
|
789
|
+
names = os.listdir(path)
|
|
790
|
+
except OSError:
|
|
791
|
+
self.send_error(http.HTTPStatus.NOT_FOUND, "No permission to list directory")
|
|
792
|
+
return None
|
|
793
|
+
try:
|
|
794
|
+
body = _render_shell(self, path, names).encode("utf-8", "surrogateescape")
|
|
795
|
+
except Exception:
|
|
796
|
+
# If anything goes wrong, fall back to uploadserver's plain listing.
|
|
797
|
+
return super().list_directory(path)
|
|
798
|
+
|
|
799
|
+
self.send_response(http.HTTPStatus.OK)
|
|
800
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
801
|
+
self.send_header("Content-Length", str(len(body)))
|
|
802
|
+
self.send_header("Content-Security-Policy", CSP)
|
|
803
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
804
|
+
self.send_header("Cache-Control", "no-store")
|
|
805
|
+
self.end_headers()
|
|
806
|
+
return io.BytesIO(body)
|
|
807
|
+
|
|
808
|
+
# ---------- helpers ----------
|
|
809
|
+
def _serve_file_shell(self):
|
|
810
|
+
"""Serve the explorer shell for a browser navigating straight to a
|
|
811
|
+
previewable file — the address bar keeps the plain uploadserver path
|
|
812
|
+
(no ?view=) and explorer.js loads the file into the pane.
|
|
813
|
+
|
|
814
|
+
Only top-level navigations opt in (Sec-Fetch-Dest: document); fetch(),
|
|
815
|
+
curl/wget, iframes and images — and anything with ?raw — still get the
|
|
816
|
+
raw bytes, so downloads and mirroring behave like stock uploadserver.
|
|
817
|
+
Returns True if the shell was served.
|
|
818
|
+
"""
|
|
819
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
820
|
+
if self.headers.get("Sec-Fetch-Dest") != "document":
|
|
821
|
+
return False
|
|
822
|
+
if "raw" in urllib.parse.parse_qs(parsed.query, keep_blank_values=True):
|
|
823
|
+
return False
|
|
824
|
+
url_path = urllib.parse.unquote(parsed.path)
|
|
825
|
+
if url_path.endswith("/") or not _is_previewable(posixpath.basename(url_path)):
|
|
826
|
+
return False
|
|
827
|
+
fs_path = self.translate_path(parsed.path)
|
|
828
|
+
if not os.path.isfile(fs_path):
|
|
829
|
+
return False
|
|
830
|
+
dir_url = url_path.rsplit("/", 1)[0] + "/"
|
|
831
|
+
try:
|
|
832
|
+
names = os.listdir(os.path.dirname(fs_path))
|
|
833
|
+
body = _render_shell(self, os.path.dirname(fs_path), names, url_path=dir_url)
|
|
834
|
+
except OSError:
|
|
835
|
+
return False
|
|
836
|
+
self._send_bytes(
|
|
837
|
+
body.encode("utf-8", "surrogateescape"),
|
|
838
|
+
"text/html; charset=utf-8",
|
|
839
|
+
cache=False,
|
|
840
|
+
csp=True,
|
|
841
|
+
)
|
|
842
|
+
return True
|
|
843
|
+
|
|
844
|
+
def _serve_index(self):
|
|
845
|
+
"""Serve a directory's listing as JSON (drives the explorer tree)."""
|
|
846
|
+
query = urllib.parse.urlsplit(self.path).query
|
|
847
|
+
raw = (urllib.parse.parse_qs(query).get("path") or ["/"])[0]
|
|
848
|
+
url_path = urllib.parse.unquote(raw)
|
|
849
|
+
if not url_path.startswith("/"):
|
|
850
|
+
url_path = "/" + url_path
|
|
851
|
+
if not url_path.endswith("/"):
|
|
852
|
+
url_path += "/"
|
|
853
|
+
try:
|
|
854
|
+
data = _dir_index(self, url_path)
|
|
855
|
+
except OSError:
|
|
856
|
+
self.send_error(http.HTTPStatus.NOT_FOUND, "No permission to list directory")
|
|
857
|
+
return
|
|
858
|
+
body = json.dumps(data).encode("utf-8")
|
|
859
|
+
self._send_bytes(body, "application/json; charset=utf-8", cache=False, csp=True)
|
|
860
|
+
|
|
861
|
+
def _serve_git(self):
|
|
862
|
+
"""Serve the git status JSON (branch, compare base, change map, counts).
|
|
863
|
+
|
|
864
|
+
`?path=<url-dir>` scopes the query to the directory being browsed (the
|
|
865
|
+
served tree may hold repos anywhere below the root); `?base=<branch>`
|
|
866
|
+
picks the compare base, which gitinfo validates against the repo's
|
|
867
|
+
real branch list. Outside a repo: {"enabled": false}.
|
|
868
|
+
"""
|
|
869
|
+
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
|
870
|
+
base = (query.get("base") or [None])[0]
|
|
871
|
+
raw = (query.get("path") or ["/"])[0]
|
|
872
|
+
url_path = urllib.parse.unquote(raw)
|
|
873
|
+
if not url_path.startswith("/"):
|
|
874
|
+
url_path = "/" + url_path
|
|
875
|
+
git_dir = _resolve_git_dir(self, url_path)
|
|
876
|
+
if git_dir is None:
|
|
877
|
+
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid path")
|
|
878
|
+
return
|
|
879
|
+
st = gitinfo.status(git_dir, base, boundary=_git_root())
|
|
880
|
+
data = {"enabled": False} if st is None else dict(st, enabled=True)
|
|
881
|
+
body = json.dumps(data).encode("utf-8")
|
|
882
|
+
self._send_bytes(body, "application/json; charset=utf-8", cache=False, csp=True)
|
|
883
|
+
|
|
884
|
+
def _serve_diff(self):
|
|
885
|
+
"""Serve one file's unified diff vs the compare base as text/plain.
|
|
886
|
+
|
|
887
|
+
`?path=<url-path>&base=<branch>`. The path is resolved the same way as
|
|
888
|
+
file serving and must stay inside the served root; git runs in the
|
|
889
|
+
file's directory so repo discovery matches what /__git__ reported.
|
|
890
|
+
"""
|
|
891
|
+
query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
|
|
892
|
+
raw = (query.get("path") or [""])[0]
|
|
893
|
+
base = (query.get("base") or [None])[0]
|
|
894
|
+
url_path = urllib.parse.unquote(raw)
|
|
895
|
+
if not url_path.startswith("/"):
|
|
896
|
+
url_path = "/" + url_path
|
|
897
|
+
root = _git_root()
|
|
898
|
+
# translate_path unquotes its argument; hand it a quoted path (see
|
|
899
|
+
# _dir_index). realpath containment defends against ../ and symlinks.
|
|
900
|
+
real = os.path.realpath(self.translate_path(urllib.parse.quote(url_path)))
|
|
901
|
+
if real == root or not real.startswith(root + os.sep):
|
|
902
|
+
self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid path")
|
|
903
|
+
return
|
|
904
|
+
text = gitinfo.file_diff(os.path.dirname(real), os.path.basename(real), base)
|
|
905
|
+
self._send_bytes(
|
|
906
|
+
text.encode("utf-8", "surrogateescape"),
|
|
907
|
+
"text/plain; charset=utf-8",
|
|
908
|
+
cache=False,
|
|
909
|
+
csp=True,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
def _serve_asset(self, name):
|
|
913
|
+
safe = posixpath.basename(urllib.parse.unquote(name))
|
|
914
|
+
fpath = os.path.join(ASSETS_DIR, safe)
|
|
915
|
+
if safe not in _ASSET_FILES or not os.path.isfile(fpath):
|
|
916
|
+
self.send_error(http.HTTPStatus.NOT_FOUND, "Asset not found")
|
|
917
|
+
return
|
|
918
|
+
if safe.endswith(".js"):
|
|
919
|
+
ctype = "text/javascript; charset=utf-8"
|
|
920
|
+
elif safe.endswith(".css"):
|
|
921
|
+
ctype = "text/css; charset=utf-8"
|
|
922
|
+
else:
|
|
923
|
+
ctype = mimetypes.guess_type(safe)[0] or "application/octet-stream"
|
|
924
|
+
try:
|
|
925
|
+
with open(fpath, "rb") as f:
|
|
926
|
+
data = f.read()
|
|
927
|
+
except OSError:
|
|
928
|
+
self.send_error(http.HTTPStatus.NOT_FOUND, "Asset not found")
|
|
929
|
+
return
|
|
930
|
+
self.send_response(http.HTTPStatus.OK)
|
|
931
|
+
self.send_header("Content-Type", ctype)
|
|
932
|
+
self.send_header("Content-Length", str(len(data)))
|
|
933
|
+
self.send_header("Cache-Control", "public, max-age=86400")
|
|
934
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
935
|
+
self.end_headers()
|
|
936
|
+
if self.command != "HEAD":
|
|
937
|
+
self.wfile.write(data)
|
|
938
|
+
|
|
939
|
+
def _send_bytes(self, data, ctype, cache=True, csp=False):
|
|
940
|
+
self.send_response(http.HTTPStatus.OK)
|
|
941
|
+
self.send_header("Content-Type", ctype)
|
|
942
|
+
self.send_header("Content-Length", str(len(data)))
|
|
943
|
+
if csp:
|
|
944
|
+
self.send_header("Content-Security-Policy", CSP)
|
|
945
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
946
|
+
self.send_header("Cache-Control", "public, max-age=86400" if cache else "no-store")
|
|
947
|
+
self.end_headers()
|
|
948
|
+
if self.command != "HEAD":
|
|
949
|
+
self.wfile.write(data)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def serve(enable_preview=True):
|
|
953
|
+
"""Launch the server, optionally with preview enabled.
|
|
954
|
+
|
|
955
|
+
Assumes `uploadserver.args` and the auth globals are already set (as
|
|
956
|
+
`main()` does). Monkeypatches the handler and delegates to
|
|
957
|
+
`uploadserver.serve_forever()`.
|
|
958
|
+
"""
|
|
959
|
+
if enable_preview and not getattr(uploadserver.args, "cgi", False):
|
|
960
|
+
uploadserver.SimpleHTTPRequestHandler = PreviewHTTPRequestHandler
|
|
961
|
+
uploadserver.serve_forever()
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _build_parser():
|
|
965
|
+
import argparse
|
|
966
|
+
|
|
967
|
+
parser = argparse.ArgumentParser(
|
|
968
|
+
prog="uploadserver-preview",
|
|
969
|
+
description="uploadserver + an in-browser previewer for developer files.",
|
|
970
|
+
)
|
|
971
|
+
# Mirror uploadserver's arguments so this is a drop-in replacement.
|
|
972
|
+
parser.add_argument("port", type=int, default=8000, nargs="?",
|
|
973
|
+
help="Specify alternate port [default: 8000]")
|
|
974
|
+
parser.add_argument("--cgi", action="store_true", help="Run as CGI server (disables preview)")
|
|
975
|
+
parser.add_argument("--allow-replace", action="store_true", default=False,
|
|
976
|
+
help="Replace existing file if uploaded file has the same name. "
|
|
977
|
+
"Auto rename by default.")
|
|
978
|
+
parser.add_argument("--bind", "-b", metavar="ADDRESS",
|
|
979
|
+
help="Specify alternate bind address [default: all interfaces]")
|
|
980
|
+
parser.add_argument("--directory", "-d", default=os.getcwd(),
|
|
981
|
+
help="Specify alternative directory [default: current directory]")
|
|
982
|
+
parser.add_argument("--theme", type=str, default="auto", choices=["light", "auto", "dark"],
|
|
983
|
+
help="Light or dark theme [default: auto]")
|
|
984
|
+
parser.add_argument("--server-certificate", "--certificate", "-c",
|
|
985
|
+
help="Specify HTTPS server certificate to use [default: none]")
|
|
986
|
+
parser.add_argument("--client-certificate",
|
|
987
|
+
help="Specify HTTPS client certificate to accept for mutual TLS [default: none]")
|
|
988
|
+
parser.add_argument("--basic-auth",
|
|
989
|
+
help="Specify user:pass for basic authentication (downloads and uploads)")
|
|
990
|
+
parser.add_argument("--basic-auth-upload",
|
|
991
|
+
help="Specify user:pass for basic authentication (uploads only)")
|
|
992
|
+
# Our addition.
|
|
993
|
+
parser.add_argument("--no-preview", action="store_true",
|
|
994
|
+
help="Disable the previewer and behave like plain uploadserver")
|
|
995
|
+
return parser
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def main():
|
|
999
|
+
import ipaddress
|
|
1000
|
+
|
|
1001
|
+
parser = _build_parser()
|
|
1002
|
+
args = parser.parse_args()
|
|
1003
|
+
if not hasattr(args, "directory"):
|
|
1004
|
+
args.directory = os.getcwd()
|
|
1005
|
+
if args.bind:
|
|
1006
|
+
try:
|
|
1007
|
+
ipaddress.ip_address(args.bind)
|
|
1008
|
+
except ValueError:
|
|
1009
|
+
parser.error(
|
|
1010
|
+
"Invalid -b/--bind address. Expected an IP address (no port). "
|
|
1011
|
+
"Example: -b 192.168.1.10 and pass the port as a separate argument."
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
# Publish state onto the uploadserver module (its code reads these globals).
|
|
1015
|
+
uploadserver.args = args
|
|
1016
|
+
if args.basic_auth:
|
|
1017
|
+
u, p = args.basic_auth.split(":", 1)
|
|
1018
|
+
uploadserver.basic_auth = (bytes(u, "utf8"), bytes(p, "utf8"))
|
|
1019
|
+
if args.basic_auth_upload:
|
|
1020
|
+
u, p = args.basic_auth_upload.split(":", 1)
|
|
1021
|
+
uploadserver.basic_auth_upload = (bytes(u, "utf8"), bytes(p, "utf8"))
|
|
1022
|
+
|
|
1023
|
+
serve(enable_preview=not args.no_preview)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
if __name__ == "__main__":
|
|
1027
|
+
main()
|