Skip to content

Commit b1ea166

Browse files
committed
chore: add tools to generate .htaccess from Netlify config
1 parent fdedb6b commit b1ea166

3 files changed

Lines changed: 286 additions & 19 deletions

File tree

.github/workflows/ovh.yaml

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,11 @@ jobs:
8282
SITEURL: ${{ inputs.url }}
8383
run: |
8484
pelican -e SITEURL="\"$SITEURL\""
85-
sed -E '/\{% (end)?verbatim %\}/d' htaccess > ./output/.htaccess
86-
# Migrated from netlify.toml
87-
cat >> ./output/.htaccess <<EOF
88-
<IfModule mod_headers.c>
89-
# Global security headers
90-
Header set Content-Security-Policy "default-src 'self'; style-src 'self'; script-src 'self'"
91-
Header set Permissions-Policy "ambient-light-sensor=(); autoplay=(); accelerometer=(); camera=(); display-capture=(); document-domain=(); encrypted-media=(); fullscreen=(); gyroscope=(); magnetometer=(); microphone=(); midi=(); payment=(); picture-in-picture=(); sync-xhr=(); usb=(); wake-lock=(); xr-spatial-tracking=()"
92-
Header set Referrer-Policy "no-referrer-when-downgrade"
93-
Header set X-Content-Type-Options "nosniff"
94-
Header set X-Frame-Options "DENY"
95-
Header set X-XSS-Protection "1; mode=block"
96-
97-
# Override CSP for /news/* pages
98-
SetEnvIf Request_URI "^/news/" is_news
99-
Header set Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'sha256-UPkidoMErzWw1gW/eY4LhAi9ZkPch3PP31d6KQoJ6Yc=' 'sha256-G40wI6OaLZXCtrb02xUq1H1kEVWjstzoQ0FXKwsWxPw=' 'sha256-1CldwzdEg2k1wTmf7s5RWVd7NMXI/7nxxjJM2C4DqII=' https://mixxx.discourse.group/javascripts/embed.js *.discourse-cdn.com; frame-src 'self' https://www.youtube-nocookie.com https://mixxx.discourse.group ; img-src 'self' https://i.ytimg.com https://raw.githubusercontent.com/mixxxdj/ ; connect-src 'self' https://mixxx.discourse.group https://*.discourse-cdn.com" env=is_news
100-
</IfModule>
101-
EOF
85+
python tools/generate_htaccess.py \
86+
--template htaccess \
87+
--redirects content/_redirects \
88+
--netlify-config netlify.toml \
89+
--output ./output/.htaccess
10290
sed -E '/\{% (end)?verbatim %\}/d' content/robots.txt > ./output/robots.txt
10391
- name: Deploy website
10492
if: ${{ inputs.action != 'teardown' }}

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ repos:
3434
]
3535
exclude: ^(.*\.svg|locale/.*|pages/(?:contact|press)\.html)$
3636
- repo: https://github.com/psf/black
37-
rev: 22.12.0
37+
rev: 24.10.0
3838
hooks:
3939
- id: black
4040
name: "Reformat Python code"
4141
- repo: https://github.com/pycqa/flake8
42-
rev: '6.0.0'
42+
rev: '7.1.1'
4343
hooks:
4444
- id: flake8
4545
name: "Check for Python warnings"

tools/generate_htaccess.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import re
4+
import sys
5+
from pathlib import Path
6+
7+
try:
8+
import tomllib
9+
except ModuleNotFoundError:
10+
import tomli as tomllib
11+
12+
13+
def parse_redirects(filepath):
14+
rules = []
15+
with open(filepath) as f:
16+
for i, line in enumerate(f, 1):
17+
line = line.strip()
18+
if not line or line.startswith("#"):
19+
continue
20+
rule = parse_redirect_line(line)
21+
if rule:
22+
rules.append(rule)
23+
else:
24+
print(
25+
f"Warning: Could not parse line {i}: {line}",
26+
file=sys.stderr,
27+
)
28+
return rules
29+
30+
31+
def parse_redirect_line(line):
32+
parts = line.split()
33+
if len(parts) < 3:
34+
return None
35+
36+
status_raw = parts[-1]
37+
status_match = re.match(r"^(\d{3})(!)?$", status_raw)
38+
if not status_match:
39+
return None
40+
status = int(status_match.group(1))
41+
force = bool(status_match.group(2))
42+
43+
before_status = parts[:-1]
44+
to_url = before_status[-1]
45+
from_parts = before_status[:-1]
46+
47+
if not from_parts:
48+
return None
49+
50+
from_pattern = from_parts[0]
51+
query_param_parts = from_parts[1:]
52+
53+
query_params = []
54+
for qp in query_param_parts:
55+
if "=" in qp:
56+
key, val = qp.split("=", 1)
57+
query_params.append((key, val))
58+
59+
return {
60+
"from": from_pattern,
61+
"to": to_url,
62+
"status": status,
63+
"force": force,
64+
"query_params": query_params,
65+
}
66+
67+
68+
def redirect_to_htaccess(rule):
69+
lines = []
70+
from_pattern = rule["from"]
71+
to_url = rule["to"]
72+
status = rule["status"]
73+
query_params = rule["query_params"]
74+
75+
if status == 404 and from_pattern == "/*":
76+
return [f"ErrorDocument 404 {to_url}"]
77+
78+
if from_pattern.startswith(("http://", "https://")):
79+
url_match = re.match(r"^(https?://)?([^/]+)(/.*)?$", from_pattern)
80+
if url_match:
81+
scheme = url_match.group(1)
82+
host = url_match.group(2)
83+
from_path = url_match.group(3)
84+
if from_path is None:
85+
from_path = "/*"
86+
host_escaped = re.escape(host)
87+
lines.append(f"RewriteCond %{{HTTP_HOST}} ^{host_escaped}$ [NC]")
88+
if scheme == "http://":
89+
lines.append("RewriteCond %{HTTPS} off")
90+
elif scheme == "https://":
91+
lines.append("RewriteCond %{HTTPS} on")
92+
else:
93+
return []
94+
else:
95+
from_path = from_pattern
96+
97+
from_regex_chars = []
98+
i = 0
99+
while i < len(from_path):
100+
ch = from_path[i]
101+
if ch == "*":
102+
from_regex_chars.append("(.*)")
103+
elif ch in ".+?^${}[]|\\()":
104+
from_regex_chars.append("\\" + ch)
105+
else:
106+
from_regex_chars.append(ch)
107+
i += 1
108+
from_regex = "".join(from_regex_chars)
109+
110+
if from_regex.startswith("/"):
111+
from_regex = from_regex[1:]
112+
113+
replacement = to_url.replace(":splat", "$1")
114+
base_url, _, qs = replacement.partition("?")
115+
if qs and re.search(r":\w+", qs):
116+
replacement = base_url
117+
118+
flags_parts = [f"R={status}", "L"]
119+
if query_params:
120+
flags_parts.append("QSA")
121+
flags = ",".join(flags_parts)
122+
123+
for key, val in query_params:
124+
escaped_key = re.escape(key)
125+
if val and not val.startswith(":"):
126+
escaped_val = re.escape(val)
127+
cond = f"(?:^|&){escaped_key}={escaped_val}(?:&|$)"
128+
else:
129+
cond = f"(?:^|&){escaped_key}=[^&]*(?:&|$)"
130+
lines.append(f"RewriteCond %{{QUERY_STRING}} {cond} [NC]")
131+
132+
lines.append(f"RewriteRule ^{from_regex}$ {replacement} [{flags}]")
133+
return lines
134+
135+
136+
def parse_netlify_headers(filepath):
137+
with open(filepath, "rb") as f:
138+
data = tomllib.load(f)
139+
140+
headers_list = data.get("headers", [])
141+
if not isinstance(headers_list, list):
142+
return []
143+
144+
result = []
145+
for entry in headers_list:
146+
for_val = entry.get("for", "/*")
147+
values = entry.get("values", {})
148+
if values:
149+
result.append({"for": for_val, "values": dict(values)})
150+
return result
151+
152+
153+
def headers_to_htaccess(headers):
154+
lines = ["<IfModule mod_headers.c>"]
155+
156+
for entry in headers:
157+
for_val = entry["for"]
158+
values = entry["values"]
159+
160+
if for_val == "/*":
161+
for key, val in values.items():
162+
lines.append(f' Header set {key} "{val}"')
163+
else:
164+
apache_pattern = for_val.replace("*", ".*")
165+
if apache_pattern.startswith("/"):
166+
apache_pattern = "^" + apache_pattern
167+
env_name = re.sub(r"[^a-zA-Z0-9_]+", "_", for_val).strip("_")
168+
env_var = "hdr_" + env_name if env_name else "hdr_custom"
169+
lines.append(
170+
f' SetEnvIf Request_URI "{apache_pattern}" {env_var}'
171+
)
172+
for key, val in values.items():
173+
lines.append(f' Header set {key} "{val}" env={env_var}')
174+
175+
lines.append("</IfModule>")
176+
return lines
177+
178+
179+
def strip_template_tags(content):
180+
out_lines = []
181+
for line in content.splitlines():
182+
stripped = line.strip()
183+
if stripped in (
184+
"{% verbatim %}",
185+
"{% endverbatim %}",
186+
"{%verbatim%}",
187+
"{%endverbatim%}",
188+
):
189+
continue
190+
out_lines.append(line)
191+
return "\n".join(out_lines)
192+
193+
194+
def main():
195+
parser = argparse.ArgumentParser(
196+
description=(
197+
"Generate .htaccess from Netlify _redirects and netlify.toml"
198+
)
199+
)
200+
parser.add_argument("--template", "-t", help="Base htaccess template file")
201+
parser.add_argument(
202+
"--redirects",
203+
"-r",
204+
default="content/_redirects",
205+
help="Path to _redirects file (default: content/_redirects)",
206+
)
207+
parser.add_argument(
208+
"--netlify-config",
209+
"-n",
210+
default="netlify.toml",
211+
help="Path to netlify.toml (default: netlify.toml)",
212+
)
213+
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
214+
args = parser.parse_args()
215+
216+
out_lines = []
217+
218+
if args.template:
219+
template_path = Path(args.template)
220+
if template_path.exists():
221+
content = strip_template_tags(template_path.read_text())
222+
if content.strip():
223+
out_lines.append(content.rstrip())
224+
else:
225+
print(
226+
f"Warning: template file not found: {args.template}",
227+
file=sys.stderr,
228+
)
229+
230+
if args.redirects:
231+
redirects_path = Path(args.redirects)
232+
if redirects_path.exists():
233+
rules = parse_redirects(args.redirects)
234+
redirect_lines = []
235+
for rule in rules:
236+
rule_lines = redirect_to_htaccess(rule)
237+
if rule_lines:
238+
redirect_lines.extend(rule_lines)
239+
redirect_lines.append("")
240+
if redirect_lines:
241+
if out_lines and out_lines[-1] != "":
242+
out_lines.append("")
243+
out_lines.append("# Redirects from content/_redirects")
244+
out_lines.extend(redirect_lines)
245+
else:
246+
print(
247+
f"Warning: redirects file not found: {args.redirects}",
248+
file=sys.stderr,
249+
)
250+
251+
if args.netlify_config:
252+
config_path = Path(args.netlify_config)
253+
if config_path.exists():
254+
headers = parse_netlify_headers(args.netlify_config)
255+
if headers:
256+
if out_lines and out_lines[-1] != "":
257+
out_lines.append("")
258+
header_lines = headers_to_htaccess(headers)
259+
out_lines.extend(header_lines)
260+
out_lines.append("")
261+
else:
262+
print(
263+
f"Warning: netlify config not found: {args.netlify_config}",
264+
file=sys.stderr,
265+
)
266+
267+
content = "\n".join(out_lines)
268+
269+
if args.output:
270+
out_path = Path(args.output)
271+
out_path.parent.mkdir(parents=True, exist_ok=True)
272+
out_path.write_text(content)
273+
else:
274+
sys.stdout.write(content)
275+
sys.stdout.write("\n")
276+
277+
278+
if __name__ == "__main__":
279+
main()

0 commit comments

Comments
 (0)