From f4f00a290c35424b5a87bcd87e6e9d686f429447 Mon Sep 17 00:00:00 2001 From: klemek Date: Sat, 25 Apr 2026 19:10:19 +0200 Subject: [PATCH] feat: SPA sites --- Dockerfile | 2 +- README.md | 28 ++++++++++++++++++---------- stapler/handlers.py | 20 ++++++++++++++++++++ stapler/page.py | 3 +++ stapler/params.py | 2 +- stapler/registry.py | 8 ++++++++ tests/test_handlers.py | 40 ++++++++++++++++++++++++++++++++++++++++ tests/test_page.py | 6 ++++++ tests/test_registry.py | 36 ++++++++++++++++++++++++++++++++++++ 9 files changed, 133 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 60f2178..27a4712 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ ENV HTTP_PORT=80 ENV HTTPS_PORT=443 ENV HOST=localhost ENV DATA_DIR=/data -ENV MAX_SIZE=2000000 +ENV MAX_SIZE=20000000 ENV BIND=0.0.0.0 ENV CERTBOT_CONF=/etc/letsencrypt ENV CERTBOT_WWW=/data/.certbot diff --git a/README.md b/README.md index 3241f58..363976b 100644 --- a/README.md +++ b/README.md @@ -105,36 +105,44 @@ PUT /{page}/ X-Token (your API token) X-Host (optional host as entrypoint) X-Host-Only (optional host as entrypoint) + X-SPA (optional SPA file) (body with tar data) ``` ```bash # create archive from 'dist' dir and upload it to /my-project/ tar -czC dist -f dist.tar.gz . -curl -X PUT \ +curl -v -X PUT \ -H 'X-Token: ' \ --data-binary "@dist.tar.gz" \ https://stapler-host/my-project/ # same thing but one-liner -tar -czC dist . | curl -X PUT \ +tar -czC dist . | curl -v -X PUT \ -H 'X-Token: ' \ --data-binary @- \ https://stapler-host/my-project/ -# make stapler server identifiers myproject.example.com and /my-project/ -tar -czC dist . | curl -X PUT \ +# make stapler server identify myproject.example.com and /my-project/ +tar -czC dist . | curl -v -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 -X PUT \ +tar -czC dist . | curl -v -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 \ + --data-binary @- \ + -H 'X-Token: ' \ + -H 'X-SPA: index.html' \ + https://stapler-host/my-project/ ``` > [!NOTE] @@ -152,13 +160,13 @@ PUT /{page}/ ```bash # create /my-project/ that redirects to https://github.com/my-project -curl -X PUT \ +curl -v -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 -X PUT \ +curl -v -X PUT \ -H 'X-Token: ' \ -H 'X-Proxy: https://www.my-website.com' \ -H 'X-Host: my-website.com' \ @@ -177,7 +185,7 @@ PUT /{page}/ ```bash # create /my-website/ that proxies to http://host.containers.internal:8000 -curl -X PUT \ +curl -v -X PUT \ -H 'X-Token: ' \ -H 'X-Proxy: http://host.containers.internal:8000' \ https://stapler-host/my-project/ @@ -192,7 +200,7 @@ DELETE /{page}/ ```bash # delete /my-project/ -curl -X DELETE \ +curl -v -X DELETE \ -H 'X-Token: ' \ https://stapler-host/my-project/ ``` @@ -218,7 +226,7 @@ curl -X DELETE \ - name: Create archive run: tar -czC dist -f dist.tar.gz . - name: Deploy to Stapler server - run: curl -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 -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/ ``` ### Redirecting hosts with DNS diff --git a/stapler/handlers.py b/stapler/handlers.py index 87a849c..92bbc82 100644 --- a/stapler/handlers.py +++ b/stapler/handlers.py @@ -225,6 +225,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): HOST_ONLY_HEADER = "X-Host-Only" REDIRECT_HEADER = "X-Redirect" PROXY_HEADER = "X-Proxy" + SPA_HEADER = "X-SPA" @typing.override def __init__( @@ -238,6 +239,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) self.token_manager: TokenManager = token_manager self.data_dir: DataDir = DataDir(params.data_dir) + self.root_path: pathlib.Path = pathlib.Path(params.data_dir) self.max_size_bytes: int = params.max_size_bytes self.registry: Registry = registry self.certbot_www: str = os.path.realpath(params.certbot_www) @@ -246,6 +248,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): self.__target_host_only: str | None = None self.__target_redirect: str | None = None self.__target_proxy: str | None = None + self.__target_spa: str | None = None super().__init__(*args, directory=params.data_dir, **kwargs, params=params) # ty:ignore[unknown-argument] @property @@ -308,6 +311,16 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): def has_target_proxy(self) -> bool: return len(self.target_proxy) > 0 + @property + def target_spa(self) -> str: + if self.__target_spa is None: + self.__target_spa = self._get_header(self.SPA_HEADER).lower() + return self.__target_spa + + @property + def has_target_spa(self) -> bool: + return len(self.target_spa) > 0 + @typing.override def do_HEAD(self) -> None: self._pre_log_request() @@ -395,6 +408,8 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): return self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e)) 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", @@ -478,6 +493,11 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): path = f"/{page.path}" + path if pathlib.Path(path).name.startswith("."): # hidden files return "" + if ( + page.spa is not None + and not (self.root_path / pathlib.Path(path[1:])).is_file() + ): + path = f"/{page.path}/{page.spa}" return super().translate_path(path) def __check_update_request(self) -> str | None: diff --git a/stapler/page.py b/stapler/page.py index fb2e58b..d64c2cc 100644 --- a/stapler/page.py +++ b/stapler/page.py @@ -10,6 +10,7 @@ class Page: token_hash: str | None = None redirect: str | None = None proxy: str | None = None + spa: str | None = None def __repr__(self) -> str: out = f"/{self.path}/" @@ -23,4 +24,6 @@ class Page: out += " (no index)" if self.host_only: out += " (host only)" + if self.spa: + out += f" (spa: {self.spa})" return out diff --git a/stapler/params.py b/stapler/params.py index f95bffd..a44c31d 100644 --- a/stapler/params.py +++ b/stapler/params.py @@ -23,7 +23,7 @@ class Parameters: https_port: int = 443 https: bool = True token_salt: str = "" - max_size_bytes: int = 2_000_000 + max_size_bytes: int = 20_000_000 bind: str = "0.0.0.0" command: typing.Literal["run", "renew", "token"] = "run" diff --git a/stapler/registry.py b/stapler/registry.py index 1e46946..a8e34a9 100644 --- a/stapler/registry.py +++ b/stapler/registry.py @@ -20,6 +20,7 @@ class Registry: TOKEN_FILE = ".token" # noqa: S105 REDIRECT_FILE = ".redirect" PROXY_FILE = ".proxy" + SPA_FILE = ".spa" def __init__(self, params: Parameters) -> None: self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) @@ -45,6 +46,7 @@ class Registry: token_hash=self.data_dir.get_file(path, self.TOKEN_FILE), redirect=self.data_dir.get_file(path, self.REDIRECT_FILE), proxy=self.data_dir.get_file(path, self.PROXY_FILE), + spa=self.data_dir.get_file(path, self.SPA_FILE), ) self.logger.info("Updated %s", self.pages[path]) @@ -91,6 +93,12 @@ class Registry: self.pages[path].proxy = proxy self.logger.debug("Updated %s", self.pages[path]) + def set_spa(self, path: str, spa: str) -> None: + if path in self.pages and (self.pages[path].spa != spa): + self.data_dir.set_file(path, self.SPA_FILE, spa) + self.pages[path].spa = spa + self.logger.debug("Updated %s", self.pages[path]) + def remove(self, path: str) -> None: if path in self.pages: page = self.pages[path] diff --git a/tests/test_handlers.py b/tests/test_handlers.py index db9daf8..27ca3e7 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -465,6 +465,29 @@ class TestRequestHandler(BaseHandlerTestCase): ): handler.do_PUT() + def test_do_put_extract_with_spa(self) -> None: + handler = self._get_handler( + "/path", {"X-Token": "secret", "X-SPA": "index.html", "Content-Length": "1"} + ) + handler.rfile.write(b"\0") + with ( + self.mock_call(self.token_manager.is_valid, ["secret"], True), # noqa: FBT003 + self.mock_call( + self.token_manager.is_valid_for_path, + ["secret", "path"], + True, # noqa: FBT003 + ), + 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.set_spa, ["path", "index.html"]), + self.expects_status_only( + handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + ), + self.seal_mocks(), + ): + handler.do_PUT() + def test_do_put_redirect_with_content(self) -> None: handler = self._get_handler( "/path", @@ -1166,6 +1189,23 @@ class TestRequestHandler(BaseHandlerTestCase): "", ) + def test_translate_path_spa(self) -> None: + handler = self._get_handler() + with ( + self.mock_call( + self.registry.get_from_path, ["path"], Page("path", spa="index.html") + ), + self.patch_call( + "http.server.SimpleHTTPRequestHandler.translate_path", + ["/path/index.html"], + ), + self.seal_mocks(), + ): + self.assertEqual( + handler.translate_path("/path/to/thing"), + None, + ) + class TestUpgradeHandler(BaseHandlerTestCase): @typing.override diff --git a/tests/test_page.py b/tests/test_page.py index dd1f3ef..66759c8 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -33,3 +33,9 @@ class TestPage(BaseTestCase): str(Page("test_1", with_index=True, host_only=True)), "/test_1/ (host only)", ) + + def test_repr_with_spa(self) -> None: + self.assertEqual( + str(Page("test_1", with_index=True, spa="index.html")), + "/test_1/ (spa: index.html)", + ) diff --git a/tests/test_registry.py b/tests/test_registry.py index 93673ad..66c2944 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -32,11 +32,13 @@ class TestRegistry(BaseTestCase): ["test_1", Registry.TOKEN_FILE], ["test_1", Registry.REDIRECT_FILE], ["test_1", Registry.PROXY_FILE], + ["test_1", Registry.SPA_FILE], ["test_2", Registry.HOST_FILE], ["test_2", Registry.HOST_ONLY_FILE], ["test_2", Registry.TOKEN_FILE], ["test_2", Registry.REDIRECT_FILE], ["test_2", Registry.PROXY_FILE], + ["test_2", Registry.SPA_FILE], ], [ None, @@ -46,9 +48,11 @@ class TestRegistry(BaseTestCase): None, None, None, + None, "test_2_token", "test_2_redirect", None, + None, ], ), self.seal_mocks(), @@ -257,6 +261,38 @@ class TestRegistry(BaseTestCase): self.assertIn("test_1", self.registry.pages) self.assertEqual(self.registry.pages["test_1"].proxy, "https://new-example.com") + def test_set_spa(self) -> None: + self.registry.pages["test_1"] = Page( + "test_1", + spa=None, + ) + with ( + self.mock_call( + self.data_dir.set_file, + ["test_1", Registry.SPA_FILE, "new_value"], + ), + self.seal_mocks(), + ): + self.registry.set_spa("test_1", "new_value") + self.assertEqual(self.registry.pages["test_1"].spa, "new_value") + + def test_set_spa_no_change(self) -> None: + self.registry.pages["test_1"] = Page( + "test_1", + spa="value", + ) + with ( + self.seal_mocks(), + ): + self.registry.set_spa("test_1", "value") + self.assertEqual(self.registry.pages["test_1"].spa, "value") + + def test_set_spa_not_found(self) -> None: + with ( + self.seal_mocks(), + ): + self.registry.set_spa("test_1", "value") + def test_remove(self) -> None: self.registry.pages["test_1"] = Page( "test_1",