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.
@@ -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 = "&#9095; %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">&larr;</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&hellip;</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 += " &middot; %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 &amp; git-ignored files">%(eye)s%(eye_off)s</button>
530
+ <a class="btn btn-accent btn-sm" id="upload-open" href="/upload">&#8593; 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">&#9776;</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">&#9095;</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">&larr;</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">&#8593;</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">&times;</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">&#8593;</span>
576
+ <p class="dz-text">Drag &amp; drop files here</p>
577
+ <p class="dz-sub">or <button type="button" class="linkbtn" id="upload-browse">browse&hellip;</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("&", "&amp;")
625
+ .replace("<", "&lt;")
626
+ .replace(">", "&gt;")
627
+ .replace('"', "&quot;")
628
+ .replace("'", "&#39;")
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()