codetrail-cli 1.4.1__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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""What the page's shell shows around every page (design section 16.1): the sidebar, a page's neighbours in its path,
|
|
2
|
+
and where to continue reading. Built from the guide's pages and the reader's learning states; malformed front matter
|
|
3
|
+
never breaks it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from codetrail.guide import Page
|
|
14
|
+
|
|
15
|
+
SIDEBAR_ANSWERS = 5
|
|
16
|
+
PINNABLE = ("path", "area", "concept", "answer") # the guide pages the sidebar lists, which can be pinned
|
|
17
|
+
WORD = re.compile(r"[^\W_]+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Navigation:
|
|
22
|
+
paths: list[tuple[Page, int, int]] = field(default_factory=list) # path, steps learned, steps
|
|
23
|
+
areas: list[Page] = field(default_factory=list)
|
|
24
|
+
concepts: list[Page] = field(default_factory=list)
|
|
25
|
+
answers: list[Page] = field(default_factory=list) # the newest few
|
|
26
|
+
answer_count: int = 0
|
|
27
|
+
statuses: Mapping[str, str] = field(default_factory=dict) # page id -> learning state
|
|
28
|
+
unread_digests: bool = False
|
|
29
|
+
branch: str = ""
|
|
30
|
+
commit: str = ""
|
|
31
|
+
pins: list[Page] = field(default_factory=list) # pinned pages, in the order they were pinned
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def initials(title: str) -> str:
|
|
35
|
+
"""A pinned tile's letters: the first letters of the title's first two words, or one word's first two."""
|
|
36
|
+
words: list[str] = WORD.findall(title)
|
|
37
|
+
if not words:
|
|
38
|
+
return "·"
|
|
39
|
+
if len(words) == 1:
|
|
40
|
+
return words[0][:1].upper() + words[0][1:2]
|
|
41
|
+
return (words[0][:1] + words[1][:1]).upper()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_pins(value: str | None) -> list[str]:
|
|
45
|
+
"""The pinned page ids as stored, a JSON list of strings; anything else reads as no pins."""
|
|
46
|
+
try:
|
|
47
|
+
loaded = json.loads(value) if value else []
|
|
48
|
+
except ValueError:
|
|
49
|
+
return []
|
|
50
|
+
return [item for item in loaded if isinstance(item, str)] if isinstance(loaded, list) else []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def steps_of(path: Page) -> list[str]:
|
|
54
|
+
steps = path.meta.get("steps")
|
|
55
|
+
return [str(step) for step in steps] if isinstance(steps, list) else []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _by_title(pages: Iterable[Page]) -> list[Page]:
|
|
59
|
+
return sorted(pages, key=lambda page: (page.title.casefold(), page.id))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def answers_newest_first(pages: Iterable[Page]) -> list[Page]:
|
|
63
|
+
"""Saved answers, newest first by when they were asked; one with no usable date sorts last."""
|
|
64
|
+
|
|
65
|
+
def asked(page: Page) -> str:
|
|
66
|
+
value = page.meta.get("asked_at")
|
|
67
|
+
return value if isinstance(value, str) else ""
|
|
68
|
+
|
|
69
|
+
listed = list(pages) # read once: callers may pass a generator
|
|
70
|
+
dated = sorted((page for page in listed if asked(page)), key=asked, reverse=True)
|
|
71
|
+
return dated + sorted((page for page in listed if not asked(page)), key=lambda page: page.id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_navigation(
|
|
75
|
+
pages: Sequence[Page], statuses: Mapping[str, str], unread_digests: Sequence[str], branch: str = "",
|
|
76
|
+
commit: str = "", pinned: Sequence[str] = (),
|
|
77
|
+
) -> Navigation: # fmt: skip
|
|
78
|
+
pinnable = {page.id: page for page in pages if page.kind in PINNABLE}
|
|
79
|
+
paths = [
|
|
80
|
+
(path, sum(1 for step in steps_of(path) if statuses.get(step) == "learned"), len(steps_of(path)))
|
|
81
|
+
for path in _by_title(page for page in pages if page.kind == "path")
|
|
82
|
+
]
|
|
83
|
+
answers = answers_newest_first(page for page in pages if page.kind == "answer")
|
|
84
|
+
return Navigation(
|
|
85
|
+
paths=paths,
|
|
86
|
+
areas=_by_title(page for page in pages if page.kind == "area"),
|
|
87
|
+
concepts=_by_title(page for page in pages if page.kind == "concept"),
|
|
88
|
+
answers=answers[:SIDEBAR_ANSWERS],
|
|
89
|
+
answer_count=len(answers),
|
|
90
|
+
statuses=dict(statuses),
|
|
91
|
+
unread_digests=bool(unread_digests),
|
|
92
|
+
branch=branch,
|
|
93
|
+
commit=commit,
|
|
94
|
+
pins=[pinnable[page_id] for page_id in pinned if page_id in pinnable],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def path_neighbours(
|
|
99
|
+
pages: Sequence[Page], page_id: str, path_hint: str | None
|
|
100
|
+
) -> tuple[Page | None, Page | None, Page | None]:
|
|
101
|
+
"""The path a page is read in, and its previous and next steps there.
|
|
102
|
+
|
|
103
|
+
The path is the one the reader came from when it really holds the page, otherwise the first one that does (by
|
|
104
|
+
title); a page in no path has none.
|
|
105
|
+
"""
|
|
106
|
+
by_id = {page.id: page for page in pages}
|
|
107
|
+
holding = [path for path in _by_title(page for page in pages if page.kind == "path") if page_id in steps_of(path)]
|
|
108
|
+
if not holding:
|
|
109
|
+
return None, None, None
|
|
110
|
+
path = next((candidate for candidate in holding if candidate.id == path_hint), holding[0])
|
|
111
|
+
steps = steps_of(path)
|
|
112
|
+
position = steps.index(page_id)
|
|
113
|
+
previous = by_id.get(steps[position - 1]) if position > 0 else None
|
|
114
|
+
following = by_id.get(steps[position + 1]) if position + 1 < len(steps) else None
|
|
115
|
+
return path, previous, following
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def continue_reading(pages: Sequence[Page], statuses: Mapping[str, str]) -> tuple[Page, Page, int, int] | None:
|
|
119
|
+
"""The first path (by title) with a step not yet learned: the path, that step, steps learned, and steps."""
|
|
120
|
+
by_id = {page.id: page for page in pages}
|
|
121
|
+
for path in _by_title(page for page in pages if page.kind == "path"):
|
|
122
|
+
steps = [step for step in steps_of(path) if step in by_id]
|
|
123
|
+
learned = sum(1 for step in steps if statuses.get(step) == "learned")
|
|
124
|
+
for step in steps:
|
|
125
|
+
if statuses.get(step) != "learned":
|
|
126
|
+
return path, by_id[step], learned, len(steps)
|
|
127
|
+
return None
|
codetrail/web/render.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Rendering a guide page (design sections 6.4 and 7.4).
|
|
2
|
+
|
|
3
|
+
Markdown is rendered by markdown-it-py with raw HTML disabled and its link validation on (no `javascript:`,
|
|
4
|
+
`vbscript:`, `file:` or `data:` links). Rationale blocks become labelled boxes, `[[fact]]` links point at the fact's
|
|
5
|
+
view, and diagram placeholders are replaced by diagrams drawn from facts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from markdown_it import MarkdownIt
|
|
16
|
+
from markdown_it.renderer import RendererHTML
|
|
17
|
+
from markdown_it.token import Token
|
|
18
|
+
from markdown_it.utils import EnvType, OptionsDict
|
|
19
|
+
from markupsafe import Markup
|
|
20
|
+
|
|
21
|
+
from codetrail.facts.store import FactStore
|
|
22
|
+
from codetrail.generate.validate import DIAGRAM, FILE_CITATION, MARKER
|
|
23
|
+
from codetrail.web.diagrams import (
|
|
24
|
+
Diagram,
|
|
25
|
+
dependencies_diagram,
|
|
26
|
+
imports_diagram,
|
|
27
|
+
resources_diagram,
|
|
28
|
+
system_diagram,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
FACT_LINK = re.compile(r"\[\[([a-z_]+:[^\]\s]+)\]\]")
|
|
32
|
+
MARKDOWN = MarkdownIt("commonmark", {"html": False, "linkify": False}).enable("table")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _aligned_cell(
|
|
36
|
+
renderer: RendererHTML, tokens: Sequence[Token], index: int, options: OptionsDict, env: EnvType
|
|
37
|
+
) -> str:
|
|
38
|
+
"""A table column's alignment as a class, where markdown-it writes an inline style: the page's policy allows no
|
|
39
|
+
inline styles (ADR 0016)."""
|
|
40
|
+
token = tokens[index]
|
|
41
|
+
style = token.attrs.pop("style", None)
|
|
42
|
+
if isinstance(style, str) and style.startswith("text-align:"):
|
|
43
|
+
token.attrSet("class", "align-" + style.removeprefix("text-align:"))
|
|
44
|
+
return renderer.renderToken(tokens, index, options, env)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
for cell in ("th_open", "td_open"):
|
|
48
|
+
MARKDOWN.add_render_rule(cell, _aligned_cell)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Segment:
|
|
53
|
+
kind: str # "html", "rationale" or "diagram"
|
|
54
|
+
html: Markup = field(default_factory=Markup)
|
|
55
|
+
label: str = ""
|
|
56
|
+
citation: str | None = None
|
|
57
|
+
link: str | None = None
|
|
58
|
+
diagram: Diagram | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_markdown(text: str) -> Markup:
|
|
62
|
+
linked = FACT_LINK.sub(lambda match: f"[{match.group(1)}](</facts/{quote(match.group(1), safe='')}>)", text)
|
|
63
|
+
# Raw HTML is off and links are validated, so the output holds only markdown-it's own markup.
|
|
64
|
+
return Markup(MARKDOWN.render(linked)) # noqa: S704
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def render_body(body: str, store: FactStore, max_nodes: int) -> list[Segment]:
|
|
68
|
+
segments: list[Segment] = []
|
|
69
|
+
pending: list[str] = []
|
|
70
|
+
|
|
71
|
+
def flush() -> None:
|
|
72
|
+
if "".join(pending).strip():
|
|
73
|
+
segments.append(Segment("html", render_markdown("\n".join(pending))))
|
|
74
|
+
pending.clear()
|
|
75
|
+
|
|
76
|
+
lines = body.splitlines()
|
|
77
|
+
index = 0
|
|
78
|
+
while index < len(lines):
|
|
79
|
+
line = lines[index]
|
|
80
|
+
marker = MARKER.match(line)
|
|
81
|
+
diagram = DIAGRAM.match(line.strip())
|
|
82
|
+
if marker:
|
|
83
|
+
flush()
|
|
84
|
+
index += 1
|
|
85
|
+
content = []
|
|
86
|
+
while index < len(lines) and lines[index].startswith(">") and not MARKER.match(lines[index]):
|
|
87
|
+
content.append(lines[index][1:].removeprefix(" "))
|
|
88
|
+
index += 1
|
|
89
|
+
citation = marker.group(2) or None
|
|
90
|
+
segments.append(
|
|
91
|
+
Segment("rationale", render_markdown("\n".join(content)), marker.group(1), citation, _link(citation))
|
|
92
|
+
)
|
|
93
|
+
continue
|
|
94
|
+
if diagram:
|
|
95
|
+
flush()
|
|
96
|
+
drawn = _diagram(diagram.group(1), store, max_nodes)
|
|
97
|
+
if drawn is not None and drawn.nodes: # a diagram with nothing to draw is left out, not shown empty
|
|
98
|
+
segments.append(Segment("diagram", diagram=drawn))
|
|
99
|
+
else:
|
|
100
|
+
pending.append(line)
|
|
101
|
+
index += 1
|
|
102
|
+
flush()
|
|
103
|
+
return segments
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _link(citation: str | None) -> str | None:
|
|
107
|
+
match = FILE_CITATION.match(citation or "")
|
|
108
|
+
return f"/source/{quote(match.group('path'))}#L{match.group('start')}" if match else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _diagram(arguments: str, store: FactStore, max_nodes: int) -> Diagram | None:
|
|
112
|
+
parts = arguments.split()
|
|
113
|
+
if parts == ["system"]:
|
|
114
|
+
return system_diagram(store, None, max_nodes)
|
|
115
|
+
if len(parts) != 2 or "=" not in parts[1]:
|
|
116
|
+
return None
|
|
117
|
+
kind, value = parts[0], parts[1].split("=", 1)[1]
|
|
118
|
+
if kind == "imports":
|
|
119
|
+
return imports_diagram(store, value, max_nodes)
|
|
120
|
+
if kind == "dependencies":
|
|
121
|
+
return dependencies_diagram(store, value)
|
|
122
|
+
if kind == "resources":
|
|
123
|
+
return resources_diagram(store, value, max_nodes)
|
|
124
|
+
if kind == "system" and parts[1].startswith("focus="):
|
|
125
|
+
return system_diagram(store, value, max_nodes)
|
|
126
|
+
return None
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""The Repository page's chart data: what `static/js/repository.js` draws, with its labels translated (design 20.3).
|
|
2
|
+
|
|
3
|
+
The document goes into the page as a JSON block the browser never runs, escaped so no text in it can end the block.
|
|
4
|
+
Counted phrases come from the catalog for a few sample numbers that between them reach every plural category of every
|
|
5
|
+
language; the script finds the sample in the same `Intl.PluralRules` category as its number. Placeholders are written
|
|
6
|
+
`{name}` for the script to fill. Babel is a development tool only (ADR 0005), so nothing here uses it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
14
|
+
from datetime import UTC, date, datetime, timedelta
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from codetrail.metrics.activity import Activity
|
|
19
|
+
from codetrail.metrics.churn import Churn, HotSpot, folder_of
|
|
20
|
+
from codetrail.metrics.repository import RepositoryReport
|
|
21
|
+
from codetrail.metrics.size import LanguageSize
|
|
22
|
+
from codetrail.web.i18n import Language
|
|
23
|
+
|
|
24
|
+
PLACEHOLDER = re.compile(r"%\((\w+)\)s")
|
|
25
|
+
LISTED_FILES = 5 # files a tile's panel lists
|
|
26
|
+
# Characters that could end the JSON block or break a script, written as JSON's \u escapes instead.
|
|
27
|
+
ESCAPES = {character: f"\\u{ord(character):04x}" for character in ("<", ">", "&", chr(0x2028), chr(0x2029))}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _template(text: str) -> str:
|
|
31
|
+
return PLACEHOLDER.sub(r"{\1}", text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Numbers that between them fall in every plural category CLDR gives any language (zero, one, two, few, many, other).
|
|
35
|
+
PLURAL_SAMPLES = (0, 1, 2, 3, 4, 5, 6, 7, 11, 12, 21, 22, 100, 101, 102, 1_000_000)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def plural_templates(choose: Callable[[int], str]) -> dict[str, str]:
|
|
39
|
+
"""`choose(n)` is an ngettext call; its template for each sample number, keyed by the number."""
|
|
40
|
+
return {str(number): _template(choose(number)) for number in PLURAL_SAMPLES}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def month_names(language: Language) -> list[str]:
|
|
44
|
+
"""The months' short names, January first, from the catalog."""
|
|
45
|
+
t = language.translations
|
|
46
|
+
return [t.pgettext("month", "Jan"), t.pgettext("month", "Feb"), t.pgettext("month", "Mar"),
|
|
47
|
+
t.pgettext("month", "Apr"), t.pgettext("month", "May"), t.pgettext("month", "Jun"),
|
|
48
|
+
t.pgettext("month", "Jul"), t.pgettext("month", "Aug"), t.pgettext("month", "Sep"),
|
|
49
|
+
t.pgettext("month", "Oct"), t.pgettext("month", "Nov"), t.pgettext("month", "Dec")] # fmt: skip
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def weekday_names(language: Language) -> list[str]:
|
|
53
|
+
"""The weekdays' short names, Monday first, from the catalog."""
|
|
54
|
+
t = language.translations
|
|
55
|
+
return [t.pgettext("weekday", "Mon"), t.pgettext("weekday", "Tue"), t.pgettext("weekday", "Wed"),
|
|
56
|
+
t.pgettext("weekday", "Thu"), t.pgettext("weekday", "Fri"), t.pgettext("weekday", "Sat"),
|
|
57
|
+
t.pgettext("weekday", "Sun")] # fmt: skip
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def weekday_full_names(language: Language) -> list[str]:
|
|
61
|
+
"""The weekdays' names, Monday first, from the catalog."""
|
|
62
|
+
t = language.translations
|
|
63
|
+
return [t.gettext("Monday"), t.gettext("Tuesday"), t.gettext("Wednesday"), t.gettext("Thursday"),
|
|
64
|
+
t.gettext("Friday"), t.gettext("Saturday"), t.gettext("Sunday")] # fmt: skip
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def script_json(document: Mapping[str, object]) -> str:
|
|
68
|
+
"""The document as JSON safe inside `<script type="application/json">`: no `<`, `>` or `&` left to end it."""
|
|
69
|
+
text = json.dumps(document, ensure_ascii=False, separators=(",", ":"))
|
|
70
|
+
return "".join(ESCAPES.get(character, character) for character in text)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _labels(language: Language) -> dict[str, object]:
|
|
74
|
+
t = language.translations
|
|
75
|
+
|
|
76
|
+
plural = plural_templates
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"months": month_names(language),
|
|
80
|
+
"commits": plural(lambda n: t.ngettext("%(count)s commit", "%(count)s commits", n)),
|
|
81
|
+
"lines": plural(lambda n: t.ngettext("%(count)s line", "%(count)s lines", n)),
|
|
82
|
+
"commitsSince": plural(
|
|
83
|
+
lambda n: t.ngettext("%(count)s commit since %(name)s", "%(count)s commits since %(name)s", n)
|
|
84
|
+
),
|
|
85
|
+
"daysAfter": plural(lambda n: t.ngettext("%(count)s day after it", "%(count)s days after it", n)),
|
|
86
|
+
"perMonth": t.gettext("Commits per month"),
|
|
87
|
+
"perWeek": t.gettext("Commits per week"),
|
|
88
|
+
"perDay": t.gettext("Commits per day"),
|
|
89
|
+
"weekOf": _template(t.gettext("Week of %(date)s")),
|
|
90
|
+
"window": _template(t.gettext("From %(start)s to %(end)s")),
|
|
91
|
+
"firstRelease": t.gettext("The first release"),
|
|
92
|
+
"arrived": _template(t.gettext("Arrived: %(name)s")),
|
|
93
|
+
"left": _template(t.gettext("Left: %(name)s")),
|
|
94
|
+
"watched": _template(t.gettext("Watched by Codetrail since %(date)s")),
|
|
95
|
+
"noReleases": t.gettext("No releases in this window."),
|
|
96
|
+
"noDeps": t.gettext("No dependency changes in this window."),
|
|
97
|
+
"notRead": t.gettext("not read"),
|
|
98
|
+
"area": t.gettext("Guide area"),
|
|
99
|
+
"folder": t.gettext("Folder"),
|
|
100
|
+
"file": t.gettext("File"),
|
|
101
|
+
"mostlyInferred": t.gettext("mostly inferred rationale"),
|
|
102
|
+
"openArea": t.gettext("Open the area page"),
|
|
103
|
+
"openFile": t.gettext("Open the file"),
|
|
104
|
+
"noFiles": t.gettext("No file in it changed in the commits read."),
|
|
105
|
+
"releases": t.gettext("Releases"),
|
|
106
|
+
"dependencies": t.gettext("Dependencies"),
|
|
107
|
+
"wholeHistory": t.gettext("Whole history"),
|
|
108
|
+
"plotHelp": t.gettext("Use the left and right arrow keys to read each bar."),
|
|
109
|
+
"calendarHelp": t.gettext(
|
|
110
|
+
"Commits per day over the last 53 weeks; the numbers by month are in the timeline's table."
|
|
111
|
+
),
|
|
112
|
+
"noAreas": t.gettext("Areas are counted once your assistant has written the guide's area pages."),
|
|
113
|
+
"noCode": t.gettext("No code to draw yet."),
|
|
114
|
+
"weekdays": weekday_names(language),
|
|
115
|
+
} # fmt: skip
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _day(seconds: int) -> str:
|
|
119
|
+
"""The UTC day of a time, or "" for one Python's dates can't hold (past the year 9999)."""
|
|
120
|
+
try:
|
|
121
|
+
return datetime.fromtimestamp(seconds, UTC).date().isoformat()
|
|
122
|
+
except ValueError, OverflowError, OSError:
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _top(files: list[HotSpot], keep: Callable[[str], bool]) -> list[list[object]]:
|
|
127
|
+
return [[spot.name, spot.count] for spot in files if keep(spot.name)][:LISTED_FILES]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _heat(churn: Churn) -> dict[str, Any]:
|
|
131
|
+
def in_folder(folder: str) -> Callable[[str], bool]:
|
|
132
|
+
return lambda path: folder_of(path, churn.folder_depth) == folder
|
|
133
|
+
|
|
134
|
+
def in_scope(scope: tuple[str, ...]) -> Callable[[str], bool]:
|
|
135
|
+
prefixes = [part.strip("/") for part in scope]
|
|
136
|
+
return lambda path: any(not part or path == part or path.startswith(part + "/") for part in prefixes)
|
|
137
|
+
|
|
138
|
+
largest_areas = sorted(churn.areas, key=lambda spot: (-spot.lines, spot.page.title))[: churn.tile_cap]
|
|
139
|
+
areas = [
|
|
140
|
+
{"name": spot.page.title, "path": spot.page.id, "href": "/pages/" + quote(spot.page.id), "lines": spot.lines,
|
|
141
|
+
"commits": spot.commits, "documented": spot.documented, "inferred": spot.inferred,
|
|
142
|
+
"mostlyInferred": spot.inferred > spot.documented * 2, "files": _top(churn.files, in_scope(spot.scope))}
|
|
143
|
+
for spot in largest_areas
|
|
144
|
+
] # fmt: skip
|
|
145
|
+
folders = [{"name": tile.name, "path": tile.path, "href": None, "lines": tile.lines, "commits": tile.commits,
|
|
146
|
+
"files": _top(churn.files, in_folder(tile.path))} for tile in churn.folder_tiles] # fmt: skip
|
|
147
|
+
files = [{"name": tile.name, "path": tile.path, "href": "/source/" + quote(tile.path), "lines": tile.lines,
|
|
148
|
+
"commits": tile.commits} for tile in churn.file_tiles] # fmt: skip
|
|
149
|
+
# A tile with no lines of code has no size to draw: it is left out and counted, like the tiles past the cap.
|
|
150
|
+
levels: dict[str, list[dict[str, Any]]] = {"areas": areas, "folders": folders, "files": files}
|
|
151
|
+
left_out = dict(churn.left_out)
|
|
152
|
+
for level, tiles in levels.items():
|
|
153
|
+
drawn = [tile for tile in tiles if tile["lines"]]
|
|
154
|
+
left_out[level] = left_out.get(level, 0) + len(tiles) - len(drawn)
|
|
155
|
+
levels[level] = drawn
|
|
156
|
+
return {**levels, "leftOut": left_out}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def chart_document(report: RepositoryReport, language: Language, today: date) -> dict[str, Any]:
|
|
160
|
+
"""Everything the page's script draws: the timeline, the calendar, the heat map and the area-by-month grid."""
|
|
161
|
+
activity, releases, churn, inventory = report.activity, report.releases, report.churn, report.inventory
|
|
162
|
+
days = None
|
|
163
|
+
if activity is not None and activity.days_start is not None:
|
|
164
|
+
days = {"start": activity.days_start.isoformat(), "counts": list(activity.days)}
|
|
165
|
+
release_list = None
|
|
166
|
+
if releases is not None:
|
|
167
|
+
release_list = [{"name": tag.name, "date": _day(tag.date), "commit": tag.commit[:7], "notes": tag.message}
|
|
168
|
+
for tag in sorted(releases.tags, key=lambda tag: (tag.date, tag.name))] # fmt: skip
|
|
169
|
+
deps = [{"name": change.package.split(":", 1)[-1], "sign": sign, "date": change.snapshot.taken_at[:10]}
|
|
170
|
+
for sign, changes in (("+", inventory.arrived), ("-", inventory.left)) for change in changes] # fmt: skip
|
|
171
|
+
grid = None
|
|
172
|
+
if churn is not None:
|
|
173
|
+
titles = {spot.page.id: spot.page.title for spot in churn.areas}
|
|
174
|
+
rows = [{"name": titles.get(page_id, page_id), "cells": list(cells)} for page_id, cells in churn.area_months]
|
|
175
|
+
grid = {"months": list(churn.months), "rows": rows}
|
|
176
|
+
return {
|
|
177
|
+
"lang": language.code,
|
|
178
|
+
"today": today.isoformat(),
|
|
179
|
+
"days": days,
|
|
180
|
+
"releases": release_list,
|
|
181
|
+
"watchStart": inventory.since.taken_at[:10] if inventory.since else None,
|
|
182
|
+
"deps": deps,
|
|
183
|
+
"heat": _heat(churn) if churn is not None else None,
|
|
184
|
+
"grid": grid,
|
|
185
|
+
"labels": _labels(language),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def months_table(activity: Activity | None) -> list[tuple[str, int]]:
|
|
190
|
+
"""Commits per month ("YYYY-MM"), oldest first, from the commits per day."""
|
|
191
|
+
if activity is None or activity.days_start is None:
|
|
192
|
+
return []
|
|
193
|
+
months: dict[str, int] = {}
|
|
194
|
+
for offset, count in enumerate(activity.days):
|
|
195
|
+
key = (activity.days_start + timedelta(days=offset)).strftime("%Y-%m")
|
|
196
|
+
months[key] = months.get(key, 0) + count
|
|
197
|
+
return list(months.items())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def bar_heights(values: Sequence[float]) -> list[int]:
|
|
201
|
+
"""Each value as a percent of the highest, for the bars the page draws on the server."""
|
|
202
|
+
highest = max(values, default=0)
|
|
203
|
+
return [round(value / highest * 100) if highest else 0 for value in values]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def ramp_step(count: int, highest: int) -> int:
|
|
207
|
+
"""The step of the five-step ramp a count falls in: 0 for nothing, then quarters of the highest."""
|
|
208
|
+
if count <= 0 or highest <= 0:
|
|
209
|
+
return 0
|
|
210
|
+
return min(4, 1 + (count * 4 - 1) // highest)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def language_segments(languages: Sequence[LanguageSize], keep: int = 4) -> list[tuple[str, int, int, int]]:
|
|
214
|
+
"""The largest languages by lines with their color slot and rounded percent, then the rest as Other."""
|
|
215
|
+
total = sum(size.lines for size in languages)
|
|
216
|
+
segments = [(size.language, size.lines, slot) for slot, size in enumerate(languages[:keep], start=1)]
|
|
217
|
+
rest = sum(size.lines for size in languages[keep:])
|
|
218
|
+
if rest:
|
|
219
|
+
segments.append(("Other", rest, keep + 1))
|
|
220
|
+
return [(name, lines, slot, round(lines / total * 100) if total else 0) for name, lines, slot in segments]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def busiest_weekday(activity: Activity | None) -> int | None:
|
|
224
|
+
"""The weekday (0 for Monday) with the most commits, or None without any."""
|
|
225
|
+
if activity is None or activity.days_start is None or not any(activity.days):
|
|
226
|
+
return None
|
|
227
|
+
totals = [0] * 7
|
|
228
|
+
for offset, count in enumerate(activity.days):
|
|
229
|
+
totals[(activity.days_start + timedelta(days=offset)).weekday()] += count
|
|
230
|
+
return totals.index(max(totals))
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The page's security model (design section 7.4).
|
|
2
|
+
|
|
3
|
+
- Only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>` are served (DNS rebinding).
|
|
4
|
+
- Every route but `/login` and `/static/` needs the session cookie, set once from a single-use login code that
|
|
5
|
+
`codetrail serve` prints; the code expires.
|
|
6
|
+
- Every write (any method but GET and HEAD) needs an Origin equal to the served origin and the per-session token in
|
|
7
|
+
the `X-Codetrail-Token` header (cross-site request forgery).
|
|
8
|
+
- A request a browser marks as coming from another site or another port (`Sec-Fetch-Site`) is refused.
|
|
9
|
+
- Every response, refusals included, carries the security headers and `Cache-Control: no-store`, unless its route
|
|
10
|
+
sets its own; static files carry `no-cache`, so the browser revalidates them.
|
|
11
|
+
- The worker that runs D2's WebAssembly build alone gets another Content-Security-Policy: its layout evaluates
|
|
12
|
+
JavaScript, so it may, but it can import nothing but Go's loader and the ELK layout, reach nothing but its wasm
|
|
13
|
+
file, and has no DOM (ADRs 0016, 0018).
|
|
14
|
+
Sessions live in memory and end with the process.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hmac
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from http.cookies import SimpleCookie
|
|
24
|
+
|
|
25
|
+
from starlette.responses import PlainTextResponse, RedirectResponse, Response
|
|
26
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
27
|
+
|
|
28
|
+
SESSION_COOKIE = "codetrail_session"
|
|
29
|
+
TOKEN_HEADER = "x-codetrail-token" # noqa: S105 - a header name, not a secret
|
|
30
|
+
CONTENT_SECURITY_POLICY = (
|
|
31
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
|
32
|
+
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
|
33
|
+
)
|
|
34
|
+
D2_WORKER = "/static/js/d2-worker.js"
|
|
35
|
+
SECURITY_HEADERS = [
|
|
36
|
+
(b"content-security-policy", CONTENT_SECURITY_POLICY.encode()),
|
|
37
|
+
(b"x-content-type-options", b"nosniff"),
|
|
38
|
+
(b"referrer-policy", b"no-referrer"),
|
|
39
|
+
(b"x-frame-options", b"DENY"),
|
|
40
|
+
(b"cross-origin-resource-policy", b"same-origin"),
|
|
41
|
+
]
|
|
42
|
+
READ_METHODS = {"GET", "HEAD"}
|
|
43
|
+
OPEN_PATHS = ("/login", "/static/")
|
|
44
|
+
# Reads that return JSON for the page's script also need the token, as defence in depth (design 16.3).
|
|
45
|
+
TOKEN_READS = ("/search/results", "/bridge/answers")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SessionState:
|
|
49
|
+
"""The one reader's session: a single-use login code, the session cookie's value and the write token."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self, login_code_ttl_seconds: int, clock: Callable[[], float] = time.monotonic, session_minutes: int = 480
|
|
53
|
+
) -> None:
|
|
54
|
+
self._ttl = login_code_ttl_seconds
|
|
55
|
+
self._clock = clock
|
|
56
|
+
# Browsers send a cookie to every port of a host, so a session must not outlive its use (design 7.4).
|
|
57
|
+
self._session_expires = clock() + session_minutes * 60
|
|
58
|
+
self._code: str | None = None
|
|
59
|
+
self._code_expires = 0.0
|
|
60
|
+
self.session_id = secrets.token_urlsafe(32)
|
|
61
|
+
self.token = secrets.token_urlsafe(32)
|
|
62
|
+
|
|
63
|
+
def issue_login_code(self) -> str:
|
|
64
|
+
self._code = secrets.token_urlsafe(32)
|
|
65
|
+
self._code_expires = self._clock() + self._ttl
|
|
66
|
+
return self._code
|
|
67
|
+
|
|
68
|
+
def redeem(self, code: str) -> bool:
|
|
69
|
+
"""True once for the issued code, before it expires; the code can't be used again."""
|
|
70
|
+
expected = self._code
|
|
71
|
+
if expected is None or not _equal(code, expected):
|
|
72
|
+
return False
|
|
73
|
+
self._code = None
|
|
74
|
+
return self._clock() <= self._code_expires
|
|
75
|
+
|
|
76
|
+
def is_session(self, cookie: str | None) -> bool:
|
|
77
|
+
return cookie is not None and _equal(cookie, self.session_id) and self._clock() <= self._session_expires
|
|
78
|
+
|
|
79
|
+
def is_token(self, token: str | None) -> bool:
|
|
80
|
+
return token is not None and _equal(token, self.token)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _equal(given: str, expected: str) -> bool:
|
|
84
|
+
return hmac.compare_digest(given.encode(), expected.encode())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def login_response(session: SessionState, code: str) -> Response:
|
|
88
|
+
if not session.redeem(code):
|
|
89
|
+
return PlainTextResponse("This sign-in link has expired or was already used. Run codetrail serve again.", 403)
|
|
90
|
+
response = RedirectResponse("/", status_code=303)
|
|
91
|
+
response.set_cookie(SESSION_COOKIE, session.session_id, httponly=True, samesite="strict", path="/")
|
|
92
|
+
return response
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SecurityMiddleware:
|
|
96
|
+
def __init__(self, app: ASGIApp, session: SessionState, port: int) -> None:
|
|
97
|
+
self.app = app
|
|
98
|
+
self.session = session
|
|
99
|
+
self.hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
|
|
100
|
+
|
|
101
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
102
|
+
if scope["type"] == "lifespan":
|
|
103
|
+
await self.app(scope, receive, send)
|
|
104
|
+
return
|
|
105
|
+
if scope["type"] != "http":
|
|
106
|
+
return # no websockets
|
|
107
|
+
refusal = self._refusal(scope)
|
|
108
|
+
# Static files are revalidated on every load (cheap on 127.0.0.1), so after an upgrade the browser never mixes
|
|
109
|
+
# last version's styles or script with this version's pages. Everything else may carry the write token or the
|
|
110
|
+
# guide, so the browser never stores it, unless its route set its own policy (ASVS 14.3.2).
|
|
111
|
+
is_static = scope["path"].startswith("/static/")
|
|
112
|
+
replaced = {name for name, _ in SECURITY_HEADERS} | ({b"cache-control"} if is_static else set())
|
|
113
|
+
security_headers = SECURITY_HEADERS
|
|
114
|
+
if scope["path"] == D2_WORKER and refusal is None: # the Host was checked, so it can name the wasm's origin
|
|
115
|
+
policy = _worker_policy(_headers(scope)["host"]).encode()
|
|
116
|
+
security_headers = [(name, policy if name == b"content-security-policy" else value)
|
|
117
|
+
for name, value in SECURITY_HEADERS] # fmt: skip
|
|
118
|
+
|
|
119
|
+
async def send_with_headers(message: Message) -> None:
|
|
120
|
+
if message["type"] == "http.response.start":
|
|
121
|
+
headers = [(name, value) for name, value in message.get("headers", []) if name not in replaced]
|
|
122
|
+
if not any(name == b"cache-control" for name, _ in headers):
|
|
123
|
+
headers.append((b"cache-control", b"no-cache" if is_static else b"no-store"))
|
|
124
|
+
message["headers"] = headers + security_headers
|
|
125
|
+
await send(message)
|
|
126
|
+
|
|
127
|
+
if refusal is not None:
|
|
128
|
+
await refusal(scope, receive, send_with_headers)
|
|
129
|
+
return
|
|
130
|
+
await self.app(scope, receive, send_with_headers)
|
|
131
|
+
|
|
132
|
+
def _refusal(self, scope: Scope) -> Response | None:
|
|
133
|
+
headers = _headers(scope)
|
|
134
|
+
host = headers.get("host", "")
|
|
135
|
+
if host not in self.hosts:
|
|
136
|
+
return PlainTextResponse("This page is only served at 127.0.0.1 or localhost.", 400)
|
|
137
|
+
path = scope["path"]
|
|
138
|
+
if path.startswith("/static/"):
|
|
139
|
+
return None
|
|
140
|
+
# Browsers say where a request came from. Only the page itself or the address bar may reach Codetrail, so
|
|
141
|
+
# another site, or a server on another 127.0.0.1 port (same-site), can't time or drive it (ASVS 3.5.8).
|
|
142
|
+
if headers.get("sec-fetch-site", "same-origin") not in ("same-origin", "none"):
|
|
143
|
+
return PlainTextResponse("This request didn't come from Codetrail's page.", 403)
|
|
144
|
+
if path == "/login":
|
|
145
|
+
return None
|
|
146
|
+
cookies = SimpleCookie(headers.get("cookie", ""))
|
|
147
|
+
cookie = cookies[SESSION_COOKIE].value if SESSION_COOKIE in cookies else None
|
|
148
|
+
if not self.session.is_session(cookie):
|
|
149
|
+
return PlainTextResponse("Sign in with the link codetrail serve printed.", 403)
|
|
150
|
+
if path in TOKEN_READS and not self.session.is_token(headers.get(TOKEN_HEADER)):
|
|
151
|
+
return PlainTextResponse("This request didn't come from Codetrail's page.", 403)
|
|
152
|
+
writes = scope["method"] not in READ_METHODS
|
|
153
|
+
if writes and (
|
|
154
|
+
headers.get("origin") != f"http://{host}" or not self.session.is_token(headers.get(TOKEN_HEADER))
|
|
155
|
+
):
|
|
156
|
+
return PlainTextResponse("This request didn't come from Codetrail's page.", 403)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _headers(scope: Scope) -> dict[str, str]:
|
|
161
|
+
return {name.decode("latin-1"): value.decode("latin-1") for name, value in scope["headers"]}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _worker_policy(host: str) -> str:
|
|
165
|
+
"""Eval, but only Go's loader and the ELK layout to import, only the wasm file to fetch and no nested workers (which
|
|
166
|
+
would otherwise fall back to script-src), at the Host already checked."""
|
|
167
|
+
vendor = f"http://{host}/static/vendor/d2"
|
|
168
|
+
return (
|
|
169
|
+
f"default-src 'none'; script-src {vendor}/wasm_exec.js {vendor}/elk.js 'unsafe-eval' 'wasm-unsafe-eval'; "
|
|
170
|
+
f"connect-src {vendor}/d2.wasm; worker-src 'none'"
|
|
171
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" role="img" aria-label="codetrail"><title>codetrail</title><rect width="32" height="32" rx="7" fill="#5b5bd6"/><path d="M7 10L13 16L7 22" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17" cy="22" r="2.5" fill="#fff" fill-opacity=".6"/><circle cx="21" cy="18" r="2.5" fill="#fff" fill-opacity=".85"/><circle cx="25" cy="14" r="2.5" fill="#fff"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" role="img" aria-label="codetrail"><title>codetrail</title><rect width="32" height="32" rx="7" fill="#5b5bd6"/><path d="M7 10.5L12.5 16L7 21.5" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="16.5" cy="21.5" r="2.25" fill="#fff" fill-opacity=".55"/><circle cx="20.5" cy="17.5" r="2.25" fill="#fff" fill-opacity=".8"/><circle cx="24.5" cy="13.5" r="2.25" fill="#fff"/></svg>
|