From 8a7d15324e1da69c393fa20bbdb4fb3f6d13f733 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 15:20:00 +0300 Subject: [PATCH 1/4] fix(dev): serve auth pages from SPA while preserving API routes --- .github/workflows/dev-ci.yml | 2 + .github/workflows/dev-nginx-ci.yml | 27 ++++++ deploy/nginx/host/dev/dev.procollab.ru | 10 +++ scripts/check_dev_routes.py | 63 ++++++++++++++ scripts/tests/test_dev_nginx_routes.py | 111 +++++++++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 .github/workflows/dev-nginx-ci.yml create mode 100644 scripts/check_dev_routes.py create mode 100644 scripts/tests/test_dev_nginx_routes.py diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index e55cd410..c0f65629 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -316,6 +316,8 @@ jobs: exit 1 fi && + python3 scripts/check_dev_routes.py https://dev.procollab.ru && + docker compose -f docker-compose.dev-ci.yml ps celery_status="" diff --git a/.github/workflows/dev-nginx-ci.yml b/.github/workflows/dev-nginx-ci.yml new file mode 100644 index 00000000..02dbb715 --- /dev/null +++ b/.github/workflows/dev-nginx-ci.yml @@ -0,0 +1,27 @@ +name: DEV Nginx routing + +on: + pull_request: + branches: [dev] + paths: + - deploy/nginx/host/dev/** + - deploy/nginx/host/includes/** + - scripts/check_dev_routes.py + - scripts/tests/test_dev_nginx_routes.py + - .github/workflows/dev-nginx-ci.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + routing: + name: SPA and API routes / Nginx + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Install Nginx + run: sudo apt-get update -qq && sudo apt-get install -y nginx + - name: Test actual DEV Nginx config with disposable SPA and API + run: python3 -m unittest discover -s scripts/tests -p test_dev_nginx_routes.py -v diff --git a/deploy/nginx/host/dev/dev.procollab.ru b/deploy/nginx/host/dev/dev.procollab.ru index 78d7b4a4..38ec2fbf 100644 --- a/deploy/nginx/host/dev/dev.procollab.ru +++ b/deploy/nginx/host/dev/dev.procollab.ru @@ -27,6 +27,16 @@ server { client_max_body_size 100M; + # Angular auth pages share /auth with Django API endpoints. Only page reads + # go to the SPA; POST password reset requests must still reach Django. + # Keep this allowlist before the broader API regex (first regex wins). + location ~ ^/auth(/(login|register|verification(/email)?|reset_password(/(send_email|confirm))?))?/?$ { + if ($request_method ~ ^(GET|HEAD)$) { + rewrite ^ /index.html last; + } + include /etc/nginx/procollab/includes/proxy_app.inc; + } + location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|evaluation|feed|api|anymail|ws)(/|$) { include /etc/nginx/procollab/includes/proxy_app.inc; } diff --git a/scripts/check_dev_routes.py b/scripts/check_dev_routes.py new file mode 100644 index 00000000..781873ef --- /dev/null +++ b/scripts/check_dev_routes.py @@ -0,0 +1,63 @@ +"""Read-only SPA/API routing smoke check (empty reset payloads send no mail).""" + +import argparse +import json +import sys +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +AUTH_PAGES = ( + "/auth", + "/auth/login", + "/auth/register", + "/auth/verification", + "/auth/verification/email", + "/auth/reset_password", + "/auth/reset_password/send_email", + "/auth/reset_password/confirm", +) + + +def request(base_url, path, method="GET", context=None): + data = b"{}" if method == "POST" else None + req = Request(base_url + path, data=data, method=method, headers={"Content-Type": "application/json"}) + try: + response = urlopen(req, timeout=15, context=context) + except HTTPError as error: + response = error + with response: + return response.status, response.headers.get_content_type(), response.read() + + +def check_routes(base_url, context=None): + status, content_type, index = request(base_url, "/", context=context) + assert status == 200 and content_type == "text/html" and b"") + (root / "asset.js").write_text("window.fixture = true;") + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=localhost", "-keyout", str(root / "key.pem"), "-out", str(root / "cert.pem"), + ], check=True, capture_output=True) + backend = ThreadingHTTPServer(("127.0.0.1", 0), Backend) + Thread(target=backend.serve_forever, daemon=True).start() + self.addCleanup(backend.server_close) + self.addCleanup(backend.shutdown) + http_port, https_port = free_port(), free_port() + proxy = (repo / "deploy/nginx/host/includes/proxy_app.inc").read_text() + proxy = proxy.replace("127.0.0.1:8000", f"127.0.0.1:{backend.server_port}") + (root / "proxy_app.inc").write_text(proxy) + config = (repo / "deploy/nginx/host/dev/dev.procollab.ru").read_text() + # Change only test environment paths/ports, retaining all routing rules. + replacements = { + "listen 80;": f"listen 127.0.0.1:{http_port};", + "listen 443 ssl;": f"listen 127.0.0.1:{https_port} ssl;", + "/home/front/app": root.as_posix(), + "/etc/nginx/procollab/includes/proxy_app.inc": (root / "proxy_app.inc").as_posix(), + "/etc/letsencrypt/live/dev.procollab.ru-0001/fullchain.pem": (root / "cert.pem").as_posix(), + "/etc/letsencrypt/live/dev.procollab.ru-0001/privkey.pem": (root / "key.pem").as_posix(), + } + for source, target in replacements.items(): + config = config.replace(source, target) + config = f"pid {root}/nginx.pid;\nerror_log stderr;\nevents {{}}\nhttp {{\n{config}\n}}\n" + config_path = root / "nginx.conf" + config_path.write_text(config) + command = ["nginx", "-p", root.as_posix() + "/", "-c", str(config_path)] + subprocess.run([*command, "-t"], check=True, capture_output=True) + with (root / "nginx.log").open("w+") as log: + nginx = subprocess.Popen([*command, "-g", "daemon off;"], stdout=log, stderr=log) + try: + context = ssl._create_unverified_context() # Disposable self-signed certificate only. + base_url = f"https://127.0.0.1:{https_port}" + for _ in range(50): + try: + request(base_url, "/", context=context) + break + except OSError: + if nginx.poll() is not None: + log.seek(0) + self.fail(log.read()) + time.sleep(0.1) + check_routes(base_url, context) + # Unknown auth URLs and protected APIs never fall through to SPA. + for path in ("/auth/unknown/", "/auth/login/unrecognized/", "/programs/6/"): + status, content_type, body = request(base_url, path, context=context) + self.assertEqual((status, content_type), (404, "application/json")) + self.assertEqual(json.loads(body)["backend_path"], path) + status, _, body = request(base_url, "/asset.js", context=context) + self.assertEqual((status, body), (200, b"window.fixture = true;")) + finally: + nginx.terminate() + nginx.wait(timeout=10) + + +if __name__ == "__main__": + unittest.main() From afb41d6c89c5c1207bbae41cb9358420cadbed06 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 15:21:27 +0300 Subject: [PATCH 2/4] test(dev): prepare isolated Nginx log directory --- scripts/tests/test_dev_nginx_routes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test_dev_nginx_routes.py b/scripts/tests/test_dev_nginx_routes.py index b9339d2d..0a957708 100644 --- a/scripts/tests/test_dev_nginx_routes.py +++ b/scripts/tests/test_dev_nginx_routes.py @@ -49,6 +49,7 @@ def test_spa_reads_and_api_requests(self): repo = Path(__file__).resolve().parents[2] with TemporaryDirectory(prefix="dev-nginx-routing-") as directory: root = Path(directory) + (root / "logs").mkdir() (root / "index.html").write_text("") (root / "asset.js").write_text("window.fixture = true;") subprocess.run([ @@ -79,7 +80,7 @@ def test_spa_reads_and_api_requests(self): config_path = root / "nginx.conf" config_path.write_text(config) command = ["nginx", "-p", root.as_posix() + "/", "-c", str(config_path)] - subprocess.run([*command, "-t"], check=True, capture_output=True) + subprocess.run([*command, "-t"], check=True) with (root / "nginx.log").open("w+") as log: nginx = subprocess.Popen([*command, "-g", "daemon off;"], stdout=log, stderr=log) try: From 83226848b043d909db4619c7a02655c2f78f3a47 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 15:22:04 +0300 Subject: [PATCH 3/4] ci(dev): fail deployment when frontend route checks fail --- .github/workflows/dev-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index c0f65629..c2afc0bd 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -316,7 +316,10 @@ jobs: exit 1 fi && - python3 scripts/check_dev_routes.py https://dev.procollab.ru && + if ! python3 scripts/check_dev_routes.py https://dev.procollab.ru; then + echo "Frontend/API routing smoke check failed" >&2 + exit 1 + fi && docker compose -f docker-compose.dev-ci.yml ps From 5c9f323ae82f5fc023f6da338d6cfdbb61d9aea7 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 15:22:44 +0300 Subject: [PATCH 4/4] test(dev): isolate Nginx runtime paths from system directories --- scripts/tests/test_dev_nginx_routes.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test_dev_nginx_routes.py b/scripts/tests/test_dev_nginx_routes.py index 0a957708..44750626 100644 --- a/scripts/tests/test_dev_nginx_routes.py +++ b/scripts/tests/test_dev_nginx_routes.py @@ -50,6 +50,10 @@ def test_spa_reads_and_api_requests(self): with TemporaryDirectory(prefix="dev-nginx-routing-") as directory: root = Path(directory) (root / "logs").mkdir() + temp_paths = "".join( + f"{name}_temp_path {root}/{name};\n" + for name in ("client_body", "proxy", "fastcgi", "uwsgi", "scgi") + ) (root / "index.html").write_text("") (root / "asset.js").write_text("window.fixture = true;") subprocess.run([ @@ -76,7 +80,10 @@ def test_spa_reads_and_api_requests(self): } for source, target in replacements.items(): config = config.replace(source, target) - config = f"pid {root}/nginx.pid;\nerror_log stderr;\nevents {{}}\nhttp {{\n{config}\n}}\n" + config = ( + f"pid {root}/nginx.pid;\nerror_log stderr;\nevents {{}}\n" + f"http {{\naccess_log off;\n{temp_paths}{config}\n}}\n" + ) config_path = root / "nginx.conf" config_path.write_text(config) command = ["nginx", "-p", root.as_posix() + "/", "-c", str(config_path)]