feat: SPA sites
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user