FossilRepo
Fix: strip emails from public views, fix branches active tab, add display_user filter
Commit
fcd8df3cf42f98b9e8fa39000e7625ef90a7512f84362975ab2eefa79efd1f5e
Parent
45192efa4fe1bfa…
25 files changed
+2
-2
+169
-133
+59
+17
+44
-28
+2
-1
+2
-1
+4
-3
+2
-1
+3
-2
+2
-1
+3
-2
+3
-2
+1
-1
+2
-1
+2
-1
+2
-1
+2
-1
+2
-1
+2
-1
+1
+2
-1
+3
-2
~
config/urls.py
~
core/__pycache__/sanitize.cpython-314.pyc
~
core/sanitize.py
+
core/url_validation.py
+
fossil/templatetags/__init__.py
+
fossil/templatetags/fossil_filters.py
~
fossil/views.py
~
templates/fossil/branch_list.html
~
templates/fossil/checkin_detail.html
~
templates/fossil/code_blame.html
~
templates/fossil/code_browser.html
~
templates/fossil/compare.html
~
templates/fossil/file_history.html
~
templates/fossil/forum_list.html
~
templates/fossil/forum_thread.html
~
templates/fossil/partials/timeline_entries.html
~
templates/fossil/repo_stats.html
~
templates/fossil/search.html
~
templates/fossil/tag_list.html
~
templates/fossil/technote_detail.html
~
templates/fossil/technote_list.html
~
templates/fossil/ticket_detail.html
~
templates/fossil/timeline.html
~
templates/fossil/wiki_page.html
~
templates/projects/project_detail.html
+2
-2
| --- config/urls.py | ||
| +++ config/urls.py | ||
| @@ -83,19 +83,19 @@ | ||
| 83 | 83 | |
| 84 | 84 | try: |
| 85 | 85 | with connection.cursor() as cursor: |
| 86 | 86 | cursor.execute("SELECT 1") |
| 87 | 87 | db_ok = True |
| 88 | - except Exception as e: | |
| 88 | + except Exception: | |
| 89 | 89 | return JsonResponse( |
| 90 | 90 | { |
| 91 | 91 | "service": "fossilrepo-django-htmx", |
| 92 | 92 | "version": settings.VERSION, |
| 93 | 93 | "status": "error", |
| 94 | 94 | "uptime": _uptime_str(), |
| 95 | 95 | "timestamp": datetime.now(UTC).isoformat(), |
| 96 | - "checks": {"database": "error", "detail": str(e)}, | |
| 96 | + "checks": {"database": "error"}, | |
| 97 | 97 | }, |
| 98 | 98 | status=503, |
| 99 | 99 | ) |
| 100 | 100 | |
| 101 | 101 | return JsonResponse( |
| 102 | 102 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -83,19 +83,19 @@ | |
| 83 | |
| 84 | try: |
| 85 | with connection.cursor() as cursor: |
| 86 | cursor.execute("SELECT 1") |
| 87 | db_ok = True |
| 88 | except Exception as e: |
| 89 | return JsonResponse( |
| 90 | { |
| 91 | "service": "fossilrepo-django-htmx", |
| 92 | "version": settings.VERSION, |
| 93 | "status": "error", |
| 94 | "uptime": _uptime_str(), |
| 95 | "timestamp": datetime.now(UTC).isoformat(), |
| 96 | "checks": {"database": "error", "detail": str(e)}, |
| 97 | }, |
| 98 | status=503, |
| 99 | ) |
| 100 | |
| 101 | return JsonResponse( |
| 102 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -83,19 +83,19 @@ | |
| 83 | |
| 84 | try: |
| 85 | with connection.cursor() as cursor: |
| 86 | cursor.execute("SELECT 1") |
| 87 | db_ok = True |
| 88 | except Exception: |
| 89 | return JsonResponse( |
| 90 | { |
| 91 | "service": "fossilrepo-django-htmx", |
| 92 | "version": settings.VERSION, |
| 93 | "status": "error", |
| 94 | "uptime": _uptime_str(), |
| 95 | "timestamp": datetime.now(UTC).isoformat(), |
| 96 | "checks": {"database": "error"}, |
| 97 | }, |
| 98 | status=503, |
| 99 | ) |
| 100 | |
| 101 | return JsonResponse( |
| 102 |
| --- core/__pycache__/sanitize.cpython-314.pyc | ||
| +++ core/__pycache__/sanitize.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- core/__pycache__/sanitize.cpython-314.pyc | |
| +++ core/__pycache__/sanitize.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- core/__pycache__/sanitize.cpython-314.pyc | |
| +++ core/__pycache__/sanitize.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
+169
-133
| --- core/sanitize.py | ||
| +++ core/sanitize.py | ||
| @@ -1,135 +1,171 @@ | ||
| 1 | 1 | """HTML sanitization for user-generated content. |
| 2 | 2 | |
| 3 | -Strips dangerous tags (<script>, <style>, <iframe>, etc.), event handlers (on*), | |
| 4 | -and dangerous URL protocols (javascript:, data:, vbscript:) while preserving | |
| 5 | -safe formatting tags used by Fossil wiki, Markdown, and Pikchr diagrams. | |
| 6 | -""" | |
| 7 | - | |
| 8 | -import re | |
| 9 | - | |
| 10 | -# Tags that are safe to render -- covers Markdown/wiki formatting and Pikchr SVG | |
| 11 | -ALLOWED_TAGS = { | |
| 12 | - "a", | |
| 13 | - "abbr", | |
| 14 | - "acronym", | |
| 15 | - "b", | |
| 16 | - "blockquote", | |
| 17 | - "br", | |
| 18 | - "code", | |
| 19 | - "dd", | |
| 20 | - "del", | |
| 21 | - "details", | |
| 22 | - "div", | |
| 23 | - "dl", | |
| 24 | - "dt", | |
| 25 | - "em", | |
| 26 | - "h1", | |
| 27 | - "h2", | |
| 28 | - "h3", | |
| 29 | - "h4", | |
| 30 | - "h5", | |
| 31 | - "h6", | |
| 32 | - "hr", | |
| 33 | - "i", | |
| 34 | - "img", | |
| 35 | - "ins", | |
| 36 | - "kbd", | |
| 37 | - "li", | |
| 38 | - "mark", | |
| 39 | - "ol", | |
| 40 | - "p", | |
| 41 | - "pre", | |
| 42 | - "q", | |
| 43 | - "s", | |
| 44 | - "samp", | |
| 45 | - "small", | |
| 46 | - "span", | |
| 47 | - "strong", | |
| 48 | - "sub", | |
| 49 | - "summary", | |
| 50 | - "sup", | |
| 51 | - "table", | |
| 52 | - "tbody", | |
| 53 | - "td", | |
| 54 | - "tfoot", | |
| 55 | - "th", | |
| 56 | - "thead", | |
| 57 | - "tr", | |
| 58 | - "tt", | |
| 59 | - "u", | |
| 60 | - "ul", | |
| 61 | - "var", | |
| 62 | - # SVG elements for Pikchr diagrams | |
| 63 | - "svg", | |
| 64 | - "path", | |
| 65 | - "circle", | |
| 66 | - "rect", | |
| 67 | - "line", | |
| 68 | - "polyline", | |
| 69 | - "polygon", | |
| 70 | - "g", | |
| 71 | - "text", | |
| 72 | - "defs", | |
| 73 | - "use", | |
| 74 | - "symbol", | |
| 75 | -} | |
| 76 | - | |
| 77 | -# Tags whose entire content (not just the tag) must be removed | |
| 78 | -_DANGEROUS_CONTENT_TAGS = re.compile( | |
| 79 | - r"<\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*>.*?</\s*\1\s*>", | |
| 80 | - re.IGNORECASE | re.DOTALL, | |
| 81 | -) | |
| 82 | - | |
| 83 | -# Self-closing / unclosed dangerous tags | |
| 84 | -_DANGEROUS_SELF_CLOSING = re.compile( | |
| 85 | - r"<\s*/?\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*/?\s*>", | |
| 86 | - re.IGNORECASE, | |
| 87 | -) | |
| 88 | - | |
| 89 | -# Event handler attributes (onclick, onload, onerror, etc.) | |
| 90 | -_EVENT_HANDLERS = re.compile( | |
| 91 | - r"""\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)""", | |
| 92 | - re.IGNORECASE, | |
| 93 | -) | |
| 94 | - | |
| 95 | -# Dangerous protocols in href/src values | |
| 96 | -_DANGEROUS_PROTOCOL = re.compile(r"^\s*(?:javascript|vbscript|data):", re.IGNORECASE) | |
| 97 | - | |
| 98 | -# href="..." and src="..." attribute pattern | |
| 99 | -_URL_ATTR = re.compile(r"""(href|src)\s*=\s*(["']?)([^"'>\s]+)\2""", re.IGNORECASE) | |
| 100 | - | |
| 101 | - | |
| 102 | -def _clean_url_attr(match: re.Match) -> str: | |
| 103 | - """Replace dangerous protocol URLs with a safe '#' anchor.""" | |
| 104 | - attr_name = match.group(1) | |
| 105 | - quote = match.group(2) or "" | |
| 106 | - url = match.group(3) | |
| 107 | - if _DANGEROUS_PROTOCOL.match(url): | |
| 108 | - return f"{attr_name}={quote}#{quote}" | |
| 109 | - return match.group(0) | |
| 110 | - | |
| 111 | - | |
| 112 | -def sanitize_html(html: str) -> str: | |
| 113 | - """Remove dangerous HTML tags and attributes while preserving safe formatting. | |
| 114 | - | |
| 115 | - Strips <script>, <style>, <iframe>, <object>, <embed>, <form>, <base>, | |
| 116 | - <meta>, <link> tags and their content. Removes event handler attributes | |
| 117 | - (on*) and replaces dangerous URL protocols (javascript:, data:, vbscript:) | |
| 118 | - in href/src with '#'. | |
| 119 | - """ | |
| 120 | - if not html: | |
| 121 | - return html | |
| 122 | - | |
| 123 | - # 1. Remove dangerous tags WITH their content (e.g. <script>...</script>) | |
| 124 | - html = _DANGEROUS_CONTENT_TAGS.sub("", html) | |
| 125 | - | |
| 126 | - # 2. Remove any remaining self-closing or orphaned dangerous tags | |
| 127 | - html = _DANGEROUS_SELF_CLOSING.sub("", html) | |
| 128 | - | |
| 129 | - # 3. Remove event handler attributes (onclick, onload, onerror, etc.) | |
| 130 | - html = _EVENT_HANDLERS.sub("", html) | |
| 131 | - | |
| 132 | - # 4. Neutralize dangerous URL protocols in href and src attributes | |
| 133 | - html = _URL_ATTR.sub(_clean_url_attr, html) | |
| 134 | - | |
| 135 | - return html | |
| 3 | +Uses Python's html.parser to properly parse HTML and enforce an allowlist | |
| 4 | +of tags and attributes. Strips everything not explicitly allowed. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import html | |
| 8 | +import re | |
| 9 | +from html.parser import HTMLParser | |
| 10 | +from io import StringIO | |
| 11 | + | |
| 12 | +# Tags that are safe to render — covers Markdown/wiki formatting and Pikchr SVG | |
| 13 | +ALLOWED_TAGS = frozenset({ | |
| 14 | + "a", "abbr", "acronym", "b", "blockquote", "br", "code", "dd", "del", | |
| 15 | + "details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", | |
| 16 | + "hr", "i", "img", "ins", "kbd", "li", "mark", "ol", "p", "pre", "q", | |
| 17 | + "s", "samp", "small", "span", "strong", "sub", "summary", "sup", | |
| 18 | + "table", "tbody", "td", "tfoot", "th", "thead", "tr", "tt", "u", "ul", "var", | |
| 19 | + # SVG elements for Pikchr diagrams | |
| 20 | + "svg", "path", "circle", "rect", "line", "polyline", "polygon", | |
| 21 | + "g", "text", "defs", "use", "symbol", | |
| 22 | +}) | |
| 23 | + | |
| 24 | +# Attributes allowed per tag (all others stripped) | |
| 25 | +ALLOWED_ATTRS = { | |
| 26 | + "a": {"href", "title", "class", "id", "name"}, | |
| 27 | + "img": {"src", "alt", "title", "width", "height", "class"}, | |
| 28 | + "div": {"class", "id"}, | |
| 29 | + "span": {"class", "id"}, | |
| 30 | + "td": {"class", "colspan", "rowspan"}, | |
| 31 | + "th": {"class", "colspan", "rowspan"}, | |
| 32 | + "table": {"class"}, | |
| 33 | + "code": {"class"}, | |
| 34 | + "pre": {"class"}, | |
| 35 | + "ol": {"class", "start", "type"}, | |
| 36 | + "ul": {"class"}, | |
| 37 | + "li": {"class", "value"}, | |
| 38 | + "details": {"open", "class"}, | |
| 39 | + "summary": {"class"}, | |
| 40 | + "h1": {"id", "class"}, "h2": {"id", "class"}, "h3": {"id", "class"}, | |
| 41 | + "h4": {"id", "class"}, "h5": {"id", "class"}, "h6": {"id", "class"}, | |
| 42 | + # SVG attributes | |
| 43 | + "svg": {"viewbox", "width", "height", "class", "xmlns", "fill", "stroke"}, | |
| 44 | + "path": {"d", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "class"}, | |
| 45 | + "circle": {"cx", "cy", "r", "fill", "stroke", "class"}, | |
| 46 | + "rect": {"x", "y", "width", "height", "fill", "stroke", "rx", "ry", "class"}, | |
| 47 | + "line": {"x1", "y1", "x2", "y2", "stroke", "stroke-width", "class"}, | |
| 48 | + "text": {"x", "y", "font-size", "text-anchor", "fill", "class"}, | |
| 49 | + "g": {"transform", "class"}, | |
| 50 | + "polyline": {"points", "fill", "stroke", "class"}, | |
| 51 | + "polygon": {"points", "fill", "stroke", "class"}, | |
| 52 | +} | |
| 53 | + | |
| 54 | +# Global attributes allowed on any tag | |
| 55 | +GLOBAL_ATTRS = frozenset() | |
| 56 | + | |
| 57 | +# Protocols allowed in href/src — everything else is stripped | |
| 58 | +ALLOWED_PROTOCOLS = frozenset({"http", "https", "mailto", "ftp", "#", ""}) | |
| 59 | + | |
| 60 | +# Regex to detect protocol in a URL (after HTML entity decoding) | |
| 61 | +_PROTOCOL_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*):.*", re.DOTALL) | |
| 62 | + | |
| 63 | + | |
| 64 | +def _is_safe_url(url: str) -> bool: | |
| 65 | + """Check if a URL uses a safe protocol. Decodes HTML entities first.""" | |
| 66 | + decoded = html.unescape(url).strip() | |
| 67 | + m = _PROTOCOL_RE.match(decoded) | |
| 68 | + if m: | |
| 69 | + return m.group(1).lower() in ALLOWED_PROTOCOLS | |
| 70 | + # Relative URLs (no protocol) are safe | |
| 71 | + return True | |
| 72 | + | |
| 73 | + | |
| 74 | +class _SanitizingParser(HTMLParser): | |
| 75 | + """HTML parser that only emits allowed tags/attributes.""" | |
| 76 | + | |
| 77 | + def __init__(self): | |
| 78 | + super().__init__(convert_charrefs=False) | |
| 79 | + self.out = StringIO() | |
| 80 | + self._skip_depth = 0 # Track depth inside dangerous tags to skip content | |
| 81 | + | |
| 82 | + def handle_starttag(self, tag, attrs): | |
| 83 | + tag_lower = tag.lower() | |
| 84 | + | |
| 85 | + # Dangerous content tags — skip tag AND all content inside | |
| 86 | + if tag_lower in ("script", "style", "iframe", "object", "embed", "form", "base", "meta", "link"): | |
| 87 | + self._skip_depth += 1 | |
| 88 | + return | |
| 89 | + | |
| 90 | + if self._skip_depth > 0: | |
| 91 | + return | |
| 92 | + | |
| 93 | + if tag_lower not in ALLOWED_TAGS: | |
| 94 | + return # Strip unknown tag (but keep its text content) | |
| 95 | + | |
| 96 | + # Filter attributes | |
| 97 | + allowed = ALLOWED_ATTRS.get(tag_lower, set()) | GLOBAL_ATTRS | |
| 98 | + safe_attrs = [] | |
| 99 | + for name, value in attrs: | |
| 100 | + name_lower = name.lower() | |
| 101 | + # Block event handlers | |
| 102 | + if name_lower.startswith("on"): | |
| 103 | + continue | |
| 104 | + if name_lower not in allowed: | |
| 105 | + continue | |
| 106 | + # Sanitize URLs in href/src | |
| 107 | + if name_lower in ("href", "src") and value and not _is_safe_url(value): | |
| 108 | + value = "#" | |
| 109 | + safe_attrs.append((name, value)) | |
| 110 | + | |
| 111 | + # Build the tag | |
| 112 | + attr_str = "" | |
| 113 | + for name, value in safe_attrs: | |
| 114 | + if value is None: | |
| 115 | + attr_str += f" {name}" | |
| 116 | + else: | |
| 117 | + escaped = value.replace("&", "&").replace('"', """) | |
| 118 | + attr_str += f' {name}="{escaped}"' | |
| 119 | + | |
| 120 | + self.out.write(f"<{tag}{attr_str}>") | |
| 121 | + | |
| 122 | + def handle_endtag(self, tag): | |
| 123 | + tag_lower = tag.lower() | |
| 124 | + if tag_lower in ("script", "style", "iframe", "object", "embed", "form", "base", "meta", "link"): | |
| 125 | + self._skip_depth = max(0, self._skip_depth - 1) | |
| 126 | + return | |
| 127 | + if self._skip_depth > 0: | |
| 128 | + return | |
| 129 | + if tag_lower in ALLOWED_TAGS: | |
| 130 | + self.out.write(f"</{tag}>") | |
| 131 | + | |
| 132 | + def handle_data(self, data): | |
| 133 | + if self._skip_depth > 0: | |
| 134 | + return # Inside a dangerous tag — skip content | |
| 135 | + self.out.write(data) | |
| 136 | + | |
| 137 | + def handle_entityref(self, name): | |
| 138 | + if self._skip_depth > 0: | |
| 139 | + return | |
| 140 | + self.out.write(f"&{name};") | |
| 141 | + | |
| 142 | + def handle_charref(self, name): | |
| 143 | + if self._skip_depth > 0: | |
| 144 | + return | |
| 145 | + self.out.write(f"&#{name};") | |
| 146 | + | |
| 147 | + def handle_comment(self, data): | |
| 148 | + pass # Strip all HTML comments | |
| 149 | + | |
| 150 | + def handle_startendtag(self, tag, attrs): | |
| 151 | + # Self-closing tags like <br/>, <img/> | |
| 152 | + self.handle_starttag(tag, attrs) | |
| 153 | + | |
| 154 | + | |
| 155 | +def sanitize_html(html_content: str) -> str: | |
| 156 | + """Sanitize HTML using a proper parser with tag/attribute allowlists. | |
| 157 | + | |
| 158 | + - Only tags in ALLOWED_TAGS are kept (all others stripped, text preserved) | |
| 159 | + - Only attributes in ALLOWED_ATTRS per tag are kept | |
| 160 | + - Event handlers (on*) are always stripped | |
| 161 | + - URLs in href/src are checked after HTML entity decoding — javascript:, | |
| 162 | + data:, vbscript: (including entity-encoded variants) are neutralized | |
| 163 | + - Content inside <script>, <style>, <iframe>, etc. is completely removed | |
| 164 | + - HTML comments are stripped | |
| 165 | + """ | |
| 166 | + if not html_content: | |
| 167 | + return html_content | |
| 168 | + | |
| 169 | + parser = _SanitizingParser() | |
| 170 | + parser.feed(html_content) | |
| 171 | + return parser.out.getvalue() | |
| 136 | 172 | |
| 137 | 173 | ADDED core/url_validation.py |
| 138 | 174 | ADDED fossil/templatetags/__init__.py |
| 139 | 175 | ADDED fossil/templatetags/fossil_filters.py |
| --- core/sanitize.py | |
| +++ core/sanitize.py | |
| @@ -1,135 +1,171 @@ | |
| 1 | """HTML sanitization for user-generated content. |
| 2 | |
| 3 | Strips dangerous tags (<script>, <style>, <iframe>, etc.), event handlers (on*), |
| 4 | and dangerous URL protocols (javascript:, data:, vbscript:) while preserving |
| 5 | safe formatting tags used by Fossil wiki, Markdown, and Pikchr diagrams. |
| 6 | """ |
| 7 | |
| 8 | import re |
| 9 | |
| 10 | # Tags that are safe to render -- covers Markdown/wiki formatting and Pikchr SVG |
| 11 | ALLOWED_TAGS = { |
| 12 | "a", |
| 13 | "abbr", |
| 14 | "acronym", |
| 15 | "b", |
| 16 | "blockquote", |
| 17 | "br", |
| 18 | "code", |
| 19 | "dd", |
| 20 | "del", |
| 21 | "details", |
| 22 | "div", |
| 23 | "dl", |
| 24 | "dt", |
| 25 | "em", |
| 26 | "h1", |
| 27 | "h2", |
| 28 | "h3", |
| 29 | "h4", |
| 30 | "h5", |
| 31 | "h6", |
| 32 | "hr", |
| 33 | "i", |
| 34 | "img", |
| 35 | "ins", |
| 36 | "kbd", |
| 37 | "li", |
| 38 | "mark", |
| 39 | "ol", |
| 40 | "p", |
| 41 | "pre", |
| 42 | "q", |
| 43 | "s", |
| 44 | "samp", |
| 45 | "small", |
| 46 | "span", |
| 47 | "strong", |
| 48 | "sub", |
| 49 | "summary", |
| 50 | "sup", |
| 51 | "table", |
| 52 | "tbody", |
| 53 | "td", |
| 54 | "tfoot", |
| 55 | "th", |
| 56 | "thead", |
| 57 | "tr", |
| 58 | "tt", |
| 59 | "u", |
| 60 | "ul", |
| 61 | "var", |
| 62 | # SVG elements for Pikchr diagrams |
| 63 | "svg", |
| 64 | "path", |
| 65 | "circle", |
| 66 | "rect", |
| 67 | "line", |
| 68 | "polyline", |
| 69 | "polygon", |
| 70 | "g", |
| 71 | "text", |
| 72 | "defs", |
| 73 | "use", |
| 74 | "symbol", |
| 75 | } |
| 76 | |
| 77 | # Tags whose entire content (not just the tag) must be removed |
| 78 | _DANGEROUS_CONTENT_TAGS = re.compile( |
| 79 | r"<\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*>.*?</\s*\1\s*>", |
| 80 | re.IGNORECASE | re.DOTALL, |
| 81 | ) |
| 82 | |
| 83 | # Self-closing / unclosed dangerous tags |
| 84 | _DANGEROUS_SELF_CLOSING = re.compile( |
| 85 | r"<\s*/?\s*(script|style|iframe|object|embed|form|base|meta|link)\b[^>]*/?\s*>", |
| 86 | re.IGNORECASE, |
| 87 | ) |
| 88 | |
| 89 | # Event handler attributes (onclick, onload, onerror, etc.) |
| 90 | _EVENT_HANDLERS = re.compile( |
| 91 | r"""\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)""", |
| 92 | re.IGNORECASE, |
| 93 | ) |
| 94 | |
| 95 | # Dangerous protocols in href/src values |
| 96 | _DANGEROUS_PROTOCOL = re.compile(r"^\s*(?:javascript|vbscript|data):", re.IGNORECASE) |
| 97 | |
| 98 | # href="..." and src="..." attribute pattern |
| 99 | _URL_ATTR = re.compile(r"""(href|src)\s*=\s*(["']?)([^"'>\s]+)\2""", re.IGNORECASE) |
| 100 | |
| 101 | |
| 102 | def _clean_url_attr(match: re.Match) -> str: |
| 103 | """Replace dangerous protocol URLs with a safe '#' anchor.""" |
| 104 | attr_name = match.group(1) |
| 105 | quote = match.group(2) or "" |
| 106 | url = match.group(3) |
| 107 | if _DANGEROUS_PROTOCOL.match(url): |
| 108 | return f"{attr_name}={quote}#{quote}" |
| 109 | return match.group(0) |
| 110 | |
| 111 | |
| 112 | def sanitize_html(html: str) -> str: |
| 113 | """Remove dangerous HTML tags and attributes while preserving safe formatting. |
| 114 | |
| 115 | Strips <script>, <style>, <iframe>, <object>, <embed>, <form>, <base>, |
| 116 | <meta>, <link> tags and their content. Removes event handler attributes |
| 117 | (on*) and replaces dangerous URL protocols (javascript:, data:, vbscript:) |
| 118 | in href/src with '#'. |
| 119 | """ |
| 120 | if not html: |
| 121 | return html |
| 122 | |
| 123 | # 1. Remove dangerous tags WITH their content (e.g. <script>...</script>) |
| 124 | html = _DANGEROUS_CONTENT_TAGS.sub("", html) |
| 125 | |
| 126 | # 2. Remove any remaining self-closing or orphaned dangerous tags |
| 127 | html = _DANGEROUS_SELF_CLOSING.sub("", html) |
| 128 | |
| 129 | # 3. Remove event handler attributes (onclick, onload, onerror, etc.) |
| 130 | html = _EVENT_HANDLERS.sub("", html) |
| 131 | |
| 132 | # 4. Neutralize dangerous URL protocols in href and src attributes |
| 133 | html = _URL_ATTR.sub(_clean_url_attr, html) |
| 134 | |
| 135 | return html |
| 136 | |
| 137 | DDED core/url_validation.py |
| 138 | DDED fossil/templatetags/__init__.py |
| 139 | DDED fossil/templatetags/fossil_filters.py |
| --- core/sanitize.py | |
| +++ core/sanitize.py | |
| @@ -1,135 +1,171 @@ | |
| 1 | """HTML sanitization for user-generated content. |
| 2 | |
| 3 | Uses Python's html.parser to properly parse HTML and enforce an allowlist |
| 4 | of tags and attributes. Strips everything not explicitly allowed. |
| 5 | """ |
| 6 | |
| 7 | import html |
| 8 | import re |
| 9 | from html.parser import HTMLParser |
| 10 | from io import StringIO |
| 11 | |
| 12 | # Tags that are safe to render — covers Markdown/wiki formatting and Pikchr SVG |
| 13 | ALLOWED_TAGS = frozenset({ |
| 14 | "a", "abbr", "acronym", "b", "blockquote", "br", "code", "dd", "del", |
| 15 | "details", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6", |
| 16 | "hr", "i", "img", "ins", "kbd", "li", "mark", "ol", "p", "pre", "q", |
| 17 | "s", "samp", "small", "span", "strong", "sub", "summary", "sup", |
| 18 | "table", "tbody", "td", "tfoot", "th", "thead", "tr", "tt", "u", "ul", "var", |
| 19 | # SVG elements for Pikchr diagrams |
| 20 | "svg", "path", "circle", "rect", "line", "polyline", "polygon", |
| 21 | "g", "text", "defs", "use", "symbol", |
| 22 | }) |
| 23 | |
| 24 | # Attributes allowed per tag (all others stripped) |
| 25 | ALLOWED_ATTRS = { |
| 26 | "a": {"href", "title", "class", "id", "name"}, |
| 27 | "img": {"src", "alt", "title", "width", "height", "class"}, |
| 28 | "div": {"class", "id"}, |
| 29 | "span": {"class", "id"}, |
| 30 | "td": {"class", "colspan", "rowspan"}, |
| 31 | "th": {"class", "colspan", "rowspan"}, |
| 32 | "table": {"class"}, |
| 33 | "code": {"class"}, |
| 34 | "pre": {"class"}, |
| 35 | "ol": {"class", "start", "type"}, |
| 36 | "ul": {"class"}, |
| 37 | "li": {"class", "value"}, |
| 38 | "details": {"open", "class"}, |
| 39 | "summary": {"class"}, |
| 40 | "h1": {"id", "class"}, "h2": {"id", "class"}, "h3": {"id", "class"}, |
| 41 | "h4": {"id", "class"}, "h5": {"id", "class"}, "h6": {"id", "class"}, |
| 42 | # SVG attributes |
| 43 | "svg": {"viewbox", "width", "height", "class", "xmlns", "fill", "stroke"}, |
| 44 | "path": {"d", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "class"}, |
| 45 | "circle": {"cx", "cy", "r", "fill", "stroke", "class"}, |
| 46 | "rect": {"x", "y", "width", "height", "fill", "stroke", "rx", "ry", "class"}, |
| 47 | "line": {"x1", "y1", "x2", "y2", "stroke", "stroke-width", "class"}, |
| 48 | "text": {"x", "y", "font-size", "text-anchor", "fill", "class"}, |
| 49 | "g": {"transform", "class"}, |
| 50 | "polyline": {"points", "fill", "stroke", "class"}, |
| 51 | "polygon": {"points", "fill", "stroke", "class"}, |
| 52 | } |
| 53 | |
| 54 | # Global attributes allowed on any tag |
| 55 | GLOBAL_ATTRS = frozenset() |
| 56 | |
| 57 | # Protocols allowed in href/src — everything else is stripped |
| 58 | ALLOWED_PROTOCOLS = frozenset({"http", "https", "mailto", "ftp", "#", ""}) |
| 59 | |
| 60 | # Regex to detect protocol in a URL (after HTML entity decoding) |
| 61 | _PROTOCOL_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*):.*", re.DOTALL) |
| 62 | |
| 63 | |
| 64 | def _is_safe_url(url: str) -> bool: |
| 65 | """Check if a URL uses a safe protocol. Decodes HTML entities first.""" |
| 66 | decoded = html.unescape(url).strip() |
| 67 | m = _PROTOCOL_RE.match(decoded) |
| 68 | if m: |
| 69 | return m.group(1).lower() in ALLOWED_PROTOCOLS |
| 70 | # Relative URLs (no protocol) are safe |
| 71 | return True |
| 72 | |
| 73 | |
| 74 | class _SanitizingParser(HTMLParser): |
| 75 | """HTML parser that only emits allowed tags/attributes.""" |
| 76 | |
| 77 | def __init__(self): |
| 78 | super().__init__(convert_charrefs=False) |
| 79 | self.out = StringIO() |
| 80 | self._skip_depth = 0 # Track depth inside dangerous tags to skip content |
| 81 | |
| 82 | def handle_starttag(self, tag, attrs): |
| 83 | tag_lower = tag.lower() |
| 84 | |
| 85 | # Dangerous content tags — skip tag AND all content inside |
| 86 | if tag_lower in ("script", "style", "iframe", "object", "embed", "form", "base", "meta", "link"): |
| 87 | self._skip_depth += 1 |
| 88 | return |
| 89 | |
| 90 | if self._skip_depth > 0: |
| 91 | return |
| 92 | |
| 93 | if tag_lower not in ALLOWED_TAGS: |
| 94 | return # Strip unknown tag (but keep its text content) |
| 95 | |
| 96 | # Filter attributes |
| 97 | allowed = ALLOWED_ATTRS.get(tag_lower, set()) | GLOBAL_ATTRS |
| 98 | safe_attrs = [] |
| 99 | for name, value in attrs: |
| 100 | name_lower = name.lower() |
| 101 | # Block event handlers |
| 102 | if name_lower.startswith("on"): |
| 103 | continue |
| 104 | if name_lower not in allowed: |
| 105 | continue |
| 106 | # Sanitize URLs in href/src |
| 107 | if name_lower in ("href", "src") and value and not _is_safe_url(value): |
| 108 | value = "#" |
| 109 | safe_attrs.append((name, value)) |
| 110 | |
| 111 | # Build the tag |
| 112 | attr_str = "" |
| 113 | for name, value in safe_attrs: |
| 114 | if value is None: |
| 115 | attr_str += f" {name}" |
| 116 | else: |
| 117 | escaped = value.replace("&", "&").replace('"', """) |
| 118 | attr_str += f' {name}="{escaped}"' |
| 119 | |
| 120 | self.out.write(f"<{tag}{attr_str}>") |
| 121 | |
| 122 | def handle_endtag(self, tag): |
| 123 | tag_lower = tag.lower() |
| 124 | if tag_lower in ("script", "style", "iframe", "object", "embed", "form", "base", "meta", "link"): |
| 125 | self._skip_depth = max(0, self._skip_depth - 1) |
| 126 | return |
| 127 | if self._skip_depth > 0: |
| 128 | return |
| 129 | if tag_lower in ALLOWED_TAGS: |
| 130 | self.out.write(f"</{tag}>") |
| 131 | |
| 132 | def handle_data(self, data): |
| 133 | if self._skip_depth > 0: |
| 134 | return # Inside a dangerous tag — skip content |
| 135 | self.out.write(data) |
| 136 | |
| 137 | def handle_entityref(self, name): |
| 138 | if self._skip_depth > 0: |
| 139 | return |
| 140 | self.out.write(f"&{name};") |
| 141 | |
| 142 | def handle_charref(self, name): |
| 143 | if self._skip_depth > 0: |
| 144 | return |
| 145 | self.out.write(f"&#{name};") |
| 146 | |
| 147 | def handle_comment(self, data): |
| 148 | pass # Strip all HTML comments |
| 149 | |
| 150 | def handle_startendtag(self, tag, attrs): |
| 151 | # Self-closing tags like <br/>, <img/> |
| 152 | self.handle_starttag(tag, attrs) |
| 153 | |
| 154 | |
| 155 | def sanitize_html(html_content: str) -> str: |
| 156 | """Sanitize HTML using a proper parser with tag/attribute allowlists. |
| 157 | |
| 158 | - Only tags in ALLOWED_TAGS are kept (all others stripped, text preserved) |
| 159 | - Only attributes in ALLOWED_ATTRS per tag are kept |
| 160 | - Event handlers (on*) are always stripped |
| 161 | - URLs in href/src are checked after HTML entity decoding — javascript:, |
| 162 | data:, vbscript: (including entity-encoded variants) are neutralized |
| 163 | - Content inside <script>, <style>, <iframe>, etc. is completely removed |
| 164 | - HTML comments are stripped |
| 165 | """ |
| 166 | if not html_content: |
| 167 | return html_content |
| 168 | |
| 169 | parser = _SanitizingParser() |
| 170 | parser.feed(html_content) |
| 171 | return parser.out.getvalue() |
| 172 | |
| 173 | DDED core/url_validation.py |
| 174 | DDED fossil/templatetags/__init__.py |
| 175 | DDED fossil/templatetags/fossil_filters.py |
+59
| --- a/core/url_validation.py | ||
| +++ b/core/url_validation.py | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +"""URL validation for outbound requests (webhooks, etc.).""" | |
| 2 | + | |
| 3 | +import ipaddress | |
| 4 | +import socket | |
| 5 | +from urllib.parse import urlparse | |
| 6 | + | |
| 7 | + | |
| 8 | +def is_safe_webhook_url(url: str) -> tuple[bool, str]: | |
| 9 | + """Validate a webhook URL is safe for server-side requests. | |
| 10 | + | |
| 11 | + Blocks: | |
| 12 | + - Non-HTTP(S) protocols | |
| 13 | + - Localhost and loopback addresses | |
| 14 | + - Private/internal IP ranges (10.x, 172.16-31.x, 192.168.x, etc.) | |
| 15 | + - Link-local addresses | |
| 16 | + - AWS metadata endpoint (169.254.169.254) | |
| 17 | + | |
| 18 | + Returns (is_safe, error_message). | |
| 19 | + """ | |
| 20 | + if not url: | |
| 21 | + return False, "URL is required." | |
| 22 | + | |
| 23 | + parsed = urlparse(url) | |
| 24 | + | |
| 25 | + if parsed.scheme not in ("http", "https"): | |
| 26 | + return False, "Only http:// and https:// URLs are allowed." | |
| 27 | + | |
| 28 | + hostname = parsed.hostname | |
| 29 | + if not hostname: | |
| 30 | + return False, "URL must include a hostname." | |
| 31 | + | |
| 32 | + # Block obvious localhost variants | |
| 33 | + if hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): | |
| 34 | + return False, "Localhost URLs are not allowed." | |
| 35 | + | |
| 36 | + # Resolve hostname and check the IP | |
| 37 | + try: | |
| 38 | + addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) | |
| 39 | + except socket.gaierror: | |
| 40 | + return False, f"Could not resolve hostname: {hostname}" | |
| 41 | + | |
| 42 | + for _family, _type, _proto, _canonname, sockaddr in addr_info: | |
| 43 | + ip_str = sockaddr[0] | |
| 44 | + try: | |
| 45 | + ip = ipaddress.ip_address(ip_str) | |
| 46 | + except ValueError: | |
| 47 | + continue | |
| 48 | + | |
| 49 | + if ip.is_loopback: | |
| 50 | + return False, "Loopback addresses are not allowed." | |
| 51 | + if ip.is_private: | |
| 52 | + return False, "Private/internal IP addresses are not allowed." | |
| 53 | + if ip.is_link_local: | |
| 54 | + return False, "Link-local addresses are not allowed." | |
| 55 | + if ip.is_reserved: | |
| 56 | + return False, "Reserved IP addresses are not allowed." | |
| 57 | + # AWS metadata endpoint | |
| 58 | + if ip_str == "169.254.169.254": | |
| 59 | + return False, "Cloud metadata endpoints are not allo |
| --- a/core/url_validation.py | |
| +++ b/core/url_validation.py | |
| @@ -0,0 +1,59 @@ | |
| --- a/core/url_validation.py | |
| +++ b/core/url_validation.py | |
| @@ -0,0 +1,59 @@ | |
| 1 | """URL validation for outbound requests (webhooks, etc.).""" |
| 2 | |
| 3 | import ipaddress |
| 4 | import socket |
| 5 | from urllib.parse import urlparse |
| 6 | |
| 7 | |
| 8 | def is_safe_webhook_url(url: str) -> tuple[bool, str]: |
| 9 | """Validate a webhook URL is safe for server-side requests. |
| 10 | |
| 11 | Blocks: |
| 12 | - Non-HTTP(S) protocols |
| 13 | - Localhost and loopback addresses |
| 14 | - Private/internal IP ranges (10.x, 172.16-31.x, 192.168.x, etc.) |
| 15 | - Link-local addresses |
| 16 | - AWS metadata endpoint (169.254.169.254) |
| 17 | |
| 18 | Returns (is_safe, error_message). |
| 19 | """ |
| 20 | if not url: |
| 21 | return False, "URL is required." |
| 22 | |
| 23 | parsed = urlparse(url) |
| 24 | |
| 25 | if parsed.scheme not in ("http", "https"): |
| 26 | return False, "Only http:// and https:// URLs are allowed." |
| 27 | |
| 28 | hostname = parsed.hostname |
| 29 | if not hostname: |
| 30 | return False, "URL must include a hostname." |
| 31 | |
| 32 | # Block obvious localhost variants |
| 33 | if hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): |
| 34 | return False, "Localhost URLs are not allowed." |
| 35 | |
| 36 | # Resolve hostname and check the IP |
| 37 | try: |
| 38 | addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) |
| 39 | except socket.gaierror: |
| 40 | return False, f"Could not resolve hostname: {hostname}" |
| 41 | |
| 42 | for _family, _type, _proto, _canonname, sockaddr in addr_info: |
| 43 | ip_str = sockaddr[0] |
| 44 | try: |
| 45 | ip = ipaddress.ip_address(ip_str) |
| 46 | except ValueError: |
| 47 | continue |
| 48 | |
| 49 | if ip.is_loopback: |
| 50 | return False, "Loopback addresses are not allowed." |
| 51 | if ip.is_private: |
| 52 | return False, "Private/internal IP addresses are not allowed." |
| 53 | if ip.is_link_local: |
| 54 | return False, "Link-local addresses are not allowed." |
| 55 | if ip.is_reserved: |
| 56 | return False, "Reserved IP addresses are not allowed." |
| 57 | # AWS metadata endpoint |
| 58 | if ip_str == "169.254.169.254": |
| 59 | return False, "Cloud metadata endpoints are not allo |
No diff available
| --- a/fossil/templatetags/fossil_filters.py | ||
| +++ b/fossil/templatetags/fossil_filters.py | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +from django import template | |
| 2 | + | |
| 3 | +register = template.Library() | |
| 4 | + | |
| 5 | + | |
| 6 | +@register.filter | |
| 7 | +def display_user(value): | |
| 8 | + """Convert email-style Fossil usernames to display names. | |
| 9 | + | |
| 10 | + [email protected] -> lmata | |
| 11 | + ragelink -> ragelink | |
| 12 | + """ | |
| 13 | + if not value: | |
| 14 | + return "" | |
| 15 | + if "@" in str(value): | |
| 16 | + return str(value).split("@")[0] | |
| 17 | + return str(value) |
| --- a/fossil/templatetags/fossil_filters.py | |
| +++ b/fossil/templatetags/fossil_filters.py | |
| @@ -0,0 +1,17 @@ | |
| --- a/fossil/templatetags/fossil_filters.py | |
| +++ b/fossil/templatetags/fossil_filters.py | |
| @@ -0,0 +1,17 @@ | |
| 1 | from django import template |
| 2 | |
| 3 | register = template.Library() |
| 4 | |
| 5 | |
| 6 | @register.filter |
| 7 | def display_user(value): |
| 8 | """Convert email-style Fossil usernames to display names. |
| 9 | |
| 10 | [email protected] -> lmata |
| 11 | ragelink -> ragelink |
| 12 | """ |
| 13 | if not value: |
| 14 | return "" |
| 15 | if "@" in str(value): |
| 16 | return str(value).split("@")[0] |
| 17 | return str(value) |
+44
-28
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -1098,21 +1098,27 @@ | ||
| 1098 | 1098 | secret = request.POST.get("secret", "").strip() |
| 1099 | 1099 | events = request.POST.getlist("events") |
| 1100 | 1100 | is_active = request.POST.get("is_active") == "on" |
| 1101 | 1101 | |
| 1102 | 1102 | if url: |
| 1103 | - events_str = ",".join(events) if events else "all" | |
| 1104 | - Webhook.objects.create( | |
| 1105 | - repository=fossil_repo, | |
| 1106 | - url=url, | |
| 1107 | - secret=secret, | |
| 1108 | - events=events_str, | |
| 1109 | - is_active=is_active, | |
| 1110 | - created_by=request.user, | |
| 1111 | - ) | |
| 1112 | - messages.success(request, f"Webhook for {url} created.") | |
| 1113 | - return redirect("fossil:webhooks", slug=slug) | |
| 1103 | + from core.url_validation import is_safe_webhook_url | |
| 1104 | + | |
| 1105 | + is_safe, url_error = is_safe_webhook_url(url) | |
| 1106 | + if not is_safe: | |
| 1107 | + messages.error(request, f"Invalid webhook URL: {url_error}") | |
| 1108 | + else: | |
| 1109 | + events_str = ",".join(events) if events else "all" | |
| 1110 | + Webhook.objects.create( | |
| 1111 | + repository=fossil_repo, | |
| 1112 | + url=url, | |
| 1113 | + secret=secret, | |
| 1114 | + events=events_str, | |
| 1115 | + is_active=is_active, | |
| 1116 | + created_by=request.user, | |
| 1117 | + ) | |
| 1118 | + messages.success(request, "Webhook created.") | |
| 1119 | + return redirect("fossil:webhooks", slug=slug) | |
| 1114 | 1120 | |
| 1115 | 1121 | return render( |
| 1116 | 1122 | request, |
| 1117 | 1123 | "fossil/webhook_form.html", |
| 1118 | 1124 | { |
| @@ -1142,20 +1148,25 @@ | ||
| 1142 | 1148 | secret = request.POST.get("secret", "").strip() |
| 1143 | 1149 | events = request.POST.getlist("events") |
| 1144 | 1150 | is_active = request.POST.get("is_active") == "on" |
| 1145 | 1151 | |
| 1146 | 1152 | if url: |
| 1147 | - webhook.url = url | |
| 1148 | - # Only update secret if a new one was provided (don't blank it on edit) | |
| 1149 | - if secret: | |
| 1150 | - webhook.secret = secret | |
| 1151 | - webhook.events = ",".join(events) if events else "all" | |
| 1152 | - webhook.is_active = is_active | |
| 1153 | - webhook.updated_by = request.user | |
| 1154 | - webhook.save() | |
| 1155 | - messages.success(request, f"Webhook for {webhook.url} updated.") | |
| 1156 | - return redirect("fossil:webhooks", slug=slug) | |
| 1153 | + from core.url_validation import is_safe_webhook_url | |
| 1154 | + | |
| 1155 | + is_safe, url_error = is_safe_webhook_url(url) | |
| 1156 | + if not is_safe: | |
| 1157 | + messages.error(request, f"Invalid webhook URL: {url_error}") | |
| 1158 | + else: | |
| 1159 | + webhook.url = url | |
| 1160 | + if secret: | |
| 1161 | + webhook.secret = secret | |
| 1162 | + webhook.events = ",".join(events) if events else "all" | |
| 1163 | + webhook.is_active = is_active | |
| 1164 | + webhook.updated_by = request.user | |
| 1165 | + webhook.save() | |
| 1166 | + messages.success(request, "Webhook updated.") | |
| 1167 | + return redirect("fossil:webhooks", slug=slug) | |
| 1157 | 1168 | |
| 1158 | 1169 | return render( |
| 1159 | 1170 | request, |
| 1160 | 1171 | "fossil/webhook_form.html", |
| 1161 | 1172 | { |
| @@ -1832,24 +1843,29 @@ | ||
| 1832 | 1843 | if request.method == "GET": |
| 1833 | 1844 | if not can_read_project(request.user, project): |
| 1834 | 1845 | from django.core.exceptions import PermissionDenied |
| 1835 | 1846 | |
| 1836 | 1847 | raise PermissionDenied |
| 1848 | + import html as html_mod | |
| 1849 | + | |
| 1837 | 1850 | clone_url = request.build_absolute_uri() |
| 1838 | 1851 | is_public = project.visibility == "public" |
| 1839 | 1852 | auth_note = "" if is_public else "<p>Authentication is required.</p>" |
| 1840 | - html = ( | |
| 1841 | - f"<html><head><title>{project.name} — Fossil Sync</title></head>" | |
| 1853 | + safe_name = html_mod.escape(project.name) | |
| 1854 | + safe_slug = html_mod.escape(project.slug) | |
| 1855 | + safe_url = html_mod.escape(clone_url) | |
| 1856 | + response_html = ( | |
| 1857 | + f"<html><head><title>{safe_name} — Fossil Sync</title></head>" | |
| 1842 | 1858 | f"<body>" |
| 1843 | - f"<h1>{project.name}</h1>" | |
| 1844 | - f"<p>This is the Fossil sync endpoint for <strong>{project.name}</strong>.</p>" | |
| 1859 | + f"<h1>{safe_name}</h1>" | |
| 1860 | + f"<p>This is the Fossil sync endpoint for <strong>{safe_name}</strong>.</p>" | |
| 1845 | 1861 | f"<p>Clone with:</p>" |
| 1846 | - f"<pre>fossil clone {clone_url} {project.slug}.fossil</pre>" | |
| 1862 | + f"<pre>fossil clone {safe_url} {safe_slug}.fossil</pre>" | |
| 1847 | 1863 | f"{auth_note}" |
| 1848 | 1864 | f"</body></html>" |
| 1849 | 1865 | ) |
| 1850 | - return HttpResponse(html) | |
| 1866 | + return HttpResponse(response_html) | |
| 1851 | 1867 | |
| 1852 | 1868 | if request.method == "POST": |
| 1853 | 1869 | if not fossil_repo.exists_on_disk: |
| 1854 | 1870 | raise Http404("Repository file not found on disk.") |
| 1855 | 1871 | |
| @@ -2504,11 +2520,11 @@ | ||
| 2504 | 2520 | "branches": branches, |
| 2505 | 2521 | "search": search, |
| 2506 | 2522 | "pagination": pagination, |
| 2507 | 2523 | "per_page": per_page, |
| 2508 | 2524 | "per_page_options": PER_PAGE_OPTIONS, |
| 2509 | - "active_tab": "code", | |
| 2525 | + "active_tab": "branches", | |
| 2510 | 2526 | }, |
| 2511 | 2527 | ) |
| 2512 | 2528 | |
| 2513 | 2529 | |
| 2514 | 2530 | # --- Tags --- |
| 2515 | 2531 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1098,21 +1098,27 @@ | |
| 1098 | secret = request.POST.get("secret", "").strip() |
| 1099 | events = request.POST.getlist("events") |
| 1100 | is_active = request.POST.get("is_active") == "on" |
| 1101 | |
| 1102 | if url: |
| 1103 | events_str = ",".join(events) if events else "all" |
| 1104 | Webhook.objects.create( |
| 1105 | repository=fossil_repo, |
| 1106 | url=url, |
| 1107 | secret=secret, |
| 1108 | events=events_str, |
| 1109 | is_active=is_active, |
| 1110 | created_by=request.user, |
| 1111 | ) |
| 1112 | messages.success(request, f"Webhook for {url} created.") |
| 1113 | return redirect("fossil:webhooks", slug=slug) |
| 1114 | |
| 1115 | return render( |
| 1116 | request, |
| 1117 | "fossil/webhook_form.html", |
| 1118 | { |
| @@ -1142,20 +1148,25 @@ | |
| 1142 | secret = request.POST.get("secret", "").strip() |
| 1143 | events = request.POST.getlist("events") |
| 1144 | is_active = request.POST.get("is_active") == "on" |
| 1145 | |
| 1146 | if url: |
| 1147 | webhook.url = url |
| 1148 | # Only update secret if a new one was provided (don't blank it on edit) |
| 1149 | if secret: |
| 1150 | webhook.secret = secret |
| 1151 | webhook.events = ",".join(events) if events else "all" |
| 1152 | webhook.is_active = is_active |
| 1153 | webhook.updated_by = request.user |
| 1154 | webhook.save() |
| 1155 | messages.success(request, f"Webhook for {webhook.url} updated.") |
| 1156 | return redirect("fossil:webhooks", slug=slug) |
| 1157 | |
| 1158 | return render( |
| 1159 | request, |
| 1160 | "fossil/webhook_form.html", |
| 1161 | { |
| @@ -1832,24 +1843,29 @@ | |
| 1832 | if request.method == "GET": |
| 1833 | if not can_read_project(request.user, project): |
| 1834 | from django.core.exceptions import PermissionDenied |
| 1835 | |
| 1836 | raise PermissionDenied |
| 1837 | clone_url = request.build_absolute_uri() |
| 1838 | is_public = project.visibility == "public" |
| 1839 | auth_note = "" if is_public else "<p>Authentication is required.</p>" |
| 1840 | html = ( |
| 1841 | f"<html><head><title>{project.name} — Fossil Sync</title></head>" |
| 1842 | f"<body>" |
| 1843 | f"<h1>{project.name}</h1>" |
| 1844 | f"<p>This is the Fossil sync endpoint for <strong>{project.name}</strong>.</p>" |
| 1845 | f"<p>Clone with:</p>" |
| 1846 | f"<pre>fossil clone {clone_url} {project.slug}.fossil</pre>" |
| 1847 | f"{auth_note}" |
| 1848 | f"</body></html>" |
| 1849 | ) |
| 1850 | return HttpResponse(html) |
| 1851 | |
| 1852 | if request.method == "POST": |
| 1853 | if not fossil_repo.exists_on_disk: |
| 1854 | raise Http404("Repository file not found on disk.") |
| 1855 | |
| @@ -2504,11 +2520,11 @@ | |
| 2504 | "branches": branches, |
| 2505 | "search": search, |
| 2506 | "pagination": pagination, |
| 2507 | "per_page": per_page, |
| 2508 | "per_page_options": PER_PAGE_OPTIONS, |
| 2509 | "active_tab": "code", |
| 2510 | }, |
| 2511 | ) |
| 2512 | |
| 2513 | |
| 2514 | # --- Tags --- |
| 2515 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1098,21 +1098,27 @@ | |
| 1098 | secret = request.POST.get("secret", "").strip() |
| 1099 | events = request.POST.getlist("events") |
| 1100 | is_active = request.POST.get("is_active") == "on" |
| 1101 | |
| 1102 | if url: |
| 1103 | from core.url_validation import is_safe_webhook_url |
| 1104 | |
| 1105 | is_safe, url_error = is_safe_webhook_url(url) |
| 1106 | if not is_safe: |
| 1107 | messages.error(request, f"Invalid webhook URL: {url_error}") |
| 1108 | else: |
| 1109 | events_str = ",".join(events) if events else "all" |
| 1110 | Webhook.objects.create( |
| 1111 | repository=fossil_repo, |
| 1112 | url=url, |
| 1113 | secret=secret, |
| 1114 | events=events_str, |
| 1115 | is_active=is_active, |
| 1116 | created_by=request.user, |
| 1117 | ) |
| 1118 | messages.success(request, "Webhook created.") |
| 1119 | return redirect("fossil:webhooks", slug=slug) |
| 1120 | |
| 1121 | return render( |
| 1122 | request, |
| 1123 | "fossil/webhook_form.html", |
| 1124 | { |
| @@ -1142,20 +1148,25 @@ | |
| 1148 | secret = request.POST.get("secret", "").strip() |
| 1149 | events = request.POST.getlist("events") |
| 1150 | is_active = request.POST.get("is_active") == "on" |
| 1151 | |
| 1152 | if url: |
| 1153 | from core.url_validation import is_safe_webhook_url |
| 1154 | |
| 1155 | is_safe, url_error = is_safe_webhook_url(url) |
| 1156 | if not is_safe: |
| 1157 | messages.error(request, f"Invalid webhook URL: {url_error}") |
| 1158 | else: |
| 1159 | webhook.url = url |
| 1160 | if secret: |
| 1161 | webhook.secret = secret |
| 1162 | webhook.events = ",".join(events) if events else "all" |
| 1163 | webhook.is_active = is_active |
| 1164 | webhook.updated_by = request.user |
| 1165 | webhook.save() |
| 1166 | messages.success(request, "Webhook updated.") |
| 1167 | return redirect("fossil:webhooks", slug=slug) |
| 1168 | |
| 1169 | return render( |
| 1170 | request, |
| 1171 | "fossil/webhook_form.html", |
| 1172 | { |
| @@ -1832,24 +1843,29 @@ | |
| 1843 | if request.method == "GET": |
| 1844 | if not can_read_project(request.user, project): |
| 1845 | from django.core.exceptions import PermissionDenied |
| 1846 | |
| 1847 | raise PermissionDenied |
| 1848 | import html as html_mod |
| 1849 | |
| 1850 | clone_url = request.build_absolute_uri() |
| 1851 | is_public = project.visibility == "public" |
| 1852 | auth_note = "" if is_public else "<p>Authentication is required.</p>" |
| 1853 | safe_name = html_mod.escape(project.name) |
| 1854 | safe_slug = html_mod.escape(project.slug) |
| 1855 | safe_url = html_mod.escape(clone_url) |
| 1856 | response_html = ( |
| 1857 | f"<html><head><title>{safe_name} — Fossil Sync</title></head>" |
| 1858 | f"<body>" |
| 1859 | f"<h1>{safe_name}</h1>" |
| 1860 | f"<p>This is the Fossil sync endpoint for <strong>{safe_name}</strong>.</p>" |
| 1861 | f"<p>Clone with:</p>" |
| 1862 | f"<pre>fossil clone {safe_url} {safe_slug}.fossil</pre>" |
| 1863 | f"{auth_note}" |
| 1864 | f"</body></html>" |
| 1865 | ) |
| 1866 | return HttpResponse(response_html) |
| 1867 | |
| 1868 | if request.method == "POST": |
| 1869 | if not fossil_repo.exists_on_disk: |
| 1870 | raise Http404("Repository file not found on disk.") |
| 1871 | |
| @@ -2504,11 +2520,11 @@ | |
| 2520 | "branches": branches, |
| 2521 | "search": search, |
| 2522 | "pagination": pagination, |
| 2523 | "per_page": per_page, |
| 2524 | "per_page_options": PER_PAGE_OPTIONS, |
| 2525 | "active_tab": "branches", |
| 2526 | }, |
| 2527 | ) |
| 2528 | |
| 2529 | |
| 2530 | # --- Tags --- |
| 2531 |
| --- templates/fossil/branch_list.html | ||
| +++ templates/fossil/branch_list.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Branches — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -44,11 +45,11 @@ | ||
| 44 | 45 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=branch.last_uuid %}" class="text-gray-300 hover:text-brand-light truncate block max-w-sm"> |
| 45 | 46 | <code class="text-xs font-mono text-brand-light">{{ branch.last_uuid|truncatechars:10 }}</code> |
| 46 | 47 | </a> |
| 47 | 48 | </td> |
| 48 | 49 | <td class="px-4 py-3"> |
| 49 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=branch.last_user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ branch.last_user }}</a> | |
| 50 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=branch.last_user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ branch.last_user|display_user }}</a> | |
| 50 | 51 | </td> |
| 51 | 52 | <td class="px-4 py-3 text-sm text-gray-400 text-right">{{ branch.checkin_count }}</td> |
| 52 | 53 | <td class="px-4 py-3 text-sm text-gray-500 text-right">{{ branch.last_checkin|timesince }} ago</td> |
| 53 | 54 | </tr> |
| 54 | 55 | {% empty %} |
| 55 | 56 |
| --- templates/fossil/branch_list.html | |
| +++ templates/fossil/branch_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Branches — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -44,11 +45,11 @@ | |
| 44 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=branch.last_uuid %}" class="text-gray-300 hover:text-brand-light truncate block max-w-sm"> |
| 45 | <code class="text-xs font-mono text-brand-light">{{ branch.last_uuid|truncatechars:10 }}</code> |
| 46 | </a> |
| 47 | </td> |
| 48 | <td class="px-4 py-3"> |
| 49 | <a href="{% url 'fossil:user_activity' slug=project.slug username=branch.last_user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ branch.last_user }}</a> |
| 50 | </td> |
| 51 | <td class="px-4 py-3 text-sm text-gray-400 text-right">{{ branch.checkin_count }}</td> |
| 52 | <td class="px-4 py-3 text-sm text-gray-500 text-right">{{ branch.last_checkin|timesince }} ago</td> |
| 53 | </tr> |
| 54 | {% empty %} |
| 55 |
| --- templates/fossil/branch_list.html | |
| +++ templates/fossil/branch_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Branches — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -44,11 +45,11 @@ | |
| 45 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=branch.last_uuid %}" class="text-gray-300 hover:text-brand-light truncate block max-w-sm"> |
| 46 | <code class="text-xs font-mono text-brand-light">{{ branch.last_uuid|truncatechars:10 }}</code> |
| 47 | </a> |
| 48 | </td> |
| 49 | <td class="px-4 py-3"> |
| 50 | <a href="{% url 'fossil:user_activity' slug=project.slug username=branch.last_user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ branch.last_user|display_user }}</a> |
| 51 | </td> |
| 52 | <td class="px-4 py-3 text-sm text-gray-400 text-right">{{ branch.checkin_count }}</td> |
| 53 | <td class="px-4 py-3 text-sm text-gray-500 text-right">{{ branch.last_checkin|timesince }} ago</td> |
| 54 | </tr> |
| 55 | {% empty %} |
| 56 |
| --- templates/fossil/checkin_detail.html | ||
| +++ templates/fossil/checkin_detail.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block extra_head %} |
| 5 | 6 | <style> |
| 6 | 7 | .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -57,11 +58,11 @@ | ||
| 57 | 58 | <!-- Commit header --> |
| 58 | 59 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 59 | 60 | <div class="px-6 py-5"> |
| 60 | 61 | <p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p> |
| 61 | 62 | <div class="mt-3 flex items-center gap-4 flex-wrap text-sm"> |
| 62 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=checkin.user %}" class="font-medium text-gray-200 hover:text-brand-light">{{ checkin.user }}</a> | |
| 63 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=checkin.user %}" class="font-medium text-gray-200 hover:text-brand-light">{{ checkin.user|display_user }}</a> | |
| 63 | 64 | <span class="text-gray-500">{{ checkin.timestamp|date:"Y-m-d H:i" }}</span> |
| 64 | 65 | {% if checkin.branch %} |
| 65 | 66 | <span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-2 py-0.5 text-xs text-brand-light"> |
| 66 | 67 | {{ checkin.branch }} |
| 67 | 68 | </span> |
| 68 | 69 |
| --- templates/fossil/checkin_detail.html | |
| +++ templates/fossil/checkin_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block extra_head %} |
| 5 | <style> |
| 6 | .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -57,11 +58,11 @@ | |
| 57 | <!-- Commit header --> |
| 58 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 59 | <div class="px-6 py-5"> |
| 60 | <p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p> |
| 61 | <div class="mt-3 flex items-center gap-4 flex-wrap text-sm"> |
| 62 | <a href="{% url 'fossil:user_activity' slug=project.slug username=checkin.user %}" class="font-medium text-gray-200 hover:text-brand-light">{{ checkin.user }}</a> |
| 63 | <span class="text-gray-500">{{ checkin.timestamp|date:"Y-m-d H:i" }}</span> |
| 64 | {% if checkin.branch %} |
| 65 | <span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-2 py-0.5 text-xs text-brand-light"> |
| 66 | {{ checkin.branch }} |
| 67 | </span> |
| 68 |
| --- templates/fossil/checkin_detail.html | |
| +++ templates/fossil/checkin_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block extra_head %} |
| 6 | <style> |
| 7 | .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -57,11 +58,11 @@ | |
| 58 | <!-- Commit header --> |
| 59 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 60 | <div class="px-6 py-5"> |
| 61 | <p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p> |
| 62 | <div class="mt-3 flex items-center gap-4 flex-wrap text-sm"> |
| 63 | <a href="{% url 'fossil:user_activity' slug=project.slug username=checkin.user %}" class="font-medium text-gray-200 hover:text-brand-light">{{ checkin.user|display_user }}</a> |
| 64 | <span class="text-gray-500">{{ checkin.timestamp|date:"Y-m-d H:i" }}</span> |
| 65 | {% if checkin.branch %} |
| 66 | <span class="inline-flex items-center rounded-md bg-brand/10 border border-brand/20 px-2 py-0.5 text-xs text-brand-light"> |
| 67 | {{ checkin.branch }} |
| 68 | </span> |
| 69 |
+4
-3
| --- templates/fossil/code_blame.html | ||
| +++ templates/fossil/code_blame.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Blame: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block extra_head %} |
| 5 | 6 | <style> |
| 6 | 7 | .blame-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -42,15 +43,15 @@ | ||
| 42 | 43 | {% if blame_lines %} |
| 43 | 44 | <table class="blame-table"> |
| 44 | 45 | <tbody> |
| 45 | 46 | {% for bl in blame_lines %} |
| 46 | 47 | <tr class="blame-row"> |
| 47 | - <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}"> | |
| 48 | + <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user|display_user }}"> | |
| 48 | 49 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=bl.uuid %}" style="color: inherit;">{{ bl.uuid|truncatechars:8 }}</a> |
| 49 | 50 | </td> |
| 50 | - <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}"> | |
| 51 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}" style="color: inherit;">{{ bl.user }}</a> | |
| 51 | + <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user|display_user }}"> | |
| 52 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}" style="color: inherit;">{{ bl.user|display_user }}</a> | |
| 52 | 53 | </td> |
| 53 | 54 | <td class="blame-num">{{ forloop.counter }}</td> |
| 54 | 55 | <td class="blame-code">{{ bl.text }}</td> |
| 55 | 56 | </tr> |
| 56 | 57 | {% endfor %} |
| 57 | 58 |
| --- templates/fossil/code_blame.html | |
| +++ templates/fossil/code_blame.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Blame: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block extra_head %} |
| 5 | <style> |
| 6 | .blame-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -42,15 +43,15 @@ | |
| 42 | {% if blame_lines %} |
| 43 | <table class="blame-table"> |
| 44 | <tbody> |
| 45 | {% for bl in blame_lines %} |
| 46 | <tr class="blame-row"> |
| 47 | <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}"> |
| 48 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=bl.uuid %}" style="color: inherit;">{{ bl.uuid|truncatechars:8 }}</a> |
| 49 | </td> |
| 50 | <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}"> |
| 51 | <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}" style="color: inherit;">{{ bl.user }}</a> |
| 52 | </td> |
| 53 | <td class="blame-num">{{ forloop.counter }}</td> |
| 54 | <td class="blame-code">{{ bl.text }}</td> |
| 55 | </tr> |
| 56 | {% endfor %} |
| 57 |
| --- templates/fossil/code_blame.html | |
| +++ templates/fossil/code_blame.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Blame: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block extra_head %} |
| 6 | <style> |
| 7 | .blame-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -42,15 +43,15 @@ | |
| 43 | {% if blame_lines %} |
| 44 | <table class="blame-table"> |
| 45 | <tbody> |
| 46 | {% for bl in blame_lines %} |
| 47 | <tr class="blame-row"> |
| 48 | <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user|display_user }}"> |
| 49 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=bl.uuid %}" style="color: inherit;">{{ bl.uuid|truncatechars:8 }}</a> |
| 50 | </td> |
| 51 | <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user|display_user }}"> |
| 52 | <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}" style="color: inherit;">{{ bl.user|display_user }}</a> |
| 53 | </td> |
| 54 | <td class="blame-num">{{ forloop.counter }}</td> |
| 55 | <td class="blame-code">{{ bl.text }}</td> |
| 56 | </tr> |
| 57 | {% endfor %} |
| 58 |
| --- templates/fossil/code_browser.html | ||
| +++ templates/fossil/code_browser.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% load humanize %} |
| 3 | 4 | {% block title %}{% if current_dir %}{{ current_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | 5 | |
| 5 | 6 | {% block content %} |
| 6 | 7 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| @@ -33,11 +34,11 @@ | ||
| 33 | 34 | </div> |
| 34 | 35 | |
| 35 | 36 | <!-- Latest commit info --> |
| 36 | 37 | {% if latest_commit %} |
| 37 | 38 | <div class="flex items-center gap-3 mt-2 text-xs text-gray-500"> |
| 38 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=latest_commit.user %}" class="font-medium text-gray-300 hover:text-brand-light">{{ latest_commit.user }}</a> | |
| 39 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=latest_commit.user %}" class="font-medium text-gray-300 hover:text-brand-light">{{ latest_commit.user|display_user }}</a> | |
| 39 | 40 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="text-gray-400 truncate hover:text-brand-light">{{ latest_commit.comment|truncatechars:80 }}</a> |
| 40 | 41 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ latest_commit.uuid|truncatechars:10 }}</a> |
| 41 | 42 | <span>{{ latest_commit.timestamp|date:"Y-m-d H:i" }}</span> |
| 42 | 43 | </div> |
| 43 | 44 | {% endif %} |
| 44 | 45 |
| --- templates/fossil/code_browser.html | |
| +++ templates/fossil/code_browser.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load humanize %} |
| 3 | {% block title %}{% if current_dir %}{{ current_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| @@ -33,11 +34,11 @@ | |
| 33 | </div> |
| 34 | |
| 35 | <!-- Latest commit info --> |
| 36 | {% if latest_commit %} |
| 37 | <div class="flex items-center gap-3 mt-2 text-xs text-gray-500"> |
| 38 | <a href="{% url 'fossil:user_activity' slug=project.slug username=latest_commit.user %}" class="font-medium text-gray-300 hover:text-brand-light">{{ latest_commit.user }}</a> |
| 39 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="text-gray-400 truncate hover:text-brand-light">{{ latest_commit.comment|truncatechars:80 }}</a> |
| 40 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ latest_commit.uuid|truncatechars:10 }}</a> |
| 41 | <span>{{ latest_commit.timestamp|date:"Y-m-d H:i" }}</span> |
| 42 | </div> |
| 43 | {% endif %} |
| 44 |
| --- templates/fossil/code_browser.html | |
| +++ templates/fossil/code_browser.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% load humanize %} |
| 4 | {% block title %}{% if current_dir %}{{ current_dir }} — {% endif %}Code — {{ project.name }} — Fossilrepo{% endblock %} |
| 5 | |
| 6 | {% block content %} |
| 7 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| @@ -33,11 +34,11 @@ | |
| 34 | </div> |
| 35 | |
| 36 | <!-- Latest commit info --> |
| 37 | {% if latest_commit %} |
| 38 | <div class="flex items-center gap-3 mt-2 text-xs text-gray-500"> |
| 39 | <a href="{% url 'fossil:user_activity' slug=project.slug username=latest_commit.user %}" class="font-medium text-gray-300 hover:text-brand-light">{{ latest_commit.user|display_user }}</a> |
| 40 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="text-gray-400 truncate hover:text-brand-light">{{ latest_commit.comment|truncatechars:80 }}</a> |
| 41 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=latest_commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ latest_commit.uuid|truncatechars:10 }}</a> |
| 42 | <span>{{ latest_commit.timestamp|date:"Y-m-d H:i" }}</span> |
| 43 | </div> |
| 44 | {% endif %} |
| 45 |
+3
-2
| --- templates/fossil/compare.html | ||
| +++ templates/fossil/compare.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Compare — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block extra_head %} |
| 5 | 6 | <style> |
| 6 | 7 | .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -54,16 +55,16 @@ | ||
| 54 | 55 | {% if from_detail and to_detail %} |
| 55 | 56 | <div class="grid grid-cols-2 gap-4 mb-6"> |
| 56 | 57 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 57 | 58 | <div class="text-xs text-gray-500 mb-1">From</div> |
| 58 | 59 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=from_detail.uuid %}" class="text-sm text-brand-light hover:text-brand">{{ from_detail.comment|truncatechars:60 }}</a> |
| 59 | - <div class="mt-1 text-xs text-gray-500">{{ from_detail.user }} · {{ from_detail.timestamp|date:"Y-m-d H:i" }}</div> | |
| 60 | + <div class="mt-1 text-xs text-gray-500">{{ from_detail.user|display_user }} · {{ from_detail.timestamp|date:"Y-m-d H:i" }}</div> | |
| 60 | 61 | </div> |
| 61 | 62 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 62 | 63 | <div class="text-xs text-gray-500 mb-1">To</div> |
| 63 | 64 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=to_detail.uuid %}" class="text-sm text-brand-light hover:text-brand">{{ to_detail.comment|truncatechars:60 }}</a> |
| 64 | - <div class="mt-1 text-xs text-gray-500">{{ to_detail.user }} · {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div> | |
| 65 | + <div class="mt-1 text-xs text-gray-500">{{ to_detail.user|display_user }} · {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div> | |
| 65 | 66 | </div> |
| 66 | 67 | </div> |
| 67 | 68 | |
| 68 | 69 | {% if file_diffs %} |
| 69 | 70 | <div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle"> |
| 70 | 71 |
| --- templates/fossil/compare.html | |
| +++ templates/fossil/compare.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Compare — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block extra_head %} |
| 5 | <style> |
| 6 | .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -54,16 +55,16 @@ | |
| 54 | {% if from_detail and to_detail %} |
| 55 | <div class="grid grid-cols-2 gap-4 mb-6"> |
| 56 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 57 | <div class="text-xs text-gray-500 mb-1">From</div> |
| 58 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=from_detail.uuid %}" class="text-sm text-brand-light hover:text-brand">{{ from_detail.comment|truncatechars:60 }}</a> |
| 59 | <div class="mt-1 text-xs text-gray-500">{{ from_detail.user }} · {{ from_detail.timestamp|date:"Y-m-d H:i" }}</div> |
| 60 | </div> |
| 61 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 62 | <div class="text-xs text-gray-500 mb-1">To</div> |
| 63 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=to_detail.uuid %}" class="text-sm text-brand-light hover:text-brand">{{ to_detail.comment|truncatechars:60 }}</a> |
| 64 | <div class="mt-1 text-xs text-gray-500">{{ to_detail.user }} · {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div> |
| 65 | </div> |
| 66 | </div> |
| 67 | |
| 68 | {% if file_diffs %} |
| 69 | <div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle"> |
| 70 |
| --- templates/fossil/compare.html | |
| +++ templates/fossil/compare.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Compare — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block extra_head %} |
| 6 | <style> |
| 7 | .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| @@ -54,16 +55,16 @@ | |
| 55 | {% if from_detail and to_detail %} |
| 56 | <div class="grid grid-cols-2 gap-4 mb-6"> |
| 57 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 58 | <div class="text-xs text-gray-500 mb-1">From</div> |
| 59 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=from_detail.uuid %}" class="text-sm text-brand-light hover:text-brand">{{ from_detail.comment|truncatechars:60 }}</a> |
| 60 | <div class="mt-1 text-xs text-gray-500">{{ from_detail.user|display_user }} · {{ from_detail.timestamp|date:"Y-m-d H:i" }}</div> |
| 61 | </div> |
| 62 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 63 | <div class="text-xs text-gray-500 mb-1">To</div> |
| 64 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=to_detail.uuid %}" class="text-sm text-brand-light hover:text-brand">{{ to_detail.comment|truncatechars:60 }}</a> |
| 65 | <div class="mt-1 text-xs text-gray-500">{{ to_detail.user|display_user }} · {{ to_detail.timestamp|date:"Y-m-d H:i" }}</div> |
| 66 | </div> |
| 67 | </div> |
| 68 | |
| 69 | {% if file_diffs %} |
| 70 | <div x-data="{ mode: localStorage.getItem('diff-mode') || 'unified' }" x-init="$watch('mode', val => localStorage.setItem('diff-mode', val))" x-ref="diffToggle"> |
| 71 |
| --- templates/fossil/file_history.html | ||
| +++ templates/fossil/file_history.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}History: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -21,11 +22,11 @@ | ||
| 21 | 22 | </div> |
| 22 | 23 | <div class="flex-1 min-w-0"> |
| 23 | 24 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 24 | 25 | class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:100 }}</a> |
| 25 | 26 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 26 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=commit.user %}" class="hover:text-gray-300">{{ commit.user }}</a> | |
| 27 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=commit.user %}" class="hover:text-gray-300">{{ commit.user|display_user }}</a> | |
| 27 | 28 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a> |
| 28 | 29 | <span>{{ commit.timestamp|date:"Y-m-d H:i" }}</span> |
| 29 | 30 | </div> |
| 30 | 31 | </div> |
| 31 | 32 | </div> |
| 32 | 33 |
| --- templates/fossil/file_history.html | |
| +++ templates/fossil/file_history.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}History: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -21,11 +22,11 @@ | |
| 21 | </div> |
| 22 | <div class="flex-1 min-w-0"> |
| 23 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 24 | class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:100 }}</a> |
| 25 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 26 | <a href="{% url 'fossil:user_activity' slug=project.slug username=commit.user %}" class="hover:text-gray-300">{{ commit.user }}</a> |
| 27 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a> |
| 28 | <span>{{ commit.timestamp|date:"Y-m-d H:i" }}</span> |
| 29 | </div> |
| 30 | </div> |
| 31 | </div> |
| 32 |
| --- templates/fossil/file_history.html | |
| +++ templates/fossil/file_history.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}History: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -21,11 +22,11 @@ | |
| 22 | </div> |
| 23 | <div class="flex-1 min-w-0"> |
| 24 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 25 | class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:100 }}</a> |
| 26 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 27 | <a href="{% url 'fossil:user_activity' slug=project.slug username=commit.user %}" class="hover:text-gray-300">{{ commit.user|display_user }}</a> |
| 28 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a> |
| 29 | <span>{{ commit.timestamp|date:"Y-m-d H:i" }}</span> |
| 30 | </div> |
| 31 | </div> |
| 32 | </div> |
| 33 |
+3
-2
| --- templates/fossil/forum_list.html | ||
| +++ templates/fossil/forum_list.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -46,13 +47,13 @@ | ||
| 46 | 47 | {% if post.body %} |
| 47 | 48 | <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p> |
| 48 | 49 | {% endif %} |
| 49 | 50 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 50 | 51 | {% if post.source == "fossil" %} |
| 51 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=post.user %}" class="font-medium text-gray-400 hover:text-brand-light">{{ post.user }}</a> | |
| 52 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=post.user %}" class="font-medium text-gray-400 hover:text-brand-light">{{ post.user|display_user }}</a> | |
| 52 | 53 | {% else %} |
| 53 | - <span class="font-medium text-gray-400">{{ post.user }}</span> | |
| 54 | + <span class="font-medium text-gray-400">{{ post.user|display_user }}</span> | |
| 54 | 55 | {% endif %} |
| 55 | 56 | <span>{{ post.timestamp|timesince }} ago</span> |
| 56 | 57 | {% if post.source == "django" %} |
| 57 | 58 | <span class="inline-flex rounded-full bg-brand/20 px-2 py-0.5 text-xs text-brand-light">local</span> |
| 58 | 59 | {% endif %} |
| 59 | 60 |
| --- templates/fossil/forum_list.html | |
| +++ templates/fossil/forum_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -46,13 +47,13 @@ | |
| 46 | {% if post.body %} |
| 47 | <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p> |
| 48 | {% endif %} |
| 49 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 50 | {% if post.source == "fossil" %} |
| 51 | <a href="{% url 'fossil:user_activity' slug=project.slug username=post.user %}" class="font-medium text-gray-400 hover:text-brand-light">{{ post.user }}</a> |
| 52 | {% else %} |
| 53 | <span class="font-medium text-gray-400">{{ post.user }}</span> |
| 54 | {% endif %} |
| 55 | <span>{{ post.timestamp|timesince }} ago</span> |
| 56 | {% if post.source == "django" %} |
| 57 | <span class="inline-flex rounded-full bg-brand/20 px-2 py-0.5 text-xs text-brand-light">local</span> |
| 58 | {% endif %} |
| 59 |
| --- templates/fossil/forum_list.html | |
| +++ templates/fossil/forum_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -46,13 +47,13 @@ | |
| 47 | {% if post.body %} |
| 48 | <p class="mt-1 text-sm text-gray-400 line-clamp-2">{{ post.body|truncatechars:200 }}</p> |
| 49 | {% endif %} |
| 50 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 51 | {% if post.source == "fossil" %} |
| 52 | <a href="{% url 'fossil:user_activity' slug=project.slug username=post.user %}" class="font-medium text-gray-400 hover:text-brand-light">{{ post.user|display_user }}</a> |
| 53 | {% else %} |
| 54 | <span class="font-medium text-gray-400">{{ post.user|display_user }}</span> |
| 55 | {% endif %} |
| 56 | <span>{{ post.timestamp|timesince }} ago</span> |
| 57 | {% if post.source == "django" %} |
| 58 | <span class="inline-flex rounded-full bg-brand/20 px-2 py-0.5 text-xs text-brand-light">local</span> |
| 59 | {% endif %} |
| 60 |
| --- templates/fossil/forum_thread.html | ||
| +++ templates/fossil/forum_thread.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -13,13 +14,13 @@ | ||
| 13 | 14 | {% for item in posts %} |
| 14 | 15 | <div class="rounded-lg bg-gray-800 border border-gray-700 {% if item.post.in_reply_to %}ml-8{% endif %}"> |
| 15 | 16 | <div class="px-5 py-4"> |
| 16 | 17 | <div class="flex items-center justify-between mb-2"> |
| 17 | 18 | {% if is_django_thread %} |
| 18 | - <span class="text-sm font-medium text-gray-200">{{ item.post.user }}</span> | |
| 19 | + <span class="text-sm font-medium text-gray-200">{{ item.post.user|display_user }}</span> | |
| 19 | 20 | {% else %} |
| 20 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=item.post.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ item.post.user }}</a> | |
| 21 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=item.post.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ item.post.user|display_user }}</a> | |
| 21 | 22 | {% endif %} |
| 22 | 23 | <span class="text-xs text-gray-500">{{ item.post.timestamp|timesince }} ago</span> |
| 23 | 24 | </div> |
| 24 | 25 | {% if item.post.title and forloop.first %} |
| 25 | 26 | <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ item.post.title }}</h2> |
| 26 | 27 |
| --- templates/fossil/forum_thread.html | |
| +++ templates/fossil/forum_thread.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -13,13 +14,13 @@ | |
| 13 | {% for item in posts %} |
| 14 | <div class="rounded-lg bg-gray-800 border border-gray-700 {% if item.post.in_reply_to %}ml-8{% endif %}"> |
| 15 | <div class="px-5 py-4"> |
| 16 | <div class="flex items-center justify-between mb-2"> |
| 17 | {% if is_django_thread %} |
| 18 | <span class="text-sm font-medium text-gray-200">{{ item.post.user }}</span> |
| 19 | {% else %} |
| 20 | <a href="{% url 'fossil:user_activity' slug=project.slug username=item.post.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ item.post.user }}</a> |
| 21 | {% endif %} |
| 22 | <span class="text-xs text-gray-500">{{ item.post.timestamp|timesince }} ago</span> |
| 23 | </div> |
| 24 | {% if item.post.title and forloop.first %} |
| 25 | <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ item.post.title }}</h2> |
| 26 |
| --- templates/fossil/forum_thread.html | |
| +++ templates/fossil/forum_thread.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -13,13 +14,13 @@ | |
| 14 | {% for item in posts %} |
| 15 | <div class="rounded-lg bg-gray-800 border border-gray-700 {% if item.post.in_reply_to %}ml-8{% endif %}"> |
| 16 | <div class="px-5 py-4"> |
| 17 | <div class="flex items-center justify-between mb-2"> |
| 18 | {% if is_django_thread %} |
| 19 | <span class="text-sm font-medium text-gray-200">{{ item.post.user|display_user }}</span> |
| 20 | {% else %} |
| 21 | <a href="{% url 'fossil:user_activity' slug=project.slug username=item.post.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ item.post.user|display_user }}</a> |
| 22 | {% endif %} |
| 23 | <span class="text-xs text-gray-500">{{ item.post.timestamp|timesince }} ago</span> |
| 24 | </div> |
| 25 | {% if item.post.title and forloop.first %} |
| 26 | <h2 class="text-lg font-semibold text-gray-100 mb-3">{{ item.post.title }}</h2> |
| 27 |
| --- templates/fossil/partials/timeline_entries.html | ||
| +++ templates/fossil/partials/timeline_entries.html | ||
| @@ -105,11 +105,11 @@ | ||
| 105 | 105 | {# Meta: hash, user, branch #} |
| 106 | 106 | <div class="tl-meta"> |
| 107 | 107 | {% if e.event_type == "ci" %} |
| 108 | 108 | {% include "fossil/_copy_hash.html" with hash=e.uuid slug=project.slug %} |
| 109 | 109 | {% endif %} |
| 110 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=e.user %}" class="tl-user">{{ e.user }}</a> | |
| 110 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=e.user %}" class="tl-user">{{ e.user|display_user }}</a> | |
| 111 | 111 | {% if e.branch %}<span class="tl-branch">{{ e.branch }}</span>{% endif %} |
| 112 | 112 | </div> |
| 113 | 113 | </div> |
| 114 | 114 | |
| 115 | 115 | {% endwith %} |
| 116 | 116 |
| --- templates/fossil/partials/timeline_entries.html | |
| +++ templates/fossil/partials/timeline_entries.html | |
| @@ -105,11 +105,11 @@ | |
| 105 | {# Meta: hash, user, branch #} |
| 106 | <div class="tl-meta"> |
| 107 | {% if e.event_type == "ci" %} |
| 108 | {% include "fossil/_copy_hash.html" with hash=e.uuid slug=project.slug %} |
| 109 | {% endif %} |
| 110 | <a href="{% url 'fossil:user_activity' slug=project.slug username=e.user %}" class="tl-user">{{ e.user }}</a> |
| 111 | {% if e.branch %}<span class="tl-branch">{{ e.branch }}</span>{% endif %} |
| 112 | </div> |
| 113 | </div> |
| 114 | |
| 115 | {% endwith %} |
| 116 |
| --- templates/fossil/partials/timeline_entries.html | |
| +++ templates/fossil/partials/timeline_entries.html | |
| @@ -105,11 +105,11 @@ | |
| 105 | {# Meta: hash, user, branch #} |
| 106 | <div class="tl-meta"> |
| 107 | {% if e.event_type == "ci" %} |
| 108 | {% include "fossil/_copy_hash.html" with hash=e.uuid slug=project.slug %} |
| 109 | {% endif %} |
| 110 | <a href="{% url 'fossil:user_activity' slug=project.slug username=e.user %}" class="tl-user">{{ e.user|display_user }}</a> |
| 111 | {% if e.branch %}<span class="tl-branch">{{ e.branch }}</span>{% endif %} |
| 112 | </div> |
| 113 | </div> |
| 114 | |
| 115 | {% endwith %} |
| 116 |
+2
-1
| --- templates/fossil/repo_stats.html | ||
| +++ templates/fossil/repo_stats.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Statistics — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block extra_head %} |
| 5 | 6 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 6 | 7 | {% endblock %} |
| @@ -77,11 +78,11 @@ | ||
| 77 | 78 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 78 | 79 | <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3> |
| 79 | 80 | <div class="space-y-1"> |
| 80 | 81 | {% for c in top_contributors %} |
| 81 | 82 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50"> |
| 82 | - <span class="text-sm text-gray-300">{{ c.user }}</span> | |
| 83 | + <span class="text-sm text-gray-300">{{ c.user|display_user }}</span> | |
| 83 | 84 | <div class="flex items-center gap-2"> |
| 84 | 85 | <div class="w-24 bg-gray-700 rounded-full h-1.5"> |
| 85 | 86 | <div class="bg-brand rounded-full h-1.5" style="width: {% widthratio c.count top_contributors.0.count 100 %}%"></div> |
| 86 | 87 | </div> |
| 87 | 88 | <span class="text-xs text-gray-500 w-16 text-right">{{ c.count }}</span> |
| 88 | 89 |
| --- templates/fossil/repo_stats.html | |
| +++ templates/fossil/repo_stats.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Statistics — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block extra_head %} |
| 5 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 6 | {% endblock %} |
| @@ -77,11 +78,11 @@ | |
| 77 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 78 | <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3> |
| 79 | <div class="space-y-1"> |
| 80 | {% for c in top_contributors %} |
| 81 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50"> |
| 82 | <span class="text-sm text-gray-300">{{ c.user }}</span> |
| 83 | <div class="flex items-center gap-2"> |
| 84 | <div class="w-24 bg-gray-700 rounded-full h-1.5"> |
| 85 | <div class="bg-brand rounded-full h-1.5" style="width: {% widthratio c.count top_contributors.0.count 100 %}%"></div> |
| 86 | </div> |
| 87 | <span class="text-xs text-gray-500 w-16 text-right">{{ c.count }}</span> |
| 88 |
| --- templates/fossil/repo_stats.html | |
| +++ templates/fossil/repo_stats.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Statistics — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block extra_head %} |
| 6 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 7 | {% endblock %} |
| @@ -77,11 +78,11 @@ | |
| 78 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-4"> |
| 79 | <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3> |
| 80 | <div class="space-y-1"> |
| 81 | {% for c in top_contributors %} |
| 82 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50"> |
| 83 | <span class="text-sm text-gray-300">{{ c.user|display_user }}</span> |
| 84 | <div class="flex items-center gap-2"> |
| 85 | <div class="w-24 bg-gray-700 rounded-full h-1.5"> |
| 86 | <div class="bg-brand rounded-full h-1.5" style="width: {% widthratio c.count top_contributors.0.count 100 %}%"></div> |
| 87 | </div> |
| 88 | <span class="text-xs text-gray-500 w-16 text-right">{{ c.count }}</span> |
| 89 |
+2
-1
| --- templates/fossil/search.html | ||
| +++ templates/fossil/search.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Search — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -22,11 +23,11 @@ | ||
| 22 | 23 | <div class="rounded-lg bg-gray-800 border border-gray-700 divide-y divide-gray-700"> |
| 23 | 24 | {% for c in results.checkins %} |
| 24 | 25 | <div class="px-4 py-3"> |
| 25 | 26 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="text-sm text-gray-200 hover:text-brand-light">{{ c.comment|truncatechars:100 }}</a> |
| 26 | 27 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 27 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="hover:text-gray-300">{{ c.user }}</a> | |
| 28 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="hover:text-gray-300">{{ c.user|display_user }}</a> | |
| 28 | 29 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="font-mono text-brand-light">{{ c.uuid|truncatechars:10 }}</a> |
| 29 | 30 | <span>{{ c.timestamp|timesince }} ago</span> |
| 30 | 31 | </div> |
| 31 | 32 | </div> |
| 32 | 33 | {% endfor %} |
| 33 | 34 |
| --- templates/fossil/search.html | |
| +++ templates/fossil/search.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Search — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -22,11 +23,11 @@ | |
| 22 | <div class="rounded-lg bg-gray-800 border border-gray-700 divide-y divide-gray-700"> |
| 23 | {% for c in results.checkins %} |
| 24 | <div class="px-4 py-3"> |
| 25 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="text-sm text-gray-200 hover:text-brand-light">{{ c.comment|truncatechars:100 }}</a> |
| 26 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 27 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="hover:text-gray-300">{{ c.user }}</a> |
| 28 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="font-mono text-brand-light">{{ c.uuid|truncatechars:10 }}</a> |
| 29 | <span>{{ c.timestamp|timesince }} ago</span> |
| 30 | </div> |
| 31 | </div> |
| 32 | {% endfor %} |
| 33 |
| --- templates/fossil/search.html | |
| +++ templates/fossil/search.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Search — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -22,11 +23,11 @@ | |
| 23 | <div class="rounded-lg bg-gray-800 border border-gray-700 divide-y divide-gray-700"> |
| 24 | {% for c in results.checkins %} |
| 25 | <div class="px-4 py-3"> |
| 26 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="text-sm text-gray-200 hover:text-brand-light">{{ c.comment|truncatechars:100 }}</a> |
| 27 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 28 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="hover:text-gray-300">{{ c.user|display_user }}</a> |
| 29 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="font-mono text-brand-light">{{ c.uuid|truncatechars:10 }}</a> |
| 30 | <span>{{ c.timestamp|timesince }} ago</span> |
| 31 | </div> |
| 32 | </div> |
| 33 | {% endfor %} |
| 34 |
+2
-1
| --- templates/fossil/tag_list.html | ||
| +++ templates/fossil/tag_list.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Tags — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -39,11 +40,11 @@ | ||
| 39 | 40 | </td> |
| 40 | 41 | <td class="px-4 py-3"> |
| 41 | 42 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=tag.uuid %}" class="font-mono text-xs text-brand-light hover:text-brand">{{ tag.uuid|truncatechars:10 }}</a> |
| 42 | 43 | </td> |
| 43 | 44 | <td class="px-4 py-3"> |
| 44 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=tag.user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ tag.user }}</a> | |
| 45 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=tag.user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ tag.user|display_user }}</a> | |
| 45 | 46 | </td> |
| 46 | 47 | <td class="px-4 py-3 text-sm text-gray-500 text-right">{{ tag.timestamp|date:"Y-m-d" }}</td> |
| 47 | 48 | </tr> |
| 48 | 49 | {% empty %} |
| 49 | 50 | <tr> |
| 50 | 51 |
| --- templates/fossil/tag_list.html | |
| +++ templates/fossil/tag_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Tags — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -39,11 +40,11 @@ | |
| 39 | </td> |
| 40 | <td class="px-4 py-3"> |
| 41 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=tag.uuid %}" class="font-mono text-xs text-brand-light hover:text-brand">{{ tag.uuid|truncatechars:10 }}</a> |
| 42 | </td> |
| 43 | <td class="px-4 py-3"> |
| 44 | <a href="{% url 'fossil:user_activity' slug=project.slug username=tag.user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ tag.user }}</a> |
| 45 | </td> |
| 46 | <td class="px-4 py-3 text-sm text-gray-500 text-right">{{ tag.timestamp|date:"Y-m-d" }}</td> |
| 47 | </tr> |
| 48 | {% empty %} |
| 49 | <tr> |
| 50 |
| --- templates/fossil/tag_list.html | |
| +++ templates/fossil/tag_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Tags — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -39,11 +40,11 @@ | |
| 40 | </td> |
| 41 | <td class="px-4 py-3"> |
| 42 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=tag.uuid %}" class="font-mono text-xs text-brand-light hover:text-brand">{{ tag.uuid|truncatechars:10 }}</a> |
| 43 | </td> |
| 44 | <td class="px-4 py-3"> |
| 45 | <a href="{% url 'fossil:user_activity' slug=project.slug username=tag.user %}" class="text-sm text-gray-400 hover:text-brand-light">{{ tag.user|display_user }}</a> |
| 46 | </td> |
| 47 | <td class="px-4 py-3 text-sm text-gray-500 text-right">{{ tag.timestamp|date:"Y-m-d" }}</td> |
| 48 | </tr> |
| 49 | {% empty %} |
| 50 | <tr> |
| 51 |
| --- templates/fossil/technote_detail.html | ||
| +++ templates/fossil/technote_detail.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}{{ note.comment|truncatechars:60 }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -15,11 +16,11 @@ | ||
| 15 | 16 | <div class="flex items-start justify-between gap-4"> |
| 16 | 17 | <div class="flex-1"> |
| 17 | 18 | <h2 class="text-xl font-bold text-gray-100 mb-1">{{ note.comment }}</h2> |
| 18 | 19 | <div class="flex items-center gap-3 text-xs text-gray-500"> |
| 19 | 20 | <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" |
| 20 | - class="hover:text-brand-light">{{ note.user }}</a> | |
| 21 | + class="hover:text-brand-light">{{ note.user|display_user }}</a> | |
| 21 | 22 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:16 }}</span> |
| 22 | 23 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 23 | 24 | </div> |
| 24 | 25 | </div> |
| 25 | 26 | {% if has_write %} |
| 26 | 27 |
| --- templates/fossil/technote_detail.html | |
| +++ templates/fossil/technote_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ note.comment|truncatechars:60 }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -15,11 +16,11 @@ | |
| 15 | <div class="flex items-start justify-between gap-4"> |
| 16 | <div class="flex-1"> |
| 17 | <h2 class="text-xl font-bold text-gray-100 mb-1">{{ note.comment }}</h2> |
| 18 | <div class="flex items-center gap-3 text-xs text-gray-500"> |
| 19 | <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" |
| 20 | class="hover:text-brand-light">{{ note.user }}</a> |
| 21 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:16 }}</span> |
| 22 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 23 | </div> |
| 24 | </div> |
| 25 | {% if has_write %} |
| 26 |
| --- templates/fossil/technote_detail.html | |
| +++ templates/fossil/technote_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}{{ note.comment|truncatechars:60 }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -15,11 +16,11 @@ | |
| 16 | <div class="flex items-start justify-between gap-4"> |
| 17 | <div class="flex-1"> |
| 18 | <h2 class="text-xl font-bold text-gray-100 mb-1">{{ note.comment }}</h2> |
| 19 | <div class="flex items-center gap-3 text-xs text-gray-500"> |
| 20 | <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" |
| 21 | class="hover:text-brand-light">{{ note.user|display_user }}</a> |
| 22 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:16 }}</span> |
| 23 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 24 | </div> |
| 25 | </div> |
| 26 | {% if has_write %} |
| 27 |
| --- templates/fossil/technote_list.html | ||
| +++ templates/fossil/technote_list.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Technotes — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -35,11 +36,11 @@ | ||
| 35 | 36 | class="block rounded-lg bg-gray-800 border border-gray-700 px-5 py-4 hover:bg-gray-700/50 transition-colors"> |
| 36 | 37 | <div class="flex items-start justify-between gap-3"> |
| 37 | 38 | <div class="flex-1 min-w-0"> |
| 38 | 39 | <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p> |
| 39 | 40 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 40 | - <span class="hover:text-brand-light">{{ note.user }}</span> | |
| 41 | + <span class="hover:text-brand-light">{{ note.user|display_user }}</span> | |
| 41 | 42 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:10 }}</span> |
| 42 | 43 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 43 | 44 | </div> |
| 44 | 45 | </div> |
| 45 | 46 | </div> |
| 46 | 47 |
| --- templates/fossil/technote_list.html | |
| +++ templates/fossil/technote_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Technotes — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -35,11 +36,11 @@ | |
| 35 | class="block rounded-lg bg-gray-800 border border-gray-700 px-5 py-4 hover:bg-gray-700/50 transition-colors"> |
| 36 | <div class="flex items-start justify-between gap-3"> |
| 37 | <div class="flex-1 min-w-0"> |
| 38 | <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p> |
| 39 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 40 | <span class="hover:text-brand-light">{{ note.user }}</span> |
| 41 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:10 }}</span> |
| 42 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 43 | </div> |
| 44 | </div> |
| 45 | </div> |
| 46 |
| --- templates/fossil/technote_list.html | |
| +++ templates/fossil/technote_list.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Technotes — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -35,11 +36,11 @@ | |
| 36 | class="block rounded-lg bg-gray-800 border border-gray-700 px-5 py-4 hover:bg-gray-700/50 transition-colors"> |
| 37 | <div class="flex items-start justify-between gap-3"> |
| 38 | <div class="flex-1 min-w-0"> |
| 39 | <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p> |
| 40 | <div class="mt-2 flex items-center gap-3 text-xs text-gray-500"> |
| 41 | <span class="hover:text-brand-light">{{ note.user|display_user }}</span> |
| 42 | <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:10 }}</span> |
| 43 | <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span> |
| 44 | </div> |
| 45 | </div> |
| 46 | </div> |
| 47 |
| --- templates/fossil/ticket_detail.html | ||
| +++ templates/fossil/ticket_detail.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}{{ ticket.title }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -79,11 +80,11 @@ | ||
| 79 | 80 | <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Comments ({{ comments|length }})</h3> |
| 80 | 81 | <div class="space-y-3"> |
| 81 | 82 | {% for c in comments %} |
| 82 | 83 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 83 | 84 | <div class="px-5 py-3 border-b border-gray-700 flex items-center justify-between"> |
| 84 | - <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ c.user }}</a> | |
| 85 | + <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ c.user|display_user }}</a> | |
| 85 | 86 | <span class="text-xs text-gray-500">{{ c.timestamp|timesince }} ago</span> |
| 86 | 87 | </div> |
| 87 | 88 | <div class="px-5 py-4"> |
| 88 | 89 | <div class="prose prose-invert prose-gray prose-sm max-w-none"> |
| 89 | 90 | {{ c.html }} |
| 90 | 91 |
| --- templates/fossil/ticket_detail.html | |
| +++ templates/fossil/ticket_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ ticket.title }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -79,11 +80,11 @@ | |
| 79 | <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Comments ({{ comments|length }})</h3> |
| 80 | <div class="space-y-3"> |
| 81 | {% for c in comments %} |
| 82 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 83 | <div class="px-5 py-3 border-b border-gray-700 flex items-center justify-between"> |
| 84 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ c.user }}</a> |
| 85 | <span class="text-xs text-gray-500">{{ c.timestamp|timesince }} ago</span> |
| 86 | </div> |
| 87 | <div class="px-5 py-4"> |
| 88 | <div class="prose prose-invert prose-gray prose-sm max-w-none"> |
| 89 | {{ c.html }} |
| 90 |
| --- templates/fossil/ticket_detail.html | |
| +++ templates/fossil/ticket_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}{{ ticket.title }} — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -79,11 +80,11 @@ | |
| 80 | <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Comments ({{ comments|length }})</h3> |
| 81 | <div class="space-y-3"> |
| 82 | {% for c in comments %} |
| 83 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 84 | <div class="px-5 py-3 border-b border-gray-700 flex items-center justify-between"> |
| 85 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ c.user|display_user }}</a> |
| 86 | <span class="text-xs text-gray-500">{{ c.timestamp|timesince }} ago</span> |
| 87 | </div> |
| 88 | <div class="px-5 py-4"> |
| 89 | <div class="prose prose-invert prose-gray prose-sm max-w-none"> |
| 90 | {{ c.html }} |
| 91 |
| --- templates/fossil/timeline.html | ||
| +++ templates/fossil/timeline.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | {% include "fossil/_live_reload.html" %} |
| 6 | 7 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | 8 |
| --- templates/fossil/timeline.html | |
| +++ templates/fossil/timeline.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | {% include "fossil/_live_reload.html" %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 |
| --- templates/fossil/timeline.html | |
| +++ templates/fossil/timeline.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | {% include "fossil/_live_reload.html" %} |
| 7 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 8 |
+2
-1
| --- templates/fossil/wiki_page.html | ||
| +++ templates/fossil/wiki_page.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block content %} |
| 5 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 7 | {% include "fossil/_project_nav.html" %} |
| @@ -10,11 +11,11 @@ | ||
| 10 | 11 | <div class="flex-1 min-w-0"> |
| 11 | 12 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 12 | 13 | <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between"> |
| 13 | 14 | <h2 class="text-lg font-semibold text-gray-100">{{ page.name }}</h2> |
| 14 | 15 | <div class="flex items-center gap-3"> |
| 15 | - <span class="text-xs text-gray-500">{{ page.last_modified|timesince }} ago by {{ page.user }}</span> | |
| 16 | + <span class="text-xs text-gray-500">{{ page.last_modified|timesince }} ago by {{ page.user|display_user }}</span> | |
| 16 | 17 | {% if perms.projects.change_project %} |
| 17 | 18 | <a href="{% url 'fossil:wiki_edit' slug=project.slug page_name=page.name %}" class="rounded-md bg-gray-700 px-2 py-1 text-xs font-semibold text-gray-300 hover:bg-gray-600">Edit</a> |
| 18 | 19 | {% endif %} |
| 19 | 20 | </div> |
| 20 | 21 | </div> |
| 21 | 22 |
| --- templates/fossil/wiki_page.html | |
| +++ templates/fossil/wiki_page.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| @@ -10,11 +11,11 @@ | |
| 10 | <div class="flex-1 min-w-0"> |
| 11 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 12 | <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between"> |
| 13 | <h2 class="text-lg font-semibold text-gray-100">{{ page.name }}</h2> |
| 14 | <div class="flex items-center gap-3"> |
| 15 | <span class="text-xs text-gray-500">{{ page.last_modified|timesince }} ago by {{ page.user }}</span> |
| 16 | {% if perms.projects.change_project %} |
| 17 | <a href="{% url 'fossil:wiki_edit' slug=project.slug page_name=page.name %}" class="rounded-md bg-gray-700 px-2 py-1 text-xs font-semibold text-gray-300 hover:bg-gray-600">Edit</a> |
| 18 | {% endif %} |
| 19 | </div> |
| 20 | </div> |
| 21 |
| --- templates/fossil/wiki_page.html | |
| +++ templates/fossil/wiki_page.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block content %} |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| @@ -10,11 +11,11 @@ | |
| 11 | <div class="flex-1 min-w-0"> |
| 12 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 13 | <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between"> |
| 14 | <h2 class="text-lg font-semibold text-gray-100">{{ page.name }}</h2> |
| 15 | <div class="flex items-center gap-3"> |
| 16 | <span class="text-xs text-gray-500">{{ page.last_modified|timesince }} ago by {{ page.user|display_user }}</span> |
| 17 | {% if perms.projects.change_project %} |
| 18 | <a href="{% url 'fossil:wiki_edit' slug=project.slug page_name=page.name %}" class="rounded-md bg-gray-700 px-2 py-1 text-xs font-semibold text-gray-300 hover:bg-gray-600">Edit</a> |
| 19 | {% endif %} |
| 20 | </div> |
| 21 | </div> |
| 22 |
| --- templates/projects/project_detail.html | ||
| +++ templates/projects/project_detail.html | ||
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | +{% load fossil_filters %} | |
| 2 | 3 | {% block title %}{{ project.name }} — Fossilrepo{% endblock %} |
| 3 | 4 | |
| 4 | 5 | {% block extra_head %} |
| 5 | 6 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 6 | 7 | {% endblock %} |
| @@ -72,11 +73,11 @@ | ||
| 72 | 73 | </div> |
| 73 | 74 | <div class="flex-1 min-w-0"> |
| 74 | 75 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 75 | 76 | class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:80 }}</a> |
| 76 | 77 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 77 | - <span>{{ commit.user }}</span> | |
| 78 | + <span>{{ commit.user|display_user }}</span> | |
| 78 | 79 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 79 | 80 | class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a> |
| 80 | 81 | <span>{{ commit.timestamp|timesince }} ago</span> |
| 81 | 82 | </div> |
| 82 | 83 | </div> |
| @@ -159,11 +160,11 @@ | ||
| 159 | 160 | <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3> |
| 160 | 161 | <div class="space-y-1"> |
| 161 | 162 | {% for c in top_contributors %} |
| 162 | 163 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" |
| 163 | 164 | class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50"> |
| 164 | - <span class="text-sm text-gray-300">{{ c.user }}</span> | |
| 165 | + <span class="text-sm text-gray-300">{{ c.user|display_user }}</span> | |
| 165 | 166 | <span class="text-xs text-gray-500">{{ c.count }} commits</span> |
| 166 | 167 | </a> |
| 167 | 168 | {% endfor %} |
| 168 | 169 | </div> |
| 169 | 170 | </div> |
| 170 | 171 |
| --- templates/projects/project_detail.html | |
| +++ templates/projects/project_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}{{ project.name }} — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block extra_head %} |
| 5 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 6 | {% endblock %} |
| @@ -72,11 +73,11 @@ | |
| 72 | </div> |
| 73 | <div class="flex-1 min-w-0"> |
| 74 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 75 | class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:80 }}</a> |
| 76 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 77 | <span>{{ commit.user }}</span> |
| 78 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 79 | class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a> |
| 80 | <span>{{ commit.timestamp|timesince }} ago</span> |
| 81 | </div> |
| 82 | </div> |
| @@ -159,11 +160,11 @@ | |
| 159 | <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3> |
| 160 | <div class="space-y-1"> |
| 161 | {% for c in top_contributors %} |
| 162 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" |
| 163 | class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50"> |
| 164 | <span class="text-sm text-gray-300">{{ c.user }}</span> |
| 165 | <span class="text-xs text-gray-500">{{ c.count }} commits</span> |
| 166 | </a> |
| 167 | {% endfor %} |
| 168 | </div> |
| 169 | </div> |
| 170 |
| --- templates/projects/project_detail.html | |
| +++ templates/projects/project_detail.html | |
| @@ -1,6 +1,7 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% load fossil_filters %} |
| 3 | {% block title %}{{ project.name }} — Fossilrepo{% endblock %} |
| 4 | |
| 5 | {% block extra_head %} |
| 6 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 7 | {% endblock %} |
| @@ -72,11 +73,11 @@ | |
| 73 | </div> |
| 74 | <div class="flex-1 min-w-0"> |
| 75 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 76 | class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:80 }}</a> |
| 77 | <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500"> |
| 78 | <span>{{ commit.user|display_user }}</span> |
| 79 | <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}" |
| 80 | class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a> |
| 81 | <span>{{ commit.timestamp|timesince }} ago</span> |
| 82 | </div> |
| 83 | </div> |
| @@ -159,11 +160,11 @@ | |
| 160 | <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3> |
| 161 | <div class="space-y-1"> |
| 162 | {% for c in top_contributors %} |
| 163 | <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" |
| 164 | class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50"> |
| 165 | <span class="text-sm text-gray-300">{{ c.user|display_user }}</span> |
| 166 | <span class="text-xs text-gray-500">{{ c.count }} commits</span> |
| 167 | </a> |
| 168 | {% endfor %} |
| 169 | </div> |
| 170 | </div> |
| 171 |