simple-module-site-lock 0.0.30__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+
10
+ # UV
11
+ uv.lock
12
+
13
+ # Node
14
+ node_modules/
15
+
16
+ # IDE
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+ *.swo
21
+
22
+ # Environment
23
+ .env
24
+
25
+ # Database
26
+ *.db
27
+ *.sqlite3
28
+
29
+ # Module-managed runtime state (e.g. uploaded dataset files,
30
+ # default storage_dir for SM_DATASETS_STORAGE_DIR).
31
+ var/
32
+
33
+ # file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
34
+ uploads/
35
+
36
+ # Vite
37
+ host/static/dist/
38
+
39
+ # VitePress (docs)
40
+ docs/.vitepress/cache/
41
+ docs/.vitepress/dist/
42
+
43
+ # Auto-generated frontend module manifest (regenerated by the host at boot
44
+ # or via `make gen-pages`).
45
+ host/client_app/modules.manifest.json
46
+ host/client_app/modules.generated.ts
47
+ host/client_app/modules.generated.css
48
+ host/client_app/modules.assets.json
49
+
50
+ # Worktrees
51
+ .worktrees/
52
+
53
+ # Performance profiles
54
+ .memray/
55
+ .benchmarks/
56
+
57
+ # OS
58
+ .DS_Store
59
+ Thumbs.db
60
+
61
+ .playwright-cli/*
62
+ .playwright-mcp/*
63
+ host/client_app/.playwright-cli/*
64
+ .superpowers/
65
+ .qa/
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_site_lock
3
+ Version: 0.0.30
4
+ Summary: Optional site-wide password gate (staging / pre-launch) for simple_module apps
5
+ Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
7
+ Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
8
+ Author-email: Anto Subash <antosubash@live.com>
9
+ License-Expression: MIT
10
+ Keywords: gate,password,simple-module,staging
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: simple-module-auth==0.0.30
22
+ Requires-Dist: simple-module-core==0.0.30
23
+ Requires-Dist: simple-module-hosting==0.0.30
24
+ Requires-Dist: simple-module-settings==0.0.30
25
+ Description-Content-Type: text/markdown
26
+
27
+ # simple_module_site_lock
28
+
29
+ Optional site-wide password gate for `simple_module` apps — a staging /
30
+ pre-launch door. When enabled, every visitor must enter one shared password
31
+ before they can see anything: not the landing page, not the API, not even
32
+ that a login form exists.
33
+
34
+ **Off by default.** Installing this module changes nothing until an operator
35
+ turns it on.
36
+
37
+ ## Install
38
+
39
+ Add the package to your host and re-sync — discovery picks it up from the
40
+ `simple_module` entry point, no host code changes:
41
+
42
+ ```toml
43
+ # host/pyproject.toml
44
+ dependencies = ["simple_module_site_lock"]
45
+ ```
46
+
47
+ ```bash
48
+ uv sync --all-packages
49
+ ```
50
+
51
+ Or scaffold a new app with it selected: `smpy new --modules site_lock`.
52
+
53
+ ## Usage
54
+
55
+ 1. Sign in as an admin and open **Settings → Modules → SiteLock**.
56
+ 2. Set `password` (and optionally `message`), tick `enabled`, and **Save**.
57
+ Saving with a blank password is refused — the error appears on the
58
+ `enabled` field and nothing is persisted.
59
+ 3. Anyone without the password now gets the gate page at `/__unlock` on every
60
+ URL. They enter the password once and are returned to where they were
61
+ heading.
62
+ 4. To lift the gate, untick `enabled` and save. To rotate the password, set a
63
+ new one — every already-unlocked visitor is logged back out of the gate.
64
+
65
+ Keep your own session alive while you do this: an admin with a live session
66
+ skips the gate, which is what lets you undo a mistake (see
67
+ [Admin bypass](#admin-bypass-and-the-lockout-you-can-still-cause) below).
68
+
69
+ ## How it works
70
+
71
+ The module installs a single middleware that runs *before* `AuthMiddleware`.
72
+ That ordering is the whole point — an anonymous visitor gets the gate rather
73
+ than a redirect to the login page, so a locked site never reveals that it has
74
+ one.
75
+
76
+ It achieves that ordering by declaring `depends_on=["Settings", "Auth"]`:
77
+ modules are installed in topological order and Starlette's `add_middleware`
78
+ is LIFO, so sorting after `Auth` makes this middleware wrap outermost.
79
+
80
+ Unlock state lives in the signed session cookie. The stored marker is a
81
+ fingerprint of the current password, so **rotating the password immediately
82
+ invalidates every unlocked session**.
83
+
84
+ ## Enabling it
85
+
86
+ Settings → Site Lock:
87
+
88
+ | Field | Default | Meaning |
89
+ |---|---|---|
90
+ | `enabled` | `false` | Master switch |
91
+ | `password` | `""` | The shared password. Masked in the admin UI |
92
+ | `message` | `""` | Optional line shown on the gate page |
93
+
94
+ Changes apply immediately — no restart. Enabling with a blank password is
95
+ rejected by a validator, so you cannot accidentally gate the site behind the
96
+ empty string.
97
+
98
+ ## What stays reachable when locked
99
+
100
+ - `/health` — always. Gating it would fail Kubernetes liveness/readiness
101
+ probes and get the pod killed.
102
+ - `/__unlock` — the gate page itself.
103
+
104
+ Everything else is gated. Requests under `/api/`, and any request carrying an
105
+ `Authorization` header, get `403 {"detail": "Site is locked"}` rather than a
106
+ redirect — a `401` would invite an auth flow that cannot succeed while the
107
+ site is locked. Browser requests get a `302` to the gate.
108
+
109
+ That includes the login API itself, so while the gate is on there is no
110
+ programmatic way in: CI smoke tests, uptime monitors, mobile clients and
111
+ webhook senders all get `403` no matter what credentials they hold. Only a
112
+ browser that has passed the gate (or an admin holding a live session) can
113
+ reach anything. Pause those integrations before enabling the gate, or point
114
+ uptime checks at `/health`, which stays open.
115
+
116
+ ## Admin bypass, and the lockout you can still cause
117
+
118
+ A user who already holds a session with the `admin` role skips the gate.
119
+ This is the intended escape hatch: if you enable the gate and mistype the
120
+ password, you are still holding the session that lets you go straight back to
121
+ Settings and fix it.
122
+
123
+ **It only rescues a live session.** If no admin is currently signed in and the
124
+ password has been forgotten, there is no in-app recovery. Clear the override
125
+ directly in the database:
126
+
127
+ ```sql
128
+ DELETE FROM settings_setting
129
+ WHERE scope = 'system' AND key = 'site_lock.enabled';
130
+ ```
131
+
132
+ Then restart the app (or save any setting) so the module re-hydrates.
133
+
134
+ ## Brute-force protection
135
+
136
+ The unlock endpoint tracks failures per client IP in memory: 10 failures
137
+ within 5 minutes trigger a 15-minute cooldown returning `429`. These
138
+ thresholds are module constants, not settings — they are the only defence on
139
+ a single shared secret, so they are not an operator-tunable surface.
140
+
141
+ The limiter is process-local, which is adequate for the single-process
142
+ staging deployments this module targets.
143
+
144
+ ## What this is not
145
+
146
+ This is not user authentication or authorisation — that is what the `auth`,
147
+ `users`, and `permissions` modules are for. The site lock is one shared
148
+ secret in front of everything, with no notion of identity.
@@ -0,0 +1,122 @@
1
+ # simple_module_site_lock
2
+
3
+ Optional site-wide password gate for `simple_module` apps — a staging /
4
+ pre-launch door. When enabled, every visitor must enter one shared password
5
+ before they can see anything: not the landing page, not the API, not even
6
+ that a login form exists.
7
+
8
+ **Off by default.** Installing this module changes nothing until an operator
9
+ turns it on.
10
+
11
+ ## Install
12
+
13
+ Add the package to your host and re-sync — discovery picks it up from the
14
+ `simple_module` entry point, no host code changes:
15
+
16
+ ```toml
17
+ # host/pyproject.toml
18
+ dependencies = ["simple_module_site_lock"]
19
+ ```
20
+
21
+ ```bash
22
+ uv sync --all-packages
23
+ ```
24
+
25
+ Or scaffold a new app with it selected: `smpy new --modules site_lock`.
26
+
27
+ ## Usage
28
+
29
+ 1. Sign in as an admin and open **Settings → Modules → SiteLock**.
30
+ 2. Set `password` (and optionally `message`), tick `enabled`, and **Save**.
31
+ Saving with a blank password is refused — the error appears on the
32
+ `enabled` field and nothing is persisted.
33
+ 3. Anyone without the password now gets the gate page at `/__unlock` on every
34
+ URL. They enter the password once and are returned to where they were
35
+ heading.
36
+ 4. To lift the gate, untick `enabled` and save. To rotate the password, set a
37
+ new one — every already-unlocked visitor is logged back out of the gate.
38
+
39
+ Keep your own session alive while you do this: an admin with a live session
40
+ skips the gate, which is what lets you undo a mistake (see
41
+ [Admin bypass](#admin-bypass-and-the-lockout-you-can-still-cause) below).
42
+
43
+ ## How it works
44
+
45
+ The module installs a single middleware that runs *before* `AuthMiddleware`.
46
+ That ordering is the whole point — an anonymous visitor gets the gate rather
47
+ than a redirect to the login page, so a locked site never reveals that it has
48
+ one.
49
+
50
+ It achieves that ordering by declaring `depends_on=["Settings", "Auth"]`:
51
+ modules are installed in topological order and Starlette's `add_middleware`
52
+ is LIFO, so sorting after `Auth` makes this middleware wrap outermost.
53
+
54
+ Unlock state lives in the signed session cookie. The stored marker is a
55
+ fingerprint of the current password, so **rotating the password immediately
56
+ invalidates every unlocked session**.
57
+
58
+ ## Enabling it
59
+
60
+ Settings → Site Lock:
61
+
62
+ | Field | Default | Meaning |
63
+ |---|---|---|
64
+ | `enabled` | `false` | Master switch |
65
+ | `password` | `""` | The shared password. Masked in the admin UI |
66
+ | `message` | `""` | Optional line shown on the gate page |
67
+
68
+ Changes apply immediately — no restart. Enabling with a blank password is
69
+ rejected by a validator, so you cannot accidentally gate the site behind the
70
+ empty string.
71
+
72
+ ## What stays reachable when locked
73
+
74
+ - `/health` — always. Gating it would fail Kubernetes liveness/readiness
75
+ probes and get the pod killed.
76
+ - `/__unlock` — the gate page itself.
77
+
78
+ Everything else is gated. Requests under `/api/`, and any request carrying an
79
+ `Authorization` header, get `403 {"detail": "Site is locked"}` rather than a
80
+ redirect — a `401` would invite an auth flow that cannot succeed while the
81
+ site is locked. Browser requests get a `302` to the gate.
82
+
83
+ That includes the login API itself, so while the gate is on there is no
84
+ programmatic way in: CI smoke tests, uptime monitors, mobile clients and
85
+ webhook senders all get `403` no matter what credentials they hold. Only a
86
+ browser that has passed the gate (or an admin holding a live session) can
87
+ reach anything. Pause those integrations before enabling the gate, or point
88
+ uptime checks at `/health`, which stays open.
89
+
90
+ ## Admin bypass, and the lockout you can still cause
91
+
92
+ A user who already holds a session with the `admin` role skips the gate.
93
+ This is the intended escape hatch: if you enable the gate and mistype the
94
+ password, you are still holding the session that lets you go straight back to
95
+ Settings and fix it.
96
+
97
+ **It only rescues a live session.** If no admin is currently signed in and the
98
+ password has been forgotten, there is no in-app recovery. Clear the override
99
+ directly in the database:
100
+
101
+ ```sql
102
+ DELETE FROM settings_setting
103
+ WHERE scope = 'system' AND key = 'site_lock.enabled';
104
+ ```
105
+
106
+ Then restart the app (or save any setting) so the module re-hydrates.
107
+
108
+ ## Brute-force protection
109
+
110
+ The unlock endpoint tracks failures per client IP in memory: 10 failures
111
+ within 5 minutes trigger a 15-minute cooldown returning `429`. These
112
+ thresholds are module constants, not settings — they are the only defence on
113
+ a single shared secret, so they are not an operator-tunable surface.
114
+
115
+ The limiter is process-local, which is adequate for the single-process
116
+ staging deployments this module targets.
117
+
118
+ ## What this is not
119
+
120
+ This is not user authentication or authorisation — that is what the `auth`,
121
+ `users`, and `permissions` modules are for. The site lock is one shared
122
+ secret in front of everything, with no notion of identity.
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "simple_module_site_lock"
3
+ version = "0.0.30"
4
+ description = "Optional site-wide password gate (staging / pre-launch) for simple_module apps"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
9
+ keywords = ["simple-module", "password", "staging", "gate"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Framework :: FastAPI",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Topic :: Internet :: WWW/HTTP",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "simple_module_core==0.0.30",
23
+ "simple_module_hosting==0.0.30",
24
+ "simple_module_settings==0.0.30",
25
+ "simple_module_auth==0.0.30",
26
+ ]
27
+
28
+ [project.entry-points.simple_module]
29
+ site_lock = "site_lock.module:SiteLockModule"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/antosubash/simple_module_python"
33
+ Repository = "https://github.com/antosubash/simple_module_python"
34
+ Issues = "https://github.com/antosubash/simple_module_python/issues"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["site_lock"]
42
+
43
+ [tool.uv.sources]
44
+ simple_module_core = { workspace = true }
45
+ simple_module_hosting = { workspace = true }
46
+ simple_module_settings = { workspace = true }
47
+ simple_module_auth = { workspace = true }
File without changes
@@ -0,0 +1,28 @@
1
+ """Every literal the site_lock module depends on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MODULE_NAME = "SiteLock"
6
+ MODULE_PACKAGE = "site_lock"
7
+
8
+ # Dependencies — names must match the other modules' ``ModuleMeta.name``.
9
+ MODULE_SETTINGS = "Settings"
10
+ MODULE_AUTH = "Auth"
11
+
12
+ # The gate endpoint. Double-underscore prefix keeps it clear of app routes.
13
+ UNLOCK_PATH = "/__unlock"
14
+ SESSION_KEY = "site_lock"
15
+
16
+ # Never gated: Kubernetes liveness/readiness probes must always succeed.
17
+ HEALTH_PREFIX = "/health"
18
+ API_PREFIX = "/api/"
19
+
20
+ # Duplicated rather than imported from ``users`` — site_lock must also work
21
+ # under the ``keycloak`` provider, which has no dependency on ``users``.
22
+ ADMIN_ROLE = "admin"
23
+
24
+ # Brute-force limits. Deliberately NOT settings fields: they are the only
25
+ # defence on a single shared secret and are not an operator-tunable surface.
26
+ MAX_FAILURES = 10
27
+ WINDOW_SECONDS = 300
28
+ COOLDOWN_SECONDS = 900
@@ -0,0 +1,177 @@
1
+ """Site-wide password gate.
2
+
3
+ Runs outermost among module middleware — the module sorts after ``Auth``, and
4
+ Starlette's ``add_middleware`` is LIFO — so this executes *before*
5
+ ``AuthMiddleware``. That ordering is the whole point: an anonymous visitor
6
+ sees the gate instead of being redirected to the login page, so a locked site
7
+ never reveals that a login form exists.
8
+
9
+ ``SessionMiddleware`` sits outside this one, so ``scope["session"]`` is
10
+ readable here and writes are persisted on the way out.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import logging
17
+ import secrets
18
+ from urllib.parse import quote
19
+
20
+ from starlette.requests import Request
21
+ from starlette.responses import (
22
+ HTMLResponse,
23
+ JSONResponse,
24
+ RedirectResponse,
25
+ Response,
26
+ )
27
+ from starlette.types import ASGIApp, Receive, Scope, Send
28
+
29
+ from site_lock import constants as c
30
+ from site_lock.page import render_unlock_page, safe_next
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _NO_STORE = {"Cache-Control": "no-store"}
35
+ _ERR_WRONG = "Incorrect password."
36
+ _ERR_THROTTLED = "Too many attempts. Try again later."
37
+
38
+
39
+ def password_fingerprint(password: str) -> str:
40
+ """Short digest of the active password, stored in the session.
41
+
42
+ Comparing this on each request is what makes a password rotation
43
+ invalidate every previously-unlocked session.
44
+ """
45
+ return hashlib.sha256(password.encode("utf-8")).hexdigest()[:16]
46
+
47
+
48
+ class SiteLockMiddleware:
49
+ """Gate every request behind a single shared password."""
50
+
51
+ def __init__(self, app: ASGIApp) -> None:
52
+ self.app = app
53
+
54
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
55
+ if scope["type"] != "http":
56
+ await self.app(scope, receive, send)
57
+ return
58
+
59
+ state = scope["app"].state.site_lock
60
+ settings = state.settings
61
+
62
+ # Default-off fast path: one attribute read, no Request construction.
63
+ if not settings.enabled:
64
+ await self.app(scope, receive, send)
65
+ return
66
+
67
+ path: str = scope["path"]
68
+
69
+ # Kubernetes probes must never be gated, or the pod gets killed.
70
+ if path.startswith(c.HEALTH_PREFIX):
71
+ await self.app(scope, receive, send)
72
+ return
73
+
74
+ fingerprint = password_fingerprint(settings.password)
75
+ session = scope.get("session")
76
+
77
+ if path == c.UNLOCK_PATH:
78
+ response = await self._unlock(scope, receive, state, fingerprint, session)
79
+ await response(scope, receive, send)
80
+ return
81
+
82
+ if session is not None and session.get(c.SESSION_KEY) == fingerprint:
83
+ await self.app(scope, receive, send)
84
+ return
85
+
86
+ if await self._is_admin(scope):
87
+ # Stamp the marker so the provider lookup costs once per session
88
+ # rather than once per request.
89
+ if session is not None:
90
+ session[c.SESSION_KEY] = fingerprint
91
+ await self.app(scope, receive, send)
92
+ return
93
+
94
+ await self._gate(scope, receive, send, path)
95
+
96
+ async def _gate(self, scope: Scope, receive: Receive, send: Send, path: str) -> None:
97
+ has_auth_header = any(k == b"authorization" for k, _ in scope.get("headers", ()))
98
+ if path.startswith(c.API_PREFIX) or has_auth_header:
99
+ # 403 rather than 401: a 401 would invite an auth flow that cannot
100
+ # succeed while the site is locked.
101
+ response: Response = JSONResponse(
102
+ {"detail": "Site is locked"}, status_code=403, headers=_NO_STORE
103
+ )
104
+ else:
105
+ target = f"{c.UNLOCK_PATH}?next={quote(safe_next(path), safe='/')}"
106
+ response = RedirectResponse(target, status_code=302, headers=_NO_STORE)
107
+ await response(scope, receive, send)
108
+
109
+ async def _unlock(
110
+ self,
111
+ scope: Scope,
112
+ receive: Receive,
113
+ state,
114
+ fingerprint: str,
115
+ session,
116
+ ) -> Response:
117
+ method: str = scope.get("method", "GET")
118
+ settings = state.settings
119
+ request = Request(scope, receive)
120
+
121
+ if method == "GET":
122
+ return HTMLResponse(
123
+ render_unlock_page(
124
+ message=settings.message,
125
+ next_url=request.query_params.get("next", "/"),
126
+ ),
127
+ headers=_NO_STORE,
128
+ )
129
+ if method != "POST":
130
+ return Response(status_code=405, headers=_NO_STORE)
131
+
132
+ client = scope.get("client")
133
+ key = client[0] if client else "unknown"
134
+ if state.limiter.is_blocked(key):
135
+ return self._page(settings, _ERR_THROTTLED, "/", status=429)
136
+
137
+ form = await request.form()
138
+ supplied = str(form.get("password", ""))
139
+ target = safe_next(str(form.get("next", "/")))
140
+
141
+ if secrets.compare_digest(supplied, settings.password):
142
+ state.limiter.reset(key)
143
+ if session is not None:
144
+ session[c.SESSION_KEY] = fingerprint
145
+ return RedirectResponse(target, status_code=303, headers=_NO_STORE)
146
+
147
+ state.limiter.record_failure(key)
148
+ return self._page(settings, _ERR_WRONG, target, status=401)
149
+
150
+ @staticmethod
151
+ def _page(settings, error: str, next_url: str, *, status: int) -> HTMLResponse:
152
+ return HTMLResponse(
153
+ render_unlock_page(message=settings.message, error=error, next_url=next_url),
154
+ status_code=status,
155
+ headers=_NO_STORE,
156
+ )
157
+
158
+ @staticmethod
159
+ async def _is_admin(scope: Scope) -> bool:
160
+ """True when the caller already holds a session for an admin user.
161
+
162
+ This is the documented lockout escape hatch: an admin who enables the
163
+ gate and mistypes the password keeps access to the settings screen.
164
+ """
165
+ auth_state = getattr(scope["app"].state, "auth", None)
166
+ provider = getattr(auth_state, "auth_provider", None)
167
+ if provider is None:
168
+ return False
169
+ try:
170
+ user = await provider.resolve_user(Request(scope))
171
+ except Exception:
172
+ logger.exception("Site lock admin bypass failed; treating as anonymous")
173
+ return False
174
+ return user is not None and c.ADMIN_ROLE in getattr(user, "roles", ())
175
+
176
+
177
+ __all__ = ["SiteLockMiddleware", "password_fingerprint"]
@@ -0,0 +1,47 @@
1
+ """Site Lock module — optional site-wide password gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from simple_module_core.module import ModuleBase, ModuleMeta
8
+
9
+ from site_lock import constants as c
10
+
11
+ if TYPE_CHECKING:
12
+ from fastapi import FastAPI
13
+
14
+
15
+ class SiteLockModule(ModuleBase):
16
+ meta = ModuleMeta(
17
+ name=c.MODULE_NAME,
18
+ # ``Settings`` so register_module_settings can reach the module
19
+ # registry; ``Auth`` so this module sorts *after* auth and its
20
+ # middleware therefore wraps outermost, executing before
21
+ # AuthMiddleware. Both are load-bearing — see the module README.
22
+ depends_on=[c.MODULE_SETTINGS, c.MODULE_AUTH],
23
+ )
24
+
25
+ def register_settings(self, app: FastAPI) -> None:
26
+ import importlib
27
+
28
+ from site_lock.settings import SiteLockSettings
29
+ from site_lock.state import SiteLockState
30
+
31
+ # SM009 is AST-based: resolving via importlib matches the convention
32
+ # used by the other settings-backed modules.
33
+ register_module_settings = importlib.import_module(
34
+ "settings.registration"
35
+ ).register_module_settings
36
+
37
+ register_module_settings(
38
+ app,
39
+ c.MODULE_PACKAGE,
40
+ SiteLockSettings,
41
+ lambda s: SiteLockState(settings=s),
42
+ )
43
+
44
+ def register_middleware(self, app: FastAPI) -> None:
45
+ from site_lock.middleware import SiteLockMiddleware
46
+
47
+ app.add_middleware(SiteLockMiddleware)
@@ -0,0 +1,55 @@
1
+ """Render the standalone unlock page.
2
+
3
+ Kept out of ``middleware.py`` so the escaping and ``next``-sanitisation rules
4
+ can be unit-tested without an ASGI harness, and so both files stay well under
5
+ the 300-line cap.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import importlib.resources
12
+ from string import Template
13
+
14
+ _TEMPLATE = Template(
15
+ (importlib.resources.files(__package__) / "templates" / "unlock.html").read_text(
16
+ encoding="utf-8"
17
+ )
18
+ )
19
+
20
+ _DEFAULT_MESSAGE = "This site is not public yet. Enter the password to continue."
21
+ _ERROR_BLOCK = '<p class="error" role="alert">{message}</p>'
22
+
23
+
24
+ def safe_next(raw: str | None) -> str:
25
+ """Return ``raw`` if it is a same-site absolute path, else ``/``.
26
+
27
+ Rejects protocol-relative (``//host``) and backslash-prefixed (``/\\host``)
28
+ targets — browsers resolve both off-site — plus anything carrying CR/LF,
29
+ which could otherwise be smuggled into the redirect header. Without this
30
+ the gate would be an open redirect.
31
+ """
32
+ if not raw or not raw.startswith("/"):
33
+ return "/"
34
+ if raw.startswith(("//", "/\\")):
35
+ return "/"
36
+ if "\r" in raw or "\n" in raw:
37
+ return "/"
38
+ return raw
39
+
40
+
41
+ def render_unlock_page(
42
+ *,
43
+ message: str = "",
44
+ error: str = "",
45
+ next_url: str = "/",
46
+ ) -> str:
47
+ """Render the gate page. Every interpolated value is HTML-escaped."""
48
+ return _TEMPLATE.safe_substitute(
49
+ message=html.escape(message or _DEFAULT_MESSAGE),
50
+ error=_ERROR_BLOCK.format(message=html.escape(error)) if error else "",
51
+ next_url=html.escape(safe_next(next_url), quote=True),
52
+ )
53
+
54
+
55
+ __all__ = ["render_unlock_page", "safe_next"]