From 95514f16cb04cd1511414b3d173233c35838dfec Mon Sep 17 00:00:00 2001 From: Klemek Date: Mon, 27 Apr 2026 14:43:27 +0200 Subject: [PATCH] fix: no message on bare curl --- README.md | 24 ++++---- stapler/handlers.py | 129 +++++++++++++++++++++++------------------ tests/test_handlers.py | 36 ++++++++---- 3 files changed, 111 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 363976b..5a3ae16 100644 --- a/README.md +++ b/README.md @@ -112,33 +112,33 @@ PUT /{page}/ ```bash # create archive from 'dist' dir and upload it to /my-project/ tar -czC dist -f dist.tar.gz . -curl -v -X PUT \ +curl -X PUT \ -H 'X-Token: ' \ --data-binary "@dist.tar.gz" \ https://stapler-host/my-project/ # same thing but one-liner -tar -czC dist . | curl -v -X PUT \ +tar -czC dist . | curl -X PUT \ -H 'X-Token: ' \ --data-binary @- \ https://stapler-host/my-project/ # make stapler server identify myproject.example.com and /my-project/ -tar -czC dist . | curl -v -X PUT \ +tar -czC dist . | curl -X PUT \ --data-binary @- \ -H 'X-Token: ' \ -H 'X-Host: myproject.example.com' \ https://stapler-host/my-project/ # make stapler server identifiers myproject.example.com only -tar -czC dist . | curl -v -X PUT \ +tar -czC dist . | curl -X PUT \ --data-binary @- \ -H 'X-Token: ' \ -H 'X-Host-Only: myproject.example.com' \ https://stapler-host/my-project/ # make a SPA site at /my-project/index.html -tar -czC dist . | curl -v -X PUT \ +tar -czC dist . | curl -X PUT \ --data-binary @- \ -H 'X-Token: ' \ -H 'X-SPA: index.html' \ @@ -160,13 +160,13 @@ PUT /{page}/ ```bash # create /my-project/ that redirects to https://github.com/my-project -curl -v -X PUT \ +curl -X PUT \ -H 'X-Token: ' \ -H 'X-Redirect: https://github.com/my-project' \ https://stapler-host/my-project/ # simple redirect from root host to www -curl -v -X PUT \ +curl -X PUT \ -H 'X-Token: ' \ -H 'X-Proxy: https://www.my-website.com' \ -H 'X-Host: my-website.com' \ @@ -185,7 +185,7 @@ PUT /{page}/ ```bash # create /my-website/ that proxies to http://host.containers.internal:8000 -curl -v -X PUT \ +curl -X PUT \ -H 'X-Token: ' \ -H 'X-Proxy: http://host.containers.internal:8000' \ https://stapler-host/my-project/ @@ -200,7 +200,7 @@ DELETE /{page}/ ```bash # delete /my-project/ -curl -v -X DELETE \ +curl -X DELETE \ -H 'X-Token: ' \ https://stapler-host/my-project/ ``` @@ -226,7 +226,11 @@ curl -v -X DELETE \ - name: Create archive run: tar -czC dist -f dist.tar.gz . - name: Deploy to Stapler server - run: curl -v -X PUT -H 'X-Token: ${{ secrets.STAPLER_TOKEN }}' -H 'X-Host: ${{ vars.TARGET_HOST }}' --data-binary "@dist.tar.gz" https://stapler-host/my-project/ + run: | + curl -X PUT --data-binary "@dist.tar.gz" \ + -H 'X-Token: ${{ secrets.STAPLER_TOKEN }}' \ + -H 'X-Host: ${{ vars.TARGET_HOST }}' \ + ${{ vars.STAPLER_URL }} ``` ### Redirecting hosts with DNS diff --git a/stapler/handlers.py b/stapler/handlers.py index e1d00b9..7c78f47 100644 --- a/stapler/handlers.py +++ b/stapler/handlers.py @@ -45,13 +45,25 @@ class BaseHandler(abc.ABC, http.server.BaseHTTPRequestHandler): code: int, message: str | None = None, explain: str | None = None, + ) -> None: + self.send_status(code, message, explain) + + def send_status( + self, + code: int, + message: str | None = None, + explain: str | None = None, ) -> None: shortmsg, longmsg = self.responses[code] if message is None: message = shortmsg if explain is None: explain = longmsg - if "text/" in self._get_header("Accept"): + if ( + not self._has_header("Accept") + or self._get_header("Accept").startswith("*/") + or self._get_header("Accept").startswith("text/") + ): self.send_basic_body( f"{code} {message}\n{explain}\n\n{self.server_signature()}", code=code, @@ -339,28 +351,26 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): def do_PUT(self) -> None: self._pre_log_request() if self._proxy_or_redirect(): - return None - if (path := self.__check_update_request()) is None: - return None - if not self.__check_put_headers(): - return None - if ( - self.has_target_host - and (page := self.registry.get_from_host(self.target_host)) is not None - and page.path != path - ): - return self.send_error(http.HTTPStatus.FORBIDDEN, "Host already taken") + return + if (path := self.__check_put_request()) is None: + return if self.has_target_redirect: - self._update_redirect(path) + if not self._update_redirect(path): + return elif self.has_target_proxy: - self._update_proxy(path) - else: - self._update_extract(path) + if not self._update_proxy(path): + return + elif not self._update_extract(path): + return if self.has_request_host: self.registry.set_host(path, self.target_host) if self.has_request_host_only: self.registry.set_host_only(path, self.target_host) - return None + self.send_status( + http.HTTPStatus.CREATED, + "Resource updated", + str(self.registry.get_from_path(path)), + ) def do_POST(self) -> None: self.do_PUT() # be gentle on them @@ -371,10 +381,15 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): def do_DELETE(self) -> None: self._pre_log_request() if self._proxy_or_redirect(): - return None + return if (path := self.__check_update_request()) is None: - return None - return self._update_remove(path) + return + if self._update_remove(path): + self.send_status( + http.HTTPStatus.OK, + f"Resource /{path}/ removed", + ) + return def do_CONNECT(self) -> None: self._pre_log_request() @@ -391,73 +406,64 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): if not self._proxy_or_redirect(): self.send_error(http.HTTPStatus.METHOD_NOT_ALLOWED) - def _update_extract(self, path: str) -> None: + def _update_extract(self, path: str) -> bool: if self.in_size == 0: - return self.send_error(http.HTTPStatus.LENGTH_REQUIRED, "No body found") + self.send_error(http.HTTPStatus.LENGTH_REQUIRED, "No body found") + return False if self.in_size > self.max_size_bytes: - return self.send_error( + self.send_error( http.HTTPStatus.CONTENT_TOO_LARGE, "Archive too large", ) + return False try: file_bytes = io.BytesIO(self.rfile.read(self.in_size)) self.data_dir.extract_tar_bytes(path, file_bytes) except tarfile.TarError: - return self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid tar archive") + self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid tar archive") + return False except Exception as e: - return self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e)) + self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e)) + return False self.registry.add(path) self.token_manager.set_token(path, self.token) if self.has_target_spa: self.registry.set_spa(path, self.target_spa) - self.send_status_only( - http.HTTPStatus.CREATED, - f"Resource /{path}/ updated", - ) - return None + return True - def _update_redirect(self, path: str) -> None: + def _update_redirect(self, path: str) -> bool: if self.in_size > 0: - return self.send_error( + self.send_error( http.HTTPStatus.BAD_REQUEST, f"No content must be sent with {self.REDIRECT_HEADER}", ) + return False self.registry.set_redirect(path, self.target_redirect) self.token_manager.set_token(path, self.token) - self.send_status_only( - http.HTTPStatus.CREATED, - f"Resource /{path}/ updated", - ) - return None + return True - def _update_proxy(self, path: str) -> None: + def _update_proxy(self, path: str) -> bool: if self.in_size > 0: - return self.send_error( + self.send_error( http.HTTPStatus.BAD_REQUEST, f"No content must be sent with {self.PROXY_HEADER}", ) + return False self.registry.set_proxy(path, self.target_proxy) self.token_manager.set_token(path, self.token) - self.send_status_only( - http.HTTPStatus.CREATED, - f"Resource /{path}/ updated", - ) - return None + return True - def _update_remove(self, path: str) -> None: + def _update_remove(self, path: str) -> bool: if not self.data_dir.exists(path): self.send_error(http.HTTPStatus.NOT_FOUND, "Not found") - return None + return False try: self.data_dir.remove(path) except Exception as e: - return self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e)) - self.send_status_only( - http.HTTPStatus.NO_CONTENT, - f"Resource /{path}/ removed", - ) + self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e)) + return False self.registry.remove(path) - return None + return True def _proxy_or_redirect(self) -> bool: if self.has_token or self.path.startswith(self.CERTBOT_CHALLENGE_PATH): @@ -518,23 +524,32 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): return None return sub_path - def __check_put_headers(self) -> bool: + def __check_put_request(self) -> str | None: + if (path := self.__check_update_request()) is None: + return None if self.has_request_host and self.has_request_host_only: self.send_error( http.HTTPStatus.BAD_REQUEST, f"Cannot use {self.HOST_ONLY_HEADER} with {self.HOST_HEADER}", ) - return False + return None if self.has_target_host and not self.__valid_host(self.target_host): self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid requested host") - return False + return None if self.has_target_proxy and self.has_target_redirect: self.send_error( http.HTTPStatus.BAD_REQUEST, f"Cannot use {self.PROXY_HEADER} with {self.REDIRECT_HEADER}", ) - return False - return True + return None + if ( + self.has_target_host + and (page := self.registry.get_from_host(self.target_host)) is not None + and page.path != path + ): + self.send_error(http.HTTPStatus.FORBIDDEN, "Host already taken") + return None + return path def __get_path(self, path: str, regex: re.Pattern) -> str | None: if (match := regex.match(path.lower())) is not None: diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 27ca3e7..63691b2 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -136,6 +136,8 @@ class TestRequestHandler(BaseHandlerTestCase): ) -> RequestHandler: if headers is None: headers = {} + if "Accept" not in headers: + headers["Accept"] = "nothing" with self.patch("http.server.BaseHTTPRequestHandler.__init__"): handler = RequestHandler( unittest.mock.MagicMock(), @@ -433,8 +435,9 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call_unchecked(self.data_dir.extract_tar_bytes), self.mock_call(self.registry.add, ["path"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -458,8 +461,9 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.registry.add, ["path"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), self.mock_call(self.registry.set_host, ["path", "example.com"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -481,8 +485,9 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.registry.add, ["path"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), self.mock_call(self.registry.set_spa, ["path", "index.html"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -530,8 +535,9 @@ class TestRequestHandler(BaseHandlerTestCase): ), self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -557,8 +563,9 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), self.mock_call(self.registry.set_host, ["path", "example.com"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -584,8 +591,9 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), self.mock_call(self.registry.set_host_only, ["path", "example.com"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -633,8 +641,9 @@ class TestRequestHandler(BaseHandlerTestCase): ), self.mock_call(self.registry.set_proxy, ["path", "https://example.com"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -660,8 +669,9 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.registry.set_proxy, ["path", "https://example.com"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), self.mock_call(self.registry.set_host, ["path", "example.com"]), + self.mock_call(self.registry.get_from_path, ["path"]), self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + handler, http.HTTPStatus.CREATED, "Resource updated" ), self.seal_mocks(), ): @@ -783,9 +793,7 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.data_dir.exists, ["path"], True), # noqa: FBT003 self.mock_call(self.data_dir.remove, ["path"]), self.mock_call(self.registry.remove, ["path"]), - self.expects_error( - handler, http.HTTPStatus.NO_CONTENT, "Resource /path/ removed" - ), + self.expects_error(handler, http.HTTPStatus.OK, "Resource /path/ removed"), self.seal_mocks(), ): handler.do_DELETE() @@ -813,6 +821,7 @@ class TestRequestHandler(BaseHandlerTestCase): "data": None, "headers": { "Host": "example.com", + "Accept": "nothing", "X-Real-IP": "127.0.0.1", "X-Forwarded-Host": "localhost", "X-Forwarded-For": "127.0.0.1", @@ -855,6 +864,7 @@ class TestRequestHandler(BaseHandlerTestCase): "data": b"hello", "headers": { "Host": "example.com", + "Accept": "nothing", "X-Real-IP": "127.0.0.1", "X-Forwarded-Host": "localhost", "X-Forwarded-For": "127.0.0.1", @@ -897,6 +907,7 @@ class TestRequestHandler(BaseHandlerTestCase): "data": None, "headers": { "Host": "example.com", + "Accept": "nothing", "X-Real-IP": "127.0.0.1", "X-Forwarded-Host": "localhost", "X-Forwarded-For": "127.0.0.1", @@ -930,6 +941,7 @@ class TestRequestHandler(BaseHandlerTestCase): "data": None, "headers": { "Host": "example.com", + "Accept": "nothing", "X-Real-IP": "127.0.0.1", "X-Forwarded-Host": "localhost", "X-Forwarded-For": "127.0.0.1", @@ -972,6 +984,7 @@ class TestRequestHandler(BaseHandlerTestCase): "data": None, "headers": { "Host": "example.com", + "Accept": "nothing", "X-Real-IP": "127.0.0.1", "X-Forwarded-Host": "localhost", "X-Forwarded-For": "127.0.0.1", @@ -1011,6 +1024,7 @@ class TestRequestHandler(BaseHandlerTestCase): "data": None, "headers": { "Host": "example.com", + "Accept": "nothing", "X-Real-IP": "127.0.0.1", "X-Forwarded-Host": "host", "X-Forwarded-For": "127.0.0.1",