Skip to content

Commit f43a8e0

Browse files
committed
chore: add tools to generate .htaccess from Netlify config
1 parent 82e533c commit f43a8e0

3 files changed

Lines changed: 283 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: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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, _ in query_params:
124+
escaped_key = re.escape(key)
125+
lines.append(
126+
f"RewriteCond %{{QUERY_STRING}} (?:^|&){escaped_key}= [NC]"
127+
)
128+
129+
lines.append(f"RewriteRule ^{from_regex}$ {replacement} [{flags}]")
130+
return lines
131+
132+
133+
def parse_netlify_headers(filepath):
134+
with open(filepath, "rb") as f:
135+
data = tomllib.load(f)
136+
137+
headers_list = data.get("headers", [])
138+
if not isinstance(headers_list, list):
139+
return []
140+
141+
result = []
142+
for entry in headers_list:
143+
for_val = entry.get("for", "/*")
144+
values = entry.get("values", {})
145+
if values:
146+
result.append({"for": for_val, "values": dict(values)})
147+
return result
148+
149+
150+
def headers_to_htaccess(headers):
151+
lines = ["<IfModule mod_headers.c>"]
152+
153+
for entry in headers:
154+
for_val = entry["for"]
155+
values = entry["values"]
156+
157+
if for_val == "/*":
158+
for key, val in values.items():
159+
lines.append(f' Header set {key} "{val}"')
160+
else:
161+
apache_pattern = for_val.replace("*", ".*")
162+
if apache_pattern.startswith("/"):
163+
apache_pattern = "^" + apache_pattern
164+
env_name = re.sub(r"[^a-zA-Z0-9_]+", "_", for_val).strip("_")
165+
env_var = "hdr_" + env_name if env_name else "hdr_custom"
166+
lines.append(
167+
f' SetEnvIf Request_URI "{apache_pattern}" {env_var}'
168+
)
169+
for key, val in values.items():
170+
lines.append(f' Header set {key} "{val}" env={env_var}')
171+
172+
lines.append("</IfModule>")
173+
return lines
174+
175+
176+
def strip_template_tags(content):
177+
out_lines = []
178+
for line in content.splitlines():
179+
stripped = line.strip()
180+
if stripped in (
181+
"{% verbatim %}",
182+
"{% endverbatim %}",
183+
"{%verbatim%}",
184+
"{%endverbatim%}",
185+
):
186+
continue
187+
out_lines.append(line)
188+
return "\n".join(out_lines)
189+
190+
191+
def main():
192+
parser = argparse.ArgumentParser(
193+
description=(
194+
"Generate .htaccess from Netlify _redirects and netlify.toml"
195+
)
196+
)
197+
parser.add_argument("--template", "-t", help="Base htaccess template file")
198+
parser.add_argument(
199+
"--redirects",
200+
"-r",
201+
default="content/_redirects",
202+
help="Path to _redirects file (default: content/_redirects)",
203+
)
204+
parser.add_argument(
205+
"--netlify-config",
206+
"-n",
207+
default="netlify.toml",
208+
help="Path to netlify.toml (default: netlify.toml)",
209+
)
210+
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
211+
args = parser.parse_args()
212+
213+
out_lines = []
214+
215+
if args.template:
216+
template_path = Path(args.template)
217+
if template_path.exists():
218+
content = strip_template_tags(template_path.read_text())
219+
if content.strip():
220+
out_lines.append(content.rstrip())
221+
else:
222+
print(
223+
f"Warning: template file not found: {args.template}",
224+
file=sys.stderr,
225+
)
226+
227+
if args.redirects:
228+
redirects_path = Path(args.redirects)
229+
if redirects_path.exists():
230+
rules = parse_redirects(args.redirects)
231+
redirect_lines = []
232+
for rule in rules:
233+
rule_lines = redirect_to_htaccess(rule)
234+
if rule_lines:
235+
redirect_lines.extend(rule_lines)
236+
redirect_lines.append("")
237+
if redirect_lines:
238+
if out_lines and out_lines[-1] != "":
239+
out_lines.append("")
240+
out_lines.append("# Redirects from content/_redirects")
241+
out_lines.extend(redirect_lines)
242+
else:
243+
print(
244+
f"Warning: redirects file not found: {args.redirects}",
245+
file=sys.stderr,
246+
)
247+
248+
if args.netlify_config:
249+
config_path = Path(args.netlify_config)
250+
if config_path.exists():
251+
headers = parse_netlify_headers(args.netlify_config)
252+
if headers:
253+
if out_lines and out_lines[-1] != "":
254+
out_lines.append("")
255+
header_lines = headers_to_htaccess(headers)
256+
out_lines.extend(header_lines)
257+
out_lines.append("")
258+
else:
259+
print(
260+
f"Warning: netlify config not found: {args.netlify_config}",
261+
file=sys.stderr,
262+
)
263+
264+
content = "\n".join(out_lines)
265+
266+
if args.output:
267+
out_path = Path(args.output)
268+
out_path.parent.mkdir(parents=True, exist_ok=True)
269+
out_path.write_text(content)
270+
else:
271+
sys.stdout.write(content)
272+
sys.stdout.write("\n")
273+
274+
275+
if __name__ == "__main__":
276+
main()

0 commit comments

Comments
 (0)