diff options
| author | kj_sh604 | 2026-04-03 01:46:49 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-04-03 01:46:49 -0400 |
| commit | 90ba16a3b4d1dfc29a614522824d87c5cae14bf8 (patch) | |
| tree | 4ffa2ccda54bd5b63221b7081a4504a5b9f13182 | |
| parent | ebfd27170bdb4426af2a284a85d195a3cbe9611b (diff) | |
refactor: admin changes and favicon
| -rw-r--r-- | auth_backend.py | 71 | ||||
| -rw-r--r-- | favicon.svg | 39 | ||||
| -rw-r--r-- | shim_app.py | 52 |
3 files changed, 155 insertions, 7 deletions
diff --git a/auth_backend.py b/auth_backend.py index 7219931..a8ad782 100644 --- a/auth_backend.py +++ b/auth_backend.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import Callable, Optional, Protocol +# fixed challenge text used for passphrase verification. +# we store encrypted challenge output instead of storing passwords. AUTH_CHALLENGE = "SHIM_AUTH_VALID" USERNAME_RE = re.compile(r"^[a-z0-9_.-]{2,64}$") @@ -26,6 +28,12 @@ class AuthBackend(Protocol): def authenticate(self, username: str, password: str) -> Optional[sqlite3.Row]: ... + def update_username(self, user_uuid: str, new_username: str) -> tuple[bool, str]: + ... + + def update_password(self, user_uuid: str, new_password: str) -> tuple[bool, str]: + ... + def normalize_username(username: str) -> str: return username.strip().lower() @@ -55,13 +63,18 @@ class LocalMojicryptAuthBackend: def create_user(self, username: str, password: str, role: str = "user") -> tuple[bool, str]: username = normalize_username(username) - if not USERNAME_RE.fullmatch(username): - return False, "username must be lowercase and use [a-z0-9_.-], 2-64 chars" - if len(password) < 2: - return False, "password must be at least 2 characters" + username_error = self._validate_username(username) + if username_error: + return False, username_error + + password_error = self._validate_password(password) + if password_error: + return False, password_error + if role not in {"admin", "user"}: return False, "invalid role" + # store an encrypted challenge, never the raw password. encrypted = self.encrypt_password(password) if not encrypted: return False, "mojicrypt encryption failed" @@ -79,6 +92,45 @@ class LocalMojicryptAuthBackend: except sqlite3.IntegrityError: return False, "username already exists" + def update_username(self, user_uuid: str, new_username: str) -> tuple[bool, str]: + normalized = normalize_username(new_username) + username_error = self._validate_username(normalized) + if username_error: + return False, username_error + + try: + with self.connect_db() as conn: + cursor = conn.execute( + "UPDATE users SET username = ? WHERE user_uuid = ?", + (normalized, user_uuid), + ) + if cursor.rowcount == 0: + return False, "user not found" + except sqlite3.IntegrityError: + return False, "username already exists" + + return True, "username updated" + + def update_password(self, user_uuid: str, new_password: str) -> tuple[bool, str]: + password_error = self._validate_password(new_password) + if password_error: + return False, password_error + + # this rotates the encrypted challenge blob for future logins. + encrypted = self.encrypt_password(new_password) + if not encrypted: + return False, "mojicrypt encryption failed" + + with self.connect_db() as conn: + cursor = conn.execute( + "UPDATE users SET encrypted_challenge = ? WHERE user_uuid = ?", + (encrypted, user_uuid), + ) + if cursor.rowcount == 0: + return False, "user not found" + + return True, "password updated" + def authenticate(self, username: str, password: str) -> Optional[sqlite3.Row]: username = normalize_username(username) with self.connect_db() as conn: @@ -99,6 +151,16 @@ class LocalMojicryptAuthBackend: def encrypt_password(self, password: str) -> Optional[str]: return self._run_mojicrypt("encrypt", AUTH_CHALLENGE, password) + def _validate_username(self, username: str) -> Optional[str]: + if not USERNAME_RE.fullmatch(username): + return "username must be lowercase and use [a-z0-9_.-], 2-64 chars" + return None + + def _validate_password(self, password: str) -> Optional[str]: + if len(password) < 2: + return "password must be at least 2 characters" + return None + def verify_password(self, encrypted_blob: str, password: str) -> bool: decrypted = self._run_mojicrypt("decrypt", encrypted_blob, password) return decrypted == AUTH_CHALLENGE @@ -118,6 +180,7 @@ class LocalMojicryptAuthBackend: self.last_error = "binary is not executable" return None + # try direct execution first, then python fallback when needed. commands_to_try = [] if direct_exec_ok: commands_to_try.append([ diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..e47c1e0 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> + +<svg + fill="#000000" + height="800px" + width="800px" + version="1.1" + id="Layer_1" + viewBox="0 0 512 512" + xml:space="preserve" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"><defs + id="defs5" /> +<g + id="g5" + style="stroke-width:0.99968;stroke-dasharray:none;fill:#7e9bc6;fill-opacity:1"> + <g + id="g4" + style="stroke-width:0.99968;stroke-dasharray:none;fill:#7e9bc6;fill-opacity:1"> + <g + id="g3" + style="stroke-width:0.99968;stroke-dasharray:none;fill:#7e9bc6;fill-opacity:1"> + <path + d="M467.191,0H44.809C20.101,0,0,20.101,0,44.809v422.381C0,491.899,20.101,512,44.809,512h422.381 C491.899,512,512,491.899,512,467.191V44.809C512,20.101,491.899,0,467.191,0z M488.727,467.191 c0,11.875-9.662,21.537-21.537,21.537H44.809c-11.875,0-21.537-9.662-21.537-21.537V44.809c0-11.875,9.662-21.537,21.537-21.537 h422.381c11.875,0,21.537,9.662,21.537,21.537V467.191z" + id="path1" + style="stroke-width:0.99968;stroke-dasharray:none;fill:#7e9bc6;fill-opacity:1" /> + <path + d="M301.097,175.127h1.164c6.426,0,11.636-5.211,11.636-11.636s-5.211-11.636-11.636-11.636h-1.164 c-6.426,0-11.636,5.211-11.636,11.636S294.671,175.127,301.097,175.127z" + id="path2" + style="stroke-width:0.99968;stroke-dasharray:none;fill:#7e9bc6;fill-opacity:1" /> + <path + d="M418.909,81.455H93.091c-6.426,0-11.636,5.211-11.636,11.636v325.818c0,6.426,5.211,11.636,11.636,11.636h325.818 c6.426,0,11.636-5.211,11.636-11.636V93.091C430.545,86.665,425.335,81.455,418.909,81.455z M407.273,151.855h-55.849 c-6.426,0-11.636,5.211-11.636,11.636s5.211,11.636,11.636,11.636h55.849v232.145H104.727V175.127h147.206 c6.426,0,11.636-5.211,11.636-11.636s-5.211-11.636-11.636-11.636H104.727v-47.127h302.545V151.855z" + id="path3" + style="stroke-width:0.99968;stroke-dasharray:none;fill:#7e9bc6;fill-opacity:1" /> + </g> + </g> +</g> +</svg> diff --git a/shim_app.py b/shim_app.py index 56799a8..f7e68af 100644 --- a/shim_app.py +++ b/shim_app.py @@ -67,6 +67,7 @@ SHELL_TEMPLATE = """<!doctype html> <meta http-equiv="cache-control" content="no-cache"> <meta http-equiv="expires" content="0"> <meta http-equiv="pragma" content="no-cache"> + <link rel="icon" href="{{ url_for('favicon') }}" type="image/svg+xml"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> <link rel="stylesheet" href="{{ url_for('noir_overrides_css') }}"> <script> @@ -158,7 +159,7 @@ SHELL_TEMPLATE = """<!doctype html> SETUP_BODY_TEMPLATE = """ <section class="stack"> - <p>first startup detected. create the initial admin account to continue.</p> + <p>first startup detected. <br><br> create the initial admin account to continue.</p> <form method="post" action="{{ url_for('setup_submit') }}" class="stack" autocomplete="off"> <label> username @@ -180,7 +181,7 @@ SETUP_BODY_TEMPLATE = """ LOGIN_BODY_TEMPLATE = """ <section class="stack"> - <p>no-frills static site hosting "hackfoo"</p> + <p>a no-frills, "hackfoo" static site hosting solution</p> <form method="post" action="{{ url_for('login_submit') }}" class="stack" autocomplete="off"> <label> username @@ -325,12 +326,21 @@ DASHBOARD_BODY_TEMPLATE = """ <td>{{ user['role'] }}</td> <td>{{ user['created_at'] }}</td> <td> + <form method="post" action="{{ url_for('admin_update_user_username', user_id=user['id']) }}" class="stack" autocomplete="off"> + <input type="text" name="username" value="{{ user['username'] }}" required minlength="2" maxlength="64" pattern="[a-z0-9_.-]+" autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false" data-lpignore="true" style="all: revert;"> + <button type="submit" style="all: revert;">rename user</button> + </form> + <br> + <form method="post" action="{{ url_for('admin_update_user_password', user_id=user['id']) }}" class="stack" autocomplete="off"> + <input type="password" name="password" required minlength="2" autocomplete="new-password" style="all: revert;"> + <button type="submit" style="all: revert;">set password</button> + </form><br> {% if user['id'] != current_user['id'] %} <form method="post" action="{{ url_for('admin_delete_user', user_id=user['id']) }}" class="inline" onsubmit="return confirm('delete this user and all owned sites?');"> <button type="submit" style="all: revert;">delete</button> </form> {% else %} - current account + <strong>current account</strong> {% endif %} </td> </tr> @@ -393,6 +403,7 @@ def normalize_archive_member_path(raw_name: str) -> Optional[Path]: def ensure_path_under(root: Path, candidate: Path) -> None: + # raises when candidate escapes root via traversal or symlink tricks. root_real = root.resolve() candidate_real = candidate.resolve() candidate_real.relative_to(root_real) @@ -417,6 +428,7 @@ def rewrite_root_path(path_without_leading_slash: str, slug: str) -> str: def build_slug_runtime_guard(slug: str) -> str: + # browser storage is origin-scoped, so this injects a best-effort slug namespace shim. slug_json = json.dumps(slug) return ( '<script id="shim-slug-runtime-guard">' @@ -463,6 +475,7 @@ def rewrite_html_for_slug(html_text: str, slug: str) -> str: rewritten = rewrite_root_path(path_value, slug) return f"{attr}={quote}{rewritten}{quote}" + # rewrite absolute-root links so hosted apps stay inside /s/<slug>/. updated = ROOT_ATTR_RE.sub(repl, html_text) head_match = re.search(r"(?i)<head[^>]*>", updated) if head_match: @@ -651,7 +664,9 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: def connect_db() -> sqlite3.Connection: conn = sqlite3.connect(str(cfg.db_path)) + # row objects keep query call sites readable and less index-fragile. conn.row_factory = sqlite3.Row + # keep fk checks enabled even on sqlite defaults that disable them. conn.execute("PRAGMA foreign_keys = ON") return conn @@ -893,6 +908,15 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: def healthz() -> tuple[str, int]: return "ok", 200 + @app.get("/favicon.svg") + def favicon() -> Response: + icon_path = cfg.base_dir / "favicon.svg" + if not icon_path.is_file(): + abort(404) + response = make_response(send_file(icon_path, mimetype="image/svg+xml", conditional=True)) + response.headers["Cache-Control"] = "public, max-age=3600" + return response + @app.get("/app/noir-overrides.css") def noir_overrides_css() -> Response: css_path = cfg.base_dir / "noir-overrides.css" @@ -1141,6 +1165,28 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: flash(message, "success" if ok else "error") return redirect(url_for("dashboard")) + @app.post("/app/admin/users/<user_id>/username") + def admin_update_user_username(user_id: str) -> Response: + admin_redirect = require_admin() + if admin_redirect is not None: + return admin_redirect + + username = request.form.get("username", "") + ok, message = auth_backend.update_username(user_uuid=user_id, new_username=username) + flash(message, "success" if ok else "error") + return redirect(url_for("dashboard")) + + @app.post("/app/admin/users/<user_id>/password") + def admin_update_user_password(user_id: str) -> Response: + admin_redirect = require_admin() + if admin_redirect is not None: + return admin_redirect + + password = request.form.get("password", "") + ok, message = auth_backend.update_password(user_uuid=user_id, new_password=password) + flash(message, "success" if ok else "error") + return redirect(url_for("dashboard")) + @app.post("/app/admin/users/<user_id>/delete") def admin_delete_user(user_id: str) -> Response: admin_redirect = require_admin() |
