ppkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pp/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
pp/commands/http.py
ADDED
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
"""Browse project folders: rendered md, highlighted code
|
|
2
|
+
|
|
3
|
+
Like `python -m http.server`, but for reading what agents write across projects: markdown rendered with math
|
|
4
|
+
(KaTeX) and diagrams (mermaid), code highlighted with line anchors, one tab per project, a cross-project
|
|
5
|
+
"Recent" view (newest files first), and live reload while a file or folder changes.
|
|
6
|
+
|
|
7
|
+
pp http roots from config (tabs + discovery rules); no config -> the current directory
|
|
8
|
+
pp http ~/projects/webapp . just these folders, one tab each (a file argument serves its folder)
|
|
9
|
+
pp http -p 9000 port (busy -> the next free one of ~20, printed)
|
|
10
|
+
pp http --no-discover configured [[http.roots]] only, skip [[http.discover]] rules
|
|
11
|
+
pp http --config-only never fall back to the current directory
|
|
12
|
+
pp http -q no request log
|
|
13
|
+
|
|
14
|
+
Pages: / Recent (filter box is instant; Enter re-queries all files, `glob` takes e.g. "*.md *.py")
|
|
15
|
+
/t/<tab>/<path> folder listing (README rendered below) or file view /raw/<tab>/<path> raw bytes
|
|
16
|
+
|
|
17
|
+
Config: ~/.config/pp/config.toml (every key optional)
|
|
18
|
+
|
|
19
|
+
[http]
|
|
20
|
+
port = 8000
|
|
21
|
+
bind = "127.0.0.1"
|
|
22
|
+
recent = ["*.md"] # what the Recent view collects
|
|
23
|
+
exclude_dirs = ["target"] # folders Recent and discovery skip (still listed and served; deny hides),
|
|
24
|
+
# ADDED to the defaults (node_modules .git .venv __pycache__ dist build ...)
|
|
25
|
+
deny = ["*.secret"] # ADDED to the defaults (.env .env.* *.pem *.key *.p12 *.pfx id_rsa* id_ed25519*
|
|
26
|
+
# id_ecdsa* id_dsa* *.sqlite credentials* .netrc .git-credentials .pgpass
|
|
27
|
+
# .pypirc .npmrc); never listed, never served, hidden or not
|
|
28
|
+
follow_symlinks = false # true: serve symlinks that leave their root
|
|
29
|
+
hosts = ["box.tailnet.ts.net"] # Host names a proxy in front may pass through (loopback names always work)
|
|
30
|
+
|
|
31
|
+
[[http.roots]] # a fixed tab
|
|
32
|
+
name = "notes"
|
|
33
|
+
path = "~/notes"
|
|
34
|
+
|
|
35
|
+
[[http.discover]] # a rule: every dir `depth` levels under `under` holding any marker is a tab
|
|
36
|
+
under = "~/projects"
|
|
37
|
+
depth = 1
|
|
38
|
+
marker = [".git", "CLAUDE.md", "README.md"] # optional; without it every subdirectory qualifies
|
|
39
|
+
|
|
40
|
+
Rules are re-read every ~10 s, so a new project shows up without a restart.
|
|
41
|
+
|
|
42
|
+
Security: binds 127.0.0.1 by default, so only this machine can connect. Exposing it is a deliberate act:
|
|
43
|
+
`--bind 0.0.0.0` (anyone on the network can read every file under every tab) or, better, keep loopback and put an
|
|
44
|
+
authenticating proxy / tunnel in front. On loopback, a request naming any Host but a loopback one (or one of
|
|
45
|
+
`hosts`) is refused, so a web page cannot rebind its own domain to 127.0.0.1 and read the tabs. Paths are confined
|
|
46
|
+
to their tab's real directory (symlinks out are listed but not served, unless follow_symlinks), deny globs apply to
|
|
47
|
+
every path component, dot-entries are hidden from listings unless ?hidden=1, pipes and devices are never opened, and
|
|
48
|
+
raw HTML/SVG/XML is served with a CSP sandbox so it cannot script this origin. Every answer comes from the file or
|
|
49
|
+
folder that opened, so a path given to something else while a request is in flight is not served. Rendering
|
|
50
|
+
libraries (marked, highlight.js, KaTeX, mermaid) load from cdnjs; without network the raw text is still shown.
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import base64
|
|
56
|
+
import email.utils
|
|
57
|
+
import errno
|
|
58
|
+
import fnmatch
|
|
59
|
+
import functools
|
|
60
|
+
import hashlib
|
|
61
|
+
import html
|
|
62
|
+
import ipaddress
|
|
63
|
+
import json
|
|
64
|
+
import mimetypes
|
|
65
|
+
import os
|
|
66
|
+
import re
|
|
67
|
+
import signal
|
|
68
|
+
import socket
|
|
69
|
+
import socketserver
|
|
70
|
+
import stat
|
|
71
|
+
import sys
|
|
72
|
+
import threading
|
|
73
|
+
import time
|
|
74
|
+
import urllib.parse
|
|
75
|
+
from dataclasses import dataclass, field
|
|
76
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
77
|
+
|
|
78
|
+
from pp.lib import config, paths, ui
|
|
79
|
+
|
|
80
|
+
EXCLUDE_DIRS = ["node_modules", ".git", ".venv", "venv", "__pycache__", ".cache", ".pkgs", ".triton_cache", "dist",
|
|
81
|
+
"build", ".shadow-cljs", ".cpcache", ".mypy_cache", ".pytest_cache", ".tox", "target", ".next",
|
|
82
|
+
"site-packages", "bazel-out", "bazel-bin", "bazel-testlogs"]
|
|
83
|
+
DENY = [".env", ".env.*", "*.pem", "*.key", "id_rsa*", "id_ed25519*", "id_ecdsa*", "id_dsa*", "*.p12", "*.pfx",
|
|
84
|
+
"*.sqlite", "credentials*", ".netrc", ".git-credentials", ".pgpass", ".pypirc", ".npmrc"]
|
|
85
|
+
|
|
86
|
+
CDNJS = "https://cdnjs.cloudflare.com/ajax/libs"
|
|
87
|
+
CDN = {
|
|
88
|
+
"marked": f"{CDNJS}/marked/12.0.2/marked.min.js",
|
|
89
|
+
"hljs": f"{CDNJS}/highlight.js/11.9.0/highlight.min.js",
|
|
90
|
+
"hljsLang": f"{CDNJS}/highlight.js/11.9.0/languages/{{}}.min.js",
|
|
91
|
+
"hljsLight": f"{CDNJS}/highlight.js/11.9.0/styles/github.min.css",
|
|
92
|
+
"hljsDark": f"{CDNJS}/highlight.js/11.9.0/styles/github-dark.min.css",
|
|
93
|
+
"katex": f"{CDNJS}/KaTeX/0.16.9/katex.min.js",
|
|
94
|
+
"katexCss": f"{CDNJS}/KaTeX/0.16.9/katex.min.css",
|
|
95
|
+
"mermaid": f"{CDNJS}/mermaid/10.9.1/mermaid.min.js",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
MD_EXT = {".md", ".markdown", ".mdown", ".mkd", ".mdx"}
|
|
99
|
+
IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".avif"}
|
|
100
|
+
VIDEO_EXT = {".mp4", ".webm", ".mov", ".m4v", ".ogv"}
|
|
101
|
+
AUDIO_EXT = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus"}
|
|
102
|
+
README_NAMES = ("readme.md", "readme.markdown", "index.md")
|
|
103
|
+
|
|
104
|
+
# extension -> highlight.js language (core bundle, or a languages/<x>.min.js the page loads on demand)
|
|
105
|
+
LANG = {
|
|
106
|
+
"py": "python", "pyi": "python", "pyx": "python", "bzl": "python", "star": "python", "ipynb": "json",
|
|
107
|
+
"js": "javascript", "mjs": "javascript", "cjs": "javascript", "jsx": "javascript",
|
|
108
|
+
"ts": "typescript", "mts": "typescript", "cts": "typescript", "tsx": "typescript",
|
|
109
|
+
"clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure", "bb": "clojure",
|
|
110
|
+
"sh": "bash", "bash": "bash", "zsh": "bash", "fish": "bash", "ksh": "bash",
|
|
111
|
+
"toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini", "properties": "properties", "env": "bash",
|
|
112
|
+
"yaml": "yaml", "yml": "yaml", "json": "json", "jsonl": "json", "json5": "json", "geojson": "json",
|
|
113
|
+
"rs": "rust", "go": "go", "c": "c", "h": "c", "cpp": "cpp", "cc": "cpp", "cxx": "cpp", "hpp": "cpp",
|
|
114
|
+
"hh": "cpp", "hxx": "cpp", "cu": "cpp", "cuh": "cpp", "ino": "cpp", "m": "objectivec", "mm": "objectivec",
|
|
115
|
+
"java": "java", "kt": "kotlin", "kts": "kotlin", "scala": "scala", "groovy": "groovy", "gradle": "gradle",
|
|
116
|
+
"rb": "ruby", "lua": "lua", "sql": "sql", "pl": "perl", "pm": "perl", "php": "php", "r": "r", "swift": "swift",
|
|
117
|
+
"cs": "csharp", "fs": "fsharp", "hs": "haskell", "ex": "elixir", "exs": "elixir", "erl": "erlang",
|
|
118
|
+
"ml": "ocaml", "jl": "julia", "dart": "dart", "el": "lisp", "lisp": "lisp", "scm": "scheme", "rkt": "scheme",
|
|
119
|
+
"vim": "vim", "nix": "nix", "proto": "protobuf", "cmake": "cmake", "mk": "makefile", "make": "makefile",
|
|
120
|
+
"css": "css", "scss": "scss", "less": "less", "html": "xml", "htm": "xml", "xml": "xml", "svg": "xml",
|
|
121
|
+
"xsl": "xml", "plist": "xml", "vue": "xml", "tex": "latex", "sty": "latex", "cls": "latex", "bib": "latex",
|
|
122
|
+
"diff": "diff", "patch": "diff", "graphql": "graphql", "gql": "graphql", "wasm": "wasm", "wat": "wasm",
|
|
123
|
+
"ps1": "powershell", "bat": "dos", "cmd": "dos", "awk": "awk", "nginx": "nginx", "dockerfile": "dockerfile",
|
|
124
|
+
"glsl": "glsl", "frag": "glsl", "vert": "glsl", "asm": "x86asm", "s": "x86asm", "ll": "llvm",
|
|
125
|
+
"txt": "plaintext", "log": "plaintext", "csv": "plaintext", "tsv": "plaintext",
|
|
126
|
+
}
|
|
127
|
+
LANG_BY_NAME = {
|
|
128
|
+
"makefile": "makefile", "gnumakefile": "makefile", "dockerfile": "dockerfile", "containerfile": "dockerfile",
|
|
129
|
+
"build": "python", "workspace": "python", "build.bazel": "python", "module.bazel": "python",
|
|
130
|
+
"workspace.bazel": "python", "cmakelists.txt": "cmake", "jenkinsfile": "groovy", "vagrantfile": "ruby",
|
|
131
|
+
"gemfile": "ruby", "rakefile": "ruby", ".bashrc": "bash", ".zshrc": "bash", ".profile": "bash",
|
|
132
|
+
".bash_profile": "bash", ".zprofile": "bash", ".gitignore": "bash", ".dockerignore": "bash",
|
|
133
|
+
".editorconfig": "ini", ".gitconfig": "ini", "pipfile": "ini", "go.mod": "go",
|
|
134
|
+
}
|
|
135
|
+
SHEBANG = {"python": "python", "bash": "bash", "sh": "bash", "zsh": "bash", "node": "javascript", "bb": "clojure",
|
|
136
|
+
"perl": "perl", "ruby": "ruby", "lua": "lua"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Settings:
|
|
141
|
+
port: int = 8000
|
|
142
|
+
bind: str = "127.0.0.1"
|
|
143
|
+
recent: list = field(default_factory=lambda: ["*.md"])
|
|
144
|
+
exclude_dirs: set = field(default_factory=lambda: set(EXCLUDE_DIRS))
|
|
145
|
+
deny: list = field(default_factory=lambda: list(DENY))
|
|
146
|
+
follow_symlinks: bool = False
|
|
147
|
+
hosts: list = field(default_factory=list) # Host names a browser may use besides loopback ones
|
|
148
|
+
cache_ttl: float = 10.0
|
|
149
|
+
scan_budget: float = 2.0 # seconds for one Recent scan over all tabs
|
|
150
|
+
scan_depth: int = 8
|
|
151
|
+
scan_entries: int = 100_000
|
|
152
|
+
recent_limit: int = 300
|
|
153
|
+
list_cap: int = 5000
|
|
154
|
+
embed_cap: int = 2_000_000 # bytes of a text file embedded in its view page
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def from_config(cls, cfg: dict) -> "Settings":
|
|
158
|
+
"""Settings from the [http] table; a value of the wrong type is a ConfigError naming the key."""
|
|
159
|
+
s = cls()
|
|
160
|
+
config.check_keys(cfg, "[http]", KEYS)
|
|
161
|
+
|
|
162
|
+
def strs(k):
|
|
163
|
+
v = cfg.get(k)
|
|
164
|
+
if isinstance(v, str):
|
|
165
|
+
return [v]
|
|
166
|
+
if v is None or (isinstance(v, list) and all(isinstance(x, str) for x in v)):
|
|
167
|
+
return list(v or [])
|
|
168
|
+
raise config.ConfigError(f"[http] {k} = {v!r}: a string or a list of strings")
|
|
169
|
+
|
|
170
|
+
def num(k, typ, lo):
|
|
171
|
+
v = cfg[k]
|
|
172
|
+
if isinstance(v, bool) or not isinstance(v, (int, float) if typ is float else int) or v < lo:
|
|
173
|
+
raise config.ConfigError(f"[http] {k} = {v!r}: {'a number' if typ is float else 'an integer'} >= {lo}")
|
|
174
|
+
return typ(v)
|
|
175
|
+
if "port" in cfg:
|
|
176
|
+
s.port = num("port", int, 0)
|
|
177
|
+
if s.port > 65535:
|
|
178
|
+
raise config.ConfigError(f"[http] port = {s.port}: a port is 0..65535")
|
|
179
|
+
if "bind" in cfg:
|
|
180
|
+
if not isinstance(cfg["bind"], str):
|
|
181
|
+
raise config.ConfigError(f"[http] bind = {cfg['bind']!r}: an address like \"127.0.0.1\"")
|
|
182
|
+
s.bind = cfg["bind"]
|
|
183
|
+
if "recent" in cfg:
|
|
184
|
+
s.recent = strs("recent") or s.recent
|
|
185
|
+
s.exclude_dirs |= set(strs("exclude_dirs"))
|
|
186
|
+
s.deny += [d for d in strs("deny") if d not in s.deny]
|
|
187
|
+
if not isinstance(cfg.get("follow_symlinks", False), bool):
|
|
188
|
+
raise config.ConfigError(f"[http] follow_symlinks = {cfg['follow_symlinks']!r}: true or false")
|
|
189
|
+
s.follow_symlinks = cfg.get("follow_symlinks", False)
|
|
190
|
+
s.hosts = strs("hosts")
|
|
191
|
+
for k in ("cache_ttl", "scan_budget"):
|
|
192
|
+
if k in cfg:
|
|
193
|
+
setattr(s, k, num(k, float, 0))
|
|
194
|
+
for k in ("scan_depth", "scan_entries", "recent_limit", "list_cap", "embed_cap"):
|
|
195
|
+
if k in cfg:
|
|
196
|
+
setattr(s, k, num(k, int, 1))
|
|
197
|
+
return s
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
KEYS = ("port", "bind", "recent", "exclude_dirs", "deny", "follow_symlinks", "hosts", "roots", "discover", "cache_ttl",
|
|
201
|
+
"scan_budget", "scan_depth", "scan_entries", "recent_limit", "list_cap", "embed_cap")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def tables(cfg: dict, key: str, need: str) -> list[dict]:
|
|
205
|
+
"""[[http.<key>]]: an array of tables, each with `need`; anything else is a ConfigError."""
|
|
206
|
+
v = cfg.get(key, [])
|
|
207
|
+
if not isinstance(v, list) or not all(isinstance(t, dict) for t in v):
|
|
208
|
+
raise config.ConfigError(f"[http] {key} must be written [[http.{key}]] (an array of tables)")
|
|
209
|
+
for t in v:
|
|
210
|
+
if not isinstance(t.get(need), str) or not t[need]:
|
|
211
|
+
raise config.ConfigError(f"[[http.{key}]] {t}: needs {need} = \"/some/dir\"")
|
|
212
|
+
for t in v if key == "discover" else []:
|
|
213
|
+
d, m = t.get("depth", 1), t.get("marker", [])
|
|
214
|
+
if isinstance(d, bool) or not isinstance(d, int) or d < 1:
|
|
215
|
+
raise config.ConfigError(f"[[http.discover]] under = {t['under']!r}: depth = {d!r} is not an integer >= 1")
|
|
216
|
+
if not isinstance(m, str) and not (isinstance(m, list) and all(isinstance(x, str) for x in m)):
|
|
217
|
+
raise config.ConfigError(f"[[http.discover]] under = {t['under']!r}: marker is a string or a list of them")
|
|
218
|
+
return v
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _globs(spec) -> list[str]:
|
|
222
|
+
if isinstance(spec, str):
|
|
223
|
+
spec = spec.replace(",", " ").split()
|
|
224
|
+
return [g for g in (spec or []) if g]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _match(globs: list[str], name: str, rel: str) -> bool:
|
|
228
|
+
name, rel = name.lower(), rel.lower()
|
|
229
|
+
for g in globs:
|
|
230
|
+
g = g.lower()
|
|
231
|
+
if "/" in g:
|
|
232
|
+
if fnmatch.fnmatchcase(rel, g) or (g.startswith("**/") and fnmatch.fnmatchcase(rel, g[3:])):
|
|
233
|
+
return True
|
|
234
|
+
elif fnmatch.fnmatchcase(name, g):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class Cached:
|
|
240
|
+
"""A value per key, computed by one caller at a time and no oftener than once every `ttl` seconds.
|
|
241
|
+
|
|
242
|
+
`get(key)` returns the value for that key, computing it with `compute(key)` when there is none or it is
|
|
243
|
+
older than the ttl. The computation runs under the lock, so callers that arrive while a value is stale
|
|
244
|
+
make one computation between them and all read its answer, and this is the one place a value is stored:
|
|
245
|
+
no two computations overlap, so no answer can replace a newer one. A value is stamped with the time its
|
|
246
|
+
computation started, so a slow scan is not passed off as fresh, and keys older than the ttl are dropped,
|
|
247
|
+
so the keys a request names cannot pile up.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __init__(self, ttl: float, compute):
|
|
251
|
+
self.ttl = ttl
|
|
252
|
+
self._compute = compute
|
|
253
|
+
self._lock = threading.Lock()
|
|
254
|
+
self._values: dict = {}
|
|
255
|
+
|
|
256
|
+
def get(self, key=()):
|
|
257
|
+
with self._lock:
|
|
258
|
+
now = time.monotonic()
|
|
259
|
+
hit = self._values.get(key)
|
|
260
|
+
if hit is not None and now - hit[0] < self.ttl:
|
|
261
|
+
return hit[1]
|
|
262
|
+
value = self._compute(key)
|
|
263
|
+
self._values = {k: v for k, v in self._values.items() if now - v[0] < self.ttl}
|
|
264
|
+
self._values[key] = (now, value)
|
|
265
|
+
return value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class App:
|
|
269
|
+
"""Roots (fixed + discovered, cached), path confinement, the Recent scan."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, settings: Settings, fixed: list[tuple[str, str]], rules: list[dict], quiet: bool = False):
|
|
272
|
+
self.s = settings
|
|
273
|
+
self.fixed = fixed
|
|
274
|
+
self.rules = rules
|
|
275
|
+
self.quiet = quiet
|
|
276
|
+
self._tabs = Cached(settings.cache_ttl, self._walk)
|
|
277
|
+
self._found = Cached(settings.cache_ttl, self._scan_once)
|
|
278
|
+
|
|
279
|
+
# ---- roots ----
|
|
280
|
+
|
|
281
|
+
def denied(self, name: str) -> bool:
|
|
282
|
+
n = name.lower()
|
|
283
|
+
return any(fnmatch.fnmatchcase(n, p.lower()) for p in self.s.deny)
|
|
284
|
+
|
|
285
|
+
def _discover(self, rule: dict) -> list[tuple[str, str]]:
|
|
286
|
+
if not rule.get("under"):
|
|
287
|
+
return []
|
|
288
|
+
under = paths.expand(str(rule["under"]))
|
|
289
|
+
depth = max(1, int(rule.get("depth", 1)))
|
|
290
|
+
markers = rule.get("marker") or []
|
|
291
|
+
markers = [markers] if isinstance(markers, str) else list(markers)
|
|
292
|
+
level = [(under, "")]
|
|
293
|
+
for _ in range(depth):
|
|
294
|
+
nxt = []
|
|
295
|
+
for d, rel in level:
|
|
296
|
+
try:
|
|
297
|
+
entries = sorted(os.scandir(d), key=lambda e: e.name)
|
|
298
|
+
except OSError:
|
|
299
|
+
continue
|
|
300
|
+
for e in entries:
|
|
301
|
+
if e.name.startswith(".") or e.name in self.s.exclude_dirs or self.denied(e.name):
|
|
302
|
+
continue
|
|
303
|
+
try:
|
|
304
|
+
if not e.is_dir():
|
|
305
|
+
continue
|
|
306
|
+
except OSError:
|
|
307
|
+
continue
|
|
308
|
+
nxt.append((e.path, f"{rel}/{e.name}" if rel else e.name))
|
|
309
|
+
level = nxt
|
|
310
|
+
return [(rel, d) for d, rel in level
|
|
311
|
+
if not markers or any(os.path.exists(os.path.join(d, m)) for m in markers)]
|
|
312
|
+
|
|
313
|
+
def roots(self) -> dict[str, str]:
|
|
314
|
+
"""tab name -> real directory, fixed tabs first, then discovered ones by name. One walk every
|
|
315
|
+
cache_ttl seconds, whoever asks."""
|
|
316
|
+
return self._tabs.get()
|
|
317
|
+
|
|
318
|
+
def _walk(self, _key) -> dict[str, str]:
|
|
319
|
+
found = list(self.fixed)
|
|
320
|
+
disc = []
|
|
321
|
+
for rule in self.rules:
|
|
322
|
+
disc += self._discover(rule)
|
|
323
|
+
found += sorted(disc)
|
|
324
|
+
out: dict[str, str] = {}
|
|
325
|
+
seen = set()
|
|
326
|
+
for name, p in found:
|
|
327
|
+
real = os.path.realpath(paths.expand(p))
|
|
328
|
+
if real in seen or not os.path.isdir(real):
|
|
329
|
+
continue
|
|
330
|
+
seen.add(real)
|
|
331
|
+
base, k = name or os.path.basename(real) or "root", 2
|
|
332
|
+
name = base
|
|
333
|
+
while name in out:
|
|
334
|
+
name, k = f"{base}-{k}", k + 1
|
|
335
|
+
out[name] = real
|
|
336
|
+
return out
|
|
337
|
+
|
|
338
|
+
def resolve(self, root_name: str, segs: list[str]) -> tuple[str, str] | None:
|
|
339
|
+
"""(root real dir, real path) for a tab-relative path, or None when it must 404."""
|
|
340
|
+
root = self.roots().get(root_name)
|
|
341
|
+
if root is None:
|
|
342
|
+
return None
|
|
343
|
+
for s in segs:
|
|
344
|
+
if s in (".", "..") or "/" in s or "\\" in s or "\0" in s or self.denied(s):
|
|
345
|
+
return None
|
|
346
|
+
real = os.path.realpath(os.path.join(root, *segs))
|
|
347
|
+
inside = real == root or real.startswith(root.rstrip(os.sep) + os.sep)
|
|
348
|
+
if not inside and not self.s.follow_symlinks:
|
|
349
|
+
return None
|
|
350
|
+
comps = os.path.relpath(real, root).split(os.sep) if inside and real != root else [os.path.basename(real)]
|
|
351
|
+
if real != root and any(self.denied(c) for c in comps):
|
|
352
|
+
return None
|
|
353
|
+
if not os.path.exists(real):
|
|
354
|
+
return None
|
|
355
|
+
return root, real
|
|
356
|
+
|
|
357
|
+
# ---- Recent ----
|
|
358
|
+
|
|
359
|
+
def _scan(self, roots: dict[str, str], globs: list[str]) -> tuple[list[dict], bool]:
|
|
360
|
+
items: list[dict] = []
|
|
361
|
+
truncated = False
|
|
362
|
+
t0 = time.monotonic()
|
|
363
|
+
n = 0
|
|
364
|
+
names = list(roots)
|
|
365
|
+
for i, name in enumerate(names):
|
|
366
|
+
# each tab gets its share of the budget; time a tab leaves unused passes to the next
|
|
367
|
+
deadline = t0 + self.s.scan_budget * (i + 1) / len(names)
|
|
368
|
+
cap = self.s.scan_entries * (i + 1) // len(names)
|
|
369
|
+
stack = [(roots[name], "", 0)]
|
|
370
|
+
while stack:
|
|
371
|
+
d, rel, depth = stack.pop()
|
|
372
|
+
try:
|
|
373
|
+
it = os.scandir(d)
|
|
374
|
+
except OSError:
|
|
375
|
+
continue
|
|
376
|
+
with it:
|
|
377
|
+
for e in it:
|
|
378
|
+
n += 1
|
|
379
|
+
if n > cap or time.monotonic() > deadline:
|
|
380
|
+
truncated = True
|
|
381
|
+
stack = []
|
|
382
|
+
break
|
|
383
|
+
nm = e.name
|
|
384
|
+
if nm.startswith(".") or self.denied(nm):
|
|
385
|
+
continue
|
|
386
|
+
try:
|
|
387
|
+
if e.is_symlink() and not self.s.follow_symlinks:
|
|
388
|
+
continue
|
|
389
|
+
if e.is_dir():
|
|
390
|
+
if nm not in self.s.exclude_dirs and depth < self.s.scan_depth:
|
|
391
|
+
stack.append((e.path, f"{rel}{nm}/", depth + 1))
|
|
392
|
+
continue
|
|
393
|
+
if not _match(globs, nm, rel + nm) or not e.is_file(): # no pipes, sockets
|
|
394
|
+
continue
|
|
395
|
+
st = e.stat()
|
|
396
|
+
except OSError:
|
|
397
|
+
continue
|
|
398
|
+
items.append({"root": name, "path": rel + nm, "size": st.st_size, "mtime": st.st_mtime})
|
|
399
|
+
items.sort(key=lambda x: -x["mtime"])
|
|
400
|
+
return items, truncated
|
|
401
|
+
|
|
402
|
+
def _scan_once(self, key) -> tuple[list[dict], bool, float]:
|
|
403
|
+
"""One scan for a (roots, globs) key, with how long it took."""
|
|
404
|
+
t0 = time.monotonic()
|
|
405
|
+
items, truncated = self._scan(dict(key[0]), list(key[1]))
|
|
406
|
+
return items, truncated, time.monotonic() - t0
|
|
407
|
+
|
|
408
|
+
def recent(self, globs: list[str] | None = None, q: str = "", limit: int | None = None) -> dict:
|
|
409
|
+
globs = globs or self.s.recent
|
|
410
|
+
roots = self.roots()
|
|
411
|
+
items, truncated, took = self._found.get((tuple(roots.items()), tuple(globs)))
|
|
412
|
+
if q:
|
|
413
|
+
ql = q.lower()
|
|
414
|
+
items = [x for x in items if ql in f"{x['root']}/{x['path']}".lower()]
|
|
415
|
+
return {"items": items[: limit or self.s.recent_limit], "total": len(items), "truncated": truncated,
|
|
416
|
+
"took": round(took, 3), "globs": globs, "roots": len(roots)}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---- HTML -----------------------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def esc(s) -> str:
|
|
422
|
+
return html.escape(str(s), quote=True)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def q(s: str) -> str:
|
|
426
|
+
return urllib.parse.quote(s, safe="")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def turl(root: str, segs: list[str], is_dir: bool = False, kind: str = "t") -> str:
|
|
430
|
+
tail = "/".join(q(s) for s in segs)
|
|
431
|
+
return f"/{kind}/{q(root)}/" + tail + ("/" if is_dir and segs else "")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _json_script(id_: str, obj) -> str:
|
|
435
|
+
data = json.dumps(obj, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
436
|
+
return f'<script type="application/json" id="{id_}">{data}</script>'
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _age(ts: float) -> str:
|
|
440
|
+
return ui.human_age(ts).replace(" ago", "")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _when(ts: float) -> str:
|
|
444
|
+
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
FAVICON = ("data:image/svg+xml," + urllib.parse.quote(
|
|
448
|
+
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='#0969da'/>"
|
|
449
|
+
"<text x='8' y='12' font-size='10' text-anchor='middle' fill='white' font-family='sans-serif'>pp</text></svg>"))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def page(app: App, title: str, active: str | None, body: str, data: dict, src: str | None = None,
|
|
453
|
+
libs_css: bool = False) -> bytes:
|
|
454
|
+
roots = app.roots()
|
|
455
|
+
tabs = [f'<a href="/" class="{"on" if active is None else ""}">Recent</a>']
|
|
456
|
+
tabs += [f'<a href="{turl(n, [], True)}" class="{"on" if n == active else ""}" title="{esc(p)}">{esc(n)}</a>'
|
|
457
|
+
for n, p in roots.items()]
|
|
458
|
+
head_css = ""
|
|
459
|
+
if libs_css:
|
|
460
|
+
head_css = (f'<link rel="stylesheet" href="{CDN["hljsLight"]}" media="(prefers-color-scheme: light)">'
|
|
461
|
+
f'<link rel="stylesheet" href="{CDN["hljsDark"]}" media="(prefers-color-scheme: dark)">'
|
|
462
|
+
f'<link rel="stylesheet" href="{CDN["katexCss"]}">')
|
|
463
|
+
data = dict(data, cdn=CDN)
|
|
464
|
+
doc = (
|
|
465
|
+
"<!doctype html><html lang=en><head><meta charset=utf-8>"
|
|
466
|
+
'<meta name=viewport content="width=device-width,initial-scale=1">'
|
|
467
|
+
f"<title>{esc(title)}</title><link rel=icon href=\"{FAVICON}\">{head_css}<style>{CSS}</style></head><body>"
|
|
468
|
+
f'<header><nav class="tabs"><span class="brand">pp</span>{"".join(tabs)}</nav></header>'
|
|
469
|
+
f"<main>{body}</main>"
|
|
470
|
+
+ _json_script("pp-page", data)
|
|
471
|
+
+ (_json_script("pp-src", src) if src is not None else "")
|
|
472
|
+
+ f"<script>{JS}</script></body></html>"
|
|
473
|
+
)
|
|
474
|
+
return doc.encode("utf-8")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def crumbs(root: str, segs: list[str], is_dir: bool) -> str:
|
|
478
|
+
parts = [f'<a href="{turl(root, [], True)}">{esc(root)}</a>']
|
|
479
|
+
for i, s in enumerate(segs):
|
|
480
|
+
if i == len(segs) - 1:
|
|
481
|
+
parts.append(f"<b>{esc(s)}</b>")
|
|
482
|
+
else:
|
|
483
|
+
parts.append(f'<a href="{turl(root, segs[: i + 1], True)}">{esc(s)}</a>')
|
|
484
|
+
return '<nav class="crumbs">' + ' <span class="sep">/</span> '.join(parts) + "</nav>"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def guess_lang(name: str, head: str) -> str:
|
|
488
|
+
low = name.lower()
|
|
489
|
+
if low in LANG_BY_NAME:
|
|
490
|
+
return LANG_BY_NAME[low]
|
|
491
|
+
if low.endswith(".dockerfile") or low.startswith("dockerfile."):
|
|
492
|
+
return "dockerfile"
|
|
493
|
+
ext = low.rsplit(".", 1)[1] if "." in low.strip(".") else ""
|
|
494
|
+
if ext in LANG:
|
|
495
|
+
return LANG[ext]
|
|
496
|
+
if head.startswith("#!"):
|
|
497
|
+
first = head.split("\n", 1)[0]
|
|
498
|
+
words = first[2:].replace("/", " ").split()
|
|
499
|
+
for w in reversed(words):
|
|
500
|
+
w = w.rstrip("0123456789.")
|
|
501
|
+
if w in SHEBANG:
|
|
502
|
+
return SHEBANG[w]
|
|
503
|
+
return ""
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def raw_type(name: str, head: bytes) -> tuple[str, bool]:
|
|
507
|
+
"""(Content-Type, needs a CSP sandbox) for raw serving. A type can only come from the name's extension;
|
|
508
|
+
everything else about the answer comes from the open file. The leading slash keeps a name like `data:x`
|
|
509
|
+
an extension rather than a URL scheme."""
|
|
510
|
+
mime, _ = mimetypes.guess_type("/" + name)
|
|
511
|
+
if mime == "image/svg+xml":
|
|
512
|
+
return mime, True
|
|
513
|
+
if mime and (mime.split("/")[0] in ("image", "audio", "video", "font") or mime == "application/pdf"):
|
|
514
|
+
return mime, False
|
|
515
|
+
if b"\0" in head:
|
|
516
|
+
return mime or "application/octet-stream", False
|
|
517
|
+
if mime in ("text/html", "application/xhtml+xml"):
|
|
518
|
+
return "text/html; charset=utf-8", True
|
|
519
|
+
if mime in ("application/xml", "text/xml"):
|
|
520
|
+
return mime + "; charset=utf-8", True
|
|
521
|
+
if mime == "text/css":
|
|
522
|
+
return "text/css; charset=utf-8", False
|
|
523
|
+
if mime in ("text/javascript", "application/javascript"):
|
|
524
|
+
return "text/javascript; charset=utf-8", False
|
|
525
|
+
if mime == "application/json":
|
|
526
|
+
return "application/json; charset=utf-8", False
|
|
527
|
+
return "text/plain; charset=utf-8", False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ---- request handling -----------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def _loopback(host: str) -> bool:
|
|
533
|
+
"""A name or address that only ever means this machine."""
|
|
534
|
+
h = host.strip("[]").lower().rstrip(".")
|
|
535
|
+
if h == "localhost" or h.endswith(".localhost"):
|
|
536
|
+
return True
|
|
537
|
+
try:
|
|
538
|
+
return ipaddress.ip_address(h).is_loopback
|
|
539
|
+
except ValueError:
|
|
540
|
+
return False
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
class Opened:
|
|
544
|
+
"""What a request may act on: a folder or file inside a tab, open, with the fstat of that open fd.
|
|
545
|
+
|
|
546
|
+
True when something opened. `why` says what to answer when nothing did: "gone" (nothing there, not this
|
|
547
|
+
tab's, or not what the approved name means any more), "kind" (a folder where a file was asked for, or the
|
|
548
|
+
other way round), "irregular" (a pipe, socket or device, whose contents pp does not read) or "unreadable"
|
|
549
|
+
(`error` is the error the open gave). Used as `with`, so the fd is closed on every way out.
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
__slots__ = ("fd", "st", "why", "error")
|
|
553
|
+
|
|
554
|
+
def __init__(self, fd: int | None = None, st: os.stat_result | None = None, why: str = "",
|
|
555
|
+
error: OSError | None = None):
|
|
556
|
+
self.fd, self.st, self.why, self.error = fd, st, why, error
|
|
557
|
+
|
|
558
|
+
def __bool__(self) -> bool:
|
|
559
|
+
return self.fd is not None
|
|
560
|
+
|
|
561
|
+
def __enter__(self) -> "Opened":
|
|
562
|
+
return self
|
|
563
|
+
|
|
564
|
+
def __exit__(self, *exc) -> bool:
|
|
565
|
+
if self.fd is not None:
|
|
566
|
+
os.close(self.fd)
|
|
567
|
+
self.fd = None
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
@property
|
|
571
|
+
def is_dir(self) -> bool:
|
|
572
|
+
return stat.S_ISDIR(self.st.st_mode)
|
|
573
|
+
|
|
574
|
+
def entries(self):
|
|
575
|
+
"""What this folder holds, read through the fd it opened on: the listing is of the folder the request
|
|
576
|
+
acted on, whatever its name means by now. Used as `with`, as os.scandir is."""
|
|
577
|
+
return os.scandir(self.fd)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _not_opened(e: OSError, real: str) -> Opened:
|
|
581
|
+
"""Why an open failed: a name that has gone in the meantime is not found, anything else is pp not reading
|
|
582
|
+
what is there. The error names the path that was asked for, not the fd it was opened through."""
|
|
583
|
+
gone = e.errno in (errno.ENOENT, errno.ENOTDIR, errno.ELOOP)
|
|
584
|
+
return Opened(why="gone" if gone else "unreadable", error=OSError(e.errno, e.strerror, real))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def open_in_tab(app: App, root: str, segs: list[str], *, dir_ok: bool, file_ok: bool) -> Opened:
|
|
588
|
+
"""Open what a tab-relative path names, so every answer about it comes from the file that opened.
|
|
589
|
+
|
|
590
|
+
App.resolve approves the name. Nothing freezes the tree between a check and an open, so a directory
|
|
591
|
+
component swapped for a symlink in between would be followed and the answer would be a file outside every
|
|
592
|
+
tab. The first open therefore takes an O_PATH fd, which reads nothing and starts no device, and
|
|
593
|
+
/proc/self/fd says whether that fd is the path that was approved. The fstat of that fd says what this is,
|
|
594
|
+
and only a folder or a regular file is then opened for its contents, through the fd already held, so the
|
|
595
|
+
bytes and the fstat returned here belong to one file. Every decision the request makes comes from that
|
|
596
|
+
fstat, the live-reload baseline included, so a page's baseline is the file its body was read from.
|
|
597
|
+
|
|
598
|
+
A tab's own root is a folder and nothing else, so an empty path is never a file here, whatever the caller
|
|
599
|
+
asks for: App._walk admits a directory and keeps the name only while it is one, and a tab whose directory
|
|
600
|
+
has become a regular file is stale. That holds for every endpoint, so none of them can be left serving it.
|
|
601
|
+
"""
|
|
602
|
+
file_ok = file_ok and bool(segs)
|
|
603
|
+
r = app.resolve(root, segs)
|
|
604
|
+
if r is None:
|
|
605
|
+
return Opened(why="gone")
|
|
606
|
+
real = r[1]
|
|
607
|
+
try:
|
|
608
|
+
pin = os.open(real, os.O_PATH)
|
|
609
|
+
except OSError as e:
|
|
610
|
+
return _not_opened(e, real)
|
|
611
|
+
try:
|
|
612
|
+
if os.path.realpath(f"/proc/self/fd/{pin}") != real:
|
|
613
|
+
return Opened(why="gone")
|
|
614
|
+
st = os.fstat(pin)
|
|
615
|
+
if stat.S_ISDIR(st.st_mode):
|
|
616
|
+
if not dir_ok:
|
|
617
|
+
return Opened(why="kind")
|
|
618
|
+
flags = os.O_RDONLY | os.O_DIRECTORY
|
|
619
|
+
elif stat.S_ISREG(st.st_mode):
|
|
620
|
+
if not file_ok:
|
|
621
|
+
return Opened(why="kind")
|
|
622
|
+
flags = os.O_RDONLY
|
|
623
|
+
else:
|
|
624
|
+
return Opened(why="irregular")
|
|
625
|
+
try:
|
|
626
|
+
fd = os.open(f"/proc/self/fd/{pin}", flags)
|
|
627
|
+
except OSError as e:
|
|
628
|
+
return _not_opened(e, real)
|
|
629
|
+
return Opened(fd, os.fstat(fd))
|
|
630
|
+
finally:
|
|
631
|
+
os.close(pin)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
class Handler(BaseHTTPRequestHandler):
|
|
635
|
+
app: App = None # set on the subclass made by make_server
|
|
636
|
+
host_guard = True # bound to loopback: refuse Host names that are not (DNS rebinding)
|
|
637
|
+
server_version = "pp-http"
|
|
638
|
+
sys_version = ""
|
|
639
|
+
|
|
640
|
+
def log_request(self, code="-", size="-"):
|
|
641
|
+
code = getattr(code, "value", code)
|
|
642
|
+
if self.app.quiet or (self.path.startswith("/_pp/stat") and code == 200):
|
|
643
|
+
return
|
|
644
|
+
sys.stderr.write(f"{time.strftime('%H:%M:%S')} {code} {self.command} "
|
|
645
|
+
f"{ui.printable(urllib.parse.unquote(self.path)[:160], tabs=False)}\n")
|
|
646
|
+
|
|
647
|
+
def log_message(self, fmt, *args):
|
|
648
|
+
if not self.app.quiet:
|
|
649
|
+
sys.stderr.write(f"{time.strftime('%H:%M:%S')} {ui.printable(fmt % args, tabs=False)}\n")
|
|
650
|
+
|
|
651
|
+
def do_GET(self):
|
|
652
|
+
self._dispatch()
|
|
653
|
+
|
|
654
|
+
def do_HEAD(self):
|
|
655
|
+
self._dispatch()
|
|
656
|
+
|
|
657
|
+
# ---- plumbing ----
|
|
658
|
+
|
|
659
|
+
def _send(self, code: int, body: bytes, ctype: str, headers: dict | None = None):
|
|
660
|
+
self.send_response(code)
|
|
661
|
+
self.send_header("Content-Type", ctype)
|
|
662
|
+
if ctype.startswith("text/html"):
|
|
663
|
+
self.send_header("Content-Security-Policy", _csp())
|
|
664
|
+
self.send_header("Content-Length", str(len(body)))
|
|
665
|
+
self.send_header("Cache-Control", "no-store")
|
|
666
|
+
for k, v in (headers or {}).items():
|
|
667
|
+
self.send_header(k, v)
|
|
668
|
+
self.end_headers()
|
|
669
|
+
if self.command != "HEAD":
|
|
670
|
+
self.wfile.write(body)
|
|
671
|
+
|
|
672
|
+
def _json(self, obj, code: int = 200):
|
|
673
|
+
self._send(code, json.dumps(obj, ensure_ascii=False).encode(), "application/json; charset=utf-8")
|
|
674
|
+
|
|
675
|
+
def _redirect(self, loc: str):
|
|
676
|
+
self.send_response(301)
|
|
677
|
+
self.send_header("Location", loc)
|
|
678
|
+
self.send_header("Content-Length", "0")
|
|
679
|
+
self.end_headers()
|
|
680
|
+
|
|
681
|
+
def _not_found(self, what: str = ""):
|
|
682
|
+
body = page(self.app, "not found", "", f'<p class="note">Not found{": " + esc(what) if what else ""}.</p>'
|
|
683
|
+
'<p><a href="/">Recent</a></p>', {"kind": "none"})
|
|
684
|
+
self._send(404, body, "text/html; charset=utf-8")
|
|
685
|
+
|
|
686
|
+
def _unreadable(self, root, ex):
|
|
687
|
+
"""403: what opened cannot be read, and the error says why."""
|
|
688
|
+
self._send(403, page(self.app, "unreadable", root, f'<p class="note">{esc(ex)}</p>', {"kind": "none"}),
|
|
689
|
+
"text/html; charset=utf-8")
|
|
690
|
+
|
|
691
|
+
def _refuse(self, o: Opened, root, segs):
|
|
692
|
+
"""The answer when nothing opened: 403 for what pp will not read, 404 for everything else."""
|
|
693
|
+
if o.why == "irregular":
|
|
694
|
+
return self._not_a_file(root, segs)
|
|
695
|
+
if o.why == "unreadable":
|
|
696
|
+
return self._unreadable(root, o.error)
|
|
697
|
+
return self._not_found("/".join([root] + segs))
|
|
698
|
+
|
|
699
|
+
def _host_ok(self) -> bool:
|
|
700
|
+
h = self.headers.get("Host")
|
|
701
|
+
if not self.host_guard or h is None: # no Host: an HTTP/1.0 tool, never a browser
|
|
702
|
+
return True
|
|
703
|
+
try:
|
|
704
|
+
name = urllib.parse.urlsplit("//" + h).hostname or ""
|
|
705
|
+
except ValueError:
|
|
706
|
+
return False
|
|
707
|
+
return _loopback(name) or name in (x.lower() for x in self.app.s.hosts)
|
|
708
|
+
|
|
709
|
+
def _dispatch(self):
|
|
710
|
+
try:
|
|
711
|
+
if not self._host_ok():
|
|
712
|
+
msg = (f"pp http: Host {self.headers['Host']!r} is not a loopback name. If a proxy in front of pp "
|
|
713
|
+
"uses it, add it to [http] hosts in ~/.config/pp/config.toml.\n")
|
|
714
|
+
return self._send(403, msg.encode(), "text/plain; charset=utf-8")
|
|
715
|
+
u = urllib.parse.urlsplit(self.path)
|
|
716
|
+
qs = {k: v[-1] for k, v in urllib.parse.parse_qs(u.query).items()}
|
|
717
|
+
if not u.path.startswith("/"):
|
|
718
|
+
return self._not_found()
|
|
719
|
+
raw = u.path.split("/")[1:]
|
|
720
|
+
if u.path == "/":
|
|
721
|
+
return self._recent_page(qs)
|
|
722
|
+
if u.path == "/_pp/stat":
|
|
723
|
+
return self._stat(qs)
|
|
724
|
+
if u.path == "/_pp/recent":
|
|
725
|
+
return self._recent_json(qs)
|
|
726
|
+
if raw[0] in ("t", "raw") and len(raw) >= 2 and raw[1]:
|
|
727
|
+
root = urllib.parse.unquote(raw[1])
|
|
728
|
+
segs = [urllib.parse.unquote(s) for s in raw[2:] if s != ""]
|
|
729
|
+
if raw[0] == "t":
|
|
730
|
+
return self._view(root, segs, u.path.endswith("/") and len(raw) > 2, qs)
|
|
731
|
+
return self._raw(root, segs, qs)
|
|
732
|
+
return self._not_found()
|
|
733
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
734
|
+
pass
|
|
735
|
+
|
|
736
|
+
# ---- endpoints ----
|
|
737
|
+
|
|
738
|
+
def _stat(self, qs):
|
|
739
|
+
"""What the live reload polls: the mtime and size of the file or folder that opened.
|
|
740
|
+
|
|
741
|
+
It answers from an fd like every other endpoint, so the page it feeds compares its baseline against
|
|
742
|
+
the same file it was built from, and a name given to something else since is not answered for."""
|
|
743
|
+
segs = [s for s in qs.get("path", "").split("/") if s]
|
|
744
|
+
with open_in_tab(self.app, qs.get("root", ""), segs, dir_ok=True, file_ok=True) as o:
|
|
745
|
+
if not o:
|
|
746
|
+
return self._json({"error": "not found"}, 404)
|
|
747
|
+
self._json({"mtime": o.st.st_mtime, "size": o.st.st_size, "dir": o.is_dir})
|
|
748
|
+
|
|
749
|
+
def _recent_json(self, qs):
|
|
750
|
+
try:
|
|
751
|
+
limit = int(qs.get("limit") or 0) or None
|
|
752
|
+
except ValueError:
|
|
753
|
+
limit = None
|
|
754
|
+
self._json(self.app.recent(_globs(qs.get("glob")), qs.get("q", ""), limit))
|
|
755
|
+
|
|
756
|
+
def _recent_page(self, qs):
|
|
757
|
+
glob_s, qtext = qs.get("glob", ""), qs.get("q", "")
|
|
758
|
+
res = self.app.recent(_globs(glob_s), qtext)
|
|
759
|
+
rows = []
|
|
760
|
+
for x in res["items"]:
|
|
761
|
+
segs = x["path"].split("/")
|
|
762
|
+
rows.append(
|
|
763
|
+
f'<tr data-k="{esc((x["root"] + "/" + x["path"]).lower())}">'
|
|
764
|
+
f'<td><a class="tag" href="{turl(x["root"], [], True)}">{esc(x["root"])}</a></td>'
|
|
765
|
+
f'<td class="p"><a href="{turl(x["root"], segs)}">{esc(x["path"])}</a></td>'
|
|
766
|
+
f'<td class="r hide-sm">{ui.human_size(x["size"])}</td>'
|
|
767
|
+
f'<td class="r" title="{_when(x["mtime"])}">{_age(x["mtime"])}</td></tr>')
|
|
768
|
+
note = (f'<span id="pp-count">{len(res["items"])}</span> of {res["total"]} files matching '
|
|
769
|
+
f'{esc(" ".join(res["globs"]))} across {res["roots"]} tab(s), scanned in {res["took"]:.2f}s')
|
|
770
|
+
if res["truncated"]:
|
|
771
|
+
note += "; <b>scan truncated</b> (time/entry cap): narrow the glob or the tabs"
|
|
772
|
+
body = (
|
|
773
|
+
'<form class="bar" method="get" action="/">'
|
|
774
|
+
f'<input id="pp-filter" name="q" value="{esc(qtext)}" placeholder="filter… (instant; Enter searches all)" autocomplete="off">'
|
|
775
|
+
f'<input name="glob" value="{esc(glob_s)}" placeholder="{esc(" ".join(self.app.s.recent))}" title="globs, space-separated" class="glob">'
|
|
776
|
+
'<button type="submit">scan</button><span id="pp-upd" class="badge" hidden></span></form>'
|
|
777
|
+
'<div id="pp-main">'
|
|
778
|
+
+ ('<div class="scroll"><table class="list"><thead><tr><th>tab</th><th>path</th><th class="r hide-sm">size</th>'
|
|
779
|
+
'<th class="r">modified</th></tr></thead><tbody>' + "".join(rows) + "</tbody></table></div>"
|
|
780
|
+
if rows else '<p class="note">Nothing matches.</p>')
|
|
781
|
+
+ f'<p class="note">{note}</p></div>')
|
|
782
|
+
self._send(200, page(self.app, "Recent (pp http)", None, body, {"kind": "recent"}), "text/html; charset=utf-8")
|
|
783
|
+
|
|
784
|
+
def _view(self, root: str, segs: list[str], slash: bool, qs):
|
|
785
|
+
with open_in_tab(self.app, root, segs, dir_ok=True, file_ok=True) as o:
|
|
786
|
+
if not o:
|
|
787
|
+
return self._refuse(o, root, segs)
|
|
788
|
+
if o.is_dir:
|
|
789
|
+
if segs and not slash:
|
|
790
|
+
return self._redirect(turl(root, segs, True) + (f"?{urllib.parse.urlencode(qs)}" if qs else ""))
|
|
791
|
+
if not segs and not self.path.split("?")[0].endswith("/"):
|
|
792
|
+
return self._redirect(turl(root, [], True))
|
|
793
|
+
return self._dir(root, segs, o, qs)
|
|
794
|
+
return self._file(root, segs, o)
|
|
795
|
+
|
|
796
|
+
def _dir(self, root, segs, o: Opened, qs):
|
|
797
|
+
hidden = qs.get("hidden") == "1"
|
|
798
|
+
sort = qs.get("sort", "name")
|
|
799
|
+
entries, capped, dots = [], False, 0
|
|
800
|
+
try:
|
|
801
|
+
with o.entries() as it: # the folder that opened, listing itself through its own fd
|
|
802
|
+
for e in it:
|
|
803
|
+
if self.app.denied(e.name):
|
|
804
|
+
continue
|
|
805
|
+
if e.name.startswith(".") and not hidden:
|
|
806
|
+
dots += 1
|
|
807
|
+
continue
|
|
808
|
+
link = e.is_symlink()
|
|
809
|
+
if link and not self.app.resolve(root, segs + [e.name]):
|
|
810
|
+
# leaves the tab, is denied or dangles: named, but neither linked nor stat'ed
|
|
811
|
+
entries.append((e.name, False, 0, 0, "out"))
|
|
812
|
+
continue
|
|
813
|
+
try:
|
|
814
|
+
is_dir = e.is_dir()
|
|
815
|
+
est = e.stat()
|
|
816
|
+
size, mtime = est.st_size, est.st_mtime
|
|
817
|
+
except OSError:
|
|
818
|
+
is_dir, size, mtime = False, 0, 0
|
|
819
|
+
entries.append((e.name, is_dir, size, mtime, "in" if link else ""))
|
|
820
|
+
if len(entries) >= self.app.s.list_cap:
|
|
821
|
+
capped = True
|
|
822
|
+
break
|
|
823
|
+
except OSError as ex:
|
|
824
|
+
return self._unreadable(root, ex)
|
|
825
|
+
keyf = {"mtime": lambda x: -x[3], "size": lambda x: -x[2]}.get(sort, lambda x: x[0].lower())
|
|
826
|
+
entries.sort(key=lambda x: (not x[1], keyf(x)))
|
|
827
|
+
extra = "?hidden=1" if hidden else ""
|
|
828
|
+
rows = []
|
|
829
|
+
for name, is_dir, size, mtime, link in entries:
|
|
830
|
+
if link == "out":
|
|
831
|
+
why = ("a symlink that leaves this tab, points at a denied name or points nowhere"
|
|
832
|
+
+ ("" if self.app.s.follow_symlinks else "; [http] follow_symlinks = true serves links out"))
|
|
833
|
+
rows.append(f'<tr><td class="p"><span class="note" title="{esc(why)}">{esc(name)} ↪ not served</span>'
|
|
834
|
+
'</td><td class="hide-sm"></td><td></td></tr>')
|
|
835
|
+
continue
|
|
836
|
+
href = turl(root, segs + [name], is_dir) + (extra if is_dir else "")
|
|
837
|
+
label = esc(name) + ("/" if is_dir else "") + (' <span class="note">↪</span>' if link else "")
|
|
838
|
+
rows.append(f'<tr><td class="p"><a href="{href}" class="{"d" if is_dir else ""}">{label}</a></td>'
|
|
839
|
+
f'<td class="r hide-sm">{"" if is_dir else ui.human_size(size)}</td>'
|
|
840
|
+
f'<td class="r" title="{_when(mtime) if mtime else ""}">{_age(mtime) if mtime else ""}</td></tr>')
|
|
841
|
+
here = turl(root, segs, True)
|
|
842
|
+
hq = "&hidden=1" if hidden else ""
|
|
843
|
+
sorts = " · ".join(f'<a href="{here}?sort={k}{hq}" class="{"on" if sort == k else ""}">{k}</a>'
|
|
844
|
+
for k in ("name", "mtime", "size"))
|
|
845
|
+
toggle = (f'<a href="{here}{"?sort=" + sort if sort != "name" else ""}">hide dotfiles</a>' if hidden
|
|
846
|
+
else f'<a href="{here}?hidden=1{"&sort=" + sort if sort != "name" else ""}">show dotfiles</a>')
|
|
847
|
+
files = {n.lower(): n for n, d, *_ in entries if not d}
|
|
848
|
+
readme_name = next((files[k] for k in README_NAMES if k in files), None)
|
|
849
|
+
readme_src = self._embed(root, segs + [readme_name]) if readme_name else None
|
|
850
|
+
if readme_src is None:
|
|
851
|
+
readme_name = None
|
|
852
|
+
body = (
|
|
853
|
+
crumbs(root, segs, True)
|
|
854
|
+
+ f'<div class="bar tools"><span class="meta">sort: {sorts}</span><span class="meta">{toggle}</span>'
|
|
855
|
+
'<span id="pp-upd" class="badge" hidden></span></div>'
|
|
856
|
+
+ '<div id="pp-main"><div class="scroll"><table class="list"><thead><tr><th>name</th>'
|
|
857
|
+
'<th class="r hide-sm">size</th><th class="r">modified</th></tr></thead><tbody>'
|
|
858
|
+
+ (f'<tr><td class="p"><a href="{turl(root, segs[:-1], True)}{extra}" class="d">../</a></td><td class="hide-sm"></td><td></td></tr>'
|
|
859
|
+
if segs else "")
|
|
860
|
+
+ "".join(rows) + "</tbody></table></div>"
|
|
861
|
+
+ ("" if rows else f'<p class="note">Empty folder{f" but for {dots} hidden dotfile(s)" if dots else ""}.</p>')
|
|
862
|
+
+ (f'<p class="note">Showing the first {self.app.s.list_cap} entries.</p>' if capped else "")
|
|
863
|
+
+ (f'<div class="readme"><div class="readme-h"><a href="{turl(root, segs + [readme_name])}">{esc(readme_name)}</a></div>'
|
|
864
|
+
'<div id="pp-body"></div></div>' if readme_name else "")
|
|
865
|
+
+ "</div>")
|
|
866
|
+
data = {"kind": "dir", "root": root, "path": "/".join(segs), "mtime": o.st.st_mtime, "size": o.st.st_size,
|
|
867
|
+
"readme": bool(readme_name)}
|
|
868
|
+
title = ("/".join(segs) or root) + " (pp http)"
|
|
869
|
+
self._send(200, page(self.app, title, root, body, data, readme_src, libs_css=bool(readme_name)),
|
|
870
|
+
"text/html; charset=utf-8")
|
|
871
|
+
|
|
872
|
+
def _embed(self, root, segs) -> str | None:
|
|
873
|
+
"""A README's text for the folder page it sits in, or None when this tab has no business reading it.
|
|
874
|
+
It opens like any other path, so a symlink out of the tab is named in the listing and not embedded,
|
|
875
|
+
and what is read is the file that opened."""
|
|
876
|
+
with open_in_tab(self.app, root, segs, dir_ok=False, file_ok=True) as o:
|
|
877
|
+
if not o:
|
|
878
|
+
return None
|
|
879
|
+
try:
|
|
880
|
+
with os.fdopen(o.fd, "rb", closefd=False) as f:
|
|
881
|
+
return f.read(self.app.s.embed_cap).decode("utf-8", "replace")
|
|
882
|
+
except OSError:
|
|
883
|
+
return None
|
|
884
|
+
|
|
885
|
+
def _not_a_file(self, root, segs):
|
|
886
|
+
"""A pipe, socket or device under a tab: never opened, the same answer from the view and from /raw/."""
|
|
887
|
+
return self._send(403, page(self.app, "not a file", root, crumbs(root, segs, False)
|
|
888
|
+
+ '<p class="note">Not a regular file (a pipe, socket or device): not opened.</p>',
|
|
889
|
+
{"kind": "none"}), "text/html; charset=utf-8")
|
|
890
|
+
|
|
891
|
+
def _file(self, root, segs, o: Opened):
|
|
892
|
+
st = o.st # the file that opened, not whatever the name means now
|
|
893
|
+
try:
|
|
894
|
+
with os.fdopen(o.fd, "rb", closefd=False) as f:
|
|
895
|
+
head = f.read(self.app.s.embed_cap + 1)
|
|
896
|
+
except OSError as ex:
|
|
897
|
+
return self._unreadable(root, ex)
|
|
898
|
+
name = segs[-1]
|
|
899
|
+
ext = os.path.splitext(name)[1].lower()
|
|
900
|
+
raw = turl(root, segs, kind="raw")
|
|
901
|
+
binary = b"\0" in head[:8192]
|
|
902
|
+
data = {"kind": "code", "root": root, "path": "/".join(segs), "mtime": st.st_mtime, "size": st.st_size,
|
|
903
|
+
"raw": raw}
|
|
904
|
+
src = None
|
|
905
|
+
tools = [f'<span class="meta">{ui.human_size(st.st_size)} · modified <span title="{_when(st.st_mtime)}">'
|
|
906
|
+
f'{_age(st.st_mtime)} ago</span></span>',
|
|
907
|
+
f'<a href="{raw}">raw</a>', f'<a href="{raw}?dl=1">download</a>']
|
|
908
|
+
if ext in IMG_EXT:
|
|
909
|
+
data["kind"] = "image"
|
|
910
|
+
main = f'<img class="media" src="{raw}?v={st.st_mtime}" alt="{esc(name)}">'
|
|
911
|
+
elif ext in VIDEO_EXT:
|
|
912
|
+
data["kind"] = "media"
|
|
913
|
+
main = f'<video class="media" src="{raw}" controls preload="metadata"></video>'
|
|
914
|
+
elif ext in AUDIO_EXT:
|
|
915
|
+
data["kind"] = "media"
|
|
916
|
+
main = f'<audio src="{raw}" controls preload="metadata"></audio>'
|
|
917
|
+
elif ext == ".pdf":
|
|
918
|
+
data["kind"] = "media"
|
|
919
|
+
main = f'<embed class="pdf" src="{raw}" type="application/pdf">'
|
|
920
|
+
elif binary:
|
|
921
|
+
data["kind"] = "binary"
|
|
922
|
+
main = (f'<p class="note">Binary file, {ui.human_size(st.st_size)}. '
|
|
923
|
+
f'<a href="{raw}?dl=1">Download</a></p>')
|
|
924
|
+
else:
|
|
925
|
+
truncated = len(head) > self.app.s.embed_cap
|
|
926
|
+
src = head[: self.app.s.embed_cap].decode("utf-8", "replace")
|
|
927
|
+
data["truncated"] = truncated
|
|
928
|
+
if ext in MD_EXT:
|
|
929
|
+
data["kind"] = "md"
|
|
930
|
+
tools.append('<button id="pp-source" type="button">source</button>')
|
|
931
|
+
else:
|
|
932
|
+
data["lang"] = guess_lang(name, src[:200])
|
|
933
|
+
tools.append('<button id="pp-wrap" type="button">wrap</button>')
|
|
934
|
+
if ext in (".html", ".htm", ".svg"):
|
|
935
|
+
tools.append(f'<a href="{raw}" target="_blank" rel="noopener">open rendered (sandboxed)</a>')
|
|
936
|
+
main = '<div id="pp-body"></div><noscript><p class="note">Needs JavaScript; see the raw link.</p></noscript>'
|
|
937
|
+
if truncated:
|
|
938
|
+
main = (f'<p class="note">Showing the first {ui.human_size(self.app.s.embed_cap)} of '
|
|
939
|
+
f'{ui.human_size(st.st_size)}. Live reload is off; <a href="{raw}">raw</a> has it all.</p>' + main)
|
|
940
|
+
tools.append('<span id="pp-upd" class="badge" hidden></span>')
|
|
941
|
+
body = (crumbs(root, segs, False) + f'<div class="bar tools">{" ".join(tools)}</div>'
|
|
942
|
+
f'<div id="pp-main" class="{"wide" if data["kind"] == "code" else ""}">{main}</div>')
|
|
943
|
+
self._send(200, page(self.app, f"{name} (pp http)", root, body, data, src, libs_css=data["kind"] in ("md", "code")),
|
|
944
|
+
"text/html; charset=utf-8")
|
|
945
|
+
|
|
946
|
+
def _raw(self, root, segs, qs):
|
|
947
|
+
with open_in_tab(self.app, root, segs, dir_ok=False, file_ok=True) as o:
|
|
948
|
+
if not o:
|
|
949
|
+
return self._refuse(o, root, segs)
|
|
950
|
+
self._send_bytes(segs, qs, o)
|
|
951
|
+
|
|
952
|
+
def _send_bytes(self, segs, qs, o: Opened):
|
|
953
|
+
"""The bytes of the file that opened, with the one byte range a media player asks for."""
|
|
954
|
+
st = o.st
|
|
955
|
+
with os.fdopen(o.fd, "rb", closefd=False) as f:
|
|
956
|
+
head = f.read(8192)
|
|
957
|
+
ctype, sandbox = raw_type(segs[-1], head)
|
|
958
|
+
start, end = 0, st.st_size - 1
|
|
959
|
+
# one byte range, so a video can seek (Safari will not play without it); anything else gets it all
|
|
960
|
+
m = re.fullmatch(r"bytes=(\d*)-(\d*)", (self.headers.get("Range") or "").strip())
|
|
961
|
+
if m and (m[1] or m[2]):
|
|
962
|
+
start, end = ((int(m[1]), min(int(m[2] or end), end)) if m[1]
|
|
963
|
+
else (max(0, st.st_size - int(m[2])), end))
|
|
964
|
+
if start > end:
|
|
965
|
+
self.send_response(416)
|
|
966
|
+
self.send_header("Content-Range", f"bytes */{st.st_size}")
|
|
967
|
+
self.send_header("Content-Length", "0")
|
|
968
|
+
return self.end_headers()
|
|
969
|
+
self.send_response(206 if m and (m[1] or m[2]) else 200)
|
|
970
|
+
self.send_header("Content-Type", ctype)
|
|
971
|
+
self.send_header("Content-Length", str(end - start + 1))
|
|
972
|
+
if m and (m[1] or m[2]):
|
|
973
|
+
self.send_header("Content-Range", f"bytes {start}-{end}/{st.st_size}")
|
|
974
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
975
|
+
self.send_header("Last-Modified", email.utils.formatdate(st.st_mtime, usegmt=True))
|
|
976
|
+
self.send_header("Cache-Control", "no-cache")
|
|
977
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
978
|
+
if sandbox:
|
|
979
|
+
self.send_header("Content-Security-Policy", "sandbox")
|
|
980
|
+
if qs.get("dl") == "1":
|
|
981
|
+
self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{q(segs[-1])}")
|
|
982
|
+
self.end_headers()
|
|
983
|
+
if self.command == "HEAD":
|
|
984
|
+
return
|
|
985
|
+
f.seek(start)
|
|
986
|
+
left = end - start + 1
|
|
987
|
+
while left > 0:
|
|
988
|
+
chunk = f.read(min(65536, left))
|
|
989
|
+
if not chunk:
|
|
990
|
+
break
|
|
991
|
+
self.wfile.write(chunk)
|
|
992
|
+
left -= len(chunk)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@functools.cache
|
|
996
|
+
def _csp() -> str:
|
|
997
|
+
"""Rendered markdown is untrusted HTML (any repo's README). Only our one inline script (pinned by hash) and
|
|
998
|
+
cdnjs may run, so an injected <img onerror=...> or javascript: link is inert. Styles stay inline-able for
|
|
999
|
+
KaTeX and mermaid; images may come from anywhere, as markdown images do."""
|
|
1000
|
+
h = base64.b64encode(hashlib.sha256(JS.encode()).digest()).decode()
|
|
1001
|
+
return (f"default-src 'self'; script-src 'sha256-{h}' https://cdnjs.cloudflare.com; "
|
|
1002
|
+
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' data: https://cdnjs.cloudflare.com; "
|
|
1003
|
+
"img-src 'self' data: blob: https: http:; media-src 'self'; object-src 'self'; frame-src 'self'; "
|
|
1004
|
+
"connect-src 'self'; base-uri 'none'; form-action 'self'")
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
class _Server(ThreadingHTTPServer):
|
|
1008
|
+
daemon_threads = True
|
|
1009
|
+
|
|
1010
|
+
def server_bind(self):
|
|
1011
|
+
# HTTPServer.server_bind does a reverse-DNS getfqdn(); skip it (slow on some hosts, useless here)
|
|
1012
|
+
socketserver.TCPServer.server_bind(self)
|
|
1013
|
+
self.server_name, self.server_port = self.server_address[:2]
|
|
1014
|
+
|
|
1015
|
+
def handle_error(self, request, client_address):
|
|
1016
|
+
if isinstance(sys.exc_info()[1], (BrokenPipeError, ConnectionResetError)):
|
|
1017
|
+
return
|
|
1018
|
+
super().handle_error(request, client_address)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def make_server(app: App, bind: str, port: int, tries: int = 20) -> ThreadingHTTPServer:
|
|
1022
|
+
"""Bind port, or the next free one of `tries` (port 0: the OS picks)."""
|
|
1023
|
+
handler = type("PPHandler", (Handler,), {"app": app, "host_guard": _loopback(bind)})
|
|
1024
|
+
family = socket.AF_INET6 if ":" in bind else socket.AF_INET
|
|
1025
|
+
cls = type("PPServer", (_Server,), {"address_family": family})
|
|
1026
|
+
last = None
|
|
1027
|
+
ports = [0] if port == 0 else range(port, min(port + tries, 65536))
|
|
1028
|
+
for p in ports:
|
|
1029
|
+
try:
|
|
1030
|
+
return cls((bind, p), handler)
|
|
1031
|
+
except OSError as e:
|
|
1032
|
+
if e.errno not in (errno.EADDRINUSE, errno.EACCES):
|
|
1033
|
+
raise OSError(f"cannot listen on {bind} port {p}: {e.strerror or e}") from None
|
|
1034
|
+
last = e
|
|
1035
|
+
raise OSError(f"no free port in {ports[0]}..{ports[-1]} on {bind}: {last.strerror}")
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
class Serving:
|
|
1039
|
+
"""The serving loop, and the clean stop SIGINT or SIGTERM asks for.
|
|
1040
|
+
|
|
1041
|
+
`with Serving(server) as s:` serves in a thread, with both signals blocked in every thread, so a stop that
|
|
1042
|
+
arrives at any moment stays pending until `s.wait()` takes it and none can be missed between two
|
|
1043
|
+
statements. `wait()` returns the signal it took. Leaving the block stops taking connections: `shutdown()`
|
|
1044
|
+
returns only once the serving loop has exited, so the thread that serves can never be the one to call it,
|
|
1045
|
+
and the thread that waits here serves nothing. The threads that answer requests are daemons, so a response
|
|
1046
|
+
still being written is cut short when pp exits.
|
|
1047
|
+
"""
|
|
1048
|
+
|
|
1049
|
+
POLL = 0.1 # how long the serving loop may take to notice the stop, so how long Ctrl-C takes
|
|
1050
|
+
|
|
1051
|
+
SIGS = (signal.SIGINT, signal.SIGTERM) # the stop a person asks for, however they ask
|
|
1052
|
+
|
|
1053
|
+
def __init__(self, server):
|
|
1054
|
+
self.server = server
|
|
1055
|
+
self.sigs = set(self.SIGS)
|
|
1056
|
+
self._thread = None
|
|
1057
|
+
self._mask = None
|
|
1058
|
+
|
|
1059
|
+
def __enter__(self) -> "Serving":
|
|
1060
|
+
self._mask = signal.pthread_sigmask(signal.SIG_BLOCK, self.sigs)
|
|
1061
|
+
self._thread = threading.Thread(target=self.server.serve_forever, args=(self.POLL,), name="pp-http",
|
|
1062
|
+
daemon=True)
|
|
1063
|
+
self._thread.start() # started under the mask, which it inherits, so no stop reaches it
|
|
1064
|
+
return self
|
|
1065
|
+
|
|
1066
|
+
def wait(self) -> int:
|
|
1067
|
+
while True:
|
|
1068
|
+
try:
|
|
1069
|
+
return signal.sigwait(self.sigs)
|
|
1070
|
+
except InterruptedError: # some other signal; the ones waited for are still pending
|
|
1071
|
+
pass
|
|
1072
|
+
|
|
1073
|
+
def __exit__(self, *exc) -> bool:
|
|
1074
|
+
self.server.shutdown()
|
|
1075
|
+
self._thread.join()
|
|
1076
|
+
signal.pthread_sigmask(signal.SIG_SETMASK, self._mask)
|
|
1077
|
+
return False
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _port(v: str) -> int:
|
|
1081
|
+
if not v.isdigit() or int(v) > 65535:
|
|
1082
|
+
raise argparse.ArgumentTypeError(f"{v!r} is not a port (0..65535)")
|
|
1083
|
+
return int(v)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def main(argv: list[str]) -> int:
|
|
1087
|
+
ap = ui.parser("http", __doc__)
|
|
1088
|
+
ap.add_argument("paths", nargs="*", metavar="PATH", help="folders to serve, one tab each (default: from config)")
|
|
1089
|
+
ap.add_argument("-p", "--port", type=_port, help="port (default 8000, or [http] port; 0: any free one)")
|
|
1090
|
+
ap.add_argument("--bind", help="address to bind (default 127.0.0.1, or [http] bind)")
|
|
1091
|
+
ap.add_argument("--config-only", action="store_true", help="never fall back to the current directory")
|
|
1092
|
+
ap.add_argument("--no-discover", action="store_true", help="skip [[http.discover]] rules")
|
|
1093
|
+
ap.add_argument("-q", "--quiet", action="store_true", help="no request log")
|
|
1094
|
+
a = ap.parse_args(argv)
|
|
1095
|
+
cfg = config.load("http")
|
|
1096
|
+
s = Settings.from_config(cfg)
|
|
1097
|
+
roots_cfg, rules_cfg = tables(cfg, "roots", "path"), tables(cfg, "discover", "under")
|
|
1098
|
+
files = []
|
|
1099
|
+
if a.paths:
|
|
1100
|
+
fixed, rules = [], []
|
|
1101
|
+
for p in a.paths:
|
|
1102
|
+
full = paths.expand(p)
|
|
1103
|
+
if os.path.isfile(full):
|
|
1104
|
+
files.append(full)
|
|
1105
|
+
full = os.path.dirname(full)
|
|
1106
|
+
if not os.path.isdir(full):
|
|
1107
|
+
ui.die(f"not a directory: {p}", 2)
|
|
1108
|
+
fixed.append((os.path.basename(os.path.normpath(full)) or full, full))
|
|
1109
|
+
else:
|
|
1110
|
+
fixed = [(str(r.get("name") or ""), r["path"]) for r in roots_cfg]
|
|
1111
|
+
rules = [] if a.no_discover else rules_cfg
|
|
1112
|
+
if not fixed and not rules:
|
|
1113
|
+
if a.config_only:
|
|
1114
|
+
ui.die(f"no [[http.roots]] or [[http.discover]] in {config.global_path()}", 2)
|
|
1115
|
+
cwd = os.getcwd()
|
|
1116
|
+
fixed = [(os.path.basename(cwd) or cwd, cwd)]
|
|
1117
|
+
app = App(s, fixed, rules, quiet=a.quiet)
|
|
1118
|
+
roots = app.roots()
|
|
1119
|
+
if not roots:
|
|
1120
|
+
ui.warn("no tabs: the configured roots/rules matched no directory (they are re-checked every ~10 s)")
|
|
1121
|
+
bind = a.bind or s.bind
|
|
1122
|
+
want = s.port if a.port is None else a.port
|
|
1123
|
+
mimetypes.init() # the type table is built here, so the threads that serve only ever read it
|
|
1124
|
+
try:
|
|
1125
|
+
server = make_server(app, bind, want)
|
|
1126
|
+
except OSError as e:
|
|
1127
|
+
ui.die(str(e))
|
|
1128
|
+
port = server.server_address[1]
|
|
1129
|
+
host = bind if bind not in ("0.0.0.0", "::", "") else socket.gethostname()
|
|
1130
|
+
url = f"http://{'[' + host + ']' if ':' in host else host}:{port}/"
|
|
1131
|
+
try:
|
|
1132
|
+
with Serving(server) as serving:
|
|
1133
|
+
print(f"pp http: serving {url}" + (f" (port {want} is busy)" if want and port != want else "")
|
|
1134
|
+
+ ("" if _loopback(bind) else ui.c(f" (bound to {bind}: reachable from the network)", "yellow")))
|
|
1135
|
+
for f in files:
|
|
1136
|
+
for name, real in roots.items():
|
|
1137
|
+
if os.path.realpath(os.path.dirname(f)) == real:
|
|
1138
|
+
print(f" file: {url.rstrip('/')}{turl(name, [os.path.basename(f)])}")
|
|
1139
|
+
break
|
|
1140
|
+
if roots:
|
|
1141
|
+
print(ui.table([[n, p] for n, p in roots.items()], indent=" "))
|
|
1142
|
+
for r in rules:
|
|
1143
|
+
m = r.get("marker")
|
|
1144
|
+
print(f" discover: {r.get('under')} depth {r.get('depth', 1)}"
|
|
1145
|
+
+ (f" marker {m if isinstance(m, str) else '|'.join(m)}" if m else "")
|
|
1146
|
+
+ " (re-checked every ~10 s)")
|
|
1147
|
+
print("Ctrl-C to stop", flush=True)
|
|
1148
|
+
if serving.wait() == signal.SIGINT:
|
|
1149
|
+
print() # the terminal has echoed ^C, so the next prompt starts on its own line
|
|
1150
|
+
except KeyboardInterrupt: # a Ctrl-C before the signals were blocked, or a second one on the way out
|
|
1151
|
+
print()
|
|
1152
|
+
finally:
|
|
1153
|
+
server.server_close()
|
|
1154
|
+
return 0
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
# ---- page assets ----------------------------------------------------------------------------------
|
|
1158
|
+
|
|
1159
|
+
CSS = r"""
|
|
1160
|
+
:root{--bg:#fff;--fg:#1f2328;--muted:#656d76;--border:#d0d7de;--accent:#0969da;--soft:#f6f8fa;--hl:#fff8c5;
|
|
1161
|
+
--tag:#ddf4ff;--tagfg:#0550ae;color-scheme:light dark}
|
|
1162
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#8d96a0;--border:#30363d;--accent:#4493f8;
|
|
1163
|
+
--soft:#161b22;--hl:#3b2e00;--tag:#12263f;--tagfg:#79c0ff}}
|
|
1164
|
+
*{box-sizing:border-box}
|
|
1165
|
+
html{-webkit-text-size-adjust:100%}
|
|
1166
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}
|
|
1167
|
+
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
1168
|
+
header{position:sticky;top:0;z-index:5;background:var(--soft);border-bottom:1px solid var(--border)}
|
|
1169
|
+
.tabs{display:flex;gap:2px;overflow-x:auto;padding:6px 16px 0;white-space:nowrap;scrollbar-width:thin}
|
|
1170
|
+
.tabs a,.tabs .brand{padding:6px 12px;border-radius:6px 6px 0 0;border:1px solid transparent;border-bottom:none;font-size:14px;color:var(--muted)}
|
|
1171
|
+
.tabs .brand{font-weight:700;color:var(--fg);padding-left:0}
|
|
1172
|
+
.tabs a.on{background:var(--bg);color:var(--fg);border-color:var(--border);font-weight:600;margin-bottom:-1px}
|
|
1173
|
+
.tabs a:hover{text-decoration:none;color:var(--fg)}
|
|
1174
|
+
main{padding:16px;max-width:1280px;margin:0 auto}main:has(#pp-main.wide){max-width:none}
|
|
1175
|
+
.crumbs{font-size:16px;margin:2px 0 8px;word-break:break-all}.crumbs .sep{color:var(--muted)}
|
|
1176
|
+
.bar{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center;margin:0 0 12px}
|
|
1177
|
+
.bar input{font:inherit;font-size:14px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);min-width:0}
|
|
1178
|
+
#pp-filter{flex:1 1 260px}.bar .glob{flex:0 1 160px}
|
|
1179
|
+
button{font:inherit;font-size:13px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;background:var(--soft);color:var(--fg);cursor:pointer}
|
|
1180
|
+
button.on{background:var(--tag);color:var(--tagfg)}
|
|
1181
|
+
.meta,.note{color:var(--muted);font-size:13px}.meta a.on{color:var(--fg);font-weight:600}
|
|
1182
|
+
.badge{font-size:12px;background:var(--tag);color:var(--tagfg);padding:1px 8px;border-radius:10px}
|
|
1183
|
+
.scroll{overflow-x:auto}
|
|
1184
|
+
table.list{width:100%;border-collapse:collapse;font-size:14px}
|
|
1185
|
+
table.list th{text-align:left;font-weight:600;color:var(--muted);border-bottom:1px solid var(--border);padding:6px 8px;font-size:12px;text-transform:uppercase;letter-spacing:.03em}
|
|
1186
|
+
table.list td{padding:5px 8px;border-bottom:1px solid var(--border);vertical-align:top}
|
|
1187
|
+
table.list tr:hover td{background:var(--soft)}
|
|
1188
|
+
table.list .r{text-align:right;white-space:nowrap;color:var(--muted)}td.p{word-break:break-all}
|
|
1189
|
+
a.d{font-weight:600}
|
|
1190
|
+
.tag{background:var(--tag);color:var(--tagfg);padding:1px 8px;border-radius:10px;font-size:12px;white-space:nowrap}
|
|
1191
|
+
.readme{margin-top:24px;border:1px solid var(--border);border-radius:6px;padding:8px 24px 24px}
|
|
1192
|
+
.readme-h{font-size:13px;border-bottom:1px solid var(--border);margin:0 -24px 8px;padding:0 16px 8px}
|
|
1193
|
+
.media{max-width:100%;height:auto;border:1px solid var(--border);border-radius:6px;background:repeating-conic-gradient(#8881 0 25%,transparent 0 50%) 0 0/16px 16px}
|
|
1194
|
+
.pdf{width:100%;height:85vh;border:1px solid var(--border);border-radius:6px}
|
|
1195
|
+
pre.plain{white-space:pre-wrap;word-break:break-word;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--soft);border:1px solid var(--border);border-radius:6px;padding:12px}
|
|
1196
|
+
.code{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--soft);border:1px solid var(--border);border-radius:6px;overflow-x:auto;padding:8px 0}
|
|
1197
|
+
.code .rows{display:inline-block;min-width:100%}.code.wrap .rows{display:block}
|
|
1198
|
+
.code .row{display:flex}
|
|
1199
|
+
.code .ln{flex:none;width:var(--lnw,4ch);box-sizing:content-box;text-align:right;padding:0 12px 0 8px;color:var(--muted);user-select:none;position:sticky;left:0;background:var(--soft)}
|
|
1200
|
+
.code .ln:hover{color:var(--accent);text-decoration:none}
|
|
1201
|
+
.code .lc{white-space:pre;padding-right:16px}
|
|
1202
|
+
.code.wrap .lc{white-space:pre-wrap;word-break:break-all;flex:1 1 auto;min-width:0}
|
|
1203
|
+
.code .row.hl,.code .row.hl .ln{background:var(--hl)}
|
|
1204
|
+
.hljs{background:transparent!important;padding:0!important}
|
|
1205
|
+
.md{max-width:980px;margin:0 auto;font-size:16px;line-height:1.65;overflow-wrap:break-word}
|
|
1206
|
+
.readme .md{max-width:none}
|
|
1207
|
+
.md>:first-child{margin-top:0}
|
|
1208
|
+
.md h1,.md h2,.md h3,.md h4,.md h5,.md h6{margin:1.4em 0 .6em;line-height:1.25;scroll-margin-top:64px}
|
|
1209
|
+
.md h1,.md h2{border-bottom:1px solid var(--border);padding-bottom:.3em}
|
|
1210
|
+
.md h1{font-size:1.9em}.md h2{font-size:1.5em}.md h3{font-size:1.25em}.md h4{font-size:1.05em}
|
|
1211
|
+
.md .hanchor{opacity:0;margin-left:.4em;font-size:.75em;font-weight:400}
|
|
1212
|
+
.md :is(h1,h2,h3,h4,h5,h6):hover .hanchor{opacity:1}
|
|
1213
|
+
.md code{font:.86em ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:var(--soft);padding:.15em .35em;border-radius:4px}
|
|
1214
|
+
.md pre{background:var(--soft);border:1px solid var(--border);border-radius:6px;padding:12px 14px;overflow:auto;line-height:1.45}
|
|
1215
|
+
.md pre code{background:none;padding:0;font-size:13px;border-radius:0}
|
|
1216
|
+
.md table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%;margin:1em 0}
|
|
1217
|
+
.md th,.md td{border:1px solid var(--border);padding:5px 12px}
|
|
1218
|
+
.md tr:nth-child(2n){background:var(--soft)}
|
|
1219
|
+
.md blockquote{margin:1em 0;padding:0 1em;color:var(--muted);border-left:.25em solid var(--border)}
|
|
1220
|
+
.md img{max-width:100%}
|
|
1221
|
+
.md hr{border:0;border-top:1px solid var(--border);margin:1.5em 0}
|
|
1222
|
+
.md li+li{margin-top:.2em}
|
|
1223
|
+
.md .katex-display{overflow-x:auto;overflow-y:hidden;padding:2px 0}
|
|
1224
|
+
.md .pp-math.raw{font-family:ui-monospace,monospace;color:var(--muted)}
|
|
1225
|
+
.md .mermaid{text-align:center;margin:1em 0}
|
|
1226
|
+
#pp-toc{display:none}
|
|
1227
|
+
@media (min-width:1560px){#pp-toc{display:block;position:fixed;top:96px;right:16px;width:250px;max-height:calc(100vh - 120px);overflow:auto;font-size:13px;border-left:1px solid var(--border);padding-left:12px}
|
|
1228
|
+
#pp-toc a{display:block;color:var(--muted);padding:2px 0}#pp-toc a:hover{color:var(--fg)}#pp-toc .l3{padding-left:12px}}
|
|
1229
|
+
@media (max-width:640px){.hide-sm{display:none}main{padding:12px 16px}.readme{padding:8px 12px 16px}.readme-h{margin:0 -12px 8px}}
|
|
1230
|
+
"""
|
|
1231
|
+
|
|
1232
|
+
JS = r"""
|
|
1233
|
+
(function () {
|
|
1234
|
+
'use strict';
|
|
1235
|
+
const PAGE = JSON.parse(document.getElementById('pp-page').textContent);
|
|
1236
|
+
const CDN = PAGE.cdn;
|
|
1237
|
+
const $ = (s, el) => (el || document).querySelector(s);
|
|
1238
|
+
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
|
1239
|
+
const store = {get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
|
|
1240
|
+
set(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }};
|
|
1241
|
+
function readSrc(doc) { const el = (doc || document).getElementById('pp-src'); return el ? JSON.parse(el.textContent) : null; }
|
|
1242
|
+
let SRC = readSrc();
|
|
1243
|
+
if (typeof SRC === 'string') SRC = SRC.replace(/\r\n/g, '\n');
|
|
1244
|
+
|
|
1245
|
+
const loading = {};
|
|
1246
|
+
function loadScript(src) {
|
|
1247
|
+
if (!loading[src]) loading[src] = new Promise((res, rej) => {
|
|
1248
|
+
const s = document.createElement('script'); s.src = src; s.async = false;
|
|
1249
|
+
s.onload = () => res(); s.onerror = () => { delete loading[src]; rej(new Error('failed: ' + src)); };
|
|
1250
|
+
document.head.appendChild(s);
|
|
1251
|
+
});
|
|
1252
|
+
return loading[src];
|
|
1253
|
+
}
|
|
1254
|
+
const soft = p => p.then(() => true, () => false);
|
|
1255
|
+
const wantMd = PAGE.kind === 'md' || (PAGE.kind === 'dir' && PAGE.readme);
|
|
1256
|
+
const libs = wantMd ? Promise.all([soft(loadScript(CDN.marked)), soft(loadScript(CDN.hljs)), soft(loadScript(CDN.katex))])
|
|
1257
|
+
: PAGE.kind === 'code' ? soft(loadScript(CDN.hljs)) : Promise.resolve();
|
|
1258
|
+
|
|
1259
|
+
// ---- highlight.js ----
|
|
1260
|
+
const ALIAS = {tex: 'latex', clj: 'clojure', cljs: 'clojure', edn: 'clojure', docker: 'dockerfile', hs: 'haskell',
|
|
1261
|
+
ex: 'elixir', exs: 'elixir', jl: 'julia', ml: 'ocaml', proto: 'protobuf', zsh: 'bash', sh: 'bash', console: 'shell',
|
|
1262
|
+
toml: 'ini', yml: 'yaml', py: 'python', js: 'javascript', ts: 'typescript', rs: 'rust', golang: 'go', 'c++': 'cpp'};
|
|
1263
|
+
const EXTRA = new Set(['clojure', 'dockerfile', 'latex', 'haskell', 'elixir', 'erlang', 'scheme', 'lisp', 'cmake',
|
|
1264
|
+
'protobuf', 'nix', 'groovy', 'gradle', 'vim', 'powershell', 'ocaml', 'fsharp', 'julia', 'dart', 'scala', 'elm',
|
|
1265
|
+
'nginx', 'apache', 'properties', 'tcl', 'fortran', 'matlab', 'prolog', 'x86asm', 'armasm', 'llvm', 'glsl', 'awk',
|
|
1266
|
+
'dos', 'pgsql', 'verilog', 'vhdl', 'zephir', 'crystal', 'd', 'nim', 'haxe', 'smalltalk', 'stata', 'coq', 'isabelle']);
|
|
1267
|
+
async function hlLang(lang) {
|
|
1268
|
+
if (!window.hljs || !lang) return null;
|
|
1269
|
+
const L = ALIAS[lang] || lang;
|
|
1270
|
+
if (!hljs.getLanguage(L) && EXTRA.has(L)) await soft(loadScript(CDN.hljsLang.replace('{}', L)));
|
|
1271
|
+
return hljs.getLanguage(L) ? L : null;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ---- code view ----
|
|
1275
|
+
function splitLines(h) {
|
|
1276
|
+
const lines = []; const open = []; let cur = '';
|
|
1277
|
+
const re = /(<span[^>]*>)|(<\/span>)|(\n)|([^<\n]+|<)/g; let m;
|
|
1278
|
+
while ((m = re.exec(h))) {
|
|
1279
|
+
if (m[1]) { open.push(m[1]); cur += m[1]; }
|
|
1280
|
+
else if (m[2]) { open.pop(); cur += m[2]; }
|
|
1281
|
+
else if (m[3]) { lines.push(cur + '</span>'.repeat(open.length)); cur = open.join(''); }
|
|
1282
|
+
else cur += m[4];
|
|
1283
|
+
}
|
|
1284
|
+
lines.push(cur + '</span>'.repeat(open.length));
|
|
1285
|
+
return lines;
|
|
1286
|
+
}
|
|
1287
|
+
function codeRows(el, h, src) {
|
|
1288
|
+
const lines = splitLines(h);
|
|
1289
|
+
if (src.endsWith('\n') && lines.length > 1) lines.pop();
|
|
1290
|
+
const wrap = store.get('pp-wrap') === '1';
|
|
1291
|
+
const w = String(lines.length).length;
|
|
1292
|
+
let out = '<div class="code' + (wrap ? ' wrap' : '') + '" style="--lnw:' + w + 'ch"><div class="rows">';
|
|
1293
|
+
for (let i = 0; i < lines.length; i++)
|
|
1294
|
+
out += '<div class="row" id="L' + (i + 1) + '"><a class="ln" href="#L' + (i + 1) + '">' + (i + 1) + '</a><span class="lc">' + (lines[i] || ' ') + '</span></div>';
|
|
1295
|
+
el.innerHTML = out + '</div></div>';
|
|
1296
|
+
const b = $('#pp-wrap'); if (b) b.classList.toggle('on', wrap);
|
|
1297
|
+
}
|
|
1298
|
+
async function renderCode(el, src, lang, upgrade) {
|
|
1299
|
+
if (!upgrade) { codeRows(el, esc(src), src); return; }
|
|
1300
|
+
await libs;
|
|
1301
|
+
let h = null;
|
|
1302
|
+
const L = src.length <= 500000 ? await hlLang(lang) : null;
|
|
1303
|
+
if (L) { try { h = hljs.highlight(src, {language: L, ignoreIllegals: true}).value; } catch (e) {} }
|
|
1304
|
+
codeRows(el, h === null ? esc(src) : h, src);
|
|
1305
|
+
}
|
|
1306
|
+
function markLine(scroll) {
|
|
1307
|
+
document.querySelectorAll('.row.hl').forEach(r => r.classList.remove('hl'));
|
|
1308
|
+
const m = /^#L(\d+)$/.exec(location.hash); if (!m) return;
|
|
1309
|
+
const r = document.getElementById('L' + m[1]);
|
|
1310
|
+
if (r) { r.classList.add('hl'); if (scroll) r.scrollIntoView({block: 'center'}); }
|
|
1311
|
+
}
|
|
1312
|
+
window.addEventListener('hashchange', () => markLine(true));
|
|
1313
|
+
|
|
1314
|
+
// ---- markdown ----
|
|
1315
|
+
const MATH = /(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)|\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\\begin\{([a-zA-Z]+\*?)\}[\s\S]+?\\end\{\2\}|(?<![\\$\w])\$(?=[^\s$])(?:[^$\\\n]|\\[\s\S])+?(?<=[^\s\\])\$(?![\d$])/g;
|
|
1316
|
+
function frontMatter(src) {
|
|
1317
|
+
const m = /^---\n([\s\S]*?)\n---\n/.exec(src);
|
|
1318
|
+
return m ? '```yaml\n' + m[1] + '\n```\n' + src.slice(m[0].length) : src;
|
|
1319
|
+
}
|
|
1320
|
+
function protect(src) {
|
|
1321
|
+
// fenced code stays verbatim; outside it, inline code stays verbatim and math becomes a placeholder marked can't mangle
|
|
1322
|
+
const math = [], parts = []; let buf = [], fence = null;
|
|
1323
|
+
for (const line of src.split('\n')) {
|
|
1324
|
+
if (fence) {
|
|
1325
|
+
buf.push(line);
|
|
1326
|
+
const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
|
|
1327
|
+
if (m && m[1][0] === fence[0] && m[1].length >= fence.length) { parts.push([true, buf.join('\n')]); buf = []; fence = null; }
|
|
1328
|
+
} else {
|
|
1329
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
1330
|
+
if (m) { if (buf.length) parts.push([false, buf.join('\n')]); buf = [line]; fence = m[1]; }
|
|
1331
|
+
else buf.push(line);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (buf.length) parts.push([fence !== null, buf.join('\n')]);
|
|
1335
|
+
const text = parts.map(([code, t]) => code ? t : t.replace(MATH, (m, ticks) => {
|
|
1336
|
+
if (ticks) return m;
|
|
1337
|
+
math.push(m); return 'PPMATH' + (math.length - 1) + 'X';
|
|
1338
|
+
})).join('\n');
|
|
1339
|
+
return [text, math];
|
|
1340
|
+
}
|
|
1341
|
+
function renderMath(root, math) {
|
|
1342
|
+
root.querySelectorAll('span.pp-math').forEach(sp => {
|
|
1343
|
+
const raw = math[+sp.dataset.i]; let tex = raw, display = false;
|
|
1344
|
+
if (raw.startsWith('$$') || raw.startsWith('\\[')) { tex = raw.slice(2, -2); display = true; }
|
|
1345
|
+
else if (raw.startsWith('\\(')) tex = raw.slice(2, -2);
|
|
1346
|
+
else if (raw.startsWith('\\begin')) display = true;
|
|
1347
|
+
else tex = raw.slice(1, -1);
|
|
1348
|
+
if (window.katex) {
|
|
1349
|
+
try { katex.render(tex, sp, {displayMode: display, throwOnError: false, strict: 'ignore'}); return; } catch (e) {}
|
|
1350
|
+
}
|
|
1351
|
+
sp.textContent = raw; sp.classList.add('raw');
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
function fixLinks(root) {
|
|
1355
|
+
// as on GitHub, a link starting with one / is relative to the tab (the repo), not to this server
|
|
1356
|
+
const tab = v => typeof PAGE.root === 'string' && /^\/(?!\/)/.test(v) ? '/t/' + encodeURIComponent(PAGE.root) + v : v;
|
|
1357
|
+
root.querySelectorAll('img[src],source[src],video[src],audio[src]').forEach(n => {
|
|
1358
|
+
try {
|
|
1359
|
+
const u = new URL(tab(n.getAttribute('src')), location.href);
|
|
1360
|
+
if (u.origin === location.origin && u.pathname.startsWith('/t/')) { u.pathname = '/raw/' + u.pathname.slice(3); n.setAttribute('src', u.href); }
|
|
1361
|
+
} catch (e) {}
|
|
1362
|
+
});
|
|
1363
|
+
root.querySelectorAll('a[href]').forEach(a => {
|
|
1364
|
+
try {
|
|
1365
|
+
a.setAttribute('href', tab(a.getAttribute('href')));
|
|
1366
|
+
if (new URL(a.getAttribute('href'), location.href).origin !== location.origin) { a.target = '_blank'; a.rel = 'noopener'; }
|
|
1367
|
+
} catch (e) {}
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
function anchors(el) {
|
|
1371
|
+
const seen = {}; const hs = [...el.querySelectorAll('h1,h2,h3,h4,h5,h6')];
|
|
1372
|
+
for (const h of hs) {
|
|
1373
|
+
let id = h.textContent.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').trim().replace(/\s+/g, '-') || 'section';
|
|
1374
|
+
if (id in seen) id += '-' + (++seen[id]); else seen[id] = 0;
|
|
1375
|
+
h.id = id;
|
|
1376
|
+
const a = document.createElement('a'); a.href = '#' + id; a.className = 'hanchor'; a.textContent = '#'; h.appendChild(a);
|
|
1377
|
+
}
|
|
1378
|
+
return hs;
|
|
1379
|
+
}
|
|
1380
|
+
function toc(hs) {
|
|
1381
|
+
const old = $('#pp-toc'); if (old) old.remove();
|
|
1382
|
+
const items = hs.filter(h => /^H[123]$/.test(h.tagName));
|
|
1383
|
+
if (PAGE.kind !== 'md' || items.length < 4) return;
|
|
1384
|
+
const nav = document.createElement('nav'); nav.id = 'pp-toc';
|
|
1385
|
+
nav.innerHTML = items.map(h => '<a class="l' + h.tagName[1] + '" href="#' + h.id + '">' + esc(h.textContent.replace(/#$/, '')) + '</a>').join('');
|
|
1386
|
+
document.body.appendChild(nav);
|
|
1387
|
+
}
|
|
1388
|
+
async function mermaidBlocks(el) {
|
|
1389
|
+
const blocks = el.querySelectorAll('pre > code.language-mermaid');
|
|
1390
|
+
if (!blocks.length || !(await soft(loadScript(CDN.mermaid)))) return;
|
|
1391
|
+
const nodes = [];
|
|
1392
|
+
blocks.forEach(c => { const d = document.createElement('div'); d.className = 'mermaid'; d.textContent = c.textContent; c.parentElement.replaceWith(d); nodes.push(d); });
|
|
1393
|
+
try {
|
|
1394
|
+
mermaid.initialize({startOnLoad: false, securityLevel: 'strict', theme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default'});
|
|
1395
|
+
await mermaid.run({nodes});
|
|
1396
|
+
} catch (e) {}
|
|
1397
|
+
}
|
|
1398
|
+
function plain(el, src) { el.innerHTML = '<pre class="plain">' + esc(src) + '</pre>'; }
|
|
1399
|
+
async function renderMd(el, src, upgrade) {
|
|
1400
|
+
if (!upgrade) { plain(el, src); return; }
|
|
1401
|
+
await libs;
|
|
1402
|
+
if (!window.marked) { plain(el, src); return; }
|
|
1403
|
+
const [prot, math] = protect(frontMatter(src));
|
|
1404
|
+
let h = marked.parse(prot, {gfm: true});
|
|
1405
|
+
h = h.replace(/<[^>]*>|PPMATH(\d+)X/g, (m, i) => i === undefined
|
|
1406
|
+
? m.replace(/PPMATH(\d+)X/g, (_, j) => esc(math[+j])) : '<span class="pp-math" data-i="' + i + '"></span>');
|
|
1407
|
+
const tpl = document.createElement('template'); tpl.innerHTML = h; // inert: relative images don't load before fixLinks
|
|
1408
|
+
fixLinks(tpl.content); renderMath(tpl.content, math);
|
|
1409
|
+
el.replaceChildren(tpl.content); el.classList.add('md');
|
|
1410
|
+
toc(anchors(el));
|
|
1411
|
+
if (window.hljs) for (const c of el.querySelectorAll('pre code')) {
|
|
1412
|
+
const m = /language-([\w+#-]+)/.exec(c.className);
|
|
1413
|
+
if (!m || m[1] === 'mermaid') continue;
|
|
1414
|
+
const L = await hlLang(m[1]);
|
|
1415
|
+
if (L) { try { c.innerHTML = hljs.highlight(c.textContent, {language: L, ignoreIllegals: true}).value; c.classList.add('hljs'); } catch (e) {} }
|
|
1416
|
+
}
|
|
1417
|
+
mermaidBlocks(el);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// ---- page ----
|
|
1421
|
+
let mode = 'rendered';
|
|
1422
|
+
async function render(upgrade) {
|
|
1423
|
+
const el = $('#pp-body'); if (!el || typeof SRC !== 'string') return;
|
|
1424
|
+
if (PAGE.kind === 'code') await renderCode(el, SRC, PAGE.lang, upgrade);
|
|
1425
|
+
else if (PAGE.kind === 'md' && mode === 'source') await renderCode(el, SRC, 'markdown', upgrade);
|
|
1426
|
+
else if (wantMd) await renderMd(el, SRC, upgrade);
|
|
1427
|
+
}
|
|
1428
|
+
render(false); markLine(true);
|
|
1429
|
+
libs.then(() => render(true)).then(() => { if (location.hash) { markLine(true); const t = document.getElementById(decodeURIComponent(location.hash.slice(1))); if (t && !/^L\d+$/.test(t.id)) t.scrollIntoView(); } });
|
|
1430
|
+
|
|
1431
|
+
const wrapBtn = $('#pp-wrap');
|
|
1432
|
+
if (wrapBtn) wrapBtn.addEventListener('click', () => {
|
|
1433
|
+
const c = $('.code'); if (!c) return; const on = !c.classList.contains('wrap');
|
|
1434
|
+
c.classList.toggle('wrap', on); wrapBtn.classList.toggle('on', on); store.set('pp-wrap', on ? '1' : '0');
|
|
1435
|
+
});
|
|
1436
|
+
const srcBtn = $('#pp-source');
|
|
1437
|
+
if (srcBtn) srcBtn.addEventListener('click', () => {
|
|
1438
|
+
mode = mode === 'source' ? 'rendered' : 'source'; srcBtn.classList.toggle('on', mode === 'source');
|
|
1439
|
+
const t = $('#pp-toc'); if (t) t.hidden = mode === 'source';
|
|
1440
|
+
render(true);
|
|
1441
|
+
});
|
|
1442
|
+
document.addEventListener('click', e => {
|
|
1443
|
+
const a = e.target.closest && e.target.closest('a.ln'); if (!a) return;
|
|
1444
|
+
e.preventDefault(); history.replaceState(null, '', a.getAttribute('href')); markLine(false);
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
function applyFilter() {
|
|
1448
|
+
const f = $('#pp-filter'); if (!f) return;
|
|
1449
|
+
const v = f.value.trim().toLowerCase(); let n = 0;
|
|
1450
|
+
document.querySelectorAll('#pp-main tr[data-k]').forEach(tr => { const show = !v || tr.dataset.k.includes(v); tr.hidden = !show; if (show) n++; });
|
|
1451
|
+
const c = $('#pp-count'); if (c) c.textContent = n;
|
|
1452
|
+
}
|
|
1453
|
+
const filt = $('#pp-filter');
|
|
1454
|
+
if (filt) { filt.addEventListener('input', applyFilter); if (!filt.value) filt.focus(); }
|
|
1455
|
+
|
|
1456
|
+
// ---- live reload ----
|
|
1457
|
+
let last = {mtime: PAGE.mtime, size: PAGE.size}, updatedAt = 0, busy = false;
|
|
1458
|
+
function tick() {
|
|
1459
|
+
const b = $('#pp-upd'); if (!b || !updatedAt) return;
|
|
1460
|
+
const s = Math.round((Date.now() - updatedAt) / 1000);
|
|
1461
|
+
b.textContent = 'updated ' + (s < 60 ? s + 's' : Math.round(s / 60) + 'm') + ' ago'; b.hidden = false;
|
|
1462
|
+
}
|
|
1463
|
+
async function swapMain() {
|
|
1464
|
+
const r = await fetch(location.href, {cache: 'no-store'}); if (!r.ok) return false;
|
|
1465
|
+
const doc = new DOMParser().parseFromString(await r.text(), 'text/html');
|
|
1466
|
+
const m = doc.getElementById('pp-main'); if (!m) return false;
|
|
1467
|
+
document.getElementById('pp-main').replaceWith(m);
|
|
1468
|
+
if (PAGE.kind === 'dir') { SRC = readSrc(doc); PAGE.readme = typeof SRC === 'string'; if (PAGE.readme) await renderMd($('#pp-body'), SRC, true); }
|
|
1469
|
+
applyFilter();
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
async function check() {
|
|
1473
|
+
if (busy || document.visibilityState !== 'visible') return;
|
|
1474
|
+
busy = true; const y = window.scrollY;
|
|
1475
|
+
try {
|
|
1476
|
+
if (PAGE.kind === 'recent') { await swapMain(); window.scrollTo(0, y); return; }
|
|
1477
|
+
if (!PAGE.root && PAGE.root !== '') return;
|
|
1478
|
+
const r = await fetch('/_pp/stat?root=' + encodeURIComponent(PAGE.root) + '&path=' + encodeURIComponent(PAGE.path), {cache: 'no-store'});
|
|
1479
|
+
if (!r.ok) { const b = $('#pp-upd'); if (b) { b.textContent = 'gone'; b.hidden = false; } return; }
|
|
1480
|
+
const st = await r.json();
|
|
1481
|
+
if (st.mtime === last.mtime && st.size === last.size) return;
|
|
1482
|
+
last = st;
|
|
1483
|
+
if (PAGE.kind === 'md' || PAGE.kind === 'code') {
|
|
1484
|
+
if (PAGE.truncated) return;
|
|
1485
|
+
const fr = await fetch(PAGE.raw, {cache: 'no-store'}); if (!fr.ok) return;
|
|
1486
|
+
SRC = (await fr.text()).replace(/\r\n/g, '\n'); await render(true);
|
|
1487
|
+
} else if (PAGE.kind === 'image') { const img = $('#pp-main img'); if (img) img.src = PAGE.raw + '?v=' + st.mtime; }
|
|
1488
|
+
else if (PAGE.kind === 'dir') await swapMain();
|
|
1489
|
+
else return;
|
|
1490
|
+
window.scrollTo(0, y); updatedAt = Date.now(); tick();
|
|
1491
|
+
} catch (e) {} finally { busy = false; }
|
|
1492
|
+
}
|
|
1493
|
+
if (['md', 'code', 'image', 'dir', 'recent'].includes(PAGE.kind)) {
|
|
1494
|
+
setInterval(check, PAGE.kind === 'recent' ? 10000 : 2000);
|
|
1495
|
+
setInterval(tick, 1000);
|
|
1496
|
+
document.addEventListener('visibilitychange', check);
|
|
1497
|
+
}
|
|
1498
|
+
})();
|
|
1499
|
+
"""
|