FossilRepo

Session cleanup: deploy flow, template fixes, docker-compose bind mount

ragelink 2026-04-07 20:58 trunk
Commit 7e1aaf6ba6205d3af1beff6d19f9ce391ae776e659f9ac39b9b5cf656e767a43
--- config/settings.py
+++ config/settings.py
@@ -136,13 +136,13 @@
136136
SESSION_COOKIE_HTTPONLY = True
137137
SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days
138138
CSRF_COOKIE_HTTPONLY = True
139139
140140
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)
144144
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
145145
146146
# --- i18n ---
147147
148148
LANGUAGE_CODE = "en-us"
149149
--- 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
11
--- 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
--- core/context_processors.py
+++ core/context_processors.py
@@ -25,11 +25,12 @@
2525
ungrouped_projects = [p for p in projects if p.id not in grouped_ids]
2626
2727
# Split pages: product docs (known slugs) vs org knowledge base (user-created)
2828
PRODUCT_DOC_SLUGS = {
2929
"agentic-development", "api-reference", "architecture",
30
- "administration", "setup-guide",
30
+ "administration", "setup-guide", "getting-started", "features",
31
+ "roadmap",
3132
}
3233
product_docs = [p for p in pages if p.slug in PRODUCT_DOC_SLUGS]
3334
kb_pages = [p for p in pages if p.slug not in PRODUCT_DOC_SLUGS]
3435
3536
return {
3637
--- 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 @@
88
import re
99
from html.parser import HTMLParser
1010
from io import StringIO
1111
1212
# 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
+)
2380
2481
# Attributes allowed per tag (all others stripped)
2582
ALLOWED_ATTRS = {
2683
"a": {"href", "title", "class", "id", "name"},
2784
"img": {"src", "alt", "title", "width", "height", "class"},
@@ -35,12 +92,16 @@
3592
"ol": {"class", "start", "type"},
3693
"ul": {"class"},
3794
"li": {"class", "value"},
3895
"details": {"open", "class"},
3996
"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"},
42103
# SVG attributes
43104
"svg": {"viewbox", "width", "height", "class", "xmlns", "fill", "stroke"},
44105
"path": {"d", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "class"},
45106
"circle": {"cx", "cy", "r", "fill", "stroke", "class"},
46107
"rect": {"x", "y", "width", "height", "fill", "stroke", "rx", "ry", "class"},
@@ -60,16 +121,23 @@
60121
# Regex to detect protocol in a URL (after HTML entity decoding)
61122
_PROTOCOL_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]*):.*", re.DOTALL)
62123
63124
64125
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)
68137
if m:
69138
return m.group(1).lower() in ALLOWED_PROTOCOLS
70
- # Relative URLs (no protocol) are safe
71139
return True
72140
73141
74142
class _SanitizingParser(HTMLParser):
75143
"""HTML parser that only emits allowed tags/attributes."""
76144
--- 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
--- core/url_validation.py
+++ core/url_validation.py
@@ -3,11 +3,11 @@
33
import ipaddress
44
import socket
55
from urllib.parse import urlparse
66
77
8
-def is_safe_webhook_url(url: str) -> tuple[bool, str]:
8
+def is_safe_outbound_url(url: str) -> tuple[bool, str]:
99
"""Validate a webhook URL is safe for server-side requests.
1010
1111
Blocks:
1212
- Non-HTTP(S) protocols
1313
- Localhost and loopback addresses
1414
--- 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 @@
11
#!/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
119
1210
set -euo pipefail
1311
1412
if [[ ! -f .env.deploy ]]; then
1513
echo "Missing .env.deploy -- copy .env.deploy.example and fill in your values"
@@ -20,32 +18,21 @@
2018
2119
MSG="${1:-Deploy $(date +%Y-%m-%d-%H%M)}"
2220
2321
echo "=== Fossil commit ==="
2422
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"
2624
2725
echo "=== Fossil push ==="
2826
fossil push "$FOSSIL_REMOTE_URL"
2927
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 ==="
4229
AWS_PROFILE=fossiladmin aws ssm send-command \
4330
--instance-ids "$EC2_INSTANCE_ID" \
4431
--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}" \
4835
--query "Command.CommandId" \
4936
--output text
5037
51
-echo "=== Deploy triggered ==="
38
+echo "=== Deployed ==="
5239
--- 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
--- docker-compose.yaml
+++ docker-compose.yaml
@@ -6,10 +6,14 @@
66
- "8000:8000"
77
- "2222:2222"
88
env_file: .env.example
99
environment:
1010
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"
1115
POSTGRES_HOST: postgres
1216
REDIS_URL: redis://redis:6379/1
1317
CELERY_BROKER: redis://redis:6379/0
1418
EMAIL_HOST: mailpit
1519
volumes:
1620
--- 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
--- fossil/api_auth.py
+++ fossil/api_auth.py
@@ -1,26 +1,31 @@
11
"""API authentication for both project-scoped and user-scoped tokens.
22
33
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
66
3. Session auth fallback (for browser testing)
77
"""
88
99
from django.http import JsonResponse
1010
from django.utils import timezone
1111
1212
13
-def authenticate_request(request, repository=None):
13
+def authenticate_request(request, repository=None, required_scope="read"):
1414
"""Authenticate an API request via Bearer token.
1515
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
+
1621
Returns (user_or_none, token_or_none, error_response_or_none).
1722
If error_response is not None, return it immediately.
1823
"""
1924
auth = request.META.get("HTTP_AUTHORIZATION", "")
2025
if not auth.startswith("Bearer "):
21
- # Fall back to session auth
26
+ # Fall back to session auth — session users have full access
2227
if request.user.is_authenticated:
2328
return request.user, None, None
2429
return None, None, JsonResponse({"error": "Authentication required"}, status=401)
2530
2631
raw_token = auth[7:]
@@ -32,13 +37,16 @@
3237
token_hash = APIToken.hash_token(raw_token)
3338
try:
3439
token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True)
3540
if token.expires_at and token.expires_at < timezone.now():
3641
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)
3745
token.last_used_at = timezone.now()
3846
token.save(update_fields=["last_used_at"])
39
- return None, token, None # No user, but valid project token
47
+ return None, token, None
4048
except APIToken.DoesNotExist:
4149
pass
4250
4351
# Try user-scoped PersonalAccessToken
4452
from accounts.models import PersonalAccessToken
@@ -46,12 +54,35 @@
4654
token_hash = PersonalAccessToken.hash_token(raw_token)
4755
try:
4856
pat = PersonalAccessToken.objects.get(token_hash=token_hash, revoked_at__isnull=True)
4957
if pat.expires_at and pat.expires_at < timezone.now():
5058
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)
5162
pat.last_used_at = timezone.now()
5263
pat.save(update_fields=["last_used_at"])
5364
return pat.user, pat, None
5465
except PersonalAccessToken.DoesNotExist:
5566
pass
5667
5768
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
5889
--- 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
--- fossil/api_views.py
+++ fossil/api_views.py
@@ -36,28 +36,34 @@
3636
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
3737
repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
3838
return project, repo
3939
4040
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.
4346
4447
Returns (user, token, error_response). If error_response is not None,
4548
the caller should return it immediately.
4649
"""
47
- user, token, err = authenticate_request(request, repository=repo)
50
+ user, token, err = authenticate_request(request, repository=repo, required_scope=required_scope)
4851
if err is not None:
4952
return None, None, err
5053
5154
# 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.
5356
if token is not None and user is None:
5457
return user, token, None
5558
5659
# 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)
5965
6066
return user, token, None
6167
6268
6369
def _paginate_params(request, default_per_page=25, max_per_page=100):
@@ -764,11 +770,11 @@
764770
"""
765771
if request.method != "POST":
766772
return JsonResponse({"error": "POST required"}, status=405)
767773
768774
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")
770776
if err is not None:
771777
return err
772778
773779
# Write access required to create workspaces
774780
if token is None and (user is None or not can_write_project(user, project)):
@@ -914,11 +920,11 @@
914920
"""
915921
if request.method != "POST":
916922
return JsonResponse({"error": "POST required"}, status=405)
917923
918924
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")
920926
if err is not None:
921927
return err
922928
923929
if token is None and (user is None or not can_write_project(user, project)):
924930
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1011,11 +1017,11 @@
10111017
"""
10121018
if request.method != "POST":
10131019
return JsonResponse({"error": "POST required"}, status=405)
10141020
10151021
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")
10171023
if err is not None:
10181024
return err
10191025
10201026
if token is None and (user is None or not can_write_project(user, project)):
10211027
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1072,11 +1078,18 @@
10721078
timeout=60,
10731079
env=cli._env,
10741080
cwd=checkout_dir,
10751081
)
10761082
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)
10781091
subprocess.run([cli.binary, "close", "--force"], capture_output=True, cwd=checkout_dir, timeout=10, env=cli._env)
10791092
shutil.rmtree(checkout_dir, ignore_errors=True)
10801093
10811094
workspace.status = "merged"
10821095
workspace.checkout_path = ""
@@ -1104,11 +1117,11 @@
11041117
"""
11051118
if request.method != "DELETE":
11061119
return JsonResponse({"error": "DELETE required"}, status=405)
11071120
11081121
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")
11101123
if err is not None:
11111124
return err
11121125
11131126
if token is None and (user is None or not can_write_project(user, project)):
11141127
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1158,11 +1171,11 @@
11581171
"""
11591172
if request.method != "POST":
11601173
return JsonResponse({"error": "POST required"}, status=405)
11611174
11621175
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")
11641177
if err is not None:
11651178
return err
11661179
11671180
if token is None and (user is None or not can_write_project(user, project)):
11681181
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1249,11 +1262,11 @@
12491262
"""
12501263
if request.method != "POST":
12511264
return JsonResponse({"error": "POST required"}, status=405)
12521265
12531266
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")
12551268
if err is not None:
12561269
return err
12571270
12581271
if token is None and (user is None or not can_write_project(user, project)):
12591272
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1297,11 +1310,11 @@
12971310
"""
12981311
if request.method != "POST":
12991312
return JsonResponse({"error": "POST required"}, status=405)
13001313
13011314
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")
13031316
if err is not None:
13041317
return err
13051318
13061319
if token is None and (user is None or not can_write_project(user, project)):
13071320
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1559,11 +1572,11 @@
15591572
"""
15601573
if request.method != "POST":
15611574
return JsonResponse({"error": "POST required"}, status=405)
15621575
15631576
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")
15651578
if err is not None:
15661579
return err
15671580
15681581
if token is None and (user is None or not can_write_project(user, project)):
15691582
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1736,11 +1749,11 @@
17361749
"""
17371750
if request.method != "POST":
17381751
return JsonResponse({"error": "POST required"}, status=405)
17391752
17401753
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")
17421755
if err is not None:
17431756
return err
17441757
17451758
from fossil.code_reviews import CodeReview, ReviewComment
17461759
@@ -1755,15 +1768,17 @@
17551768
17561769
body = (data.get("body") or "").strip()
17571770
if not body:
17581771
return JsonResponse({"error": "Comment body is required"}, status=400)
17591772
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:
17621775
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"
17651780
17661781
comment = ReviewComment.objects.create(
17671782
review=review,
17681783
body=body,
17691784
file_path=data.get("file_path", ""),
@@ -1793,11 +1808,11 @@
17931808
"""
17941809
if request.method != "POST":
17951810
return JsonResponse({"error": "POST required"}, status=405)
17961811
17971812
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")
17991814
if err is not None:
18001815
return err
18011816
18021817
if token is None and (user is None or not can_write_project(user, project)):
18031818
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1826,11 +1841,11 @@
18261841
"""
18271842
if request.method != "POST":
18281843
return JsonResponse({"error": "POST required"}, status=405)
18291844
18301845
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")
18321847
if err is not None:
18331848
return err
18341849
18351850
if token is None and (user is None or not can_write_project(user, project)):
18361851
return JsonResponse({"error": "Write access required"}, status=403)
@@ -1877,11 +1892,11 @@
18771892
"""
18781893
if request.method != "POST":
18791894
return JsonResponse({"error": "POST required"}, status=405)
18801895
18811896
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")
18831898
if err is not None:
18841899
return err
18851900
18861901
if token is None and (user is None or not can_write_project(user, project)):
18871902
return JsonResponse({"error": "Write access required"}, status=403)
18881903
--- 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
--- fossil/reader.py
+++ fossil/reader.py
@@ -1042,21 +1042,28 @@
10421042
def get_wiki_pages(self) -> list[WikiPage]:
10431043
pages = []
10441044
try:
10451045
rows = self.conn.execute(
10461046
"""
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
10481049
FROM tag
10491050
JOIN tagxref ON tag.tagid = tagxref.tagid
10501051
JOIN event ON tagxref.rid = event.objid
1052
+ JOIN blob ON event.objid = blob.rid
10511053
WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w'
10521054
GROUP BY tag.tagname
10531055
HAVING event.mtime = MAX(event.mtime)
10541056
ORDER BY event.mtime DESC
10551057
"""
10561058
).fetchall()
10571059
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
10581065
pages.append(
10591066
WikiPage(
10601067
name=row["name"],
10611068
content="",
10621069
last_modified=_julian_to_datetime(row["mtime"]),
10631070
--- 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 @@
273273
try:
274274
webhook = Webhook.objects.get(id=webhook_id)
275275
except Webhook.DoesNotExist:
276276
logger.warning("Webhook %s not found, skipping delivery", webhook_id)
277277
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
278296
279297
headers = {"Content-Type": "application/json", "X-Fossilrepo-Event": event_type}
280298
body = json.dumps(payload)
281299
282300
if webhook.secret:
@@ -283,11 +301,11 @@
283301
sig = hmac.new(webhook.secret.encode(), body.encode(), hashlib.sha256).hexdigest()
284302
headers["X-Fossilrepo-Signature"] = f"sha256={sig}"
285303
286304
start = time.monotonic()
287305
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)
289307
duration = int((time.monotonic() - start) * 1000)
290308
291309
WebhookDelivery.objects.create(
292310
webhook=webhook,
293311
event_type=event_type,
294312
--- 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 @@
762762
with reader:
763763
pages = reader.get_wiki_pages()
764764
home_page = reader.get_wiki_page("Home")
765765
766766
# 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())
768768
769769
search = request.GET.get("search", "").strip()
770770
if search:
771771
pages = [p for p in pages if search.lower() in p.name.lower()]
772772
@@ -805,11 +805,11 @@
805805
806806
if not page:
807807
raise Http404(f"Wiki page not found: {page_name}")
808808
809809
# 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())
811811
812812
content_html = mark_safe(sanitize_html(_render_fossil_content(page.content, project_slug=slug)))
813813
814814
return render(
815815
request,
@@ -1098,13 +1098,13 @@
10981098
secret = request.POST.get("secret", "").strip()
10991099
events = request.POST.getlist("events")
11001100
is_active = request.POST.get("is_active") == "on"
11011101
11021102
if url:
1103
- from core.url_validation import is_safe_webhook_url
1103
+ from core.url_validation import is_safe_outbound_url
11041104
1105
- is_safe, url_error = is_safe_webhook_url(url)
1105
+ is_safe, url_error = is_safe_outbound_url(url)
11061106
if not is_safe:
11071107
messages.error(request, f"Invalid webhook URL: {url_error}")
11081108
else:
11091109
events_str = ",".join(events) if events else "all"
11101110
Webhook.objects.create(
@@ -1148,13 +1148,13 @@
11481148
secret = request.POST.get("secret", "").strip()
11491149
events = request.POST.getlist("events")
11501150
is_active = request.POST.get("is_active") == "on"
11511151
11521152
if url:
1153
- from core.url_validation import is_safe_webhook_url
1153
+ from core.url_validation import is_safe_outbound_url
11541154
1155
- is_safe, url_error = is_safe_webhook_url(url)
1155
+ is_safe, url_error = is_safe_outbound_url(url)
11561156
if not is_safe:
11571157
messages.error(request, f"Invalid webhook URL: {url_error}")
11581158
else:
11591159
webhook.url = url
11601160
if secret:
@@ -1445,10 +1445,21 @@
14451445
14461446
if action == "configure":
14471447
# Save remote URL configuration
14481448
url = request.POST.get("remote_url", "").strip()
14491449
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
+
14501461
fossil_repo.remote_url = url
14511462
fossil_repo.save(update_fields=["remote_url", "updated_at", "version"])
14521463
cli.ensure_default_user(fossil_repo.full_path)
14531464
from django.contrib import messages
14541465
@@ -3990,24 +4001,24 @@
39904001
rows = []
39914002
39924003
if error:
39934004
pass # error is shown in template
39944005
else:
3995
- # Replace placeholders with request params
4006
+ # Replace placeholders with named parameters for safe execution
39964007
sql = report.sql_query
39974008
status_param = request.GET.get("status", "")
39984009
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}
40014012
40024013
# Execute against the Fossil SQLite file in read-only mode
40034014
repo_path = fossil_repo.full_path
40044015
uri = f"file:{repo_path}?mode=ro"
40054016
try:
40064017
conn = sqlite3.connect(uri, uri=True)
40074018
try:
4008
- cursor = conn.execute(sql)
4019
+ cursor = conn.execute(sql, params)
40094020
columns = [desc[0] for desc in cursor.description] if cursor.description else []
40104021
rows = [list(row) for row in cursor.fetchall()[:1000]]
40114022
except sqlite3.OperationalError as e:
40124023
error = f"SQL error: {e}"
40134024
finally:
40144025
--- 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
--- projects/views.py
+++ projects/views.py
@@ -58,11 +58,17 @@
5858
# Handle repo source: clone from URL if requested
5959
repo_source = form.cleaned_data.get("repo_source", "empty")
6060
clone_url = form.cleaned_data.get("clone_url", "").strip()
6161
6262
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)
6470
6571
messages.success(request, f'Project "{project.name}" created.')
6672
return redirect("projects:detail", slug=project.slug)
6773
else:
6874
form = ProjectForm()
6975
--- 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>
1734
--- 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>
1734
--- 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
--- templates/base.html
+++ templates/base.html
@@ -45,10 +45,23 @@
4545
focus:border-brand focus:ring-brand sm:text-sm;
4646
}
4747
}
4848
</style>
4949
<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>
5063
/*
5164
* Light mode — matches Django admin dark_theme.css palette
5265
* Brand: #DC394C red, #8B3138 crimson, #2B2D2C charcoal
5366
* Nav bar stays dark. Only main content area switches.
5467
*/
@@ -106,10 +119,11 @@
106119
/* Selected/hover rows — matches admin --selected-bg */
107120
html:not(.dark) main .group:hover { border-color: #DC394C !important; }
108121
</style>
109122
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
110123
<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>
111125
<script src="https://unpkg.com/[email protected]"></script>
112126
<script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script>
113127
<script>
114128
document.body.addEventListener('htmx:configRequest', function(event) {
115129
var token = document.querySelector('meta[name="csrf-token"]');
116130
--- 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 @@
66
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
77
{% include "fossil/_project_nav.html" %}
88
99
<div class="flex items-center justify-between mb-4">
1010
<div>
11
+ <span class="search-wrap">
1112
<input type="search"
1213
name="search"
1314
value="{{ search }}"
1415
placeholder="Search branches..."
16
+ aria-label="Search branches"
1517
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"
1618
hx-get="{% url 'fossil:branches' slug=project.slug %}"
1719
hx-trigger="input changed delay:300ms, search"
1820
hx-target="#branch-content"
1921
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>
2126
</div>
2227
</div>
2328
2429
<div id="branch-content">
2530
<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2631
--- 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
--- templates/fossil/forum_form.html
+++ templates/fossil/forum_form.html
@@ -16,11 +16,11 @@
1616
<div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }">
1717
<div class="flex items-center justify-between mb-4">
1818
<h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
1919
<div class="flex items-center gap-1 text-xs">
2020
<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>
2222
</div>
2323
</div>
2424
2525
<form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
2626
{% csrf_token %}
@@ -27,11 +27,11 @@
2727
2828
{% if not parent %}
2929
<div>
3030
<label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label>
3131
<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">
3333
</div>
3434
{% endif %}
3535
3636
<div x-show="tab === 'write'">
3737
<label class="block text-sm font-medium text-gray-300 mb-1">Body (Markdown) <span class="text-red-400">*</span></label>
3838
--- 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
--- templates/fossil/forum_list.html
+++ templates/fossil/forum_list.html
@@ -7,20 +7,25 @@
77
{% include "fossil/_project_nav.html" %}
88
99
<div class="flex items-center justify-between mb-6">
1010
<h2 class="text-lg font-semibold text-gray-200">Forum</h2>
1111
<div class="flex items-center gap-3">
12
+ <span class="search-wrap">
1213
<input type="search"
1314
name="search"
1415
value="{{ search }}"
1516
placeholder="Search forum..."
17
+ aria-label="Search forum"
1618
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"
1719
hx-get="{% url 'fossil:forum' slug=project.slug %}"
1820
hx-trigger="input changed delay:300ms, search"
1921
hx-target="#forum-content"
2022
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>
2227
{% if has_write %}
2328
<a href="{% url 'fossil:forum_create' slug=project.slug %}"
2429
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">
2530
New Thread
2631
</a>
2732
--- 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 %}
12
<style>
23
.tl-dag { position: relative; flex-shrink: 0; }
34
.tl-node {
45
position: absolute; top: 50%; z-index: 2; border-radius: 50%;
56
transform: translate(-50%, -50%); width: 10px; height: 10px;
67
--- 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 @@
1717
<div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }">
1818
<div class="flex items-center justify-between mb-4">
1919
<h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
2020
<div class="flex items-center gap-1 text-xs">
2121
<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>
2323
</div>
2424
</div>
2525
2626
<form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
2727
{% csrf_token %}
2828
--- 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 @@
77
{% include "fossil/_project_nav.html" %}
88
99
<div class="flex items-center justify-between mb-6">
1010
<h2 class="text-lg font-semibold text-gray-200">Releases</h2>
1111
<div class="flex items-center gap-3">
12
+ <span class="search-wrap">
1213
<input type="search"
1314
name="search"
1415
value="{{ search }}"
1516
placeholder="Search releases..."
17
+ aria-label="Search releases"
1618
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"
1719
hx-get="{% url 'fossil:releases' slug=project.slug %}"
1820
hx-trigger="input changed delay:300ms, search"
1921
hx-target="#release-content"
2022
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>
2227
{% if has_write %}
2328
<a href="{% url 'fossil:release_create' slug=project.slug %}"
2429
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">
2530
Create Release
2631
</a>
2732
--- 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
--- templates/fossil/tag_list.html
+++ templates/fossil/tag_list.html
@@ -6,20 +6,25 @@
66
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
77
{% include "fossil/_project_nav.html" %}
88
99
<div class="flex items-center justify-between mb-4">
1010
<div>
11
+ <span class="search-wrap">
1112
<input type="search"
1213
name="search"
1314
value="{{ search }}"
1415
placeholder="Search tags..."
16
+ aria-label="Search tags"
1517
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"
1618
hx-get="{% url 'fossil:tags' slug=project.slug %}"
1719
hx-trigger="input changed delay:300ms, search"
1820
hx-target="#tag-content"
1921
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>
2126
</div>
2227
</div>
2328
2429
<div id="tag-content">
2530
<div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2631
--- 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 @@
1616
<div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }">
1717
<div class="flex items-center justify-between mb-4">
1818
<h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
1919
<div class="flex items-center gap-1 text-xs">
2020
<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>
2222
</div>
2323
</div>
2424
2525
<form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
2626
{% csrf_token %}
2727
--- 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 @@
1616
{% csrf_token %}
1717
1818
<div>
1919
<label class="block text-sm font-medium text-gray-300 mb-1">Title</label>
2020
<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">
2222
</div>
2323
2424
<div class="grid grid-cols-2 gap-4">
2525
<div>
2626
<label class="block text-sm font-medium text-gray-300 mb-1">Status</label>
@@ -79,17 +79,17 @@
7979
{% if cf.is_required %}<span class="text-red-400">*</span>{% endif %}
8080
</label>
8181
{% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %}
8282
<input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}"
8383
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">
8585
{% elif cf.field_type == "textarea" %}
8686
<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>
8888
{% elif cf.field_type == "select" %}
8989
<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">
9191
<option value="">--</option>
9292
{% for choice in cf.choices_list %}
9393
<option value="{{ choice }}">{{ choice }}</option>
9494
{% endfor %}
9595
</select>
9696
--- 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 @@
1616
{% csrf_token %}
1717
1818
<div>
1919
<label class="block text-sm font-medium text-gray-300 mb-1">Title <span class="text-red-400">*</span></label>
2020
<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">
2222
</div>
2323
2424
<div class="grid grid-cols-2 gap-4">
2525
<div>
2626
<label class="block text-sm font-medium text-gray-300 mb-1">Type</label>
@@ -44,11 +44,11 @@
4444
</div>
4545
4646
<div>
4747
<label class="block text-sm font-medium text-gray-300 mb-1">Description</label>
4848
<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>
5050
</div>
5151
5252
{% if custom_fields %}
5353
<div class="border-t border-gray-700 pt-4 mt-4">
5454
<h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3>
@@ -60,17 +60,17 @@
6060
{% if cf.is_required %}<span class="text-red-400">*</span>{% endif %}
6161
</label>
6262
{% if cf.field_type == "text" or cf.field_type == "url" or cf.field_type == "date" %}
6363
<input type="{% if cf.field_type == "url" %}url{% elif cf.field_type == "date" %}date{% else %}text{% endif %}"
6464
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">
6666
{% elif cf.field_type == "textarea" %}
6767
<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>
6969
{% elif cf.field_type == "select" %}
7070
<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">
7272
<option value="">--</option>
7373
{% for choice in cf.choices_list %}
7474
<option value="{{ choice }}">{{ choice }}</option>
7575
{% endfor %}
7676
</select>
7777
--- 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 @@
2020
<div class="flex items-center gap-3">
2121
<a href="{% url 'fossil:tickets_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a>
2222
{% if perms.projects.change_project %}
2323
<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>
2424
{% endif %}
25
+ <span class="search-wrap">
2526
<input type="search"
2627
name="search"
2728
value="{{ search }}"
2829
placeholder="Search tickets..."
30
+ aria-label="Search tickets"
2931
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"
3032
hx-get="{% url 'fossil:tickets' slug=project.slug %}{% if status_filter %}?status={{ status_filter }}{% endif %}"
3133
hx-trigger="input changed delay:300ms, search"
3234
hx-target="#ticket-table"
3335
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>
3540
</div>
3641
</div>
3742
3843
{% include "fossil/partials/ticket_table.html" %}
3944
4045
--- 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 @@
11
{% extends "base.html" %}
2
-{% load fossil_filters %}
32
{% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblock %}
43
54
{% block content %}
65
{% include "fossil/_live_reload.html" %}
76
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
87
--- 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 @@
66
{% include "fossil/_project_nav.html" %}
77
88
<div class="flex items-center justify-between mb-6">
99
<h2 class="text-lg font-semibold text-gray-200">Unversioned Files</h2>
1010
<div>
11
+ <span class="search-wrap">
1112
<input type="search"
1213
name="search"
1314
value="{{ search }}"
1415
placeholder="Search files..."
16
+ aria-label="Search files"
1517
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"
1618
hx-get="{% url 'fossil:unversioned' slug=project.slug %}"
1719
hx-trigger="input changed delay:300ms, search"
1820
hx-target="#unversioned-content"
1921
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>
2126
</div>
2227
</div>
2328
2429
<div id="unversioned-content">
2530
{% if files %}
2631
--- 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 @@
66
{% include "fossil/_project_nav.html" %}
77
88
<div class="flex items-center justify-between mb-6">
99
<h2 class="text-lg font-semibold text-gray-200">Webhooks</h2>
1010
<div class="flex items-center gap-3">
11
+ <span class="search-wrap">
1112
<input type="search"
1213
name="search"
1314
value="{{ search }}"
1415
placeholder="Search webhooks..."
16
+ aria-label="Search webhooks"
1517
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"
1618
hx-get="{% url 'fossil:webhooks' slug=project.slug %}"
1719
hx-trigger="input changed delay:300ms, search"
1820
hx-target="#webhook-content"
1921
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>
2126
<a href="{% url 'fossil:webhook_create' slug=project.slug %}"
2227
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">
2328
Add Webhook
2429
</a>
2530
</div>
2631
--- 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
--- templates/fossil/wiki_form.html
+++ templates/fossil/wiki_form.html
@@ -16,11 +16,11 @@
1616
<div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }">
1717
<div class="flex items-center justify-between mb-4">
1818
<h2 class="text-xl font-bold text-gray-100">{{ title }}</h2>
1919
<div class="flex items-center gap-1 text-xs">
2020
<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>
2222
</div>
2323
</div>
2424
2525
<form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
2626
{% csrf_token %}
@@ -27,11 +27,11 @@
2727
2828
{% if not page %}
2929
<div>
3030
<label class="block text-sm font-medium text-gray-300 mb-1">Page Name <span class="text-red-400">*</span></label>
3131
<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">
3333
</div>
3434
{% endif %}
3535
3636
<div x-show="tab === 'write'">
3737
<label class="block text-sm font-medium text-gray-300 mb-1">Content (Markdown)</label>
3838
--- 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
--- templates/fossil/wiki_list.html
+++ templates/fossil/wiki_list.html
@@ -5,20 +5,25 @@
55
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
66
{% include "fossil/_project_nav.html" %}
77
88
<div class="flex items-center justify-between mb-4">
99
<div>
10
+ <span class="search-wrap">
1011
<input type="search"
1112
name="search"
1213
value="{{ search }}"
1314
placeholder="Search wiki pages..."
15
+ aria-label="Search wiki pages"
1416
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"
1517
hx-get="{% url 'fossil:wiki' slug=project.slug %}"
1618
hx-trigger="input changed delay:300ms, search"
1719
hx-target="#wiki-content"
1820
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>
2025
</div>
2126
{% if perms.projects.change_project %}
2227
<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>
2328
{% endif %}
2429
</div>
2530
--- 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
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -85,37 +85,10 @@
8585
{% endif %}
8686
</div>
8787
</div>
8888
{% endif %}
8989
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
-
11790
<!-- Knowledge Base (org wiki — user-editable) -->
11891
{% if perms.pages.view_page %}
11992
<div x-data="{ kbOpen: false }">
12093
<button @click="collapsed ? (collapsed = false, kbOpen = true) : (kbOpen = !kbOpen)"
12194
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 @@
145118
class="block rounded-md px-3 py-1.5 text-sm text-gray-600 hover:text-brand-light">
146119
+ New
147120
</a>
148121
{% endif %}
149122
</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>
150150
</div>
151151
{% endif %}
152152
153153
<!-- FossilSCM Guide -->
154154
<a href="{% url 'fossil:docs' slug='fossil-scm' %}"
155155
--- 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 @@
2222
</a>
2323
{% endif %}
2424
</div>
2525
</div>
2626
27
-<div class="mb-4">
27
+<div class="mb-4 search-wrap max-w-md">
2828
<input type="search"
2929
name="search"
3030
value="{{ search }}"
3131
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"
3334
hx-get="{% url 'organization:members' %}"
3435
hx-trigger="input changed delay:300ms, search"
3536
hx-target="#member-table"
3637
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>
3841
</div>
3942
4043
{% include "organization/partials/member_table.html" %}
4144
{% include "includes/_pagination.html" %}
4245
{% endblock %}
4346
--- 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 @@
1010
New Project
1111
</a>
1212
{% endif %}
1313
</div>
1414
15
-<div class="mb-4">
15
+<div class="mb-4 search-wrap max-w-md">
1616
<input type="search"
1717
name="search"
1818
value="{{ search }}"
1919
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"
2122
hx-get="{% url 'projects:list' %}"
2223
hx-trigger="input changed delay:300ms, search"
2324
hx-target="#project-table"
2425
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>
2629
</div>
2730
2831
{% include "projects/partials/project_table.html" %}
2932
{% include "includes/_pagination.html" %}
3033
{% endblock %}
3134
--- 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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button