aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-04-03 01:46:49 -0400
committerkj_sh6042026-04-03 01:46:49 -0400
commit90ba16a3b4d1dfc29a614522824d87c5cae14bf8 (patch)
tree4ffa2ccda54bd5b63221b7081a4504a5b9f13182
parentebfd27170bdb4426af2a284a85d195a3cbe9611b (diff)
refactor: admin changes and favicon
-rw-r--r--auth_backend.py71
-rw-r--r--favicon.svg39
-rw-r--r--shim_app.py52
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()