feat: SPA sites

This commit is contained in:
2026-04-25 19:10:19 +02:00
committed by Kleπek
parent 46a23d2ed0
commit f4f00a290c
9 changed files with 133 additions and 12 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ ENV HTTP_PORT=80
ENV HTTPS_PORT=443 ENV HTTPS_PORT=443
ENV HOST=localhost ENV HOST=localhost
ENV DATA_DIR=/data ENV DATA_DIR=/data
ENV MAX_SIZE=2000000 ENV MAX_SIZE=20000000
ENV BIND=0.0.0.0 ENV BIND=0.0.0.0
ENV CERTBOT_CONF=/etc/letsencrypt ENV CERTBOT_CONF=/etc/letsencrypt
ENV CERTBOT_WWW=/data/.certbot ENV CERTBOT_WWW=/data/.certbot
+18 -10
View File
@@ -105,36 +105,44 @@ PUT /{page}/
X-Token (your API token) X-Token (your API token)
X-Host (optional host as entrypoint) X-Host (optional host as entrypoint)
X-Host-Only (optional host as entrypoint) X-Host-Only (optional host as entrypoint)
X-SPA (optional SPA file)
(body with tar data) (body with tar data)
``` ```
```bash ```bash
# create archive from 'dist' dir and upload it to /my-project/ # create archive from 'dist' dir and upload it to /my-project/
tar -czC dist -f dist.tar.gz . tar -czC dist -f dist.tar.gz .
curl -X PUT \ curl -v -X PUT \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
--data-binary "@dist.tar.gz" \ --data-binary "@dist.tar.gz" \
https://stapler-host/my-project/ https://stapler-host/my-project/
# same thing but one-liner # same thing but one-liner
tar -czC dist . | curl -X PUT \ tar -czC dist . | curl -v -X PUT \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
--data-binary @- \ --data-binary @- \
https://stapler-host/my-project/ https://stapler-host/my-project/
# make stapler server identifiers myproject.example.com and /my-project/ # make stapler server identify myproject.example.com and /my-project/
tar -czC dist . | curl -X PUT \ tar -czC dist . | curl -v -X PUT \
--data-binary @- \ --data-binary @- \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
-H 'X-Host: myproject.example.com' \ -H 'X-Host: myproject.example.com' \
https://stapler-host/my-project/ https://stapler-host/my-project/
# make stapler server identifiers myproject.example.com only # make stapler server identifiers myproject.example.com only
tar -czC dist . | curl -X PUT \ tar -czC dist . | curl -v -X PUT \
--data-binary @- \ --data-binary @- \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
-H 'X-Host-Only: myproject.example.com' \ -H 'X-Host-Only: myproject.example.com' \
https://stapler-host/my-project/ 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: <TOKEN>' \
-H 'X-SPA: index.html' \
https://stapler-host/my-project/
``` ```
> [!NOTE] > [!NOTE]
@@ -152,13 +160,13 @@ PUT /{page}/
```bash ```bash
# create /my-project/ that redirects to https://github.com/my-project # create /my-project/ that redirects to https://github.com/my-project
curl -X PUT \ curl -v -X PUT \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
-H 'X-Redirect: https://github.com/my-project' \ -H 'X-Redirect: https://github.com/my-project' \
https://stapler-host/my-project/ https://stapler-host/my-project/
# simple redirect from root host to www # simple redirect from root host to www
curl -X PUT \ curl -v -X PUT \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
-H 'X-Proxy: https://www.my-website.com' \ -H 'X-Proxy: https://www.my-website.com' \
-H 'X-Host: my-website.com' \ -H 'X-Host: my-website.com' \
@@ -177,7 +185,7 @@ PUT /{page}/
```bash ```bash
# create /my-website/ that proxies to http://host.containers.internal:8000 # create /my-website/ that proxies to http://host.containers.internal:8000
curl -X PUT \ curl -v -X PUT \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
-H 'X-Proxy: http://host.containers.internal:8000' \ -H 'X-Proxy: http://host.containers.internal:8000' \
https://stapler-host/my-project/ https://stapler-host/my-project/
@@ -192,7 +200,7 @@ DELETE /{page}/
```bash ```bash
# delete /my-project/ # delete /my-project/
curl -X DELETE \ curl -v -X DELETE \
-H 'X-Token: <TOKEN>' \ -H 'X-Token: <TOKEN>' \
https://stapler-host/my-project/ https://stapler-host/my-project/
``` ```
@@ -218,7 +226,7 @@ curl -X DELETE \
- name: Create archive - name: Create archive
run: tar -czC dist -f dist.tar.gz . run: tar -czC dist -f dist.tar.gz .
- name: Deploy to Stapler server - 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 ### Redirecting hosts with DNS
+20
View File
@@ -225,6 +225,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
HOST_ONLY_HEADER = "X-Host-Only" HOST_ONLY_HEADER = "X-Host-Only"
REDIRECT_HEADER = "X-Redirect" REDIRECT_HEADER = "X-Redirect"
PROXY_HEADER = "X-Proxy" PROXY_HEADER = "X-Proxy"
SPA_HEADER = "X-SPA"
@typing.override @typing.override
def __init__( def __init__(
@@ -238,6 +239,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.token_manager: TokenManager = token_manager self.token_manager: TokenManager = token_manager
self.data_dir: DataDir = DataDir(params.data_dir) 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.max_size_bytes: int = params.max_size_bytes
self.registry: Registry = registry self.registry: Registry = registry
self.certbot_www: str = os.path.realpath(params.certbot_www) 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_host_only: str | None = None
self.__target_redirect: str | None = None self.__target_redirect: str | None = None
self.__target_proxy: 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] super().__init__(*args, directory=params.data_dir, **kwargs, params=params) # ty:ignore[unknown-argument]
@property @property
@@ -308,6 +311,16 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
def has_target_proxy(self) -> bool: def has_target_proxy(self) -> bool:
return len(self.target_proxy) > 0 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 @typing.override
def do_HEAD(self) -> None: def do_HEAD(self) -> None:
self._pre_log_request() 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)) return self.send_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, str(e))
self.registry.add(path) self.registry.add(path)
self.token_manager.set_token(path, self.token) 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( self.send_status_only(
http.HTTPStatus.CREATED, http.HTTPStatus.CREATED,
f"Resource /{path}/ updated", f"Resource /{path}/ updated",
@@ -478,6 +493,11 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
path = f"/{page.path}" + path path = f"/{page.path}" + path
if pathlib.Path(path).name.startswith("."): # hidden files if pathlib.Path(path).name.startswith("."): # hidden files
return "" 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) return super().translate_path(path)
def __check_update_request(self) -> str | None: def __check_update_request(self) -> str | None:
+3
View File
@@ -10,6 +10,7 @@ class Page:
token_hash: str | None = None token_hash: str | None = None
redirect: str | None = None redirect: str | None = None
proxy: str | None = None proxy: str | None = None
spa: str | None = None
def __repr__(self) -> str: def __repr__(self) -> str:
out = f"/{self.path}/" out = f"/{self.path}/"
@@ -23,4 +24,6 @@ class Page:
out += " (no index)" out += " (no index)"
if self.host_only: if self.host_only:
out += " (host only)" out += " (host only)"
if self.spa:
out += f" (spa: {self.spa})"
return out return out
+1 -1
View File
@@ -23,7 +23,7 @@ class Parameters:
https_port: int = 443 https_port: int = 443
https: bool = True https: bool = True
token_salt: str = "" token_salt: str = ""
max_size_bytes: int = 2_000_000 max_size_bytes: int = 20_000_000
bind: str = "0.0.0.0" bind: str = "0.0.0.0"
command: typing.Literal["run", "renew", "token"] = "run" command: typing.Literal["run", "renew", "token"] = "run"
+8
View File
@@ -20,6 +20,7 @@ class Registry:
TOKEN_FILE = ".token" # noqa: S105 TOKEN_FILE = ".token" # noqa: S105
REDIRECT_FILE = ".redirect" REDIRECT_FILE = ".redirect"
PROXY_FILE = ".proxy" PROXY_FILE = ".proxy"
SPA_FILE = ".spa"
def __init__(self, params: Parameters) -> None: def __init__(self, params: Parameters) -> None:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) 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), token_hash=self.data_dir.get_file(path, self.TOKEN_FILE),
redirect=self.data_dir.get_file(path, self.REDIRECT_FILE), redirect=self.data_dir.get_file(path, self.REDIRECT_FILE),
proxy=self.data_dir.get_file(path, self.PROXY_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]) self.logger.info("Updated %s", self.pages[path])
@@ -91,6 +93,12 @@ class Registry:
self.pages[path].proxy = proxy self.pages[path].proxy = proxy
self.logger.debug("Updated %s", self.pages[path]) 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: def remove(self, path: str) -> None:
if path in self.pages: if path in self.pages:
page = self.pages[path] page = self.pages[path]
+40
View File
@@ -465,6 +465,29 @@ class TestRequestHandler(BaseHandlerTestCase):
): ):
handler.do_PUT() 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: def test_do_put_redirect_with_content(self) -> None:
handler = self._get_handler( handler = self._get_handler(
"/path", "/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): class TestUpgradeHandler(BaseHandlerTestCase):
@typing.override @typing.override
+6
View File
@@ -33,3 +33,9 @@ class TestPage(BaseTestCase):
str(Page("test_1", with_index=True, host_only=True)), str(Page("test_1", with_index=True, host_only=True)),
"/test_1/ (host only)", "/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)",
)
+36
View File
@@ -32,11 +32,13 @@ class TestRegistry(BaseTestCase):
["test_1", Registry.TOKEN_FILE], ["test_1", Registry.TOKEN_FILE],
["test_1", Registry.REDIRECT_FILE], ["test_1", Registry.REDIRECT_FILE],
["test_1", Registry.PROXY_FILE], ["test_1", Registry.PROXY_FILE],
["test_1", Registry.SPA_FILE],
["test_2", Registry.HOST_FILE], ["test_2", Registry.HOST_FILE],
["test_2", Registry.HOST_ONLY_FILE], ["test_2", Registry.HOST_ONLY_FILE],
["test_2", Registry.TOKEN_FILE], ["test_2", Registry.TOKEN_FILE],
["test_2", Registry.REDIRECT_FILE], ["test_2", Registry.REDIRECT_FILE],
["test_2", Registry.PROXY_FILE], ["test_2", Registry.PROXY_FILE],
["test_2", Registry.SPA_FILE],
], ],
[ [
None, None,
@@ -46,9 +48,11 @@ class TestRegistry(BaseTestCase):
None, None,
None, None,
None, None,
None,
"test_2_token", "test_2_token",
"test_2_redirect", "test_2_redirect",
None, None,
None,
], ],
), ),
self.seal_mocks(), self.seal_mocks(),
@@ -257,6 +261,38 @@ class TestRegistry(BaseTestCase):
self.assertIn("test_1", self.registry.pages) self.assertIn("test_1", self.registry.pages)
self.assertEqual(self.registry.pages["test_1"].proxy, "https://new-example.com") 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: def test_remove(self) -> None:
self.registry.pages["test_1"] = Page( self.registry.pages["test_1"] = Page(
"test_1", "test_1",