FossilRepo
Session cleanup: deploy flow, template fixes, docker-compose bind mount
Commit
7e1aaf6ba6205d3af1beff6d19f9ce391ae776e659f9ac39b9b5cf656e767a43
Parent
fcd8df3cf42f98b…
35 files changed
+3
-3
+2
-1
+84
-16
+1
-1
+13
-26
+4
+36
-5
+38
-23
+8
-1
+19
-1
+21
-10
+7
-1
+33
-16
+33
-16
+14
+6
-1
+2
-2
+6
-1
+1
+1
-1
+6
-1
+6
-1
+1
-1
+4
-4
+5
-5
+6
-1
-1
+6
-1
+6
-1
+2
-2
+6
-1
+27
-27
+6
-3
+6
-3
~
config/settings.py
~
core/__pycache__/sanitize.cpython-314.pyc
~
core/context_processors.py
~
core/sanitize.py
~
core/url_validation.py
~
deploy.sh
~
docker-compose.yaml
~
fossil/api_auth.py
~
fossil/api_views.py
~
fossil/reader.py
~
fossil/tasks.py
~
fossil/views.py
~
projects/views.py
~
templates/403.html
~
templates/404.html
~
templates/base.html
~
templates/fossil/branch_list.html
~
templates/fossil/forum_form.html
~
templates/fossil/forum_list.html
~
templates/fossil/partials/timeline_entries.html
~
templates/fossil/release_form.html
~
templates/fossil/release_list.html
~
templates/fossil/tag_list.html
~
templates/fossil/technote_form.html
~
templates/fossil/ticket_edit.html
~
templates/fossil/ticket_form.html
~
templates/fossil/ticket_list.html
~
templates/fossil/timeline.html
~
templates/fossil/unversioned_list.html
~
templates/fossil/webhook_list.html
~
templates/fossil/wiki_form.html
~
templates/fossil/wiki_list.html
~
templates/includes/sidebar.html
~
templates/organization/member_list.html
~
templates/projects/project_list.html
+3
-3
| --- config/settings.py | ||
| +++ config/settings.py | ||
| @@ -136,13 +136,13 @@ | ||
| 136 | 136 | SESSION_COOKIE_HTTPONLY = True |
| 137 | 137 | SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days |
| 138 | 138 | CSRF_COOKIE_HTTPONLY = True |
| 139 | 139 | |
| 140 | 140 | if not DEBUG: |
| 141 | - SESSION_COOKIE_SECURE = True | |
| 142 | - CSRF_COOKIE_SECURE = True | |
| 143 | - SECURE_SSL_REDIRECT = True | |
| 141 | + SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True) | |
| 142 | + CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True) | |
| 143 | + SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", True) | |
| 144 | 144 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") |
| 145 | 145 | |
| 146 | 146 | # --- i18n --- |
| 147 | 147 | |
| 148 | 148 | LANGUAGE_CODE = "en-us" |
| 149 | 149 |
| --- config/settings.py | |
| +++ config/settings.py | |
| @@ -136,13 +136,13 @@ | |
| 136 | SESSION_COOKIE_HTTPONLY = True |
| 137 | SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days |
| 138 | CSRF_COOKIE_HTTPONLY = True |
| 139 | |
| 140 | if not DEBUG: |
| 141 | SESSION_COOKIE_SECURE = True |
| 142 | CSRF_COOKIE_SECURE = True |
| 143 | SECURE_SSL_REDIRECT = True |
| 144 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") |
| 145 | |
| 146 | # --- i18n --- |
| 147 | |
| 148 | LANGUAGE_CODE = "en-us" |
| 149 |
| --- config/settings.py | |
| +++ config/settings.py | |
| @@ -136,13 +136,13 @@ | |
| 136 | SESSION_COOKIE_HTTPONLY = True |
| 137 | SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days |
| 138 | CSRF_COOKIE_HTTPONLY = True |
| 139 | |
| 140 | if not DEBUG: |
| 141 | SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True) |
| 142 | CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True) |
| 143 | SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", True) |
| 144 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") |
| 145 | |
| 146 | # --- i18n --- |
| 147 | |
| 148 | LANGUAGE_CODE = "en-us" |
| 149 |
| --- 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 |
+2
-1
| --- core/context_processors.py | ||
| +++ core/context_processors.py | ||
| @@ -25,11 +25,12 @@ | ||
| 25 | 25 | ungrouped_projects = [p for p in projects if p.id not in grouped_ids] |
| 26 | 26 | |
| 27 | 27 | # Split pages: product docs (known slugs) vs org knowledge base (user-created) |
| 28 | 28 | PRODUCT_DOC_SLUGS = { |
| 29 | 29 | "agentic-development", "api-reference", "architecture", |
| 30 | - "administration", "setup-guide", | |
| 30 | + "administration", "setup-guide", "getting-started", "features", | |
| 31 | + "roadmap", | |
| 31 | 32 | } |
| 32 | 33 | product_docs = [p for p in pages if p.slug in PRODUCT_DOC_SLUGS] |
| 33 | 34 | kb_pages = [p for p in pages if p.slug not in PRODUCT_DOC_SLUGS] |
| 34 | 35 | |
| 35 | 36 | return { |
| 36 | 37 |
| --- core/context_processors.py | |
| +++ core/context_processors.py | |
| @@ -25,11 +25,12 @@ | |
| 25 | ungrouped_projects = [p for p in projects if p.id not in grouped_ids] |
| 26 | |
| 27 | # Split pages: product docs (known slugs) vs org knowledge base (user-created) |
| 28 | PRODUCT_DOC_SLUGS = { |
| 29 | "agentic-development", "api-reference", "architecture", |
| 30 | "administration", "setup-guide", |
| 31 | } |
| 32 | product_docs = [p for p in pages if p.slug in PRODUCT_DOC_SLUGS] |
| 33 | kb_pages = [p for p in pages if p.slug not in PRODUCT_DOC_SLUGS] |
| 34 | |
| 35 | return { |
| 36 |
| --- core/context_processors.py | |
| +++ core/context_processors.py | |
| @@ -25,11 +25,12 @@ | |
| 25 | ungrouped_projects = [p for p in projects if p.id not in grouped_ids] |
| 26 | |
| 27 | # Split pages: product docs (known slugs) vs org knowledge base (user-created) |
| 28 | PRODUCT_DOC_SLUGS = { |
| 29 | "agentic-development", "api-reference", "architecture", |
| 30 | "administration", "setup-guide", "getting-started", "features", |
| 31 | "roadmap", |
| 32 | } |
| 33 | product_docs = [p for p in pages if p.slug in PRODUCT_DOC_SLUGS] |
| 34 | kb_pages = [p for p in pages if p.slug not in PRODUCT_DOC_SLUGS] |
| 35 | |
| 36 | return { |
| 37 |
+84
-16
| --- core/sanitize.py | ||
| +++ core/sanitize.py | ||
| @@ -8,20 +8,77 @@ | ||
| 8 | 8 | import re |
| 9 | 9 | from html.parser import HTMLParser |
| 10 | 10 | from io import StringIO |
| 11 | 11 | |
| 12 | 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 | -}) | |
| 13 | +ALLOWED_TAGS = frozenset( | |
| 14 | + { | |
| 15 | + "a", | |
| 16 | + "abbr", | |
| 17 | + "acronym", | |
| 18 | + "b", | |
| 19 | + "blockquote", | |
| 20 | + "br", | |
| 21 | + "code", | |
| 22 | + "dd", | |
| 23 | + "del", | |
| 24 | + "details", | |
| 25 | + "div", | |
| 26 | + "dl", | |
| 27 | + "dt", | |
| 28 | + "em", | |
| 29 | + "h1", | |
| 30 | + "h2", | |
| 31 | + "h3", | |
| 32 | + "h4", | |
| 33 | + "h5", | |
| 34 | + "h6", | |
| 35 | + "hr", | |
| 36 | + "i", | |
| 37 | + "img", | |
| 38 | + "ins", | |
| 39 | + "kbd", | |
| 40 | + "li", | |
| 41 | + "mark", | |
| 42 | + "ol", | |
| 43 | + "p", | |
| 44 | + "pre", | |
| 45 | + "q", | |
| 46 | + "s", | |
| 47 | + "samp", | |
| 48 | + "small", | |
| 49 | + "span", | |
| 50 | + "strong", | |
| 51 | + "sub", | |
| 52 | + "summary", | |
| 53 | + "sup", | |
| 54 | + "table", | |
| 55 | + "tbody", | |
| 56 | + "td", | |
| 57 | + "tfoot", | |
| 58 | + "th", | |
| 59 | + "thead", | |
| 60 | + "tr", | |
| 61 | + "tt", | |
| 62 | + "u", | |
| 63 | + "ul", | |
| 64 | + "var", | |
| 65 | + # SVG elements for Pikchr diagrams | |
| 66 | + "svg", | |
| 67 | + "path", | |
| 68 | + "circle", | |
| 69 | + "rect", | |
| 70 | + "line", | |
| 71 | + "polyline", | |
| 72 | + "polygon", | |
| 73 | + "g", | |
| 74 | + "text", | |
| 75 | + "defs", | |
| 76 | + "use", | |
| 77 | + "symbol", | |
| 78 | + } | |
| 79 | +) | |
| 23 | 80 | |
| 24 | 81 | # Attributes allowed per tag (all others stripped) |
| 25 | 82 | ALLOWED_ATTRS = { |
| 26 | 83 | "a": {"href", "title", "class", "id", "name"}, |
| 27 | 84 | "img": {"src", "alt", "title", "width", "height", "class"}, |
| @@ -35,12 +92,16 @@ | ||
| 35 | 92 | "ol": {"class", "start", "type"}, |
| 36 | 93 | "ul": {"class"}, |
| 37 | 94 | "li": {"class", "value"}, |
| 38 | 95 | "details": {"open", "class"}, |
| 39 | 96 | "summary": {"class"}, |
| 40 | - "h1": {"id", "class"}, "h2": {"id", "class"}, "h3": {"id", "class"}, | |
| 41 | - "h4": {"id", "class"}, "h5": {"id", "class"}, "h6": {"id", "class"}, | |
| 97 | + "h1": {"id", "class"}, | |
| 98 | + "h2": {"id", "class"}, | |
| 99 | + "h3": {"id", "class"}, | |
| 100 | + "h4": {"id", "class"}, | |
| 101 | + "h5": {"id", "class"}, | |
| 102 | + "h6": {"id", "class"}, | |
| 42 | 103 | # SVG attributes |
| 43 | 104 | "svg": {"viewbox", "width", "height", "class", "xmlns", "fill", "stroke"}, |
| 44 | 105 | "path": {"d", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "class"}, |
| 45 | 106 | "circle": {"cx", "cy", "r", "fill", "stroke", "class"}, |
| 46 | 107 | "rect": {"x", "y", "width", "height", "fill", "stroke", "rx", "ry", "class"}, |
| @@ -60,16 +121,23 @@ | ||
| 60 | 121 | # Regex to detect protocol in a URL (after HTML entity decoding) |
| 61 | 122 | _PROTOCOL_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*):.*", re.DOTALL) |
| 62 | 123 | |
| 63 | 124 | |
| 64 | 125 | 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) | |
| 126 | + """Check if a URL uses a safe protocol. | |
| 127 | + | |
| 128 | + Decodes HTML entities, then strips ASCII control characters (tabs, CRs, NULs, | |
| 129 | + etc.) that browsers silently ignore but can be used to bypass protocol checks | |
| 130 | + (e.g. ``jav	ascript:`` or ``java
script:``). | |
| 131 | + """ | |
| 132 | + decoded = html.unescape(url) | |
| 133 | + # Strip all ASCII control characters (0x00-0x1F, 0x7F) — browsers ignore them | |
| 134 | + # in URL scheme parsing, so "jav\tascript:" is treated as "javascript:" | |
| 135 | + cleaned = re.sub(r"[\x00-\x1f\x7f]", "", decoded).strip() | |
| 136 | + m = _PROTOCOL_RE.match(cleaned) | |
| 68 | 137 | if m: |
| 69 | 138 | return m.group(1).lower() in ALLOWED_PROTOCOLS |
| 70 | - # Relative URLs (no protocol) are safe | |
| 71 | 139 | return True |
| 72 | 140 | |
| 73 | 141 | |
| 74 | 142 | class _SanitizingParser(HTMLParser): |
| 75 | 143 | """HTML parser that only emits allowed tags/attributes.""" |
| 76 | 144 |
| --- core/sanitize.py | |
| +++ core/sanitize.py | |
| @@ -8,20 +8,77 @@ | |
| 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"}, |
| @@ -35,12 +92,16 @@ | |
| 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"}, |
| @@ -60,16 +121,23 @@ | |
| 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 |
| --- core/sanitize.py | |
| +++ core/sanitize.py | |
| @@ -8,20 +8,77 @@ | |
| 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 | { |
| 15 | "a", |
| 16 | "abbr", |
| 17 | "acronym", |
| 18 | "b", |
| 19 | "blockquote", |
| 20 | "br", |
| 21 | "code", |
| 22 | "dd", |
| 23 | "del", |
| 24 | "details", |
| 25 | "div", |
| 26 | "dl", |
| 27 | "dt", |
| 28 | "em", |
| 29 | "h1", |
| 30 | "h2", |
| 31 | "h3", |
| 32 | "h4", |
| 33 | "h5", |
| 34 | "h6", |
| 35 | "hr", |
| 36 | "i", |
| 37 | "img", |
| 38 | "ins", |
| 39 | "kbd", |
| 40 | "li", |
| 41 | "mark", |
| 42 | "ol", |
| 43 | "p", |
| 44 | "pre", |
| 45 | "q", |
| 46 | "s", |
| 47 | "samp", |
| 48 | "small", |
| 49 | "span", |
| 50 | "strong", |
| 51 | "sub", |
| 52 | "summary", |
| 53 | "sup", |
| 54 | "table", |
| 55 | "tbody", |
| 56 | "td", |
| 57 | "tfoot", |
| 58 | "th", |
| 59 | "thead", |
| 60 | "tr", |
| 61 | "tt", |
| 62 | "u", |
| 63 | "ul", |
| 64 | "var", |
| 65 | # SVG elements for Pikchr diagrams |
| 66 | "svg", |
| 67 | "path", |
| 68 | "circle", |
| 69 | "rect", |
| 70 | "line", |
| 71 | "polyline", |
| 72 | "polygon", |
| 73 | "g", |
| 74 | "text", |
| 75 | "defs", |
| 76 | "use", |
| 77 | "symbol", |
| 78 | } |
| 79 | ) |
| 80 | |
| 81 | # Attributes allowed per tag (all others stripped) |
| 82 | ALLOWED_ATTRS = { |
| 83 | "a": {"href", "title", "class", "id", "name"}, |
| 84 | "img": {"src", "alt", "title", "width", "height", "class"}, |
| @@ -35,12 +92,16 @@ | |
| 92 | "ol": {"class", "start", "type"}, |
| 93 | "ul": {"class"}, |
| 94 | "li": {"class", "value"}, |
| 95 | "details": {"open", "class"}, |
| 96 | "summary": {"class"}, |
| 97 | "h1": {"id", "class"}, |
| 98 | "h2": {"id", "class"}, |
| 99 | "h3": {"id", "class"}, |
| 100 | "h4": {"id", "class"}, |
| 101 | "h5": {"id", "class"}, |
| 102 | "h6": {"id", "class"}, |
| 103 | # SVG attributes |
| 104 | "svg": {"viewbox", "width", "height", "class", "xmlns", "fill", "stroke"}, |
| 105 | "path": {"d", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "class"}, |
| 106 | "circle": {"cx", "cy", "r", "fill", "stroke", "class"}, |
| 107 | "rect": {"x", "y", "width", "height", "fill", "stroke", "rx", "ry", "class"}, |
| @@ -60,16 +121,23 @@ | |
| 121 | # Regex to detect protocol in a URL (after HTML entity decoding) |
| 122 | _PROTOCOL_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*):.*", re.DOTALL) |
| 123 | |
| 124 | |
| 125 | def _is_safe_url(url: str) -> bool: |
| 126 | """Check if a URL uses a safe protocol. |
| 127 | |
| 128 | Decodes HTML entities, then strips ASCII control characters (tabs, CRs, NULs, |
| 129 | etc.) that browsers silently ignore but can be used to bypass protocol checks |
| 130 | (e.g. ``jav	ascript:`` or ``java
script:``). |
| 131 | """ |
| 132 | decoded = html.unescape(url) |
| 133 | # Strip all ASCII control characters (0x00-0x1F, 0x7F) — browsers ignore them |
| 134 | # in URL scheme parsing, so "jav\tascript:" is treated as "javascript:" |
| 135 | cleaned = re.sub(r"[\x00-\x1f\x7f]", "", decoded).strip() |
| 136 | m = _PROTOCOL_RE.match(cleaned) |
| 137 | if m: |
| 138 | return m.group(1).lower() in ALLOWED_PROTOCOLS |
| 139 | return True |
| 140 | |
| 141 | |
| 142 | class _SanitizingParser(HTMLParser): |
| 143 | """HTML parser that only emits allowed tags/attributes.""" |
| 144 |
+1
-1
| --- core/url_validation.py | ||
| +++ core/url_validation.py | ||
| @@ -3,11 +3,11 @@ | ||
| 3 | 3 | import ipaddress |
| 4 | 4 | import socket |
| 5 | 5 | from urllib.parse import urlparse |
| 6 | 6 | |
| 7 | 7 | |
| 8 | -def is_safe_webhook_url(url: str) -> tuple[bool, str]: | |
| 8 | +def is_safe_outbound_url(url: str) -> tuple[bool, str]: | |
| 9 | 9 | """Validate a webhook URL is safe for server-side requests. |
| 10 | 10 | |
| 11 | 11 | Blocks: |
| 12 | 12 | - Non-HTTP(S) protocols |
| 13 | 13 | - Localhost and loopback addresses |
| 14 | 14 |
| --- core/url_validation.py | |
| +++ core/url_validation.py | |
| @@ -3,11 +3,11 @@ | |
| 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 |
| --- core/url_validation.py | |
| +++ core/url_validation.py | |
| @@ -3,11 +3,11 @@ | |
| 3 | import ipaddress |
| 4 | import socket |
| 5 | from urllib.parse import urlparse |
| 6 | |
| 7 | |
| 8 | def is_safe_outbound_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 |
+13
-26
| --- deploy.sh | ||
| +++ deploy.sh | ||
| @@ -1,15 +1,13 @@ | ||
| 1 | 1 | #!/bin/bash |
| 2 | -# deploy.sh — push local state to fossilrepo.io | |
| 3 | -# | |
| 4 | -# Usage: ./deploy.sh [message] | |
| 5 | -# | |
| 6 | -# Requires .env.deploy with: | |
| 7 | -# FOSSIL_REMOTE_URL=https://admin:[email protected]/projects/fossilrepo/fossil/xfer | |
| 8 | -# EC2_INSTANCE_ID=i-xxxx | |
| 9 | -# S3_BUCKET=dev-fossilrepo-storage | |
| 10 | -# AWS_REGION=us-west-2 | |
| 2 | +# deploy.sh — push local changes to fossilrepo.io | |
| 3 | +# | |
| 4 | +# Flow: | |
| 5 | +# 1. Fossil commit + push (writes to /data/repos/fossilrepo.fossil on server) | |
| 6 | +# 2. SSM runs fossilrepo-deploy (fossil update + pip + migrate + collectstatic + restart) | |
| 7 | +# | |
| 8 | +# Requires .env.deploy with FOSSIL_REMOTE_URL and EC2_INSTANCE_ID | |
| 11 | 9 | |
| 12 | 10 | set -euo pipefail |
| 13 | 11 | |
| 14 | 12 | if [[ ! -f .env.deploy ]]; then |
| 15 | 13 | echo "Missing .env.deploy -- copy .env.deploy.example and fill in your values" |
| @@ -20,32 +18,21 @@ | ||
| 20 | 18 | |
| 21 | 19 | MSG="${1:-Deploy $(date +%Y-%m-%d-%H%M)}" |
| 22 | 20 | |
| 23 | 21 | echo "=== Fossil commit ===" |
| 24 | 22 | fossil addremove 2>/dev/null || true |
| 25 | -fossil commit -m "$MSG" 2>&1 || echo "Nothing to commit" | |
| 23 | +fossil commit --no-warnings -m "$MSG" 2>&1 || echo "Nothing to commit" | |
| 26 | 24 | |
| 27 | 25 | echo "=== Fossil push ===" |
| 28 | 26 | fossil push "$FOSSIL_REMOTE_URL" |
| 29 | 27 | |
| 30 | -echo "=== Sync repos to S3 ===" | |
| 31 | -AWS_PROFILE=fossiladmin aws s3 sync repos/ "s3://${S3_BUCKET}/sync/repos/" --region "$AWS_REGION" --exclude "*.fossil-shm" --exclude "*.fossil-wal" 2>&1 | tail -3 | |
| 32 | - | |
| 33 | -echo "=== Sync DB to S3 ===" | |
| 34 | -docker compose exec -T postgres pg_dump -U dbadmin --data-only --inserts --no-owner --no-privileges fossilrepo > /tmp/fossilrepo-data.sql | |
| 35 | -AWS_PROFILE=fossiladmin aws s3 cp /tmp/fossilrepo-data.sql "s3://${S3_BUCKET}/sync/data.sql" --region "$AWS_REGION" 2>&1 | tail -1 | |
| 36 | - | |
| 37 | -echo "=== Push code to git ===" | |
| 38 | -git add -A && git commit -m "$MSG" 2>/dev/null || true | |
| 39 | -git push origin main 2>&1 || echo "Git push failed (non-critical)" | |
| 40 | - | |
| 41 | -echo "=== Deploy to EC2 ===" | |
| 28 | +echo "=== Deploy to server ===" | |
| 42 | 29 | AWS_PROFILE=fossiladmin aws ssm send-command \ |
| 43 | 30 | --instance-ids "$EC2_INSTANCE_ID" \ |
| 44 | 31 | --document-name "AWS-RunShellScript" \ |
| 45 | - --timeout-seconds 300 \ | |
| 46 | - --parameters "{\"commands\":[\"export HOME=/root && aws s3 cp s3://${S3_BUCKET}/sync-to-cloud.sh /tmp/sync-to-cloud.sh --region ${AWS_REGION} && bash /tmp/sync-to-cloud.sh 2>&1\"]}" \ | |
| 47 | - --region "$AWS_REGION" \ | |
| 32 | + --timeout-seconds 120 \ | |
| 33 | + --parameters '{"commands":["export HOME=/root && fossilrepo-deploy 2>&1"]}' \ | |
| 34 | + --region "${AWS_REGION:-us-west-2}" \ | |
| 48 | 35 | --query "Command.CommandId" \ |
| 49 | 36 | --output text |
| 50 | 37 | |
| 51 | -echo "=== Deploy triggered ===" | |
| 38 | +echo "=== Deployed ===" | |
| 52 | 39 |
| --- deploy.sh | |
| +++ deploy.sh | |
| @@ -1,15 +1,13 @@ | |
| 1 | #!/bin/bash |
| 2 | # deploy.sh — push local state to fossilrepo.io |
| 3 | # |
| 4 | # Usage: ./deploy.sh [message] |
| 5 | # |
| 6 | # Requires .env.deploy with: |
| 7 | # FOSSIL_REMOTE_URL=https://admin:[email protected]/projects/fossilrepo/fossil/xfer |
| 8 | # EC2_INSTANCE_ID=i-xxxx |
| 9 | # S3_BUCKET=dev-fossilrepo-storage |
| 10 | # AWS_REGION=us-west-2 |
| 11 | |
| 12 | set -euo pipefail |
| 13 | |
| 14 | if [[ ! -f .env.deploy ]]; then |
| 15 | echo "Missing .env.deploy -- copy .env.deploy.example and fill in your values" |
| @@ -20,32 +18,21 @@ | |
| 20 | |
| 21 | MSG="${1:-Deploy $(date +%Y-%m-%d-%H%M)}" |
| 22 | |
| 23 | echo "=== Fossil commit ===" |
| 24 | fossil addremove 2>/dev/null || true |
| 25 | fossil commit -m "$MSG" 2>&1 || echo "Nothing to commit" |
| 26 | |
| 27 | echo "=== Fossil push ===" |
| 28 | fossil push "$FOSSIL_REMOTE_URL" |
| 29 | |
| 30 | echo "=== Sync repos to S3 ===" |
| 31 | AWS_PROFILE=fossiladmin aws s3 sync repos/ "s3://${S3_BUCKET}/sync/repos/" --region "$AWS_REGION" --exclude "*.fossil-shm" --exclude "*.fossil-wal" 2>&1 | tail -3 |
| 32 | |
| 33 | echo "=== Sync DB to S3 ===" |
| 34 | docker compose exec -T postgres pg_dump -U dbadmin --data-only --inserts --no-owner --no-privileges fossilrepo > /tmp/fossilrepo-data.sql |
| 35 | AWS_PROFILE=fossiladmin aws s3 cp /tmp/fossilrepo-data.sql "s3://${S3_BUCKET}/sync/data.sql" --region "$AWS_REGION" 2>&1 | tail -1 |
| 36 | |
| 37 | echo "=== Push code to git ===" |
| 38 | git add -A && git commit -m "$MSG" 2>/dev/null || true |
| 39 | git push origin main 2>&1 || echo "Git push failed (non-critical)" |
| 40 | |
| 41 | echo "=== Deploy to EC2 ===" |
| 42 | AWS_PROFILE=fossiladmin aws ssm send-command \ |
| 43 | --instance-ids "$EC2_INSTANCE_ID" \ |
| 44 | --document-name "AWS-RunShellScript" \ |
| 45 | --timeout-seconds 300 \ |
| 46 | --parameters "{\"commands\":[\"export HOME=/root && aws s3 cp s3://${S3_BUCKET}/sync-to-cloud.sh /tmp/sync-to-cloud.sh --region ${AWS_REGION} && bash /tmp/sync-to-cloud.sh 2>&1\"]}" \ |
| 47 | --region "$AWS_REGION" \ |
| 48 | --query "Command.CommandId" \ |
| 49 | --output text |
| 50 | |
| 51 | echo "=== Deploy triggered ===" |
| 52 |
| --- deploy.sh | |
| +++ deploy.sh | |
| @@ -1,15 +1,13 @@ | |
| 1 | #!/bin/bash |
| 2 | # deploy.sh — push local changes to fossilrepo.io |
| 3 | # |
| 4 | # Flow: |
| 5 | # 1. Fossil commit + push (writes to /data/repos/fossilrepo.fossil on server) |
| 6 | # 2. SSM runs fossilrepo-deploy (fossil update + pip + migrate + collectstatic + restart) |
| 7 | # |
| 8 | # Requires .env.deploy with FOSSIL_REMOTE_URL and EC2_INSTANCE_ID |
| 9 | |
| 10 | set -euo pipefail |
| 11 | |
| 12 | if [[ ! -f .env.deploy ]]; then |
| 13 | echo "Missing .env.deploy -- copy .env.deploy.example and fill in your values" |
| @@ -20,32 +18,21 @@ | |
| 18 | |
| 19 | MSG="${1:-Deploy $(date +%Y-%m-%d-%H%M)}" |
| 20 | |
| 21 | echo "=== Fossil commit ===" |
| 22 | fossil addremove 2>/dev/null || true |
| 23 | fossil commit --no-warnings -m "$MSG" 2>&1 || echo "Nothing to commit" |
| 24 | |
| 25 | echo "=== Fossil push ===" |
| 26 | fossil push "$FOSSIL_REMOTE_URL" |
| 27 | |
| 28 | echo "=== Deploy to server ===" |
| 29 | AWS_PROFILE=fossiladmin aws ssm send-command \ |
| 30 | --instance-ids "$EC2_INSTANCE_ID" \ |
| 31 | --document-name "AWS-RunShellScript" \ |
| 32 | --timeout-seconds 120 \ |
| 33 | --parameters '{"commands":["export HOME=/root && fossilrepo-deploy 2>&1"]}' \ |
| 34 | --region "${AWS_REGION:-us-west-2}" \ |
| 35 | --query "Command.CommandId" \ |
| 36 | --output text |
| 37 | |
| 38 | echo "=== Deployed ===" |
| 39 |
+4
| --- docker-compose.yaml | ||
| +++ docker-compose.yaml | ||
| @@ -6,10 +6,14 @@ | ||
| 6 | 6 | - "8000:8000" |
| 7 | 7 | - "2222:2222" |
| 8 | 8 | env_file: .env.example |
| 9 | 9 | environment: |
| 10 | 10 | DJANGO_DEBUG: "true" |
| 11 | + DJANGO_SECRET_KEY: "local-dev-only-not-for-production-use-change-in-prod" | |
| 12 | + SECURE_SSL_REDIRECT: "false" | |
| 13 | + SESSION_COOKIE_SECURE: "false" | |
| 14 | + CSRF_COOKIE_SECURE: "false" | |
| 11 | 15 | POSTGRES_HOST: postgres |
| 12 | 16 | REDIS_URL: redis://redis:6379/1 |
| 13 | 17 | CELERY_BROKER: redis://redis:6379/0 |
| 14 | 18 | EMAIL_HOST: mailpit |
| 15 | 19 | volumes: |
| 16 | 20 |
| --- docker-compose.yaml | |
| +++ docker-compose.yaml | |
| @@ -6,10 +6,14 @@ | |
| 6 | - "8000:8000" |
| 7 | - "2222:2222" |
| 8 | env_file: .env.example |
| 9 | environment: |
| 10 | DJANGO_DEBUG: "true" |
| 11 | POSTGRES_HOST: postgres |
| 12 | REDIS_URL: redis://redis:6379/1 |
| 13 | CELERY_BROKER: redis://redis:6379/0 |
| 14 | EMAIL_HOST: mailpit |
| 15 | volumes: |
| 16 |
| --- docker-compose.yaml | |
| +++ docker-compose.yaml | |
| @@ -6,10 +6,14 @@ | |
| 6 | - "8000:8000" |
| 7 | - "2222:2222" |
| 8 | env_file: .env.example |
| 9 | environment: |
| 10 | DJANGO_DEBUG: "true" |
| 11 | DJANGO_SECRET_KEY: "local-dev-only-not-for-production-use-change-in-prod" |
| 12 | SECURE_SSL_REDIRECT: "false" |
| 13 | SESSION_COOKIE_SECURE: "false" |
| 14 | CSRF_COOKIE_SECURE: "false" |
| 15 | POSTGRES_HOST: postgres |
| 16 | REDIS_URL: redis://redis:6379/1 |
| 17 | CELERY_BROKER: redis://redis:6379/0 |
| 18 | EMAIL_HOST: mailpit |
| 19 | volumes: |
| 20 |
+36
-5
| --- fossil/api_auth.py | ||
| +++ fossil/api_auth.py | ||
| @@ -1,26 +1,31 @@ | ||
| 1 | 1 | """API authentication for both project-scoped and user-scoped tokens. |
| 2 | 2 | |
| 3 | 3 | Supports: |
| 4 | -1. Project-scoped APIToken (tied to a FossilRepository) | |
| 5 | -2. User-scoped PersonalAccessToken (tied to a Django User) | |
| 4 | +1. Project-scoped APIToken (tied to a FossilRepository) — permissions enforced | |
| 5 | +2. User-scoped PersonalAccessToken (tied to a Django User) — scopes enforced | |
| 6 | 6 | 3. Session auth fallback (for browser testing) |
| 7 | 7 | """ |
| 8 | 8 | |
| 9 | 9 | from django.http import JsonResponse |
| 10 | 10 | from django.utils import timezone |
| 11 | 11 | |
| 12 | 12 | |
| 13 | -def authenticate_request(request, repository=None): | |
| 13 | +def authenticate_request(request, repository=None, required_scope="read"): | |
| 14 | 14 | """Authenticate an API request via Bearer token. |
| 15 | 15 | |
| 16 | + Args: | |
| 17 | + request: Django request object | |
| 18 | + repository: FossilRepository instance (for project-scoped token lookup) | |
| 19 | + required_scope: "read", "write", or "admin" — the minimum scope needed | |
| 20 | + | |
| 16 | 21 | Returns (user_or_none, token_or_none, error_response_or_none). |
| 17 | 22 | If error_response is not None, return it immediately. |
| 18 | 23 | """ |
| 19 | 24 | auth = request.META.get("HTTP_AUTHORIZATION", "") |
| 20 | 25 | if not auth.startswith("Bearer "): |
| 21 | - # Fall back to session auth | |
| 26 | + # Fall back to session auth — session users have full access | |
| 22 | 27 | if request.user.is_authenticated: |
| 23 | 28 | return request.user, None, None |
| 24 | 29 | return None, None, JsonResponse({"error": "Authentication required"}, status=401) |
| 25 | 30 | |
| 26 | 31 | raw_token = auth[7:] |
| @@ -32,13 +37,16 @@ | ||
| 32 | 37 | token_hash = APIToken.hash_token(raw_token) |
| 33 | 38 | try: |
| 34 | 39 | token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True) |
| 35 | 40 | if token.expires_at and token.expires_at < timezone.now(): |
| 36 | 41 | return None, None, JsonResponse({"error": "Token expired"}, status=401) |
| 42 | + # Enforce token permissions | |
| 43 | + if not _token_has_scope(token.permissions, required_scope): | |
| 44 | + return None, None, JsonResponse({"error": f"Token lacks required scope: {required_scope}"}, status=403) | |
| 37 | 45 | token.last_used_at = timezone.now() |
| 38 | 46 | token.save(update_fields=["last_used_at"]) |
| 39 | - return None, token, None # No user, but valid project token | |
| 47 | + return None, token, None | |
| 40 | 48 | except APIToken.DoesNotExist: |
| 41 | 49 | pass |
| 42 | 50 | |
| 43 | 51 | # Try user-scoped PersonalAccessToken |
| 44 | 52 | from accounts.models import PersonalAccessToken |
| @@ -46,12 +54,35 @@ | ||
| 46 | 54 | token_hash = PersonalAccessToken.hash_token(raw_token) |
| 47 | 55 | try: |
| 48 | 56 | pat = PersonalAccessToken.objects.get(token_hash=token_hash, revoked_at__isnull=True) |
| 49 | 57 | if pat.expires_at and pat.expires_at < timezone.now(): |
| 50 | 58 | return None, None, JsonResponse({"error": "Token expired"}, status=401) |
| 59 | + # Enforce PAT scopes | |
| 60 | + if not _token_has_scope(pat.scopes, required_scope): | |
| 61 | + return None, None, JsonResponse({"error": f"Token lacks required scope: {required_scope}"}, status=403) | |
| 51 | 62 | pat.last_used_at = timezone.now() |
| 52 | 63 | pat.save(update_fields=["last_used_at"]) |
| 53 | 64 | return pat.user, pat, None |
| 54 | 65 | except PersonalAccessToken.DoesNotExist: |
| 55 | 66 | pass |
| 56 | 67 | |
| 57 | 68 | return None, None, JsonResponse({"error": "Invalid token"}, status=401) |
| 69 | + | |
| 70 | + | |
| 71 | +def _token_has_scope(token_scopes: str, required: str) -> bool: | |
| 72 | + """Check if a comma-separated scope string includes the required scope. | |
| 73 | + | |
| 74 | + Scope hierarchy: admin > write > read | |
| 75 | + A token with "write" scope can do "read" operations. | |
| 76 | + A token with "*" or "admin" can do everything. | |
| 77 | + """ | |
| 78 | + scopes = {s.strip().lower() for s in token_scopes.split(",") if s.strip()} | |
| 79 | + | |
| 80 | + if "*" in scopes or "admin" in scopes: | |
| 81 | + return True | |
| 82 | + if required == "read": | |
| 83 | + return bool(scopes & {"read", "write", "admin", "status:write"}) | |
| 84 | + if required == "write": | |
| 85 | + return "write" in scopes | |
| 86 | + if required == "status:write": | |
| 87 | + return bool(scopes & {"status:write", "write", "admin", "*"}) | |
| 88 | + return False | |
| 58 | 89 |
| --- fossil/api_auth.py | |
| +++ fossil/api_auth.py | |
| @@ -1,26 +1,31 @@ | |
| 1 | """API authentication for both project-scoped and user-scoped tokens. |
| 2 | |
| 3 | Supports: |
| 4 | 1. Project-scoped APIToken (tied to a FossilRepository) |
| 5 | 2. User-scoped PersonalAccessToken (tied to a Django User) |
| 6 | 3. Session auth fallback (for browser testing) |
| 7 | """ |
| 8 | |
| 9 | from django.http import JsonResponse |
| 10 | from django.utils import timezone |
| 11 | |
| 12 | |
| 13 | def authenticate_request(request, repository=None): |
| 14 | """Authenticate an API request via Bearer token. |
| 15 | |
| 16 | Returns (user_or_none, token_or_none, error_response_or_none). |
| 17 | If error_response is not None, return it immediately. |
| 18 | """ |
| 19 | auth = request.META.get("HTTP_AUTHORIZATION", "") |
| 20 | if not auth.startswith("Bearer "): |
| 21 | # Fall back to session auth |
| 22 | if request.user.is_authenticated: |
| 23 | return request.user, None, None |
| 24 | return None, None, JsonResponse({"error": "Authentication required"}, status=401) |
| 25 | |
| 26 | raw_token = auth[7:] |
| @@ -32,13 +37,16 @@ | |
| 32 | token_hash = APIToken.hash_token(raw_token) |
| 33 | try: |
| 34 | token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True) |
| 35 | if token.expires_at and token.expires_at < timezone.now(): |
| 36 | return None, None, JsonResponse({"error": "Token expired"}, status=401) |
| 37 | token.last_used_at = timezone.now() |
| 38 | token.save(update_fields=["last_used_at"]) |
| 39 | return None, token, None # No user, but valid project token |
| 40 | except APIToken.DoesNotExist: |
| 41 | pass |
| 42 | |
| 43 | # Try user-scoped PersonalAccessToken |
| 44 | from accounts.models import PersonalAccessToken |
| @@ -46,12 +54,35 @@ | |
| 46 | token_hash = PersonalAccessToken.hash_token(raw_token) |
| 47 | try: |
| 48 | pat = PersonalAccessToken.objects.get(token_hash=token_hash, revoked_at__isnull=True) |
| 49 | if pat.expires_at and pat.expires_at < timezone.now(): |
| 50 | return None, None, JsonResponse({"error": "Token expired"}, status=401) |
| 51 | pat.last_used_at = timezone.now() |
| 52 | pat.save(update_fields=["last_used_at"]) |
| 53 | return pat.user, pat, None |
| 54 | except PersonalAccessToken.DoesNotExist: |
| 55 | pass |
| 56 | |
| 57 | return None, None, JsonResponse({"error": "Invalid token"}, status=401) |
| 58 |
| --- fossil/api_auth.py | |
| +++ fossil/api_auth.py | |
| @@ -1,26 +1,31 @@ | |
| 1 | """API authentication for both project-scoped and user-scoped tokens. |
| 2 | |
| 3 | Supports: |
| 4 | 1. Project-scoped APIToken (tied to a FossilRepository) — permissions enforced |
| 5 | 2. User-scoped PersonalAccessToken (tied to a Django User) — scopes enforced |
| 6 | 3. Session auth fallback (for browser testing) |
| 7 | """ |
| 8 | |
| 9 | from django.http import JsonResponse |
| 10 | from django.utils import timezone |
| 11 | |
| 12 | |
| 13 | def authenticate_request(request, repository=None, required_scope="read"): |
| 14 | """Authenticate an API request via Bearer token. |
| 15 | |
| 16 | Args: |
| 17 | request: Django request object |
| 18 | repository: FossilRepository instance (for project-scoped token lookup) |
| 19 | required_scope: "read", "write", or "admin" — the minimum scope needed |
| 20 | |
| 21 | Returns (user_or_none, token_or_none, error_response_or_none). |
| 22 | If error_response is not None, return it immediately. |
| 23 | """ |
| 24 | auth = request.META.get("HTTP_AUTHORIZATION", "") |
| 25 | if not auth.startswith("Bearer "): |
| 26 | # Fall back to session auth — session users have full access |
| 27 | if request.user.is_authenticated: |
| 28 | return request.user, None, None |
| 29 | return None, None, JsonResponse({"error": "Authentication required"}, status=401) |
| 30 | |
| 31 | raw_token = auth[7:] |
| @@ -32,13 +37,16 @@ | |
| 37 | token_hash = APIToken.hash_token(raw_token) |
| 38 | try: |
| 39 | token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True) |
| 40 | if token.expires_at and token.expires_at < timezone.now(): |
| 41 | return None, None, JsonResponse({"error": "Token expired"}, status=401) |
| 42 | # Enforce token permissions |
| 43 | if not _token_has_scope(token.permissions, required_scope): |
| 44 | return None, None, JsonResponse({"error": f"Token lacks required scope: {required_scope}"}, status=403) |
| 45 | token.last_used_at = timezone.now() |
| 46 | token.save(update_fields=["last_used_at"]) |
| 47 | return None, token, None |
| 48 | except APIToken.DoesNotExist: |
| 49 | pass |
| 50 | |
| 51 | # Try user-scoped PersonalAccessToken |
| 52 | from accounts.models import PersonalAccessToken |
| @@ -46,12 +54,35 @@ | |
| 54 | token_hash = PersonalAccessToken.hash_token(raw_token) |
| 55 | try: |
| 56 | pat = PersonalAccessToken.objects.get(token_hash=token_hash, revoked_at__isnull=True) |
| 57 | if pat.expires_at and pat.expires_at < timezone.now(): |
| 58 | return None, None, JsonResponse({"error": "Token expired"}, status=401) |
| 59 | # Enforce PAT scopes |
| 60 | if not _token_has_scope(pat.scopes, required_scope): |
| 61 | return None, None, JsonResponse({"error": f"Token lacks required scope: {required_scope}"}, status=403) |
| 62 | pat.last_used_at = timezone.now() |
| 63 | pat.save(update_fields=["last_used_at"]) |
| 64 | return pat.user, pat, None |
| 65 | except PersonalAccessToken.DoesNotExist: |
| 66 | pass |
| 67 | |
| 68 | return None, None, JsonResponse({"error": "Invalid token"}, status=401) |
| 69 | |
| 70 | |
| 71 | def _token_has_scope(token_scopes: str, required: str) -> bool: |
| 72 | """Check if a comma-separated scope string includes the required scope. |
| 73 | |
| 74 | Scope hierarchy: admin > write > read |
| 75 | A token with "write" scope can do "read" operations. |
| 76 | A token with "*" or "admin" can do everything. |
| 77 | """ |
| 78 | scopes = {s.strip().lower() for s in token_scopes.split(",") if s.strip()} |
| 79 | |
| 80 | if "*" in scopes or "admin" in scopes: |
| 81 | return True |
| 82 | if required == "read": |
| 83 | return bool(scopes & {"read", "write", "admin", "status:write"}) |
| 84 | if required == "write": |
| 85 | return "write" in scopes |
| 86 | if required == "status:write": |
| 87 | return bool(scopes & {"status:write", "write", "admin", "*"}) |
| 88 | return False |
| 89 |
+38
-23
| --- fossil/api_views.py | ||
| +++ fossil/api_views.py | ||
| @@ -36,28 +36,34 @@ | ||
| 36 | 36 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 37 | 37 | repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 38 | 38 | return project, repo |
| 39 | 39 | |
| 40 | 40 | |
| 41 | -def _check_api_auth(request, project, repo): | |
| 42 | - """Authenticate request and check read access. | |
| 41 | +def _check_api_auth(request, project, repo, required_scope="read"): | |
| 42 | + """Authenticate request and check access. | |
| 43 | + | |
| 44 | + Args: | |
| 45 | + required_scope: "read" or "write" — enforced on both API tokens and PAT scopes. | |
| 43 | 46 | |
| 44 | 47 | Returns (user, token, error_response). If error_response is not None, |
| 45 | 48 | the caller should return it immediately. |
| 46 | 49 | """ |
| 47 | - user, token, err = authenticate_request(request, repository=repo) | |
| 50 | + user, token, err = authenticate_request(request, repository=repo, required_scope=required_scope) | |
| 48 | 51 | if err is not None: |
| 49 | 52 | return None, None, err |
| 50 | 53 | |
| 51 | 54 | # For project-scoped APITokens (no user), the token itself grants access |
| 52 | - # since it's already scoped to this repository. | |
| 55 | + # since it's scoped to this repository and scope was already checked. | |
| 53 | 56 | if token is not None and user is None: |
| 54 | 57 | return user, token, None |
| 55 | 58 | |
| 56 | 59 | # For user-scoped auth (PAT or session), check project visibility |
| 57 | - if user is not None and not can_read_project(user, project): | |
| 58 | - return None, None, JsonResponse({"error": "Access denied"}, status=403) | |
| 60 | + if user is not None: | |
| 61 | + if required_scope == "write" and not can_write_project(user, project): | |
| 62 | + return None, None, JsonResponse({"error": "Write access required"}, status=403) | |
| 63 | + if not can_read_project(user, project): | |
| 64 | + return None, None, JsonResponse({"error": "Access denied"}, status=403) | |
| 59 | 65 | |
| 60 | 66 | return user, token, None |
| 61 | 67 | |
| 62 | 68 | |
| 63 | 69 | def _paginate_params(request, default_per_page=25, max_per_page=100): |
| @@ -764,11 +770,11 @@ | ||
| 764 | 770 | """ |
| 765 | 771 | if request.method != "POST": |
| 766 | 772 | return JsonResponse({"error": "POST required"}, status=405) |
| 767 | 773 | |
| 768 | 774 | project, repo = _get_repo(slug) |
| 769 | - user, token, err = _check_api_auth(request, project, repo) | |
| 775 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 770 | 776 | if err is not None: |
| 771 | 777 | return err |
| 772 | 778 | |
| 773 | 779 | # Write access required to create workspaces |
| 774 | 780 | if token is None and (user is None or not can_write_project(user, project)): |
| @@ -914,11 +920,11 @@ | ||
| 914 | 920 | """ |
| 915 | 921 | if request.method != "POST": |
| 916 | 922 | return JsonResponse({"error": "POST required"}, status=405) |
| 917 | 923 | |
| 918 | 924 | project, repo = _get_repo(slug) |
| 919 | - user, token, err = _check_api_auth(request, project, repo) | |
| 925 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 920 | 926 | if err is not None: |
| 921 | 927 | return err |
| 922 | 928 | |
| 923 | 929 | if token is None and (user is None or not can_write_project(user, project)): |
| 924 | 930 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1011,11 +1017,11 @@ | ||
| 1011 | 1017 | """ |
| 1012 | 1018 | if request.method != "POST": |
| 1013 | 1019 | return JsonResponse({"error": "POST required"}, status=405) |
| 1014 | 1020 | |
| 1015 | 1021 | project, repo = _get_repo(slug) |
| 1016 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1022 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1017 | 1023 | if err is not None: |
| 1018 | 1024 | return err |
| 1019 | 1025 | |
| 1020 | 1026 | if token is None and (user is None or not can_write_project(user, project)): |
| 1021 | 1027 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1072,11 +1078,18 @@ | ||
| 1072 | 1078 | timeout=60, |
| 1073 | 1079 | env=cli._env, |
| 1074 | 1080 | cwd=checkout_dir, |
| 1075 | 1081 | ) |
| 1076 | 1082 | |
| 1077 | - # Close the checkout and clean up | |
| 1083 | + if commit_result.returncode != 0: | |
| 1084 | + # Merge commit failed — don't close the workspace, let the user retry | |
| 1085 | + return JsonResponse( | |
| 1086 | + {"error": "Merge commit failed", "detail": commit_result.stderr.strip()}, | |
| 1087 | + status=500, | |
| 1088 | + ) | |
| 1089 | + | |
| 1090 | + # Close the checkout and clean up (only on successful commit) | |
| 1078 | 1091 | subprocess.run([cli.binary, "close", "--force"], capture_output=True, cwd=checkout_dir, timeout=10, env=cli._env) |
| 1079 | 1092 | shutil.rmtree(checkout_dir, ignore_errors=True) |
| 1080 | 1093 | |
| 1081 | 1094 | workspace.status = "merged" |
| 1082 | 1095 | workspace.checkout_path = "" |
| @@ -1104,11 +1117,11 @@ | ||
| 1104 | 1117 | """ |
| 1105 | 1118 | if request.method != "DELETE": |
| 1106 | 1119 | return JsonResponse({"error": "DELETE required"}, status=405) |
| 1107 | 1120 | |
| 1108 | 1121 | project, repo = _get_repo(slug) |
| 1109 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1122 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1110 | 1123 | if err is not None: |
| 1111 | 1124 | return err |
| 1112 | 1125 | |
| 1113 | 1126 | if token is None and (user is None or not can_write_project(user, project)): |
| 1114 | 1127 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1158,11 +1171,11 @@ | ||
| 1158 | 1171 | """ |
| 1159 | 1172 | if request.method != "POST": |
| 1160 | 1173 | return JsonResponse({"error": "POST required"}, status=405) |
| 1161 | 1174 | |
| 1162 | 1175 | project, repo = _get_repo(slug) |
| 1163 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1176 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1164 | 1177 | if err is not None: |
| 1165 | 1178 | return err |
| 1166 | 1179 | |
| 1167 | 1180 | if token is None and (user is None or not can_write_project(user, project)): |
| 1168 | 1181 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1249,11 +1262,11 @@ | ||
| 1249 | 1262 | """ |
| 1250 | 1263 | if request.method != "POST": |
| 1251 | 1264 | return JsonResponse({"error": "POST required"}, status=405) |
| 1252 | 1265 | |
| 1253 | 1266 | project, repo = _get_repo(slug) |
| 1254 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1267 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1255 | 1268 | if err is not None: |
| 1256 | 1269 | return err |
| 1257 | 1270 | |
| 1258 | 1271 | if token is None and (user is None or not can_write_project(user, project)): |
| 1259 | 1272 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1297,11 +1310,11 @@ | ||
| 1297 | 1310 | """ |
| 1298 | 1311 | if request.method != "POST": |
| 1299 | 1312 | return JsonResponse({"error": "POST required"}, status=405) |
| 1300 | 1313 | |
| 1301 | 1314 | project, repo = _get_repo(slug) |
| 1302 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1315 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1303 | 1316 | if err is not None: |
| 1304 | 1317 | return err |
| 1305 | 1318 | |
| 1306 | 1319 | if token is None and (user is None or not can_write_project(user, project)): |
| 1307 | 1320 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1559,11 +1572,11 @@ | ||
| 1559 | 1572 | """ |
| 1560 | 1573 | if request.method != "POST": |
| 1561 | 1574 | return JsonResponse({"error": "POST required"}, status=405) |
| 1562 | 1575 | |
| 1563 | 1576 | project, repo = _get_repo(slug) |
| 1564 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1577 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1565 | 1578 | if err is not None: |
| 1566 | 1579 | return err |
| 1567 | 1580 | |
| 1568 | 1581 | if token is None and (user is None or not can_write_project(user, project)): |
| 1569 | 1582 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1736,11 +1749,11 @@ | ||
| 1736 | 1749 | """ |
| 1737 | 1750 | if request.method != "POST": |
| 1738 | 1751 | return JsonResponse({"error": "POST required"}, status=405) |
| 1739 | 1752 | |
| 1740 | 1753 | project, repo = _get_repo(slug) |
| 1741 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1754 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1742 | 1755 | if err is not None: |
| 1743 | 1756 | return err |
| 1744 | 1757 | |
| 1745 | 1758 | from fossil.code_reviews import CodeReview, ReviewComment |
| 1746 | 1759 | |
| @@ -1755,15 +1768,17 @@ | ||
| 1755 | 1768 | |
| 1756 | 1769 | body = (data.get("body") or "").strip() |
| 1757 | 1770 | if not body: |
| 1758 | 1771 | return JsonResponse({"error": "Comment body is required"}, status=400) |
| 1759 | 1772 | |
| 1760 | - author = (data.get("author") or "").strip() | |
| 1761 | - if not author and user: | |
| 1773 | + # Determine author from auth context, not caller-supplied data | |
| 1774 | + if user: | |
| 1762 | 1775 | author = user.username |
| 1763 | - if not author: | |
| 1764 | - return JsonResponse({"error": "Author is required"}, status=400) | |
| 1776 | + elif token: | |
| 1777 | + author = f"token:{token.name}" if hasattr(token, "name") else "api-token" | |
| 1778 | + else: | |
| 1779 | + author = "anonymous" | |
| 1765 | 1780 | |
| 1766 | 1781 | comment = ReviewComment.objects.create( |
| 1767 | 1782 | review=review, |
| 1768 | 1783 | body=body, |
| 1769 | 1784 | file_path=data.get("file_path", ""), |
| @@ -1793,11 +1808,11 @@ | ||
| 1793 | 1808 | """ |
| 1794 | 1809 | if request.method != "POST": |
| 1795 | 1810 | return JsonResponse({"error": "POST required"}, status=405) |
| 1796 | 1811 | |
| 1797 | 1812 | project, repo = _get_repo(slug) |
| 1798 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1813 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1799 | 1814 | if err is not None: |
| 1800 | 1815 | return err |
| 1801 | 1816 | |
| 1802 | 1817 | if token is None and (user is None or not can_write_project(user, project)): |
| 1803 | 1818 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1826,11 +1841,11 @@ | ||
| 1826 | 1841 | """ |
| 1827 | 1842 | if request.method != "POST": |
| 1828 | 1843 | return JsonResponse({"error": "POST required"}, status=405) |
| 1829 | 1844 | |
| 1830 | 1845 | project, repo = _get_repo(slug) |
| 1831 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1846 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1832 | 1847 | if err is not None: |
| 1833 | 1848 | return err |
| 1834 | 1849 | |
| 1835 | 1850 | if token is None and (user is None or not can_write_project(user, project)): |
| 1836 | 1851 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1877,11 +1892,11 @@ | ||
| 1877 | 1892 | """ |
| 1878 | 1893 | if request.method != "POST": |
| 1879 | 1894 | return JsonResponse({"error": "POST required"}, status=405) |
| 1880 | 1895 | |
| 1881 | 1896 | project, repo = _get_repo(slug) |
| 1882 | - user, token, err = _check_api_auth(request, project, repo) | |
| 1897 | + user, token, err = _check_api_auth(request, project, repo, required_scope="write") | |
| 1883 | 1898 | if err is not None: |
| 1884 | 1899 | return err |
| 1885 | 1900 | |
| 1886 | 1901 | if token is None and (user is None or not can_write_project(user, project)): |
| 1887 | 1902 | return JsonResponse({"error": "Write access required"}, status=403) |
| 1888 | 1903 |
| --- fossil/api_views.py | |
| +++ fossil/api_views.py | |
| @@ -36,28 +36,34 @@ | |
| 36 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 37 | repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 38 | return project, repo |
| 39 | |
| 40 | |
| 41 | def _check_api_auth(request, project, repo): |
| 42 | """Authenticate request and check read access. |
| 43 | |
| 44 | Returns (user, token, error_response). If error_response is not None, |
| 45 | the caller should return it immediately. |
| 46 | """ |
| 47 | user, token, err = authenticate_request(request, repository=repo) |
| 48 | if err is not None: |
| 49 | return None, None, err |
| 50 | |
| 51 | # For project-scoped APITokens (no user), the token itself grants access |
| 52 | # since it's already scoped to this repository. |
| 53 | if token is not None and user is None: |
| 54 | return user, token, None |
| 55 | |
| 56 | # For user-scoped auth (PAT or session), check project visibility |
| 57 | if user is not None and not can_read_project(user, project): |
| 58 | return None, None, JsonResponse({"error": "Access denied"}, status=403) |
| 59 | |
| 60 | return user, token, None |
| 61 | |
| 62 | |
| 63 | def _paginate_params(request, default_per_page=25, max_per_page=100): |
| @@ -764,11 +770,11 @@ | |
| 764 | """ |
| 765 | if request.method != "POST": |
| 766 | return JsonResponse({"error": "POST required"}, status=405) |
| 767 | |
| 768 | project, repo = _get_repo(slug) |
| 769 | user, token, err = _check_api_auth(request, project, repo) |
| 770 | if err is not None: |
| 771 | return err |
| 772 | |
| 773 | # Write access required to create workspaces |
| 774 | if token is None and (user is None or not can_write_project(user, project)): |
| @@ -914,11 +920,11 @@ | |
| 914 | """ |
| 915 | if request.method != "POST": |
| 916 | return JsonResponse({"error": "POST required"}, status=405) |
| 917 | |
| 918 | project, repo = _get_repo(slug) |
| 919 | user, token, err = _check_api_auth(request, project, repo) |
| 920 | if err is not None: |
| 921 | return err |
| 922 | |
| 923 | if token is None and (user is None or not can_write_project(user, project)): |
| 924 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1011,11 +1017,11 @@ | |
| 1011 | """ |
| 1012 | if request.method != "POST": |
| 1013 | return JsonResponse({"error": "POST required"}, status=405) |
| 1014 | |
| 1015 | project, repo = _get_repo(slug) |
| 1016 | user, token, err = _check_api_auth(request, project, repo) |
| 1017 | if err is not None: |
| 1018 | return err |
| 1019 | |
| 1020 | if token is None and (user is None or not can_write_project(user, project)): |
| 1021 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1072,11 +1078,18 @@ | |
| 1072 | timeout=60, |
| 1073 | env=cli._env, |
| 1074 | cwd=checkout_dir, |
| 1075 | ) |
| 1076 | |
| 1077 | # Close the checkout and clean up |
| 1078 | subprocess.run([cli.binary, "close", "--force"], capture_output=True, cwd=checkout_dir, timeout=10, env=cli._env) |
| 1079 | shutil.rmtree(checkout_dir, ignore_errors=True) |
| 1080 | |
| 1081 | workspace.status = "merged" |
| 1082 | workspace.checkout_path = "" |
| @@ -1104,11 +1117,11 @@ | |
| 1104 | """ |
| 1105 | if request.method != "DELETE": |
| 1106 | return JsonResponse({"error": "DELETE required"}, status=405) |
| 1107 | |
| 1108 | project, repo = _get_repo(slug) |
| 1109 | user, token, err = _check_api_auth(request, project, repo) |
| 1110 | if err is not None: |
| 1111 | return err |
| 1112 | |
| 1113 | if token is None and (user is None or not can_write_project(user, project)): |
| 1114 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1158,11 +1171,11 @@ | |
| 1158 | """ |
| 1159 | if request.method != "POST": |
| 1160 | return JsonResponse({"error": "POST required"}, status=405) |
| 1161 | |
| 1162 | project, repo = _get_repo(slug) |
| 1163 | user, token, err = _check_api_auth(request, project, repo) |
| 1164 | if err is not None: |
| 1165 | return err |
| 1166 | |
| 1167 | if token is None and (user is None or not can_write_project(user, project)): |
| 1168 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1249,11 +1262,11 @@ | |
| 1249 | """ |
| 1250 | if request.method != "POST": |
| 1251 | return JsonResponse({"error": "POST required"}, status=405) |
| 1252 | |
| 1253 | project, repo = _get_repo(slug) |
| 1254 | user, token, err = _check_api_auth(request, project, repo) |
| 1255 | if err is not None: |
| 1256 | return err |
| 1257 | |
| 1258 | if token is None and (user is None or not can_write_project(user, project)): |
| 1259 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1297,11 +1310,11 @@ | |
| 1297 | """ |
| 1298 | if request.method != "POST": |
| 1299 | return JsonResponse({"error": "POST required"}, status=405) |
| 1300 | |
| 1301 | project, repo = _get_repo(slug) |
| 1302 | user, token, err = _check_api_auth(request, project, repo) |
| 1303 | if err is not None: |
| 1304 | return err |
| 1305 | |
| 1306 | if token is None and (user is None or not can_write_project(user, project)): |
| 1307 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1559,11 +1572,11 @@ | |
| 1559 | """ |
| 1560 | if request.method != "POST": |
| 1561 | return JsonResponse({"error": "POST required"}, status=405) |
| 1562 | |
| 1563 | project, repo = _get_repo(slug) |
| 1564 | user, token, err = _check_api_auth(request, project, repo) |
| 1565 | if err is not None: |
| 1566 | return err |
| 1567 | |
| 1568 | if token is None and (user is None or not can_write_project(user, project)): |
| 1569 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1736,11 +1749,11 @@ | |
| 1736 | """ |
| 1737 | if request.method != "POST": |
| 1738 | return JsonResponse({"error": "POST required"}, status=405) |
| 1739 | |
| 1740 | project, repo = _get_repo(slug) |
| 1741 | user, token, err = _check_api_auth(request, project, repo) |
| 1742 | if err is not None: |
| 1743 | return err |
| 1744 | |
| 1745 | from fossil.code_reviews import CodeReview, ReviewComment |
| 1746 | |
| @@ -1755,15 +1768,17 @@ | |
| 1755 | |
| 1756 | body = (data.get("body") or "").strip() |
| 1757 | if not body: |
| 1758 | return JsonResponse({"error": "Comment body is required"}, status=400) |
| 1759 | |
| 1760 | author = (data.get("author") or "").strip() |
| 1761 | if not author and user: |
| 1762 | author = user.username |
| 1763 | if not author: |
| 1764 | return JsonResponse({"error": "Author is required"}, status=400) |
| 1765 | |
| 1766 | comment = ReviewComment.objects.create( |
| 1767 | review=review, |
| 1768 | body=body, |
| 1769 | file_path=data.get("file_path", ""), |
| @@ -1793,11 +1808,11 @@ | |
| 1793 | """ |
| 1794 | if request.method != "POST": |
| 1795 | return JsonResponse({"error": "POST required"}, status=405) |
| 1796 | |
| 1797 | project, repo = _get_repo(slug) |
| 1798 | user, token, err = _check_api_auth(request, project, repo) |
| 1799 | if err is not None: |
| 1800 | return err |
| 1801 | |
| 1802 | if token is None and (user is None or not can_write_project(user, project)): |
| 1803 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1826,11 +1841,11 @@ | |
| 1826 | """ |
| 1827 | if request.method != "POST": |
| 1828 | return JsonResponse({"error": "POST required"}, status=405) |
| 1829 | |
| 1830 | project, repo = _get_repo(slug) |
| 1831 | user, token, err = _check_api_auth(request, project, repo) |
| 1832 | if err is not None: |
| 1833 | return err |
| 1834 | |
| 1835 | if token is None and (user is None or not can_write_project(user, project)): |
| 1836 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1877,11 +1892,11 @@ | |
| 1877 | """ |
| 1878 | if request.method != "POST": |
| 1879 | return JsonResponse({"error": "POST required"}, status=405) |
| 1880 | |
| 1881 | project, repo = _get_repo(slug) |
| 1882 | user, token, err = _check_api_auth(request, project, repo) |
| 1883 | if err is not None: |
| 1884 | return err |
| 1885 | |
| 1886 | if token is None and (user is None or not can_write_project(user, project)): |
| 1887 | return JsonResponse({"error": "Write access required"}, status=403) |
| 1888 |
| --- fossil/api_views.py | |
| +++ fossil/api_views.py | |
| @@ -36,28 +36,34 @@ | |
| 36 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 37 | repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 38 | return project, repo |
| 39 | |
| 40 | |
| 41 | def _check_api_auth(request, project, repo, required_scope="read"): |
| 42 | """Authenticate request and check access. |
| 43 | |
| 44 | Args: |
| 45 | required_scope: "read" or "write" — enforced on both API tokens and PAT scopes. |
| 46 | |
| 47 | Returns (user, token, error_response). If error_response is not None, |
| 48 | the caller should return it immediately. |
| 49 | """ |
| 50 | user, token, err = authenticate_request(request, repository=repo, required_scope=required_scope) |
| 51 | if err is not None: |
| 52 | return None, None, err |
| 53 | |
| 54 | # For project-scoped APITokens (no user), the token itself grants access |
| 55 | # since it's scoped to this repository and scope was already checked. |
| 56 | if token is not None and user is None: |
| 57 | return user, token, None |
| 58 | |
| 59 | # For user-scoped auth (PAT or session), check project visibility |
| 60 | if user is not None: |
| 61 | if required_scope == "write" and not can_write_project(user, project): |
| 62 | return None, None, JsonResponse({"error": "Write access required"}, status=403) |
| 63 | if not can_read_project(user, project): |
| 64 | return None, None, JsonResponse({"error": "Access denied"}, status=403) |
| 65 | |
| 66 | return user, token, None |
| 67 | |
| 68 | |
| 69 | def _paginate_params(request, default_per_page=25, max_per_page=100): |
| @@ -764,11 +770,11 @@ | |
| 770 | """ |
| 771 | if request.method != "POST": |
| 772 | return JsonResponse({"error": "POST required"}, status=405) |
| 773 | |
| 774 | project, repo = _get_repo(slug) |
| 775 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 776 | if err is not None: |
| 777 | return err |
| 778 | |
| 779 | # Write access required to create workspaces |
| 780 | if token is None and (user is None or not can_write_project(user, project)): |
| @@ -914,11 +920,11 @@ | |
| 920 | """ |
| 921 | if request.method != "POST": |
| 922 | return JsonResponse({"error": "POST required"}, status=405) |
| 923 | |
| 924 | project, repo = _get_repo(slug) |
| 925 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 926 | if err is not None: |
| 927 | return err |
| 928 | |
| 929 | if token is None and (user is None or not can_write_project(user, project)): |
| 930 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1011,11 +1017,11 @@ | |
| 1017 | """ |
| 1018 | if request.method != "POST": |
| 1019 | return JsonResponse({"error": "POST required"}, status=405) |
| 1020 | |
| 1021 | project, repo = _get_repo(slug) |
| 1022 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1023 | if err is not None: |
| 1024 | return err |
| 1025 | |
| 1026 | if token is None and (user is None or not can_write_project(user, project)): |
| 1027 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1072,11 +1078,18 @@ | |
| 1078 | timeout=60, |
| 1079 | env=cli._env, |
| 1080 | cwd=checkout_dir, |
| 1081 | ) |
| 1082 | |
| 1083 | if commit_result.returncode != 0: |
| 1084 | # Merge commit failed — don't close the workspace, let the user retry |
| 1085 | return JsonResponse( |
| 1086 | {"error": "Merge commit failed", "detail": commit_result.stderr.strip()}, |
| 1087 | status=500, |
| 1088 | ) |
| 1089 | |
| 1090 | # Close the checkout and clean up (only on successful commit) |
| 1091 | subprocess.run([cli.binary, "close", "--force"], capture_output=True, cwd=checkout_dir, timeout=10, env=cli._env) |
| 1092 | shutil.rmtree(checkout_dir, ignore_errors=True) |
| 1093 | |
| 1094 | workspace.status = "merged" |
| 1095 | workspace.checkout_path = "" |
| @@ -1104,11 +1117,11 @@ | |
| 1117 | """ |
| 1118 | if request.method != "DELETE": |
| 1119 | return JsonResponse({"error": "DELETE required"}, status=405) |
| 1120 | |
| 1121 | project, repo = _get_repo(slug) |
| 1122 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1123 | if err is not None: |
| 1124 | return err |
| 1125 | |
| 1126 | if token is None and (user is None or not can_write_project(user, project)): |
| 1127 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1158,11 +1171,11 @@ | |
| 1171 | """ |
| 1172 | if request.method != "POST": |
| 1173 | return JsonResponse({"error": "POST required"}, status=405) |
| 1174 | |
| 1175 | project, repo = _get_repo(slug) |
| 1176 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1177 | if err is not None: |
| 1178 | return err |
| 1179 | |
| 1180 | if token is None and (user is None or not can_write_project(user, project)): |
| 1181 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1249,11 +1262,11 @@ | |
| 1262 | """ |
| 1263 | if request.method != "POST": |
| 1264 | return JsonResponse({"error": "POST required"}, status=405) |
| 1265 | |
| 1266 | project, repo = _get_repo(slug) |
| 1267 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1268 | if err is not None: |
| 1269 | return err |
| 1270 | |
| 1271 | if token is None and (user is None or not can_write_project(user, project)): |
| 1272 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1297,11 +1310,11 @@ | |
| 1310 | """ |
| 1311 | if request.method != "POST": |
| 1312 | return JsonResponse({"error": "POST required"}, status=405) |
| 1313 | |
| 1314 | project, repo = _get_repo(slug) |
| 1315 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1316 | if err is not None: |
| 1317 | return err |
| 1318 | |
| 1319 | if token is None and (user is None or not can_write_project(user, project)): |
| 1320 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1559,11 +1572,11 @@ | |
| 1572 | """ |
| 1573 | if request.method != "POST": |
| 1574 | return JsonResponse({"error": "POST required"}, status=405) |
| 1575 | |
| 1576 | project, repo = _get_repo(slug) |
| 1577 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1578 | if err is not None: |
| 1579 | return err |
| 1580 | |
| 1581 | if token is None and (user is None or not can_write_project(user, project)): |
| 1582 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1736,11 +1749,11 @@ | |
| 1749 | """ |
| 1750 | if request.method != "POST": |
| 1751 | return JsonResponse({"error": "POST required"}, status=405) |
| 1752 | |
| 1753 | project, repo = _get_repo(slug) |
| 1754 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1755 | if err is not None: |
| 1756 | return err |
| 1757 | |
| 1758 | from fossil.code_reviews import CodeReview, ReviewComment |
| 1759 | |
| @@ -1755,15 +1768,17 @@ | |
| 1768 | |
| 1769 | body = (data.get("body") or "").strip() |
| 1770 | if not body: |
| 1771 | return JsonResponse({"error": "Comment body is required"}, status=400) |
| 1772 | |
| 1773 | # Determine author from auth context, not caller-supplied data |
| 1774 | if user: |
| 1775 | author = user.username |
| 1776 | elif token: |
| 1777 | author = f"token:{token.name}" if hasattr(token, "name") else "api-token" |
| 1778 | else: |
| 1779 | author = "anonymous" |
| 1780 | |
| 1781 | comment = ReviewComment.objects.create( |
| 1782 | review=review, |
| 1783 | body=body, |
| 1784 | file_path=data.get("file_path", ""), |
| @@ -1793,11 +1808,11 @@ | |
| 1808 | """ |
| 1809 | if request.method != "POST": |
| 1810 | return JsonResponse({"error": "POST required"}, status=405) |
| 1811 | |
| 1812 | project, repo = _get_repo(slug) |
| 1813 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1814 | if err is not None: |
| 1815 | return err |
| 1816 | |
| 1817 | if token is None and (user is None or not can_write_project(user, project)): |
| 1818 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1826,11 +1841,11 @@ | |
| 1841 | """ |
| 1842 | if request.method != "POST": |
| 1843 | return JsonResponse({"error": "POST required"}, status=405) |
| 1844 | |
| 1845 | project, repo = _get_repo(slug) |
| 1846 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1847 | if err is not None: |
| 1848 | return err |
| 1849 | |
| 1850 | if token is None and (user is None or not can_write_project(user, project)): |
| 1851 | return JsonResponse({"error": "Write access required"}, status=403) |
| @@ -1877,11 +1892,11 @@ | |
| 1892 | """ |
| 1893 | if request.method != "POST": |
| 1894 | return JsonResponse({"error": "POST required"}, status=405) |
| 1895 | |
| 1896 | project, repo = _get_repo(slug) |
| 1897 | user, token, err = _check_api_auth(request, project, repo, required_scope="write") |
| 1898 | if err is not None: |
| 1899 | return err |
| 1900 | |
| 1901 | if token is None and (user is None or not can_write_project(user, project)): |
| 1902 | return JsonResponse({"error": "Write access required"}, status=403) |
| 1903 |
+8
-1
| --- fossil/reader.py | ||
| +++ fossil/reader.py | ||
| @@ -1042,21 +1042,28 @@ | ||
| 1042 | 1042 | def get_wiki_pages(self) -> list[WikiPage]: |
| 1043 | 1043 | pages = [] |
| 1044 | 1044 | try: |
| 1045 | 1045 | rows = self.conn.execute( |
| 1046 | 1046 | """ |
| 1047 | - SELECT substr(tag.tagname, 6) as name, event.mtime, event.user | |
| 1047 | + SELECT substr(tag.tagname, 6) as name, event.mtime, event.user, | |
| 1048 | + blob.size as content_size | |
| 1048 | 1049 | FROM tag |
| 1049 | 1050 | JOIN tagxref ON tag.tagid = tagxref.tagid |
| 1050 | 1051 | JOIN event ON tagxref.rid = event.objid |
| 1052 | + JOIN blob ON event.objid = blob.rid | |
| 1051 | 1053 | WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w' |
| 1052 | 1054 | GROUP BY tag.tagname |
| 1053 | 1055 | HAVING event.mtime = MAX(event.mtime) |
| 1054 | 1056 | ORDER BY event.mtime DESC |
| 1055 | 1057 | """ |
| 1056 | 1058 | ).fetchall() |
| 1057 | 1059 | for row in rows: |
| 1060 | + # Skip pages with empty content. Fossil wiki artifacts include | |
| 1061 | + # a manifest header (~140 bytes) even for empty pages. Real | |
| 1062 | + # wiki pages with actual content are always > 200 bytes. | |
| 1063 | + if row["content_size"] is not None and row["content_size"] < 200: | |
| 1064 | + continue | |
| 1058 | 1065 | pages.append( |
| 1059 | 1066 | WikiPage( |
| 1060 | 1067 | name=row["name"], |
| 1061 | 1068 | content="", |
| 1062 | 1069 | last_modified=_julian_to_datetime(row["mtime"]), |
| 1063 | 1070 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -1042,21 +1042,28 @@ | |
| 1042 | def get_wiki_pages(self) -> list[WikiPage]: |
| 1043 | pages = [] |
| 1044 | try: |
| 1045 | rows = self.conn.execute( |
| 1046 | """ |
| 1047 | SELECT substr(tag.tagname, 6) as name, event.mtime, event.user |
| 1048 | FROM tag |
| 1049 | JOIN tagxref ON tag.tagid = tagxref.tagid |
| 1050 | JOIN event ON tagxref.rid = event.objid |
| 1051 | WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w' |
| 1052 | GROUP BY tag.tagname |
| 1053 | HAVING event.mtime = MAX(event.mtime) |
| 1054 | ORDER BY event.mtime DESC |
| 1055 | """ |
| 1056 | ).fetchall() |
| 1057 | for row in rows: |
| 1058 | pages.append( |
| 1059 | WikiPage( |
| 1060 | name=row["name"], |
| 1061 | content="", |
| 1062 | last_modified=_julian_to_datetime(row["mtime"]), |
| 1063 |
| --- fossil/reader.py | |
| +++ fossil/reader.py | |
| @@ -1042,21 +1042,28 @@ | |
| 1042 | def get_wiki_pages(self) -> list[WikiPage]: |
| 1043 | pages = [] |
| 1044 | try: |
| 1045 | rows = self.conn.execute( |
| 1046 | """ |
| 1047 | SELECT substr(tag.tagname, 6) as name, event.mtime, event.user, |
| 1048 | blob.size as content_size |
| 1049 | FROM tag |
| 1050 | JOIN tagxref ON tag.tagid = tagxref.tagid |
| 1051 | JOIN event ON tagxref.rid = event.objid |
| 1052 | JOIN blob ON event.objid = blob.rid |
| 1053 | WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w' |
| 1054 | GROUP BY tag.tagname |
| 1055 | HAVING event.mtime = MAX(event.mtime) |
| 1056 | ORDER BY event.mtime DESC |
| 1057 | """ |
| 1058 | ).fetchall() |
| 1059 | for row in rows: |
| 1060 | # Skip pages with empty content. Fossil wiki artifacts include |
| 1061 | # a manifest header (~140 bytes) even for empty pages. Real |
| 1062 | # wiki pages with actual content are always > 200 bytes. |
| 1063 | if row["content_size"] is not None and row["content_size"] < 200: |
| 1064 | continue |
| 1065 | pages.append( |
| 1066 | WikiPage( |
| 1067 | name=row["name"], |
| 1068 | content="", |
| 1069 | last_modified=_julian_to_datetime(row["mtime"]), |
| 1070 |
+19
-1
| --- fossil/tasks.py | ||
| +++ fossil/tasks.py | ||
| @@ -273,10 +273,28 @@ | ||
| 273 | 273 | try: |
| 274 | 274 | webhook = Webhook.objects.get(id=webhook_id) |
| 275 | 275 | except Webhook.DoesNotExist: |
| 276 | 276 | logger.warning("Webhook %s not found, skipping delivery", webhook_id) |
| 277 | 277 | return |
| 278 | + | |
| 279 | + # Re-validate URL at dispatch time (hostname could resolve differently than at save time) | |
| 280 | + from core.url_validation import is_safe_outbound_url | |
| 281 | + | |
| 282 | + is_safe, url_error = is_safe_outbound_url(webhook.url) | |
| 283 | + if not is_safe: | |
| 284 | + logger.warning("Webhook %s URL failed safety check at dispatch: %s", webhook_id, url_error) | |
| 285 | + WebhookDelivery.objects.create( | |
| 286 | + webhook=webhook, | |
| 287 | + event_type=event_type, | |
| 288 | + payload=payload, | |
| 289 | + response_status=0, | |
| 290 | + response_body=f"Blocked: {url_error}", | |
| 291 | + success=False, | |
| 292 | + duration_ms=0, | |
| 293 | + attempt=self.request.retries + 1, | |
| 294 | + ) | |
| 295 | + return | |
| 278 | 296 | |
| 279 | 297 | headers = {"Content-Type": "application/json", "X-Fossilrepo-Event": event_type} |
| 280 | 298 | body = json.dumps(payload) |
| 281 | 299 | |
| 282 | 300 | if webhook.secret: |
| @@ -283,11 +301,11 @@ | ||
| 283 | 301 | sig = hmac.new(webhook.secret.encode(), body.encode(), hashlib.sha256).hexdigest() |
| 284 | 302 | headers["X-Fossilrepo-Signature"] = f"sha256={sig}" |
| 285 | 303 | |
| 286 | 304 | start = time.monotonic() |
| 287 | 305 | try: |
| 288 | - resp = requests.post(webhook.url, data=body, headers=headers, timeout=30) | |
| 306 | + resp = requests.post(webhook.url, data=body, headers=headers, timeout=30, allow_redirects=False) | |
| 289 | 307 | duration = int((time.monotonic() - start) * 1000) |
| 290 | 308 | |
| 291 | 309 | WebhookDelivery.objects.create( |
| 292 | 310 | webhook=webhook, |
| 293 | 311 | event_type=event_type, |
| 294 | 312 |
| --- fossil/tasks.py | |
| +++ fossil/tasks.py | |
| @@ -273,10 +273,28 @@ | |
| 273 | try: |
| 274 | webhook = Webhook.objects.get(id=webhook_id) |
| 275 | except Webhook.DoesNotExist: |
| 276 | logger.warning("Webhook %s not found, skipping delivery", webhook_id) |
| 277 | return |
| 278 | |
| 279 | headers = {"Content-Type": "application/json", "X-Fossilrepo-Event": event_type} |
| 280 | body = json.dumps(payload) |
| 281 | |
| 282 | if webhook.secret: |
| @@ -283,11 +301,11 @@ | |
| 283 | sig = hmac.new(webhook.secret.encode(), body.encode(), hashlib.sha256).hexdigest() |
| 284 | headers["X-Fossilrepo-Signature"] = f"sha256={sig}" |
| 285 | |
| 286 | start = time.monotonic() |
| 287 | try: |
| 288 | resp = requests.post(webhook.url, data=body, headers=headers, timeout=30) |
| 289 | duration = int((time.monotonic() - start) * 1000) |
| 290 | |
| 291 | WebhookDelivery.objects.create( |
| 292 | webhook=webhook, |
| 293 | event_type=event_type, |
| 294 |
| --- fossil/tasks.py | |
| +++ fossil/tasks.py | |
| @@ -273,10 +273,28 @@ | |
| 273 | try: |
| 274 | webhook = Webhook.objects.get(id=webhook_id) |
| 275 | except Webhook.DoesNotExist: |
| 276 | logger.warning("Webhook %s not found, skipping delivery", webhook_id) |
| 277 | return |
| 278 | |
| 279 | # Re-validate URL at dispatch time (hostname could resolve differently than at save time) |
| 280 | from core.url_validation import is_safe_outbound_url |
| 281 | |
| 282 | is_safe, url_error = is_safe_outbound_url(webhook.url) |
| 283 | if not is_safe: |
| 284 | logger.warning("Webhook %s URL failed safety check at dispatch: %s", webhook_id, url_error) |
| 285 | WebhookDelivery.objects.create( |
| 286 | webhook=webhook, |
| 287 | event_type=event_type, |
| 288 | payload=payload, |
| 289 | response_status=0, |
| 290 | response_body=f"Blocked: {url_error}", |
| 291 | success=False, |
| 292 | duration_ms=0, |
| 293 | attempt=self.request.retries + 1, |
| 294 | ) |
| 295 | return |
| 296 | |
| 297 | headers = {"Content-Type": "application/json", "X-Fossilrepo-Event": event_type} |
| 298 | body = json.dumps(payload) |
| 299 | |
| 300 | if webhook.secret: |
| @@ -283,11 +301,11 @@ | |
| 301 | sig = hmac.new(webhook.secret.encode(), body.encode(), hashlib.sha256).hexdigest() |
| 302 | headers["X-Fossilrepo-Signature"] = f"sha256={sig}" |
| 303 | |
| 304 | start = time.monotonic() |
| 305 | try: |
| 306 | resp = requests.post(webhook.url, data=body, headers=headers, timeout=30, allow_redirects=False) |
| 307 | duration = int((time.monotonic() - start) * 1000) |
| 308 | |
| 309 | WebhookDelivery.objects.create( |
| 310 | webhook=webhook, |
| 311 | event_type=event_type, |
| 312 |
+21
-10
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -762,11 +762,11 @@ | ||
| 762 | 762 | with reader: |
| 763 | 763 | pages = reader.get_wiki_pages() |
| 764 | 764 | home_page = reader.get_wiki_page("Home") |
| 765 | 765 | |
| 766 | 766 | # Sort: Home first, then alphabetical |
| 767 | - pages = sorted(pages, key=lambda p: ("" if p.name == "Home" else "~" + p.name.lower())) | |
| 767 | + pages = sorted(pages, key=lambda p: "" if p.name == "Home" else "~" + p.name.lower()) | |
| 768 | 768 | |
| 769 | 769 | search = request.GET.get("search", "").strip() |
| 770 | 770 | if search: |
| 771 | 771 | pages = [p for p in pages if search.lower() in p.name.lower()] |
| 772 | 772 | |
| @@ -805,11 +805,11 @@ | ||
| 805 | 805 | |
| 806 | 806 | if not page: |
| 807 | 807 | raise Http404(f"Wiki page not found: {page_name}") |
| 808 | 808 | |
| 809 | 809 | # Sort: Home first, then alphabetical |
| 810 | - all_pages = sorted(all_pages, key=lambda p: ("" if p.name == "Home" else "~" + p.name.lower())) | |
| 810 | + all_pages = sorted(all_pages, key=lambda p: "" if p.name == "Home" else "~" + p.name.lower()) | |
| 811 | 811 | |
| 812 | 812 | content_html = mark_safe(sanitize_html(_render_fossil_content(page.content, project_slug=slug))) |
| 813 | 813 | |
| 814 | 814 | return render( |
| 815 | 815 | request, |
| @@ -1098,13 +1098,13 @@ | ||
| 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 | - from core.url_validation import is_safe_webhook_url | |
| 1103 | + from core.url_validation import is_safe_outbound_url | |
| 1104 | 1104 | |
| 1105 | - is_safe, url_error = is_safe_webhook_url(url) | |
| 1105 | + is_safe, url_error = is_safe_outbound_url(url) | |
| 1106 | 1106 | if not is_safe: |
| 1107 | 1107 | messages.error(request, f"Invalid webhook URL: {url_error}") |
| 1108 | 1108 | else: |
| 1109 | 1109 | events_str = ",".join(events) if events else "all" |
| 1110 | 1110 | Webhook.objects.create( |
| @@ -1148,13 +1148,13 @@ | ||
| 1148 | 1148 | secret = request.POST.get("secret", "").strip() |
| 1149 | 1149 | events = request.POST.getlist("events") |
| 1150 | 1150 | is_active = request.POST.get("is_active") == "on" |
| 1151 | 1151 | |
| 1152 | 1152 | if url: |
| 1153 | - from core.url_validation import is_safe_webhook_url | |
| 1153 | + from core.url_validation import is_safe_outbound_url | |
| 1154 | 1154 | |
| 1155 | - is_safe, url_error = is_safe_webhook_url(url) | |
| 1155 | + is_safe, url_error = is_safe_outbound_url(url) | |
| 1156 | 1156 | if not is_safe: |
| 1157 | 1157 | messages.error(request, f"Invalid webhook URL: {url_error}") |
| 1158 | 1158 | else: |
| 1159 | 1159 | webhook.url = url |
| 1160 | 1160 | if secret: |
| @@ -1445,10 +1445,21 @@ | ||
| 1445 | 1445 | |
| 1446 | 1446 | if action == "configure": |
| 1447 | 1447 | # Save remote URL configuration |
| 1448 | 1448 | url = request.POST.get("remote_url", "").strip() |
| 1449 | 1449 | if url: |
| 1450 | + from core.url_validation import is_safe_outbound_url | |
| 1451 | + | |
| 1452 | + is_safe, url_error = is_safe_outbound_url(url) | |
| 1453 | + if not is_safe: | |
| 1454 | + from django.contrib import messages | |
| 1455 | + | |
| 1456 | + messages.error(request, f"Invalid remote URL: {url_error}") | |
| 1457 | + from django.shortcuts import redirect | |
| 1458 | + | |
| 1459 | + return redirect("fossil:sync", slug=slug) | |
| 1460 | + | |
| 1450 | 1461 | fossil_repo.remote_url = url |
| 1451 | 1462 | fossil_repo.save(update_fields=["remote_url", "updated_at", "version"]) |
| 1452 | 1463 | cli.ensure_default_user(fossil_repo.full_path) |
| 1453 | 1464 | from django.contrib import messages |
| 1454 | 1465 | |
| @@ -3990,24 +4001,24 @@ | ||
| 3990 | 4001 | rows = [] |
| 3991 | 4002 | |
| 3992 | 4003 | if error: |
| 3993 | 4004 | pass # error is shown in template |
| 3994 | 4005 | else: |
| 3995 | - # Replace placeholders with request params | |
| 4006 | + # Replace placeholders with named parameters for safe execution | |
| 3996 | 4007 | sql = report.sql_query |
| 3997 | 4008 | status_param = request.GET.get("status", "") |
| 3998 | 4009 | type_param = request.GET.get("type", "") |
| 3999 | - sql = sql.replace("{status}", status_param) | |
| 4000 | - sql = sql.replace("{type}", type_param) | |
| 4010 | + sql = sql.replace("{status}", ":status").replace("{type}", ":type") | |
| 4011 | + params = {"status": status_param, "type": type_param} | |
| 4001 | 4012 | |
| 4002 | 4013 | # Execute against the Fossil SQLite file in read-only mode |
| 4003 | 4014 | repo_path = fossil_repo.full_path |
| 4004 | 4015 | uri = f"file:{repo_path}?mode=ro" |
| 4005 | 4016 | try: |
| 4006 | 4017 | conn = sqlite3.connect(uri, uri=True) |
| 4007 | 4018 | try: |
| 4008 | - cursor = conn.execute(sql) | |
| 4019 | + cursor = conn.execute(sql, params) | |
| 4009 | 4020 | columns = [desc[0] for desc in cursor.description] if cursor.description else [] |
| 4010 | 4021 | rows = [list(row) for row in cursor.fetchall()[:1000]] |
| 4011 | 4022 | except sqlite3.OperationalError as e: |
| 4012 | 4023 | error = f"SQL error: {e}" |
| 4013 | 4024 | finally: |
| 4014 | 4025 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -762,11 +762,11 @@ | |
| 762 | with reader: |
| 763 | pages = reader.get_wiki_pages() |
| 764 | home_page = reader.get_wiki_page("Home") |
| 765 | |
| 766 | # Sort: Home first, then alphabetical |
| 767 | pages = sorted(pages, key=lambda p: ("" if p.name == "Home" else "~" + p.name.lower())) |
| 768 | |
| 769 | search = request.GET.get("search", "").strip() |
| 770 | if search: |
| 771 | pages = [p for p in pages if search.lower() in p.name.lower()] |
| 772 | |
| @@ -805,11 +805,11 @@ | |
| 805 | |
| 806 | if not page: |
| 807 | raise Http404(f"Wiki page not found: {page_name}") |
| 808 | |
| 809 | # Sort: Home first, then alphabetical |
| 810 | all_pages = sorted(all_pages, key=lambda p: ("" if p.name == "Home" else "~" + p.name.lower())) |
| 811 | |
| 812 | content_html = mark_safe(sanitize_html(_render_fossil_content(page.content, project_slug=slug))) |
| 813 | |
| 814 | return render( |
| 815 | request, |
| @@ -1098,13 +1098,13 @@ | |
| 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( |
| @@ -1148,13 +1148,13 @@ | |
| 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: |
| @@ -1445,10 +1445,21 @@ | |
| 1445 | |
| 1446 | if action == "configure": |
| 1447 | # Save remote URL configuration |
| 1448 | url = request.POST.get("remote_url", "").strip() |
| 1449 | if url: |
| 1450 | fossil_repo.remote_url = url |
| 1451 | fossil_repo.save(update_fields=["remote_url", "updated_at", "version"]) |
| 1452 | cli.ensure_default_user(fossil_repo.full_path) |
| 1453 | from django.contrib import messages |
| 1454 | |
| @@ -3990,24 +4001,24 @@ | |
| 3990 | rows = [] |
| 3991 | |
| 3992 | if error: |
| 3993 | pass # error is shown in template |
| 3994 | else: |
| 3995 | # Replace placeholders with request params |
| 3996 | sql = report.sql_query |
| 3997 | status_param = request.GET.get("status", "") |
| 3998 | type_param = request.GET.get("type", "") |
| 3999 | sql = sql.replace("{status}", status_param) |
| 4000 | sql = sql.replace("{type}", type_param) |
| 4001 | |
| 4002 | # Execute against the Fossil SQLite file in read-only mode |
| 4003 | repo_path = fossil_repo.full_path |
| 4004 | uri = f"file:{repo_path}?mode=ro" |
| 4005 | try: |
| 4006 | conn = sqlite3.connect(uri, uri=True) |
| 4007 | try: |
| 4008 | cursor = conn.execute(sql) |
| 4009 | columns = [desc[0] for desc in cursor.description] if cursor.description else [] |
| 4010 | rows = [list(row) for row in cursor.fetchall()[:1000]] |
| 4011 | except sqlite3.OperationalError as e: |
| 4012 | error = f"SQL error: {e}" |
| 4013 | finally: |
| 4014 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -762,11 +762,11 @@ | |
| 762 | with reader: |
| 763 | pages = reader.get_wiki_pages() |
| 764 | home_page = reader.get_wiki_page("Home") |
| 765 | |
| 766 | # Sort: Home first, then alphabetical |
| 767 | pages = sorted(pages, key=lambda p: "" if p.name == "Home" else "~" + p.name.lower()) |
| 768 | |
| 769 | search = request.GET.get("search", "").strip() |
| 770 | if search: |
| 771 | pages = [p for p in pages if search.lower() in p.name.lower()] |
| 772 | |
| @@ -805,11 +805,11 @@ | |
| 805 | |
| 806 | if not page: |
| 807 | raise Http404(f"Wiki page not found: {page_name}") |
| 808 | |
| 809 | # Sort: Home first, then alphabetical |
| 810 | all_pages = sorted(all_pages, key=lambda p: "" if p.name == "Home" else "~" + p.name.lower()) |
| 811 | |
| 812 | content_html = mark_safe(sanitize_html(_render_fossil_content(page.content, project_slug=slug))) |
| 813 | |
| 814 | return render( |
| 815 | request, |
| @@ -1098,13 +1098,13 @@ | |
| 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_outbound_url |
| 1104 | |
| 1105 | is_safe, url_error = is_safe_outbound_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( |
| @@ -1148,13 +1148,13 @@ | |
| 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_outbound_url |
| 1154 | |
| 1155 | is_safe, url_error = is_safe_outbound_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: |
| @@ -1445,10 +1445,21 @@ | |
| 1445 | |
| 1446 | if action == "configure": |
| 1447 | # Save remote URL configuration |
| 1448 | url = request.POST.get("remote_url", "").strip() |
| 1449 | if url: |
| 1450 | from core.url_validation import is_safe_outbound_url |
| 1451 | |
| 1452 | is_safe, url_error = is_safe_outbound_url(url) |
| 1453 | if not is_safe: |
| 1454 | from django.contrib import messages |
| 1455 | |
| 1456 | messages.error(request, f"Invalid remote URL: {url_error}") |
| 1457 | from django.shortcuts import redirect |
| 1458 | |
| 1459 | return redirect("fossil:sync", slug=slug) |
| 1460 | |
| 1461 | fossil_repo.remote_url = url |
| 1462 | fossil_repo.save(update_fields=["remote_url", "updated_at", "version"]) |
| 1463 | cli.ensure_default_user(fossil_repo.full_path) |
| 1464 | from django.contrib import messages |
| 1465 | |
| @@ -3990,24 +4001,24 @@ | |
| 4001 | rows = [] |
| 4002 | |
| 4003 | if error: |
| 4004 | pass # error is shown in template |
| 4005 | else: |
| 4006 | # Replace placeholders with named parameters for safe execution |
| 4007 | sql = report.sql_query |
| 4008 | status_param = request.GET.get("status", "") |
| 4009 | type_param = request.GET.get("type", "") |
| 4010 | sql = sql.replace("{status}", ":status").replace("{type}", ":type") |
| 4011 | params = {"status": status_param, "type": type_param} |
| 4012 | |
| 4013 | # Execute against the Fossil SQLite file in read-only mode |
| 4014 | repo_path = fossil_repo.full_path |
| 4015 | uri = f"file:{repo_path}?mode=ro" |
| 4016 | try: |
| 4017 | conn = sqlite3.connect(uri, uri=True) |
| 4018 | try: |
| 4019 | cursor = conn.execute(sql, params) |
| 4020 | columns = [desc[0] for desc in cursor.description] if cursor.description else [] |
| 4021 | rows = [list(row) for row in cursor.fetchall()[:1000]] |
| 4022 | except sqlite3.OperationalError as e: |
| 4023 | error = f"SQL error: {e}" |
| 4024 | finally: |
| 4025 |
+7
-1
| --- projects/views.py | ||
| +++ projects/views.py | ||
| @@ -58,11 +58,17 @@ | ||
| 58 | 58 | # Handle repo source: clone from URL if requested |
| 59 | 59 | repo_source = form.cleaned_data.get("repo_source", "empty") |
| 60 | 60 | clone_url = form.cleaned_data.get("clone_url", "").strip() |
| 61 | 61 | |
| 62 | 62 | if repo_source == "fossil_url" and clone_url: |
| 63 | - _clone_fossil_repo(request, project, clone_url) | |
| 63 | + from core.url_validation import is_safe_outbound_url | |
| 64 | + | |
| 65 | + is_safe, url_error = is_safe_outbound_url(clone_url) | |
| 66 | + if not is_safe: | |
| 67 | + messages.error(request, f"Invalid clone URL: {url_error}") | |
| 68 | + else: | |
| 69 | + _clone_fossil_repo(request, project, clone_url) | |
| 64 | 70 | |
| 65 | 71 | messages.success(request, f'Project "{project.name}" created.') |
| 66 | 72 | return redirect("projects:detail", slug=project.slug) |
| 67 | 73 | else: |
| 68 | 74 | form = ProjectForm() |
| 69 | 75 |
| --- projects/views.py | |
| +++ projects/views.py | |
| @@ -58,11 +58,17 @@ | |
| 58 | # Handle repo source: clone from URL if requested |
| 59 | repo_source = form.cleaned_data.get("repo_source", "empty") |
| 60 | clone_url = form.cleaned_data.get("clone_url", "").strip() |
| 61 | |
| 62 | if repo_source == "fossil_url" and clone_url: |
| 63 | _clone_fossil_repo(request, project, clone_url) |
| 64 | |
| 65 | messages.success(request, f'Project "{project.name}" created.') |
| 66 | return redirect("projects:detail", slug=project.slug) |
| 67 | else: |
| 68 | form = ProjectForm() |
| 69 |
| --- projects/views.py | |
| +++ projects/views.py | |
| @@ -58,11 +58,17 @@ | |
| 58 | # Handle repo source: clone from URL if requested |
| 59 | repo_source = form.cleaned_data.get("repo_source", "empty") |
| 60 | clone_url = form.cleaned_data.get("clone_url", "").strip() |
| 61 | |
| 62 | if repo_source == "fossil_url" and clone_url: |
| 63 | from core.url_validation import is_safe_outbound_url |
| 64 | |
| 65 | is_safe, url_error = is_safe_outbound_url(clone_url) |
| 66 | if not is_safe: |
| 67 | messages.error(request, f"Invalid clone URL: {url_error}") |
| 68 | else: |
| 69 | _clone_fossil_repo(request, project, clone_url) |
| 70 | |
| 71 | messages.success(request, f'Project "{project.name}" created.') |
| 72 | return redirect("projects:detail", slug=project.slug) |
| 73 | else: |
| 74 | form = ProjectForm() |
| 75 |
+33
-16
| --- templates/403.html | ||
| +++ templates/403.html | ||
| @@ -1,16 +1,33 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}Access Denied — Fossilrepo{% endblock %} | |
| 3 | - | |
| 4 | -{% block content %} | |
| 5 | -<div class="flex flex-col items-center justify-center py-20"> | |
| 6 | - <div class="text-6xl font-bold text-brand mb-4">403</div> | |
| 7 | - <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1> | |
| 8 | - <p class="text-gray-400 mb-6 text-center max-w-md"> | |
| 9 | - You don't have permission to access this page. | |
| 10 | - </p> | |
| 11 | - <div class="flex gap-3"> | |
| 12 | - <a href="/" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Go to Dashboard</a> | |
| 13 | - <button onclick="history.back()" class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> | |
| 14 | - </div> | |
| 15 | -</div> | |
| 16 | -{% endblock %} | |
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>Access Denied — FossilRepo</title> | |
| 7 | + <script src="https://cdn.tailwindcss.com"></script> | |
| 8 | + <style>:root { --brand: #DC394C; }</style> | |
| 9 | +</head> | |
| 10 | +<body class="bg-gray-950 text-gray-100 min-h-screen flex items-center justify-center"> | |
| 11 | + <div class="text-center px-6"> | |
| 12 | + <div class="mb-6"> | |
| 13 | + <svg class="h-12 w-12 mx-auto text-[var(--brand)] opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | |
| 14 | + <circle cx="12" cy="12" r="10" stroke-opacity="0.6"/> | |
| 15 | + <circle cx="12" cy="12" r="4" fill="currentColor" fill-opacity="0.3"/> | |
| 16 | + <path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke-opacity="0.4"/> | |
| 17 | + </svg> | |
| 18 | + <div class="mt-2 text-sm font-bold tracking-tight"> | |
| 19 | + <span class="text-gray-100">fossil</span><span class="text-[var(--brand)]">repo</span> | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | + <div class="text-7xl font-bold text-[var(--brand)] mb-4">403</div> | |
| 23 | + <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1> | |
| 24 | + <p class="text-gray-400 mb-8 max-w-md mx-auto"> | |
| 25 | + You don't have permission to access this page. Try signing in or contact your administrator. | |
| 26 | + </p> | |
| 27 | + <div class="flex gap-3 justify-center"> | |
| 28 | + <a href="/" class="rounded-md bg-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a> | |
| 29 | + <a href="/auth/login/" class="rounded-md bg-gray-800 px-5 py-2.5 text-sm font-semibold text-gray-300 ring-1 ring-gray-700 hover:bg-gray-700 transition">Sign In</a> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | +</body> | |
| 33 | +</html> | |
| 17 | 34 |
| --- templates/403.html | |
| +++ templates/403.html | |
| @@ -1,16 +1,33 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Access Denied — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="flex flex-col items-center justify-center py-20"> |
| 6 | <div class="text-6xl font-bold text-brand mb-4">403</div> |
| 7 | <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1> |
| 8 | <p class="text-gray-400 mb-6 text-center max-w-md"> |
| 9 | You don't have permission to access this page. |
| 10 | </p> |
| 11 | <div class="flex gap-3"> |
| 12 | <a href="/" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Go to Dashboard</a> |
| 13 | <button onclick="history.back()" class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> |
| 14 | </div> |
| 15 | </div> |
| 16 | {% endblock %} |
| 17 |
| --- templates/403.html | |
| +++ templates/403.html | |
| @@ -1,16 +1,33 @@ | |
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | <title>Access Denied — FossilRepo</title> |
| 7 | <script src="https://cdn.tailwindcss.com"></script> |
| 8 | <style>:root { --brand: #DC394C; }</style> |
| 9 | </head> |
| 10 | <body class="bg-gray-950 text-gray-100 min-h-screen flex items-center justify-center"> |
| 11 | <div class="text-center px-6"> |
| 12 | <div class="mb-6"> |
| 13 | <svg class="h-12 w-12 mx-auto text-[var(--brand)] opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| 14 | <circle cx="12" cy="12" r="10" stroke-opacity="0.6"/> |
| 15 | <circle cx="12" cy="12" r="4" fill="currentColor" fill-opacity="0.3"/> |
| 16 | <path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke-opacity="0.4"/> |
| 17 | </svg> |
| 18 | <div class="mt-2 text-sm font-bold tracking-tight"> |
| 19 | <span class="text-gray-100">fossil</span><span class="text-[var(--brand)]">repo</span> |
| 20 | </div> |
| 21 | </div> |
| 22 | <div class="text-7xl font-bold text-[var(--brand)] mb-4">403</div> |
| 23 | <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1> |
| 24 | <p class="text-gray-400 mb-8 max-w-md mx-auto"> |
| 25 | You don't have permission to access this page. Try signing in or contact your administrator. |
| 26 | </p> |
| 27 | <div class="flex gap-3 justify-center"> |
| 28 | <a href="/" class="rounded-md bg-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a> |
| 29 | <a href="/auth/login/" class="rounded-md bg-gray-800 px-5 py-2.5 text-sm font-semibold text-gray-300 ring-1 ring-gray-700 hover:bg-gray-700 transition">Sign In</a> |
| 30 | </div> |
| 31 | </div> |
| 32 | </body> |
| 33 | </html> |
| 34 |
+33
-16
| --- templates/404.html | ||
| +++ templates/404.html | ||
| @@ -1,16 +1,33 @@ | ||
| 1 | -{% extends "base.html" %} | |
| 2 | -{% block title %}Page Not Found — Fossilrepo{% endblock %} | |
| 3 | - | |
| 4 | -{% block content %} | |
| 5 | -<div class="flex flex-col items-center justify-center py-20"> | |
| 6 | - <div class="text-6xl font-bold text-brand mb-4">404</div> | |
| 7 | - <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1> | |
| 8 | - <p class="text-gray-400 mb-6 text-center max-w-md"> | |
| 9 | - The page you're looking for doesn't exist or has been moved. | |
| 10 | - </p> | |
| 11 | - <div class="flex gap-3"> | |
| 12 | - <a href="/" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Go to Dashboard</a> | |
| 13 | - <button onclick="history.back()" class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> | |
| 14 | - </div> | |
| 15 | -</div> | |
| 16 | -{% endblock %} | |
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>Page Not Found — FossilRepo</title> | |
| 7 | + <script src="https://cdn.tailwindcss.com"></script> | |
| 8 | + <style>:root { --brand: #DC394C; }</style> | |
| 9 | +</head> | |
| 10 | +<body class="bg-gray-950 text-gray-100 min-h-screen flex items-center justify-center"> | |
| 11 | + <div class="text-center px-6"> | |
| 12 | + <div class="mb-6"> | |
| 13 | + <svg class="h-12 w-12 mx-auto text-[var(--brand)] opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | |
| 14 | + <circle cx="12" cy="12" r="10" stroke-opacity="0.6"/> | |
| 15 | + <circle cx="12" cy="12" r="4" fill="currentColor" fill-opacity="0.3"/> | |
| 16 | + <path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke-opacity="0.4"/> | |
| 17 | + </svg> | |
| 18 | + <div class="mt-2 text-sm font-bold tracking-tight"> | |
| 19 | + <span class="text-gray-100">fossil</span><span class="text-[var(--brand)]">repo</span> | |
| 20 | + </div> | |
| 21 | + </div> | |
| 22 | + <div class="text-7xl font-bold text-[var(--brand)] mb-4">404</div> | |
| 23 | + <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1> | |
| 24 | + <p class="text-gray-400 mb-8 max-w-md mx-auto"> | |
| 25 | + The page you're looking for doesn't exist or has been moved. | |
| 26 | + </p> | |
| 27 | + <div class="flex gap-3 justify-center"> | |
| 28 | + <a href="/" class="rounded-md bg-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a> | |
| 29 | + <button onclick="history.back()" class="rounded-md bg-gray-800 px-5 py-2.5 text-sm font-semibold text-gray-300 ring-1 ring-gray-700 hover:bg-gray-700 transition">Go Back</button> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | +</body> | |
| 33 | +</html> | |
| 17 | 34 |
| --- templates/404.html | |
| +++ templates/404.html | |
| @@ -1,16 +1,33 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}Page Not Found — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <div class="flex flex-col items-center justify-center py-20"> |
| 6 | <div class="text-6xl font-bold text-brand mb-4">404</div> |
| 7 | <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1> |
| 8 | <p class="text-gray-400 mb-6 text-center max-w-md"> |
| 9 | The page you're looking for doesn't exist or has been moved. |
| 10 | </p> |
| 11 | <div class="flex gap-3"> |
| 12 | <a href="/" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Go to Dashboard</a> |
| 13 | <button onclick="history.back()" class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button> |
| 14 | </div> |
| 15 | </div> |
| 16 | {% endblock %} |
| 17 |
| --- templates/404.html | |
| +++ templates/404.html | |
| @@ -1,16 +1,33 @@ | |
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | <title>Page Not Found — FossilRepo</title> |
| 7 | <script src="https://cdn.tailwindcss.com"></script> |
| 8 | <style>:root { --brand: #DC394C; }</style> |
| 9 | </head> |
| 10 | <body class="bg-gray-950 text-gray-100 min-h-screen flex items-center justify-center"> |
| 11 | <div class="text-center px-6"> |
| 12 | <div class="mb-6"> |
| 13 | <svg class="h-12 w-12 mx-auto text-[var(--brand)] opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| 14 | <circle cx="12" cy="12" r="10" stroke-opacity="0.6"/> |
| 15 | <circle cx="12" cy="12" r="4" fill="currentColor" fill-opacity="0.3"/> |
| 16 | <path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke-opacity="0.4"/> |
| 17 | </svg> |
| 18 | <div class="mt-2 text-sm font-bold tracking-tight"> |
| 19 | <span class="text-gray-100">fossil</span><span class="text-[var(--brand)]">repo</span> |
| 20 | </div> |
| 21 | </div> |
| 22 | <div class="text-7xl font-bold text-[var(--brand)] mb-4">404</div> |
| 23 | <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1> |
| 24 | <p class="text-gray-400 mb-8 max-w-md mx-auto"> |
| 25 | The page you're looking for doesn't exist or has been moved. |
| 26 | </p> |
| 27 | <div class="flex gap-3 justify-center"> |
| 28 | <a href="/" class="rounded-md bg-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a> |
| 29 | <button onclick="history.back()" class="rounded-md bg-gray-800 px-5 py-2.5 text-sm font-semibold text-gray-300 ring-1 ring-gray-700 hover:bg-gray-700 transition">Go Back</button> |
| 30 | </div> |
| 31 | </div> |
| 32 | </body> |
| 33 | </html> |
| 34 |
+14
| --- templates/base.html | ||
| +++ templates/base.html | ||
| @@ -45,10 +45,23 @@ | ||
| 45 | 45 | focus:border-brand focus:ring-brand sm:text-sm; |
| 46 | 46 | } |
| 47 | 47 | } |
| 48 | 48 | </style> |
| 49 | 49 | <style> |
| 50 | + /* HTMX loading indicator: hidden by default, shown when htmx is in flight */ | |
| 51 | + .htmx-indicator { display: none; } | |
| 52 | + .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-flex; } | |
| 53 | + /* Spinner next to search inputs during HTMX requests */ | |
| 54 | + .search-wrap { position: relative; display: inline-flex; align-items: center; } | |
| 55 | + .search-wrap .search-spinner { | |
| 56 | + position: absolute; right: 8px; top: 50%; transform: translateY(-50%); | |
| 57 | + display: none; color: #6b7280; | |
| 58 | + } | |
| 59 | + .search-wrap:has(input.htmx-request) .search-spinner, | |
| 60 | + .search-wrap.htmx-request .search-spinner { display: block; } | |
| 61 | + </style> | |
| 62 | + <style> | |
| 50 | 63 | /* |
| 51 | 64 | * Light mode — matches Django admin dark_theme.css palette |
| 52 | 65 | * Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal |
| 53 | 66 | * Nav bar stays dark. Only main content area switches. |
| 54 | 67 | */ |
| @@ -106,10 +119,11 @@ | ||
| 106 | 119 | /* Selected/hover rows — matches admin --selected-bg */ |
| 107 | 120 | html:not(.dark) main .group:hover { border-color: #DC394C !important; } |
| 108 | 121 | </style> |
| 109 | 122 | <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> |
| 110 | 123 | <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> |
| 124 | + <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"></script> | |
| 111 | 125 | <script src="https://unpkg.com/[email protected]"></script> |
| 112 | 126 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 113 | 127 | <script> |
| 114 | 128 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 115 | 129 | var token = document.querySelector('meta[name="csrf-token"]'); |
| 116 | 130 |
| --- templates/base.html | |
| +++ templates/base.html | |
| @@ -45,10 +45,23 @@ | |
| 45 | focus:border-brand focus:ring-brand sm:text-sm; |
| 46 | } |
| 47 | } |
| 48 | </style> |
| 49 | <style> |
| 50 | /* |
| 51 | * Light mode — matches Django admin dark_theme.css palette |
| 52 | * Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal |
| 53 | * Nav bar stays dark. Only main content area switches. |
| 54 | */ |
| @@ -106,10 +119,11 @@ | |
| 106 | /* Selected/hover rows — matches admin --selected-bg */ |
| 107 | html:not(.dark) main .group:hover { border-color: #DC394C !important; } |
| 108 | </style> |
| 109 | <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> |
| 110 | <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> |
| 111 | <script src="https://unpkg.com/[email protected]"></script> |
| 112 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 113 | <script> |
| 114 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 115 | var token = document.querySelector('meta[name="csrf-token"]'); |
| 116 |
| --- templates/base.html | |
| +++ templates/base.html | |
| @@ -45,10 +45,23 @@ | |
| 45 | focus:border-brand focus:ring-brand sm:text-sm; |
| 46 | } |
| 47 | } |
| 48 | </style> |
| 49 | <style> |
| 50 | /* HTMX loading indicator: hidden by default, shown when htmx is in flight */ |
| 51 | .htmx-indicator { display: none; } |
| 52 | .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-flex; } |
| 53 | /* Spinner next to search inputs during HTMX requests */ |
| 54 | .search-wrap { position: relative; display: inline-flex; align-items: center; } |
| 55 | .search-wrap .search-spinner { |
| 56 | position: absolute; right: 8px; top: 50%; transform: translateY(-50%); |
| 57 | display: none; color: #6b7280; |
| 58 | } |
| 59 | .search-wrap:has(input.htmx-request) .search-spinner, |
| 60 | .search-wrap.htmx-request .search-spinner { display: block; } |
| 61 | </style> |
| 62 | <style> |
| 63 | /* |
| 64 | * Light mode — matches Django admin dark_theme.css palette |
| 65 | * Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal |
| 66 | * Nav bar stays dark. Only main content area switches. |
| 67 | */ |
| @@ -106,10 +119,11 @@ | |
| 119 | /* Selected/hover rows — matches admin --selected-bg */ |
| 120 | html:not(.dark) main .group:hover { border-color: #DC394C !important; } |
| 121 | </style> |
| 122 | <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> |
| 123 | <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> |
| 124 | <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"></script> |
| 125 | <script src="https://unpkg.com/[email protected]"></script> |
| 126 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 127 | <script> |
| 128 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 129 | var token = document.querySelector('meta[name="csrf-token"]'); |
| 130 |
| --- templates/fossil/branch_list.html | ||
| +++ templates/fossil/branch_list.html | ||
| @@ -6,20 +6,25 @@ | ||
| 6 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 9 | <div class="flex items-center justify-between mb-4"> |
| 10 | 10 | <div> |
| 11 | + <span class="search-wrap"> | |
| 11 | 12 | <input type="search" |
| 12 | 13 | name="search" |
| 13 | 14 | value="{{ search }}" |
| 14 | 15 | placeholder="Search branches..." |
| 16 | + aria-label="Search branches" | |
| 15 | 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | 18 | hx-get="{% url 'fossil:branches' slug=project.slug %}" |
| 17 | 19 | hx-trigger="input changed delay:300ms, search" |
| 18 | 20 | hx-target="#branch-content" |
| 19 | 21 | hx-swap="innerHTML" |
| 20 | - hx-push-url="true" /> | |
| 22 | + hx-push-url="true" | |
| 23 | + hx-indicator="closest .search-wrap" /> | |
| 24 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 25 | + </span> | |
| 21 | 26 | </div> |
| 22 | 27 | </div> |
| 23 | 28 | |
| 24 | 29 | <div id="branch-content"> |
| 25 | 30 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 26 | 31 |
| --- templates/fossil/branch_list.html | |
| +++ templates/fossil/branch_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-4"> |
| 10 | <div> |
| 11 | <input type="search" |
| 12 | name="search" |
| 13 | value="{{ search }}" |
| 14 | placeholder="Search branches..." |
| 15 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | hx-get="{% url 'fossil:branches' slug=project.slug %}" |
| 17 | hx-trigger="input changed delay:300ms, search" |
| 18 | hx-target="#branch-content" |
| 19 | hx-swap="innerHTML" |
| 20 | hx-push-url="true" /> |
| 21 | </div> |
| 22 | </div> |
| 23 | |
| 24 | <div id="branch-content"> |
| 25 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 26 |
| --- templates/fossil/branch_list.html | |
| +++ templates/fossil/branch_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-4"> |
| 10 | <div> |
| 11 | <span class="search-wrap"> |
| 12 | <input type="search" |
| 13 | name="search" |
| 14 | value="{{ search }}" |
| 15 | placeholder="Search branches..." |
| 16 | aria-label="Search branches" |
| 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 18 | hx-get="{% url 'fossil:branches' slug=project.slug %}" |
| 19 | hx-trigger="input changed delay:300ms, search" |
| 20 | hx-target="#branch-content" |
| 21 | hx-swap="innerHTML" |
| 22 | hx-push-url="true" |
| 23 | hx-indicator="closest .search-wrap" /> |
| 24 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 25 | </span> |
| 26 | </div> |
| 27 | </div> |
| 28 | |
| 29 | <div id="branch-content"> |
| 30 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 31 |
+2
-2
| --- templates/fossil/forum_form.html | ||
| +++ templates/fossil/forum_form.html | ||
| @@ -16,11 +16,11 @@ | ||
| 16 | 16 | <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }"> |
| 17 | 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | 18 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 19 | 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | - <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('body-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 21 | + <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('body-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 22 | 22 | </div> |
| 23 | 23 | </div> |
| 24 | 24 | |
| 25 | 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | 26 | {% csrf_token %} |
| @@ -27,11 +27,11 @@ | ||
| 27 | 27 | |
| 28 | 28 | {% if not parent %} |
| 29 | 29 | <div> |
| 30 | 30 | <label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label> |
| 31 | 31 | <input type="text" name="title" required placeholder="Thread title" |
| 32 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 32 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 33 | 33 | </div> |
| 34 | 34 | {% endif %} |
| 35 | 35 | |
| 36 | 36 | <div x-show="tab === 'write'"> |
| 37 | 37 | <label class="block text-sm font-medium text-gray-300 mb-1">Body (Markdown) <span class="text-red-400">*</span></label> |
| 38 | 38 |
| --- templates/fossil/forum_form.html | |
| +++ templates/fossil/forum_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }"> |
| 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('body-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 22 | </div> |
| 23 | </div> |
| 24 | |
| 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | {% csrf_token %} |
| @@ -27,11 +27,11 @@ | |
| 27 | |
| 28 | {% if not parent %} |
| 29 | <div> |
| 30 | <label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label> |
| 31 | <input type="text" name="title" required placeholder="Thread title" |
| 32 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 33 | </div> |
| 34 | {% endif %} |
| 35 | |
| 36 | <div x-show="tab === 'write'"> |
| 37 | <label class="block text-sm font-medium text-gray-300 mb-1">Body (Markdown) <span class="text-red-400">*</span></label> |
| 38 |
| --- templates/fossil/forum_form.html | |
| +++ templates/fossil/forum_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }"> |
| 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('body-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 22 | </div> |
| 23 | </div> |
| 24 | |
| 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | {% csrf_token %} |
| @@ -27,11 +27,11 @@ | |
| 27 | |
| 28 | {% if not parent %} |
| 29 | <div> |
| 30 | <label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label> |
| 31 | <input type="text" name="title" required placeholder="Thread title" |
| 32 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 33 | </div> |
| 34 | {% endif %} |
| 35 | |
| 36 | <div x-show="tab === 'write'"> |
| 37 | <label class="block text-sm font-medium text-gray-300 mb-1">Body (Markdown) <span class="text-red-400">*</span></label> |
| 38 |
+6
-1
| --- templates/fossil/forum_list.html | ||
| +++ templates/fossil/forum_list.html | ||
| @@ -7,20 +7,25 @@ | ||
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | 10 | <h2 class="text-lg font-semibold text-gray-200">Forum</h2> |
| 11 | 11 | <div class="flex items-center gap-3"> |
| 12 | + <span class="search-wrap"> | |
| 12 | 13 | <input type="search" |
| 13 | 14 | name="search" |
| 14 | 15 | value="{{ search }}" |
| 15 | 16 | placeholder="Search forum..." |
| 17 | + aria-label="Search forum" | |
| 16 | 18 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 17 | 19 | hx-get="{% url 'fossil:forum' slug=project.slug %}" |
| 18 | 20 | hx-trigger="input changed delay:300ms, search" |
| 19 | 21 | hx-target="#forum-content" |
| 20 | 22 | hx-swap="innerHTML" |
| 21 | - hx-push-url="true" /> | |
| 23 | + hx-push-url="true" | |
| 24 | + hx-indicator="closest .search-wrap" /> | |
| 25 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 26 | + </span> | |
| 22 | 27 | {% if has_write %} |
| 23 | 28 | <a href="{% url 'fossil:forum_create' slug=project.slug %}" |
| 24 | 29 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 25 | 30 | New Thread |
| 26 | 31 | </a> |
| 27 | 32 |
| --- templates/fossil/forum_list.html | |
| +++ templates/fossil/forum_list.html | |
| @@ -7,20 +7,25 @@ | |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">Forum</h2> |
| 11 | <div class="flex items-center gap-3"> |
| 12 | <input type="search" |
| 13 | name="search" |
| 14 | value="{{ search }}" |
| 15 | placeholder="Search forum..." |
| 16 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 17 | hx-get="{% url 'fossil:forum' slug=project.slug %}" |
| 18 | hx-trigger="input changed delay:300ms, search" |
| 19 | hx-target="#forum-content" |
| 20 | hx-swap="innerHTML" |
| 21 | hx-push-url="true" /> |
| 22 | {% if has_write %} |
| 23 | <a href="{% url 'fossil:forum_create' slug=project.slug %}" |
| 24 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 25 | New Thread |
| 26 | </a> |
| 27 |
| --- templates/fossil/forum_list.html | |
| +++ templates/fossil/forum_list.html | |
| @@ -7,20 +7,25 @@ | |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">Forum</h2> |
| 11 | <div class="flex items-center gap-3"> |
| 12 | <span class="search-wrap"> |
| 13 | <input type="search" |
| 14 | name="search" |
| 15 | value="{{ search }}" |
| 16 | placeholder="Search forum..." |
| 17 | aria-label="Search forum" |
| 18 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 19 | hx-get="{% url 'fossil:forum' slug=project.slug %}" |
| 20 | hx-trigger="input changed delay:300ms, search" |
| 21 | hx-target="#forum-content" |
| 22 | hx-swap="innerHTML" |
| 23 | hx-push-url="true" |
| 24 | hx-indicator="closest .search-wrap" /> |
| 25 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 26 | </span> |
| 27 | {% if has_write %} |
| 28 | <a href="{% url 'fossil:forum_create' slug=project.slug %}" |
| 29 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 30 | New Thread |
| 31 | </a> |
| 32 |
| --- templates/fossil/partials/timeline_entries.html | ||
| +++ templates/fossil/partials/timeline_entries.html | ||
| @@ -1,5 +1,6 @@ | ||
| 1 | +{% load fossil_filters %} | |
| 1 | 2 | <style> |
| 2 | 3 | .tl-dag { position: relative; flex-shrink: 0; } |
| 3 | 4 | .tl-node { |
| 4 | 5 | position: absolute; top: 50%; z-index: 2; border-radius: 50%; |
| 5 | 6 | transform: translate(-50%, -50%); width: 10px; height: 10px; |
| 6 | 7 |
| --- templates/fossil/partials/timeline_entries.html | |
| +++ templates/fossil/partials/timeline_entries.html | |
| @@ -1,5 +1,6 @@ | |
| 1 | <style> |
| 2 | .tl-dag { position: relative; flex-shrink: 0; } |
| 3 | .tl-node { |
| 4 | position: absolute; top: 50%; z-index: 2; border-radius: 50%; |
| 5 | transform: translate(-50%, -50%); width: 10px; height: 10px; |
| 6 |
| --- templates/fossil/partials/timeline_entries.html | |
| +++ templates/fossil/partials/timeline_entries.html | |
| @@ -1,5 +1,6 @@ | |
| 1 | {% load fossil_filters %} |
| 2 | <style> |
| 3 | .tl-dag { position: relative; flex-shrink: 0; } |
| 4 | .tl-node { |
| 5 | position: absolute; top: 50%; z-index: 2; border-radius: 50%; |
| 6 | transform: translate(-50%, -50%); width: 10px; height: 10px; |
| 7 |
| --- templates/fossil/release_form.html | ||
| +++ templates/fossil/release_form.html | ||
| @@ -17,11 +17,11 @@ | ||
| 17 | 17 | <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }"> |
| 18 | 18 | <div class="flex items-center justify-between mb-4"> |
| 19 | 19 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 20 | 20 | <div class="flex items-center gap-1 text-xs"> |
| 21 | 21 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 22 | - <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('body-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 22 | + <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('body-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 23 | 23 | </div> |
| 24 | 24 | </div> |
| 25 | 25 | |
| 26 | 26 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 27 | 27 | {% csrf_token %} |
| 28 | 28 |
| --- templates/fossil/release_form.html | |
| +++ templates/fossil/release_form.html | |
| @@ -17,11 +17,11 @@ | |
| 17 | <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }"> |
| 18 | <div class="flex items-center justify-between mb-4"> |
| 19 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 20 | <div class="flex items-center gap-1 text-xs"> |
| 21 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 22 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('body-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 23 | </div> |
| 24 | </div> |
| 25 | |
| 26 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 27 | {% csrf_token %} |
| 28 |
| --- templates/fossil/release_form.html | |
| +++ templates/fossil/release_form.html | |
| @@ -17,11 +17,11 @@ | |
| 17 | <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }"> |
| 18 | <div class="flex items-center justify-between mb-4"> |
| 19 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 20 | <div class="flex items-center gap-1 text-xs"> |
| 21 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 22 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('body-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 23 | </div> |
| 24 | </div> |
| 25 | |
| 26 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 27 | {% csrf_token %} |
| 28 |
| --- templates/fossil/release_list.html | ||
| +++ templates/fossil/release_list.html | ||
| @@ -7,20 +7,25 @@ | ||
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | 10 | <h2 class="text-lg font-semibold text-gray-200">Releases</h2> |
| 11 | 11 | <div class="flex items-center gap-3"> |
| 12 | + <span class="search-wrap"> | |
| 12 | 13 | <input type="search" |
| 13 | 14 | name="search" |
| 14 | 15 | value="{{ search }}" |
| 15 | 16 | placeholder="Search releases..." |
| 17 | + aria-label="Search releases" | |
| 16 | 18 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 17 | 19 | hx-get="{% url 'fossil:releases' slug=project.slug %}" |
| 18 | 20 | hx-trigger="input changed delay:300ms, search" |
| 19 | 21 | hx-target="#release-content" |
| 20 | 22 | hx-swap="innerHTML" |
| 21 | - hx-push-url="true" /> | |
| 23 | + hx-push-url="true" | |
| 24 | + hx-indicator="closest .search-wrap" /> | |
| 25 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 26 | + </span> | |
| 22 | 27 | {% if has_write %} |
| 23 | 28 | <a href="{% url 'fossil:release_create' slug=project.slug %}" |
| 24 | 29 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 25 | 30 | Create Release |
| 26 | 31 | </a> |
| 27 | 32 |
| --- templates/fossil/release_list.html | |
| +++ templates/fossil/release_list.html | |
| @@ -7,20 +7,25 @@ | |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">Releases</h2> |
| 11 | <div class="flex items-center gap-3"> |
| 12 | <input type="search" |
| 13 | name="search" |
| 14 | value="{{ search }}" |
| 15 | placeholder="Search releases..." |
| 16 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 17 | hx-get="{% url 'fossil:releases' slug=project.slug %}" |
| 18 | hx-trigger="input changed delay:300ms, search" |
| 19 | hx-target="#release-content" |
| 20 | hx-swap="innerHTML" |
| 21 | hx-push-url="true" /> |
| 22 | {% if has_write %} |
| 23 | <a href="{% url 'fossil:release_create' slug=project.slug %}" |
| 24 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 25 | Create Release |
| 26 | </a> |
| 27 |
| --- templates/fossil/release_list.html | |
| +++ templates/fossil/release_list.html | |
| @@ -7,20 +7,25 @@ | |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | <h2 class="text-lg font-semibold text-gray-200">Releases</h2> |
| 11 | <div class="flex items-center gap-3"> |
| 12 | <span class="search-wrap"> |
| 13 | <input type="search" |
| 14 | name="search" |
| 15 | value="{{ search }}" |
| 16 | placeholder="Search releases..." |
| 17 | aria-label="Search releases" |
| 18 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 19 | hx-get="{% url 'fossil:releases' slug=project.slug %}" |
| 20 | hx-trigger="input changed delay:300ms, search" |
| 21 | hx-target="#release-content" |
| 22 | hx-swap="innerHTML" |
| 23 | hx-push-url="true" |
| 24 | hx-indicator="closest .search-wrap" /> |
| 25 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 26 | </span> |
| 27 | {% if has_write %} |
| 28 | <a href="{% url 'fossil:release_create' slug=project.slug %}" |
| 29 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 30 | Create Release |
| 31 | </a> |
| 32 |
+6
-1
| --- templates/fossil/tag_list.html | ||
| +++ templates/fossil/tag_list.html | ||
| @@ -6,20 +6,25 @@ | ||
| 6 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 9 | <div class="flex items-center justify-between mb-4"> |
| 10 | 10 | <div> |
| 11 | + <span class="search-wrap"> | |
| 11 | 12 | <input type="search" |
| 12 | 13 | name="search" |
| 13 | 14 | value="{{ search }}" |
| 14 | 15 | placeholder="Search tags..." |
| 16 | + aria-label="Search tags" | |
| 15 | 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | 18 | hx-get="{% url 'fossil:tags' slug=project.slug %}" |
| 17 | 19 | hx-trigger="input changed delay:300ms, search" |
| 18 | 20 | hx-target="#tag-content" |
| 19 | 21 | hx-swap="innerHTML" |
| 20 | - hx-push-url="true" /> | |
| 22 | + hx-push-url="true" | |
| 23 | + hx-indicator="closest .search-wrap" /> | |
| 24 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 25 | + </span> | |
| 21 | 26 | </div> |
| 22 | 27 | </div> |
| 23 | 28 | |
| 24 | 29 | <div id="tag-content"> |
| 25 | 30 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 26 | 31 |
| --- templates/fossil/tag_list.html | |
| +++ templates/fossil/tag_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-4"> |
| 10 | <div> |
| 11 | <input type="search" |
| 12 | name="search" |
| 13 | value="{{ search }}" |
| 14 | placeholder="Search tags..." |
| 15 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | hx-get="{% url 'fossil:tags' slug=project.slug %}" |
| 17 | hx-trigger="input changed delay:300ms, search" |
| 18 | hx-target="#tag-content" |
| 19 | hx-swap="innerHTML" |
| 20 | hx-push-url="true" /> |
| 21 | </div> |
| 22 | </div> |
| 23 | |
| 24 | <div id="tag-content"> |
| 25 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 26 |
| --- templates/fossil/tag_list.html | |
| +++ templates/fossil/tag_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | {% include "fossil/_project_nav.html" %} |
| 8 | |
| 9 | <div class="flex items-center justify-between mb-4"> |
| 10 | <div> |
| 11 | <span class="search-wrap"> |
| 12 | <input type="search" |
| 13 | name="search" |
| 14 | value="{{ search }}" |
| 15 | placeholder="Search tags..." |
| 16 | aria-label="Search tags" |
| 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 18 | hx-get="{% url 'fossil:tags' slug=project.slug %}" |
| 19 | hx-trigger="input changed delay:300ms, search" |
| 20 | hx-target="#tag-content" |
| 21 | hx-swap="innerHTML" |
| 22 | hx-push-url="true" |
| 23 | hx-indicator="closest .search-wrap" /> |
| 24 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 25 | </span> |
| 26 | </div> |
| 27 | </div> |
| 28 | |
| 29 | <div id="tag-content"> |
| 30 | <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm"> |
| 31 |
| --- templates/fossil/technote_form.html | ||
| +++ templates/fossil/technote_form.html | ||
| @@ -16,11 +16,11 @@ | ||
| 16 | 16 | <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> |
| 17 | 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | 18 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 19 | 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | - <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('content-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 21 | + <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('content-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 22 | 22 | </div> |
| 23 | 23 | </div> |
| 24 | 24 | |
| 25 | 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | 26 | {% csrf_token %} |
| 27 | 27 |
| --- templates/fossil/technote_form.html | |
| +++ templates/fossil/technote_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> |
| 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('content-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 22 | </div> |
| 23 | </div> |
| 24 | |
| 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | {% csrf_token %} |
| 27 |
| --- templates/fossil/technote_form.html | |
| +++ templates/fossil/technote_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> |
| 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2> |
| 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('content-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 22 | </div> |
| 23 | </div> |
| 24 | |
| 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | {% csrf_token %} |
| 27 |
| --- templates/fossil/ticket_edit.html | ||
| +++ templates/fossil/ticket_edit.html | ||
| @@ -16,11 +16,11 @@ | ||
| 16 | 16 | {% csrf_token %} |
| 17 | 17 | |
| 18 | 18 | <div> |
| 19 | 19 | <label class="block text-sm font-medium text-gray-300 mb-1">Title</label> |
| 20 | 20 | <input type="text" name="title" value="{{ ticket.title }}" |
| 21 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 21 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 22 | 22 | </div> |
| 23 | 23 | |
| 24 | 24 | <div class="grid grid-cols-2 gap-4"> |
| 25 | 25 | <div> |
| 26 | 26 | <label class="block text-sm font-medium text-gray-300 mb-1">Status</label> |
| @@ -79,17 +79,17 @@ | ||
| 79 | 79 | {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %} |
| 80 | 80 | </label> |
| 81 | 81 | {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %} |
| 82 | 82 | <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}" |
| 83 | 83 | name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 84 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 84 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 85 | 85 | {% elif cf.field_type == "textarea" %} |
| 86 | 86 | <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %} |
| 87 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> | |
| 87 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> | |
| 88 | 88 | {% elif cf.field_type == "select" %} |
| 89 | 89 | <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 90 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 90 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 91 | 91 | <option value="">--</option> |
| 92 | 92 | {% for choice in cf.choices_list %} |
| 93 | 93 | <option value="{{ choice }}">{{ choice }}</option> |
| 94 | 94 | {% endfor %} |
| 95 | 95 | </select> |
| 96 | 96 |
| --- templates/fossil/ticket_edit.html | |
| +++ templates/fossil/ticket_edit.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | {% csrf_token %} |
| 17 | |
| 18 | <div> |
| 19 | <label class="block text-sm font-medium text-gray-300 mb-1">Title</label> |
| 20 | <input type="text" name="title" value="{{ ticket.title }}" |
| 21 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 22 | </div> |
| 23 | |
| 24 | <div class="grid grid-cols-2 gap-4"> |
| 25 | <div> |
| 26 | <label class="block text-sm font-medium text-gray-300 mb-1">Status</label> |
| @@ -79,17 +79,17 @@ | |
| 79 | {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %} |
| 80 | </label> |
| 81 | {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %} |
| 82 | <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}" |
| 83 | name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 84 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 85 | {% elif cf.field_type == "textarea" %} |
| 86 | <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %} |
| 87 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> |
| 88 | {% elif cf.field_type == "select" %} |
| 89 | <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 90 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 91 | <option value="">--</option> |
| 92 | {% for choice in cf.choices_list %} |
| 93 | <option value="{{ choice }}">{{ choice }}</option> |
| 94 | {% endfor %} |
| 95 | </select> |
| 96 |
| --- templates/fossil/ticket_edit.html | |
| +++ templates/fossil/ticket_edit.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | {% csrf_token %} |
| 17 | |
| 18 | <div> |
| 19 | <label class="block text-sm font-medium text-gray-300 mb-1">Title</label> |
| 20 | <input type="text" name="title" value="{{ ticket.title }}" |
| 21 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 22 | </div> |
| 23 | |
| 24 | <div class="grid grid-cols-2 gap-4"> |
| 25 | <div> |
| 26 | <label class="block text-sm font-medium text-gray-300 mb-1">Status</label> |
| @@ -79,17 +79,17 @@ | |
| 79 | {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %} |
| 80 | </label> |
| 81 | {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %} |
| 82 | <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}" |
| 83 | name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 84 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 85 | {% elif cf.field_type == "textarea" %} |
| 86 | <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %} |
| 87 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> |
| 88 | {% elif cf.field_type == "select" %} |
| 89 | <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 90 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 91 | <option value="">--</option> |
| 92 | {% for choice in cf.choices_list %} |
| 93 | <option value="{{ choice }}">{{ choice }}</option> |
| 94 | {% endfor %} |
| 95 | </select> |
| 96 |
| --- templates/fossil/ticket_form.html | ||
| +++ templates/fossil/ticket_form.html | ||
| @@ -16,11 +16,11 @@ | ||
| 16 | 16 | {% csrf_token %} |
| 17 | 17 | |
| 18 | 18 | <div> |
| 19 | 19 | <label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label> |
| 20 | 20 | <input type="text" name="title" required placeholder="Ticket title" |
| 21 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 21 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 22 | 22 | </div> |
| 23 | 23 | |
| 24 | 24 | <div class="grid grid-cols-2 gap-4"> |
| 25 | 25 | <div> |
| 26 | 26 | <label class="block text-sm font-medium text-gray-300 mb-1">Type</label> |
| @@ -44,11 +44,11 @@ | ||
| 44 | 44 | </div> |
| 45 | 45 | |
| 46 | 46 | <div> |
| 47 | 47 | <label class="block text-sm font-medium text-gray-300 mb-1">Description</label> |
| 48 | 48 | <textarea name="body" rows="10" placeholder="Describe the issue..." |
| 49 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> | |
| 49 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> | |
| 50 | 50 | </div> |
| 51 | 51 | |
| 52 | 52 | {% if custom_fields %} |
| 53 | 53 | <div class="border-t border-gray-700 pt-4 mt-4"> |
| 54 | 54 | <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3> |
| @@ -60,17 +60,17 @@ | ||
| 60 | 60 | {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %} |
| 61 | 61 | </label> |
| 62 | 62 | {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %} |
| 63 | 63 | <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}" |
| 64 | 64 | name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 65 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 65 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 66 | 66 | {% elif cf.field_type == "textarea" %} |
| 67 | 67 | <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %} |
| 68 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> | |
| 68 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> | |
| 69 | 69 | {% elif cf.field_type == "select" %} |
| 70 | 70 | <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 71 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 71 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 72 | 72 | <option value="">--</option> |
| 73 | 73 | {% for choice in cf.choices_list %} |
| 74 | 74 | <option value="{{ choice }}">{{ choice }}</option> |
| 75 | 75 | {% endfor %} |
| 76 | 76 | </select> |
| 77 | 77 |
| --- templates/fossil/ticket_form.html | |
| +++ templates/fossil/ticket_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | {% csrf_token %} |
| 17 | |
| 18 | <div> |
| 19 | <label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label> |
| 20 | <input type="text" name="title" required placeholder="Ticket title" |
| 21 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 22 | </div> |
| 23 | |
| 24 | <div class="grid grid-cols-2 gap-4"> |
| 25 | <div> |
| 26 | <label class="block text-sm font-medium text-gray-300 mb-1">Type</label> |
| @@ -44,11 +44,11 @@ | |
| 44 | </div> |
| 45 | |
| 46 | <div> |
| 47 | <label class="block text-sm font-medium text-gray-300 mb-1">Description</label> |
| 48 | <textarea name="body" rows="10" placeholder="Describe the issue..." |
| 49 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> |
| 50 | </div> |
| 51 | |
| 52 | {% if custom_fields %} |
| 53 | <div class="border-t border-gray-700 pt-4 mt-4"> |
| 54 | <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3> |
| @@ -60,17 +60,17 @@ | |
| 60 | {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %} |
| 61 | </label> |
| 62 | {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %} |
| 63 | <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}" |
| 64 | name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 65 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 66 | {% elif cf.field_type == "textarea" %} |
| 67 | <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %} |
| 68 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> |
| 69 | {% elif cf.field_type == "select" %} |
| 70 | <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 71 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 72 | <option value="">--</option> |
| 73 | {% for choice in cf.choices_list %} |
| 74 | <option value="{{ choice }}">{{ choice }}</option> |
| 75 | {% endfor %} |
| 76 | </select> |
| 77 |
| --- templates/fossil/ticket_form.html | |
| +++ templates/fossil/ticket_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | {% csrf_token %} |
| 17 | |
| 18 | <div> |
| 19 | <label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label> |
| 20 | <input type="text" name="title" required placeholder="Ticket title" |
| 21 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 22 | </div> |
| 23 | |
| 24 | <div class="grid grid-cols-2 gap-4"> |
| 25 | <div> |
| 26 | <label class="block text-sm font-medium text-gray-300 mb-1">Type</label> |
| @@ -44,11 +44,11 @@ | |
| 44 | </div> |
| 45 | |
| 46 | <div> |
| 47 | <label class="block text-sm font-medium text-gray-300 mb-1">Description</label> |
| 48 | <textarea name="body" rows="10" placeholder="Describe the issue..." |
| 49 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> |
| 50 | </div> |
| 51 | |
| 52 | {% if custom_fields %} |
| 53 | <div class="border-t border-gray-700 pt-4 mt-4"> |
| 54 | <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3> |
| @@ -60,17 +60,17 @@ | |
| 60 | {% if cf.is_required %}<span class="text-red-400">*</span>{% endif %} |
| 61 | </label> |
| 62 | {% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %} |
| 63 | <input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}" |
| 64 | name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 65 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 66 | {% elif cf.field_type == "textarea" %} |
| 67 | <textarea name="custom_{{ cf.name }}" rows="3" {% if cf.is_required %}required{% endif %} |
| 68 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"></textarea> |
| 69 | {% elif cf.field_type == "select" %} |
| 70 | <select name="custom_{{ cf.name }}" {% if cf.is_required %}required{% endif %} |
| 71 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 72 | <option value="">--</option> |
| 73 | {% for choice in cf.choices_list %} |
| 74 | <option value="{{ choice }}">{{ choice }}</option> |
| 75 | {% endfor %} |
| 76 | </select> |
| 77 |
| --- templates/fossil/ticket_list.html | ||
| +++ templates/fossil/ticket_list.html | ||
| @@ -20,20 +20,25 @@ | ||
| 20 | 20 | <div class="flex items-center gap-3"> |
| 21 | 21 | <a href="{% url 'fossil:tickets_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a> |
| 22 | 22 | {% if perms.projects.change_project %} |
| 23 | 23 | <a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Ticket</a> |
| 24 | 24 | {% endif %} |
| 25 | + <span class="search-wrap"> | |
| 25 | 26 | <input type="search" |
| 26 | 27 | name="search" |
| 27 | 28 | value="{{ search }}" |
| 28 | 29 | placeholder="Search tickets..." |
| 30 | + aria-label="Search tickets" | |
| 29 | 31 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 30 | 32 | hx-get="{% url 'fossil:tickets' slug=project.slug %}{% if status_filter %}?status={{ status_filter }}{% endif %}" |
| 31 | 33 | hx-trigger="input changed delay:300ms, search" |
| 32 | 34 | hx-target="#ticket-table" |
| 33 | 35 | hx-swap="outerHTML" |
| 34 | - hx-push-url="true" /> | |
| 36 | + hx-push-url="true" | |
| 37 | + hx-indicator="closest .search-wrap" /> | |
| 38 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 39 | + </span> | |
| 35 | 40 | </div> |
| 36 | 41 | </div> |
| 37 | 42 | |
| 38 | 43 | {% include "fossil/partials/ticket_table.html" %} |
| 39 | 44 | |
| 40 | 45 |
| --- templates/fossil/ticket_list.html | |
| +++ templates/fossil/ticket_list.html | |
| @@ -20,20 +20,25 @@ | |
| 20 | <div class="flex items-center gap-3"> |
| 21 | <a href="{% url 'fossil:tickets_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a> |
| 22 | {% if perms.projects.change_project %} |
| 23 | <a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Ticket</a> |
| 24 | {% endif %} |
| 25 | <input type="search" |
| 26 | name="search" |
| 27 | value="{{ search }}" |
| 28 | placeholder="Search tickets..." |
| 29 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 30 | hx-get="{% url 'fossil:tickets' slug=project.slug %}{% if status_filter %}?status={{ status_filter }}{% endif %}" |
| 31 | hx-trigger="input changed delay:300ms, search" |
| 32 | hx-target="#ticket-table" |
| 33 | hx-swap="outerHTML" |
| 34 | hx-push-url="true" /> |
| 35 | </div> |
| 36 | </div> |
| 37 | |
| 38 | {% include "fossil/partials/ticket_table.html" %} |
| 39 | |
| 40 |
| --- templates/fossil/ticket_list.html | |
| +++ templates/fossil/ticket_list.html | |
| @@ -20,20 +20,25 @@ | |
| 20 | <div class="flex items-center gap-3"> |
| 21 | <a href="{% url 'fossil:tickets_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a> |
| 22 | {% if perms.projects.change_project %} |
| 23 | <a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Ticket</a> |
| 24 | {% endif %} |
| 25 | <span class="search-wrap"> |
| 26 | <input type="search" |
| 27 | name="search" |
| 28 | value="{{ search }}" |
| 29 | placeholder="Search tickets..." |
| 30 | aria-label="Search tickets" |
| 31 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 32 | hx-get="{% url 'fossil:tickets' slug=project.slug %}{% if status_filter %}?status={{ status_filter }}{% endif %}" |
| 33 | hx-trigger="input changed delay:300ms, search" |
| 34 | hx-target="#ticket-table" |
| 35 | hx-swap="outerHTML" |
| 36 | hx-push-url="true" |
| 37 | hx-indicator="closest .search-wrap" /> |
| 38 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 39 | </span> |
| 40 | </div> |
| 41 | </div> |
| 42 | |
| 43 | {% include "fossil/partials/ticket_table.html" %} |
| 44 | |
| 45 |
| --- templates/fossil/timeline.html | ||
| +++ templates/fossil/timeline.html | ||
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | -{% load fossil_filters %} | |
| 3 | 2 | {% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblock %} |
| 4 | 3 | |
| 5 | 4 | {% block content %} |
| 6 | 5 | {% include "fossil/_live_reload.html" %} |
| 7 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 8 | 7 |
| --- templates/fossil/timeline.html | |
| +++ templates/fossil/timeline.html | |
| @@ -1,7 +1,6 @@ | |
| 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 |
| --- templates/fossil/timeline.html | |
| +++ templates/fossil/timeline.html | |
| @@ -1,7 +1,6 @@ | |
| 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/unversioned_list.html | ||
| +++ templates/fossil/unversioned_list.html | ||
| @@ -6,20 +6,25 @@ | ||
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | 9 | <h2 class="text-lg font-semibold text-gray-200">Unversioned Files</h2> |
| 10 | 10 | <div> |
| 11 | + <span class="search-wrap"> | |
| 11 | 12 | <input type="search" |
| 12 | 13 | name="search" |
| 13 | 14 | value="{{ search }}" |
| 14 | 15 | placeholder="Search files..." |
| 16 | + aria-label="Search files" | |
| 15 | 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | 18 | hx-get="{% url 'fossil:unversioned' slug=project.slug %}" |
| 17 | 19 | hx-trigger="input changed delay:300ms, search" |
| 18 | 20 | hx-target="#unversioned-content" |
| 19 | 21 | hx-swap="innerHTML" |
| 20 | - hx-push-url="true" /> | |
| 22 | + hx-push-url="true" | |
| 23 | + hx-indicator="closest .search-wrap" /> | |
| 24 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 25 | + </span> | |
| 21 | 26 | </div> |
| 22 | 27 | </div> |
| 23 | 28 | |
| 24 | 29 | <div id="unversioned-content"> |
| 25 | 30 | {% if files %} |
| 26 | 31 |
| --- templates/fossil/unversioned_list.html | |
| +++ templates/fossil/unversioned_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | <h2 class="text-lg font-semibold text-gray-200">Unversioned Files</h2> |
| 10 | <div> |
| 11 | <input type="search" |
| 12 | name="search" |
| 13 | value="{{ search }}" |
| 14 | placeholder="Search files..." |
| 15 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | hx-get="{% url 'fossil:unversioned' slug=project.slug %}" |
| 17 | hx-trigger="input changed delay:300ms, search" |
| 18 | hx-target="#unversioned-content" |
| 19 | hx-swap="innerHTML" |
| 20 | hx-push-url="true" /> |
| 21 | </div> |
| 22 | </div> |
| 23 | |
| 24 | <div id="unversioned-content"> |
| 25 | {% if files %} |
| 26 |
| --- templates/fossil/unversioned_list.html | |
| +++ templates/fossil/unversioned_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | <h2 class="text-lg font-semibold text-gray-200">Unversioned Files</h2> |
| 10 | <div> |
| 11 | <span class="search-wrap"> |
| 12 | <input type="search" |
| 13 | name="search" |
| 14 | value="{{ search }}" |
| 15 | placeholder="Search files..." |
| 16 | aria-label="Search files" |
| 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 18 | hx-get="{% url 'fossil:unversioned' slug=project.slug %}" |
| 19 | hx-trigger="input changed delay:300ms, search" |
| 20 | hx-target="#unversioned-content" |
| 21 | hx-swap="innerHTML" |
| 22 | hx-push-url="true" |
| 23 | hx-indicator="closest .search-wrap" /> |
| 24 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 25 | </span> |
| 26 | </div> |
| 27 | </div> |
| 28 | |
| 29 | <div id="unversioned-content"> |
| 30 | {% if files %} |
| 31 |
| --- templates/fossil/webhook_list.html | ||
| +++ templates/fossil/webhook_list.html | ||
| @@ -6,20 +6,25 @@ | ||
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | 9 | <h2 class="text-lg font-semibold text-gray-200">Webhooks</h2> |
| 10 | 10 | <div class="flex items-center gap-3"> |
| 11 | + <span class="search-wrap"> | |
| 11 | 12 | <input type="search" |
| 12 | 13 | name="search" |
| 13 | 14 | value="{{ search }}" |
| 14 | 15 | placeholder="Search webhooks..." |
| 16 | + aria-label="Search webhooks" | |
| 15 | 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | 18 | hx-get="{% url 'fossil:webhooks' slug=project.slug %}" |
| 17 | 19 | hx-trigger="input changed delay:300ms, search" |
| 18 | 20 | hx-target="#webhook-content" |
| 19 | 21 | hx-swap="innerHTML" |
| 20 | - hx-push-url="true" /> | |
| 22 | + hx-push-url="true" | |
| 23 | + hx-indicator="closest .search-wrap" /> | |
| 24 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 25 | + </span> | |
| 21 | 26 | <a href="{% url 'fossil:webhook_create' slug=project.slug %}" |
| 22 | 27 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 23 | 28 | Add Webhook |
| 24 | 29 | </a> |
| 25 | 30 | </div> |
| 26 | 31 |
| --- templates/fossil/webhook_list.html | |
| +++ templates/fossil/webhook_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | <h2 class="text-lg font-semibold text-gray-200">Webhooks</h2> |
| 10 | <div class="flex items-center gap-3"> |
| 11 | <input type="search" |
| 12 | name="search" |
| 13 | value="{{ search }}" |
| 14 | placeholder="Search webhooks..." |
| 15 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 16 | hx-get="{% url 'fossil:webhooks' slug=project.slug %}" |
| 17 | hx-trigger="input changed delay:300ms, search" |
| 18 | hx-target="#webhook-content" |
| 19 | hx-swap="innerHTML" |
| 20 | hx-push-url="true" /> |
| 21 | <a href="{% url 'fossil:webhook_create' slug=project.slug %}" |
| 22 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 23 | Add Webhook |
| 24 | </a> |
| 25 | </div> |
| 26 |
| --- templates/fossil/webhook_list.html | |
| +++ templates/fossil/webhook_list.html | |
| @@ -6,20 +6,25 @@ | |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | <h2 class="text-lg font-semibold text-gray-200">Webhooks</h2> |
| 10 | <div class="flex items-center gap-3"> |
| 11 | <span class="search-wrap"> |
| 12 | <input type="search" |
| 13 | name="search" |
| 14 | value="{{ search }}" |
| 15 | placeholder="Search webhooks..." |
| 16 | aria-label="Search webhooks" |
| 17 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 18 | hx-get="{% url 'fossil:webhooks' slug=project.slug %}" |
| 19 | hx-trigger="input changed delay:300ms, search" |
| 20 | hx-target="#webhook-content" |
| 21 | hx-swap="innerHTML" |
| 22 | hx-push-url="true" |
| 23 | hx-indicator="closest .search-wrap" /> |
| 24 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 25 | </span> |
| 26 | <a href="{% url 'fossil:webhook_create' slug=project.slug %}" |
| 27 | class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover"> |
| 28 | Add Webhook |
| 29 | </a> |
| 30 | </div> |
| 31 |
+2
-2
| --- templates/fossil/wiki_form.html | ||
| +++ templates/fossil/wiki_form.html | ||
| @@ -16,11 +16,11 @@ | ||
| 16 | 16 | <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> |
| 17 | 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | 18 | <h2 class="text-xl font-bold text-gray-100">{{ title }}</h2> |
| 19 | 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | - <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('content-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 21 | + <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('content-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> | |
| 22 | 22 | </div> |
| 23 | 23 | </div> |
| 24 | 24 | |
| 25 | 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | 26 | {% csrf_token %} |
| @@ -27,11 +27,11 @@ | ||
| 27 | 27 | |
| 28 | 28 | {% if not page %} |
| 29 | 29 | <div> |
| 30 | 30 | <label class="block text-sm font-medium text-gray-300 mb-1">Page Name <span class="text-red-400">*</span></label> |
| 31 | 31 | <input type="text" name="name" required placeholder="Page title" |
| 32 | - class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 32 | + class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> | |
| 33 | 33 | </div> |
| 34 | 34 | {% endif %} |
| 35 | 35 | |
| 36 | 36 | <div x-show="tab === 'write'"> |
| 37 | 37 | <label class="block text-sm font-medium text-gray-300 mb-1">Content (Markdown)</label> |
| 38 | 38 |
| --- templates/fossil/wiki_form.html | |
| +++ templates/fossil/wiki_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> |
| 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | <h2 class="text-xl font-bold text-gray-100">{{ title }}</h2> |
| 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = marked.parse(document.getElementById('content-input').value)" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 22 | </div> |
| 23 | </div> |
| 24 | |
| 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | {% csrf_token %} |
| @@ -27,11 +27,11 @@ | |
| 27 | |
| 28 | {% if not page %} |
| 29 | <div> |
| 30 | <label class="block text-sm font-medium text-gray-300 mb-1">Page Name <span class="text-red-400">*</span></label> |
| 31 | <input type="text" name="name" required placeholder="Page title" |
| 32 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 33 | </div> |
| 34 | {% endif %} |
| 35 | |
| 36 | <div x-show="tab === 'write'"> |
| 37 | <label class="block text-sm font-medium text-gray-300 mb-1">Content (Markdown)</label> |
| 38 |
| --- templates/fossil/wiki_form.html | |
| +++ templates/fossil/wiki_form.html | |
| @@ -16,11 +16,11 @@ | |
| 16 | <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }"> |
| 17 | <div class="flex items-center justify-between mb-4"> |
| 18 | <h2 class="text-xl font-bold text-gray-100">{{ title }}</h2> |
| 19 | <div class="flex items-center gap-1 text-xs"> |
| 20 | <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button> |
| 21 | <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = DOMPurify.sanitize(marked.parse(document.getElementById('content-input').value))" :class="tab === 'preview' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button> |
| 22 | </div> |
| 23 | </div> |
| 24 | |
| 25 | <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700"> |
| 26 | {% csrf_token %} |
| @@ -27,11 +27,11 @@ | |
| 27 | |
| 28 | {% if not page %} |
| 29 | <div> |
| 30 | <label class="block text-sm font-medium text-gray-300 mb-1">Page Name <span class="text-red-400">*</span></label> |
| 31 | <input type="text" name="name" required placeholder="Page title" |
| 32 | class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"> |
| 33 | </div> |
| 34 | {% endif %} |
| 35 | |
| 36 | <div x-show="tab === 'write'"> |
| 37 | <label class="block text-sm font-medium text-gray-300 mb-1">Content (Markdown)</label> |
| 38 |
+6
-1
| --- templates/fossil/wiki_list.html | ||
| +++ templates/fossil/wiki_list.html | ||
| @@ -5,20 +5,25 @@ | ||
| 5 | 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | 8 | <div class="flex items-center justify-between mb-4"> |
| 9 | 9 | <div> |
| 10 | + <span class="search-wrap"> | |
| 10 | 11 | <input type="search" |
| 11 | 12 | name="search" |
| 12 | 13 | value="{{ search }}" |
| 13 | 14 | placeholder="Search wiki pages..." |
| 15 | + aria-label="Search wiki pages" | |
| 14 | 16 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 15 | 17 | hx-get="{% url 'fossil:wiki' slug=project.slug %}" |
| 16 | 18 | hx-trigger="input changed delay:300ms, search" |
| 17 | 19 | hx-target="#wiki-content" |
| 18 | 20 | hx-swap="innerHTML" |
| 19 | - hx-push-url="true" /> | |
| 21 | + hx-push-url="true" | |
| 22 | + hx-indicator="closest .search-wrap" /> | |
| 23 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 24 | + </span> | |
| 20 | 25 | </div> |
| 21 | 26 | {% if perms.projects.change_project %} |
| 22 | 27 | <a href="{% url 'fossil:wiki_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Page</a> |
| 23 | 28 | {% endif %} |
| 24 | 29 | </div> |
| 25 | 30 |
| --- templates/fossil/wiki_list.html | |
| +++ templates/fossil/wiki_list.html | |
| @@ -5,20 +5,25 @@ | |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="flex items-center justify-between mb-4"> |
| 9 | <div> |
| 10 | <input type="search" |
| 11 | name="search" |
| 12 | value="{{ search }}" |
| 13 | placeholder="Search wiki pages..." |
| 14 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 15 | hx-get="{% url 'fossil:wiki' slug=project.slug %}" |
| 16 | hx-trigger="input changed delay:300ms, search" |
| 17 | hx-target="#wiki-content" |
| 18 | hx-swap="innerHTML" |
| 19 | hx-push-url="true" /> |
| 20 | </div> |
| 21 | {% if perms.projects.change_project %} |
| 22 | <a href="{% url 'fossil:wiki_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Page</a> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 |
| --- templates/fossil/wiki_list.html | |
| +++ templates/fossil/wiki_list.html | |
| @@ -5,20 +5,25 @@ | |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | {% include "fossil/_project_nav.html" %} |
| 7 | |
| 8 | <div class="flex items-center justify-between mb-4"> |
| 9 | <div> |
| 10 | <span class="search-wrap"> |
| 11 | <input type="search" |
| 12 | name="search" |
| 13 | value="{{ search }}" |
| 14 | placeholder="Search wiki pages..." |
| 15 | aria-label="Search wiki pages" |
| 16 | class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5" |
| 17 | hx-get="{% url 'fossil:wiki' slug=project.slug %}" |
| 18 | hx-trigger="input changed delay:300ms, search" |
| 19 | hx-target="#wiki-content" |
| 20 | hx-swap="innerHTML" |
| 21 | hx-push-url="true" |
| 22 | hx-indicator="closest .search-wrap" /> |
| 23 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 24 | </span> |
| 25 | </div> |
| 26 | {% if perms.projects.change_project %} |
| 27 | <a href="{% url 'fossil:wiki_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">New Page</a> |
| 28 | {% endif %} |
| 29 | </div> |
| 30 |
+27
-27
| --- templates/includes/sidebar.html | ||
| +++ templates/includes/sidebar.html | ||
| @@ -85,37 +85,10 @@ | ||
| 85 | 85 | {% endif %} |
| 86 | 86 | </div> |
| 87 | 87 | </div> |
| 88 | 88 | {% endif %} |
| 89 | 89 | |
| 90 | - <!-- FossilRepo Docs (product docs — read-only) --> | |
| 91 | - {% if sidebar_product_docs %} | |
| 92 | - <div x-data="{ docsOpen: false }"> | |
| 93 | - <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" | |
| 94 | - class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/kb/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 95 | - :title="collapsed ? 'FossilRepo Docs' : ''"> | |
| 96 | - <span class="flex items-center gap-2"> | |
| 97 | - <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 98 | - <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> | |
| 99 | - </svg> | |
| 100 | - <span x-show="!collapsed" class="truncate">FossilRepo Docs</span> | |
| 101 | - </span> | |
| 102 | - <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 103 | - <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| 104 | - </svg> | |
| 105 | - </button> | |
| 106 | - <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3"> | |
| 107 | - {% for p in sidebar_product_docs %} | |
| 108 | - <a href="{% url 'pages:detail' slug=p.slug %}" | |
| 109 | - class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate"> | |
| 110 | - {{ p.name }} | |
| 111 | - </a> | |
| 112 | - {% endfor %} | |
| 113 | - </div> | |
| 114 | - </div> | |
| 115 | - {% endif %} | |
| 116 | - | |
| 117 | 90 | <!-- Knowledge Base (org wiki — user-editable) --> |
| 118 | 91 | {% if perms.pages.view_page %} |
| 119 | 92 | <div x-data="{ kbOpen: false }"> |
| 120 | 93 | <button @click="collapsed ? (collapsed = false, kbOpen = true) : (kbOpen = !kbOpen)" |
| 121 | 94 | class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" |
| @@ -145,10 +118,37 @@ | ||
| 145 | 118 | class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 146 | 119 | + New |
| 147 | 120 | </a> |
| 148 | 121 | {% endif %} |
| 149 | 122 | </div> |
| 123 | + </div> | |
| 124 | + {% endif %} | |
| 125 | + | |
| 126 | + <!-- FossilRepo Docs (product docs — read-only) --> | |
| 127 | + {% if sidebar_product_docs %} | |
| 128 | + <div x-data="{ docsOpen: false }"> | |
| 129 | + <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" | |
| 130 | + class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/kb/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" | |
| 131 | + :title="collapsed ? 'FossilRepo Docs' : ''"> | |
| 132 | + <span class="flex items-center gap-2"> | |
| 133 | + <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 134 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> | |
| 135 | + </svg> | |
| 136 | + <span x-show="!collapsed" class="truncate">FossilRepo Docs</span> | |
| 137 | + </span> | |
| 138 | + <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 139 | + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> | |
| 140 | + </svg> | |
| 141 | + </button> | |
| 142 | + <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3"> | |
| 143 | + {% for p in sidebar_product_docs %} | |
| 144 | + <a href="{% url 'pages:detail' slug=p.slug %}" | |
| 145 | + class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate"> | |
| 146 | + {{ p.name }} | |
| 147 | + </a> | |
| 148 | + {% endfor %} | |
| 149 | + </div> | |
| 150 | 150 | </div> |
| 151 | 151 | {% endif %} |
| 152 | 152 | |
| 153 | 153 | <!-- FossilSCM Guide --> |
| 154 | 154 | <a href="{% url 'fossil:docs' slug='fossil-scm' %}" |
| 155 | 155 |
| --- templates/includes/sidebar.html | |
| +++ templates/includes/sidebar.html | |
| @@ -85,37 +85,10 @@ | |
| 85 | {% endif %} |
| 86 | </div> |
| 87 | </div> |
| 88 | {% endif %} |
| 89 | |
| 90 | <!-- FossilRepo Docs (product docs — read-only) --> |
| 91 | {% if sidebar_product_docs %} |
| 92 | <div x-data="{ docsOpen: false }"> |
| 93 | <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" |
| 94 | class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/kb/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 95 | :title="collapsed ? 'FossilRepo Docs' : ''"> |
| 96 | <span class="flex items-center gap-2"> |
| 97 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 98 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> |
| 99 | </svg> |
| 100 | <span x-show="!collapsed" class="truncate">FossilRepo Docs</span> |
| 101 | </span> |
| 102 | <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 103 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 104 | </svg> |
| 105 | </button> |
| 106 | <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3"> |
| 107 | {% for p in sidebar_product_docs %} |
| 108 | <a href="{% url 'pages:detail' slug=p.slug %}" |
| 109 | class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate"> |
| 110 | {{ p.name }} |
| 111 | </a> |
| 112 | {% endfor %} |
| 113 | </div> |
| 114 | </div> |
| 115 | {% endif %} |
| 116 | |
| 117 | <!-- Knowledge Base (org wiki — user-editable) --> |
| 118 | {% if perms.pages.view_page %} |
| 119 | <div x-data="{ kbOpen: false }"> |
| 120 | <button @click="collapsed ? (collapsed = false, kbOpen = true) : (kbOpen = !kbOpen)" |
| 121 | class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" |
| @@ -145,10 +118,37 @@ | |
| 145 | class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 146 | + New |
| 147 | </a> |
| 148 | {% endif %} |
| 149 | </div> |
| 150 | </div> |
| 151 | {% endif %} |
| 152 | |
| 153 | <!-- FossilSCM Guide --> |
| 154 | <a href="{% url 'fossil:docs' slug='fossil-scm' %}" |
| 155 |
| --- templates/includes/sidebar.html | |
| +++ templates/includes/sidebar.html | |
| @@ -85,37 +85,10 @@ | |
| 85 | {% endif %} |
| 86 | </div> |
| 87 | </div> |
| 88 | {% endif %} |
| 89 | |
| 90 | <!-- Knowledge Base (org wiki — user-editable) --> |
| 91 | {% if perms.pages.view_page %} |
| 92 | <div x-data="{ kbOpen: false }"> |
| 93 | <button @click="collapsed ? (collapsed = false, kbOpen = true) : (kbOpen = !kbOpen)" |
| 94 | class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white" |
| @@ -145,10 +118,37 @@ | |
| 118 | class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light"> |
| 119 | + New |
| 120 | </a> |
| 121 | {% endif %} |
| 122 | </div> |
| 123 | </div> |
| 124 | {% endif %} |
| 125 | |
| 126 | <!-- FossilRepo Docs (product docs — read-only) --> |
| 127 | {% if sidebar_product_docs %} |
| 128 | <div x-data="{ docsOpen: false }"> |
| 129 | <button @click="collapsed ? (collapsed = false, docsOpen = true) : (docsOpen = !docsOpen)" |
| 130 | class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/kb/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}" |
| 131 | :title="collapsed ? 'FossilRepo Docs' : ''"> |
| 132 | <span class="flex items-center gap-2"> |
| 133 | <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 134 | <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /> |
| 135 | </svg> |
| 136 | <span x-show="!collapsed" class="truncate">FossilRepo Docs</span> |
| 137 | </span> |
| 138 | <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="docsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 139 | <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> |
| 140 | </svg> |
| 141 | </button> |
| 142 | <div x-show="docsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-3"> |
| 143 | {% for p in sidebar_product_docs %} |
| 144 | <a href="{% url 'pages:detail' slug=p.slug %}" |
| 145 | class="block rounded-md px-3 py-1.5 text-sm {% if p.slug in request.path %}text-brand-light font-medium{% else %}text-gray-500 hover:text-gray-300{% endif %} truncate"> |
| 146 | {{ p.name }} |
| 147 | </a> |
| 148 | {% endfor %} |
| 149 | </div> |
| 150 | </div> |
| 151 | {% endif %} |
| 152 | |
| 153 | <!-- FossilSCM Guide --> |
| 154 | <a href="{% url 'fossil:docs' slug='fossil-scm' %}" |
| 155 |
| --- templates/organization/member_list.html | ||
| +++ templates/organization/member_list.html | ||
| @@ -22,21 +22,24 @@ | ||
| 22 | 22 | </a> |
| 23 | 23 | {% endif %} |
| 24 | 24 | </div> |
| 25 | 25 | </div> |
| 26 | 26 | |
| 27 | -<div class="mb-4"> | |
| 27 | +<div class="mb-4 search-wrap max-w-md"> | |
| 28 | 28 | <input type="search" |
| 29 | 29 | name="search" |
| 30 | 30 | value="{{ search }}" |
| 31 | 31 | placeholder="Search members..." |
| 32 | - class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 32 | + aria-label="Search members" | |
| 33 | + class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 33 | 34 | hx-get="{% url 'organization:members' %}" |
| 34 | 35 | hx-trigger="input changed delay:300ms, search" |
| 35 | 36 | hx-target="#member-table" |
| 36 | 37 | hx-swap="outerHTML" |
| 37 | - hx-push-url="true" /> | |
| 38 | + hx-push-url="true" | |
| 39 | + hx-indicator="closest .search-wrap" /> | |
| 40 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 38 | 41 | </div> |
| 39 | 42 | |
| 40 | 43 | {% include "organization/partials/member_table.html" %} |
| 41 | 44 | {% include "includes/_pagination.html" %} |
| 42 | 45 | {% endblock %} |
| 43 | 46 |
| --- templates/organization/member_list.html | |
| +++ templates/organization/member_list.html | |
| @@ -22,21 +22,24 @@ | |
| 22 | </a> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | </div> |
| 26 | |
| 27 | <div class="mb-4"> |
| 28 | <input type="search" |
| 29 | name="search" |
| 30 | value="{{ search }}" |
| 31 | placeholder="Search members..." |
| 32 | class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 33 | hx-get="{% url 'organization:members' %}" |
| 34 | hx-trigger="input changed delay:300ms, search" |
| 35 | hx-target="#member-table" |
| 36 | hx-swap="outerHTML" |
| 37 | hx-push-url="true" /> |
| 38 | </div> |
| 39 | |
| 40 | {% include "organization/partials/member_table.html" %} |
| 41 | {% include "includes/_pagination.html" %} |
| 42 | {% endblock %} |
| 43 |
| --- templates/organization/member_list.html | |
| +++ templates/organization/member_list.html | |
| @@ -22,21 +22,24 @@ | |
| 22 | </a> |
| 23 | {% endif %} |
| 24 | </div> |
| 25 | </div> |
| 26 | |
| 27 | <div class="mb-4 search-wrap max-w-md"> |
| 28 | <input type="search" |
| 29 | name="search" |
| 30 | value="{{ search }}" |
| 31 | placeholder="Search members..." |
| 32 | aria-label="Search members" |
| 33 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 34 | hx-get="{% url 'organization:members' %}" |
| 35 | hx-trigger="input changed delay:300ms, search" |
| 36 | hx-target="#member-table" |
| 37 | hx-swap="outerHTML" |
| 38 | hx-push-url="true" |
| 39 | hx-indicator="closest .search-wrap" /> |
| 40 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 41 | </div> |
| 42 | |
| 43 | {% include "organization/partials/member_table.html" %} |
| 44 | {% include "includes/_pagination.html" %} |
| 45 | {% endblock %} |
| 46 |
| --- templates/projects/project_list.html | ||
| +++ templates/projects/project_list.html | ||
| @@ -10,21 +10,24 @@ | ||
| 10 | 10 | New Project |
| 11 | 11 | </a> |
| 12 | 12 | {% endif %} |
| 13 | 13 | </div> |
| 14 | 14 | |
| 15 | -<div class="mb-4"> | |
| 15 | +<div class="mb-4 search-wrap max-w-md"> | |
| 16 | 16 | <input type="search" |
| 17 | 17 | name="search" |
| 18 | 18 | value="{{ search }}" |
| 19 | 19 | placeholder="Search projects..." |
| 20 | - class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 20 | + aria-label="Search projects" | |
| 21 | + class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" | |
| 21 | 22 | hx-get="{% url 'projects:list' %}" |
| 22 | 23 | hx-trigger="input changed delay:300ms, search" |
| 23 | 24 | hx-target="#project-table" |
| 24 | 25 | hx-swap="outerHTML" |
| 25 | - hx-push-url="true" /> | |
| 26 | + hx-push-url="true" | |
| 27 | + hx-indicator="closest .search-wrap" /> | |
| 28 | + <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| 26 | 29 | </div> |
| 27 | 30 | |
| 28 | 31 | {% include "projects/partials/project_table.html" %} |
| 29 | 32 | {% include "includes/_pagination.html" %} |
| 30 | 33 | {% endblock %} |
| 31 | 34 |
| --- templates/projects/project_list.html | |
| +++ templates/projects/project_list.html | |
| @@ -10,21 +10,24 @@ | |
| 10 | New Project |
| 11 | </a> |
| 12 | {% endif %} |
| 13 | </div> |
| 14 | |
| 15 | <div class="mb-4"> |
| 16 | <input type="search" |
| 17 | name="search" |
| 18 | value="{{ search }}" |
| 19 | placeholder="Search projects..." |
| 20 | class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 21 | hx-get="{% url 'projects:list' %}" |
| 22 | hx-trigger="input changed delay:300ms, search" |
| 23 | hx-target="#project-table" |
| 24 | hx-swap="outerHTML" |
| 25 | hx-push-url="true" /> |
| 26 | </div> |
| 27 | |
| 28 | {% include "projects/partials/project_table.html" %} |
| 29 | {% include "includes/_pagination.html" %} |
| 30 | {% endblock %} |
| 31 |
| --- templates/projects/project_list.html | |
| +++ templates/projects/project_list.html | |
| @@ -10,21 +10,24 @@ | |
| 10 | New Project |
| 11 | </a> |
| 12 | {% endif %} |
| 13 | </div> |
| 14 | |
| 15 | <div class="mb-4 search-wrap max-w-md"> |
| 16 | <input type="search" |
| 17 | name="search" |
| 18 | value="{{ search }}" |
| 19 | placeholder="Search projects..." |
| 20 | aria-label="Search projects" |
| 21 | class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" |
| 22 | hx-get="{% url 'projects:list' %}" |
| 23 | hx-trigger="input changed delay:300ms, search" |
| 24 | hx-target="#project-table" |
| 25 | hx-swap="outerHTML" |
| 26 | hx-push-url="true" |
| 27 | hx-indicator="closest .search-wrap" /> |
| 28 | <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> |
| 29 | </div> |
| 30 | |
| 31 | {% include "projects/partials/project_table.html" %} |
| 32 | {% include "includes/_pagination.html" %} |
| 33 | {% endblock %} |
| 34 |