From 12e72d0418e3d868901fcc78fedb746017ca7e02 Mon Sep 17 00:00:00 2001 From: rezayaghobi-dev Date: Tue, 20 Oct 2026 17:57:04 +0330 Subject: [PATCH] ci: add site pipeline --- .dockerignore | 4 +++ .gitea/workflows/site.yaml | 30 +++++++++++++++++++ Dockerfile | 5 ++++ nginx/nginx.conf | 34 +++++++++++++++++++++ nginx/security-headers.conf | 5 ++++ tools/check_site.py | 59 +++++++++++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/site.yaml create mode 100644 Dockerfile create mode 100644 nginx/nginx.conf create mode 100644 nginx/security-headers.conf create mode 100644 tools/check_site.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b39d724 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.gitea +images +tools diff --git a/.gitea/workflows/site.yaml b/.gitea/workflows/site.yaml new file mode 100644 index 0000000..562bba3 --- /dev/null +++ b/.gitea/workflows/site.yaml @@ -0,0 +1,30 @@ +name: site + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check file references and external requests + run: python3 tools/check_site.py + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Check tooling + run: docker version --format '{{.Server.Version}}' && docker compose version + - name: Build image + run: docker build -t rezayaghobi-site:${GITHUB_SHA::7} . + - name: Validate nginx config + run: docker run --rm rezayaghobi-site:${GITHUB_SHA::7} nginx -t + - name: Check site files are in the image + run: | + docker run --rm --entrypoint sh rezayaghobi-site:${GITHUB_SHA::7} \ + -c 'test -f /usr/share/nginx/html/index.html && test -d /usr/share/nginx/html/assets' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e82d01e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM nginxinc/nginx-unprivileged:1.28-alpine +COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf +COPY index.html favicon.svg robots.txt /usr/share/nginx/html/ +COPY assets/ /usr/share/nginx/html/assets/ diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..88f4a64 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 8080; + server_name _; + server_tokens off; + root /usr/share/nginx/html; + + gzip on; + gzip_min_length 1024; + gzip_types text/css application/javascript image/svg+xml application/json; + + include /etc/nginx/snippets/security-headers.conf; + + # Only these paths are served; .git, tools/, README.md, images/ etc. return 404. + location = / { + try_files /index.html =404; + add_header Cache-Control "no-cache" always; + include /etc/nginx/snippets/security-headers.conf; + } + location = /index.html { + add_header Cache-Control "no-cache" always; + include /etc/nginx/snippets/security-headers.conf; + } + location ~ ^/(favicon\.svg|robots\.txt)$ { + add_header Cache-Control "public, max-age=86400" always; + include /etc/nginx/snippets/security-headers.conf; + } + location ^~ /assets/ { + add_header Cache-Control "public, max-age=3600" always; + include /etc/nginx/snippets/security-headers.conf; + } + location / { + return 404; + } +} diff --git a/nginx/security-headers.conf b/nginx/security-headers.conf new file mode 100644 index 0000000..bc80ff6 --- /dev/null +++ b/nginx/security-headers.conf @@ -0,0 +1,5 @@ +add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; diff --git a/tools/check_site.py b/tools/check_site.py new file mode 100644 index 0000000..02dea28 --- /dev/null +++ b/tools/check_site.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import re +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parent.parent +INDEX = ROOT / "index.html" +SKIP = ("#", "mailto:", "tel:", "data:", "javascript:") +FETCH_RELS = {"stylesheet", "icon", "preload", "modulepreload", "manifest", "apple-touch-icon"} +errors = [] + + +def check(ref, base, where, fetched): + if not ref or ref.startswith(SKIP): + return + if ref.startswith(("http://", "https://", "//")): + if fetched: + errors.append(f"{where}: external request {ref}") + return + path = urlparse(ref).path + target = ROOT / path.lstrip("/") if path.startswith("/") else base.parent / path + if not target.exists(): + errors.append(f"{where}: missing file {ref}") + + +class Page(HTMLParser): + def handle_starttag(self, tag, attrs): + a = dict(attrs) + refs = [] + if tag in ("script", "img", "source", "iframe", "video", "audio"): + refs.append(a.get("src")) + for part in (a.get("srcset") or "").split(","): + if part.strip(): + refs.append(part.split()[0]) + elif tag == "link" and FETCH_RELS & set((a.get("rel") or "").split()): + refs.append(a.get("href")) + for ref in refs: + check(ref, INDEX, "index.html", True) + if tag == "a": + check(a.get("href"), INDEX, "index.html", False) + + +for name in ("index.html", "favicon.svg", "robots.txt"): + if not (ROOT / name).is_file(): + errors.append(f"missing required file: {name}") + +if INDEX.is_file(): + Page().feed(INDEX.read_text(encoding="utf-8")) + +for css in ROOT.glob("assets/*.css"): + for ref in re.findall(r"url\(\s*['\"]?([^'\")]+)", css.read_text(encoding="utf-8")): + check(ref, css, str(css.relative_to(ROOT)), True) + +if errors: + print("\n".join(errors)) + sys.exit(1) +print("site check passed")