diff options
| author | kj_sh604 | 2026-04-03 01:27:11 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-04-03 01:27:11 -0400 |
| commit | ebfd27170bdb4426af2a284a85d195a3cbe9611b (patch) | |
| tree | 208658569e128312e818610139986fe570951803 | |
| parent | b5b1a685bf9c2dd172545091c049717360a23648 (diff) | |
refactor: usability and styling simplicity
| -rw-r--r-- | auth_backend.py | 7 | ||||
| -rw-r--r-- | noir-overrides.css | 80 | ||||
| -rw-r--r-- | shim_app.py | 305 |
3 files changed, 321 insertions, 71 deletions
diff --git a/auth_backend.py b/auth_backend.py index e1ac01c..7219931 100644 --- a/auth_backend.py +++ b/auth_backend.py @@ -5,6 +5,7 @@ import re import sqlite3 import subprocess import sys +import uuid from pathlib import Path from typing import Callable, Optional, Protocol @@ -69,10 +70,10 @@ class LocalMojicryptAuthBackend: with self.connect_db() as conn: conn.execute( """ - INSERT INTO users (username, role, encrypted_challenge, created_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO users (user_uuid, username, role, encrypted_challenge, created_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) """, - (username, role, encrypted), + (str(uuid.uuid4()), username, role, encrypted), ) return True, "user created" except sqlite3.IntegrityError: diff --git a/noir-overrides.css b/noir-overrides.css new file mode 100644 index 0000000..2a1b977 --- /dev/null +++ b/noir-overrides.css @@ -0,0 +1,80 @@ +body { + max-width: 1200px; +} + + +main { + max-width: 1120px; + margin: 0 auto; +} + +.topbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + justify-content: space-between; +} + +.topbar nav { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.inline { + display: inline; +} + +.quiet { + opacity: 0.8; +} + +.stack { + display: grid; + gap: 1rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1rem; +} + +.flash { + padding: 0.75rem 1rem; + border-left: 0.35rem solid; +} + +.flash.error { + border-color: #b91c1c; +} + +.flash.success { + border-color: #0f766e; +} + +table { + width: 100%; +} + +td form { + margin: 0; +} + +input[type="text"], +input[type="password"], +input[type="file"], +select { + width: 100%; +} + +@media (max-width: 760px) { + .topbar { + align-items: flex-start; + } + + .topbar nav { + flex-wrap: wrap; + } +}
\ No newline at end of file diff --git a/shim_app.py b/shim_app.py index 7e6891a..56799a8 100644 --- a/shim_app.py +++ b/shim_app.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import mimetypes +import json import os import re import secrets @@ -10,6 +11,7 @@ import stat import tarfile import tempfile import time +import uuid import zipfile from dataclasses import dataclass from pathlib import Path @@ -43,14 +45,14 @@ SESSION_COOKIE = "shim_session" ACTIVE_SITE_COOKIE = "shim_active_site" SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$") ARCHIVE_SUFFIXES = ( - ".tar.gz", - ".tgz", + ".zip", + ".tar", ".tar.bz2", - ".tbz2", + ".tar.gz", ".tar.xz", + ".tbz2", + ".tgz", ".txz", - ".tar", - ".zip", ) ROOT_ATTR_RE = re.compile(r"(?i)\b(href|src|action|poster)=([\"'])/([^\"']*)\2") CSS_URL_RE = re.compile(r"(?i)url\(\s*([\"']?)/([^\)'\"\s]+)\1\s*\)") @@ -66,26 +68,61 @@ SHELL_TEMPLATE = """<!doctype html> <meta http-equiv="expires" content="0"> <meta http-equiv="pragma" content="no-cache"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> - <title>{{ title }} - {{ app_name }}</title> - <style> - main { max-width: 1120px; margin: 0 auto; } - .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; justify-content: space-between; } - .topbar nav { display: flex; gap: 0.75rem; align-items: center; } - .inline { display: inline; } - .quiet { opacity: 0.8; } - .stack { display: grid; gap: 1rem; } - .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; } - .flash { padding: 0.75rem 1rem; border-left: 0.35rem solid; } - .flash.error { border-color: #b91c1c; } - .flash.success { border-color: #0f766e; } - table { width: 100%; } - td form { margin: 0; } - input[type="text"], input[type="password"], input[type="file"], select { width: 100%; } - @media (max-width: 760px) { - .topbar { align-items: flex-start; } - .topbar nav { flex-wrap: wrap; } + <link rel="stylesheet" href="{{ url_for('noir_overrides_css') }}"> + <script> + if ("serviceWorker" in navigator && navigator.serviceWorker.getRegistrations) { + navigator.serviceWorker.getRegistrations().then(function (registrations) { + registrations.forEach(function (registration) { + try { + var scopePath = new URL(registration.scope).pathname; + if (scopePath === "/") { + registration.unregister(); + } + } catch (_err) { + } + }); + }); } - </style> + + (function () { + var scrollKey = "__shim_form_scroll_restore__"; + + try { + var saved = sessionStorage.getItem(scrollKey); + if (saved) { + sessionStorage.removeItem(scrollKey); + var parsed = JSON.parse(saved); + if (parsed && parsed.path === window.location.pathname) { + var y = Number(parsed.y); + if (!Number.isNaN(y)) { + window.requestAnimationFrame(function () { + window.scrollTo(0, y); + }); + } + } + } + } catch (_err) { + } + + document.addEventListener("submit", function (event) { + var form = event.target; + if (!(form instanceof HTMLFormElement)) { + return; + } + try { + sessionStorage.setItem( + scrollKey, + JSON.stringify({ + path: window.location.pathname, + y: window.scrollY || window.pageYOffset || 0, + }) + ); + } catch (_err) { + } + }, true); + })(); + </script> + <title>{{ title }} - {{ app_name }}</title> </head> <body> <main> @@ -143,7 +180,7 @@ SETUP_BODY_TEMPLATE = """ LOGIN_BODY_TEMPLATE = """ <section class="stack"> - <p>sign in to manage uploads and slugs.</p> + <p>no-frills static site hosting "hackfoo"</p> <form method="post" action="{{ url_for('login_submit') }}" class="stack" autocomplete="off"> <label> username @@ -162,7 +199,7 @@ LOGIN_BODY_TEMPLATE = """ DASHBOARD_BODY_TEMPLATE = """ <section class="stack"> <h2>upload static site</h2> - <p>upload one archive containing your built static files. users always get a random 40 character slug.</p> + <p>upload one archive containing your built static files. <br><br> this will generate a random 40 character slug, you can ask your admin to change it to a custom one.</p> <p class="quiet">accepted formats: {{ allowed_suffixes | join(', ') }}</p> <form method="post" action="{{ url_for('upload_site') }}" enctype="multipart/form-data" class="stack"> <label> @@ -179,8 +216,8 @@ DASHBOARD_BODY_TEMPLATE = """ <table> <thead> <tr> - <th>id</th> - <th>slug</th> + {% if is_admin %}<th>id</th>{% endif %} + <th>link</th> {% if is_admin %}<th>owner</th>{% endif %} <th>archive</th> <th>created</th> @@ -190,24 +227,16 @@ DASHBOARD_BODY_TEMPLATE = """ <tbody> {% for site in sites %} <tr> - <td>{{ site['id'] }}</td> + {% if is_admin %}<td>{{ site['id'] }}</td>{% endif %} <td> - {% if is_admin %} - <form method="post" action="{{ url_for('admin_update_slug', site_id=site['id']) }}" class="stack"> - <input type="text" name="slug" value="{{ site['slug'] }}" required minlength="1" maxlength="128" pattern="[a-z0-9][a-z0-9-]*"> - <button type="submit">rename slug</button> - </form> - {% else %} - {{ site['slug'] }} - {% endif %} + <a href="/s/{{ site['slug'] }}/" target="_blank" rel="noopener">{{ site['slug'] }}</a> </td> {% if is_admin %}<td>{{ site['owner_username'] }}</td>{% endif %} <td>{{ site['original_filename'] }}</td> <td>{{ site['created_at'] }}</td> <td> - <a href="/s/{{ site['slug'] }}/" target="_blank" rel="noopener">open</a> <form method="post" action="{{ url_for('delete_site', site_id=site['id']) }}" class="inline" onsubmit="return confirm('delete this site?');"> - <button type="submit">delete</button> + <button type="submit" style="all: revert;">delete</button> </form> </td> </tr> @@ -220,7 +249,40 @@ DASHBOARD_BODY_TEMPLATE = """ </section> {% if is_admin %} -<section class="grid"> +<section> + <article class="stack"> + <h2>rename slugs</h2> + {% if sites %} + <table> + <thead> + <tr> + <th>id</th> + <th>current slug</th> + <th>owner</th> + <th>rename</th> + </tr> + </thead> + <tbody> + {% for site in sites %} + <tr> + <td>{{ site['id'] }}</td> + <td>{{ site['slug'] }}</td> + <td>{{ site['owner_username'] }}</td> + <td> + <form method="post" action="{{ url_for('admin_update_slug', site_id=site['id']) }}" class="stack"> + <input type="text" name="slug" value="{{ site['slug'] }}" required minlength="1" maxlength="128" pattern="[a-z0-9][a-z0-9-]*" style="all: revert;" autocomplete="off"> + <button type="submit" style="all: revert;">rename slug</button> + </form> + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% else %} + <p>no sites uploaded yet.</p> + {% endif %} + </article> + <article class="stack"> <h2>create user</h2> <form method="post" action="{{ url_for('admin_create_user') }}" class="stack" autocomplete="off"> @@ -265,7 +327,7 @@ DASHBOARD_BODY_TEMPLATE = """ <td> {% 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">delete</button> + <button type="submit" style="all: revert;">delete</button> </form> {% else %} current account @@ -354,6 +416,45 @@ def rewrite_root_path(path_without_leading_slash: str, slug: str) -> str: return f"/s/{slug}/{value}" +def build_slug_runtime_guard(slug: str) -> str: + slug_json = json.dumps(slug) + return ( + '<script id="shim-slug-runtime-guard">' + "(function(){" + f"const slug={slug_json};" + "const prefix='__shim_store__'+slug+'__:';" + "function patchStorage(storage, namespace){" + "if(!storage){return;}" + "const ns=prefix+namespace+':';" + "try{" + "const rawGet=storage.getItem.bind(storage);" + "const rawSet=storage.setItem.bind(storage);" + "const rawRemove=storage.removeItem.bind(storage);" + "const rawKey=storage.key.bind(storage);" + "storage.getItem=function(key){return rawGet(ns+String(key));};" + "storage.setItem=function(key,value){return rawSet(ns+String(key),String(value));};" + "storage.removeItem=function(key){return rawRemove(ns+String(key));};" + "storage.clear=function(){var keys=[];for(var i=0;i<storage.length;i++){var current=rawKey(i);if(current&¤t.indexOf(ns)===0){keys.push(current);}}for(var j=0;j<keys.length;j++){rawRemove(keys[j]);}};" + "storage.key=function(index){var keys=[];for(var i=0;i<storage.length;i++){var current=rawKey(i);if(current&¤t.indexOf(ns)===0){keys.push(current.slice(ns.length));}}return (typeof keys[index]==='undefined')?null:keys[index];};" + "}catch(_err){}" + "}" + "patchStorage(window.localStorage,'l');" + "patchStorage(window.sessionStorage,'s');" + "if('serviceWorker' in navigator && navigator.serviceWorker && navigator.serviceWorker.register){" + "const rawRegister=navigator.serviceWorker.register.bind(navigator.serviceWorker);" + "navigator.serviceWorker.register=function(scriptURL, options){" + "var nextScript=scriptURL;" + "try{var raw=String(scriptURL||'');if(raw.indexOf('/')===0){nextScript='/s/'+slug+raw;}}catch(_err){}" + "var nextOptions=Object.assign({}, options||{});" + "if(!nextOptions.scope){nextOptions.scope='/s/'+slug+'/';}" + "return rawRegister(nextScript, nextOptions);" + "};" + "}" + "})();" + "</script>" + ) + + def rewrite_html_for_slug(html_text: str, slug: str) -> str: def repl(match: re.Match[str]) -> str: attr = match.group(1) @@ -363,12 +464,16 @@ def rewrite_html_for_slug(html_text: str, slug: str) -> str: return f"{attr}={quote}{rewritten}{quote}" updated = ROOT_ATTR_RE.sub(repl, html_text) - if "<base " not in updated.lower(): - head_match = re.search(r"(?i)<head[^>]*>", updated) - if head_match: - base_tag = f"\n <base href=\"/s/{slug}/\">" + head_match = re.search(r"(?i)<head[^>]*>", updated) + if head_match: + inject_parts = [] + if "id=\"shim-slug-runtime-guard\"" not in updated: + inject_parts.append("\n " + build_slug_runtime_guard(slug)) + if "<base " not in updated.lower(): + inject_parts.append(f"\n <base href=\"/s/{slug}/\">") + if inject_parts: insert_at = head_match.end() - updated = updated[:insert_at] + base_tag + updated[insert_at:] + updated = updated[:insert_at] + "".join(inject_parts) + updated[insert_at:] return updated @@ -555,6 +660,7 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: """ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_uuid TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'user')), encrypted_challenge TEXT NOT NULL, @@ -571,6 +677,7 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: CREATE TABLE IF NOT EXISTS sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, + site_uuid TEXT UNIQUE NOT NULL, owner_user_id INTEGER NOT NULL, slug TEXT UNIQUE NOT NULL, storage_key TEXT UNIQUE NOT NULL, @@ -586,6 +693,54 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: """ ) + # migration for existing installs - add and backfill uuid ids for users. + user_columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(users)").fetchall() + } + if "user_uuid" not in user_columns: + conn.execute("ALTER TABLE users ADD COLUMN user_uuid TEXT") + + missing_user_ids = conn.execute( + """ + SELECT id FROM users + WHERE user_uuid IS NULL OR user_uuid = '' + """ + ).fetchall() + for row in missing_user_ids: + conn.execute( + "UPDATE users SET user_uuid = ? WHERE id = ?", + (str(uuid.uuid4()), row["id"]), + ) + + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_user_uuid ON users(user_uuid)" + ) + + # migration for existing installs - add and backfill uuid ids for sites. + site_columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(sites)").fetchall() + } + if "site_uuid" not in site_columns: + conn.execute("ALTER TABLE sites ADD COLUMN site_uuid TEXT") + + missing_site_ids = conn.execute( + """ + SELECT id FROM sites + WHERE site_uuid IS NULL OR site_uuid = '' + """ + ).fetchall() + for row in missing_site_ids: + conn.execute( + "UPDATE sites SET site_uuid = ? WHERE id = ?", + (str(uuid.uuid4()), row["id"]), + ) + + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_sites_site_uuid ON sites(site_uuid)" + ) + # auth provider is injected as a single backend to keep swap-over simple. auth_backend: AuthBackend = LocalMojicryptAuthBackend( connect_db=connect_db, @@ -625,7 +780,7 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (now,)) return conn.execute( """ - SELECT u.id, u.username, u.role + SELECT u.id AS internal_id, u.user_uuid AS id, u.username, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? @@ -738,6 +893,15 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: def healthz() -> tuple[str, int]: return "ok", 200 + @app.get("/app/noir-overrides.css") + def noir_overrides_css() -> Response: + css_path = cfg.base_dir / "noir-overrides.css" + if not css_path.is_file(): + abort(404) + response = make_response(send_file(css_path, mimetype="text/css", conditional=True)) + response.headers["Cache-Control"] = "no-cache" + return response + @app.get("/app") def dashboard() -> Response: if auth_backend.bootstrap_required(): @@ -751,24 +915,24 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: if is_admin: sites = conn.execute( """ - SELECT s.id, s.slug, s.original_filename, s.created_at, u.username AS owner_username + SELECT s.site_uuid AS id, s.slug, s.original_filename, s.created_at, u.username AS owner_username FROM sites s JOIN users u ON u.id = s.owner_user_id ORDER BY s.id DESC """ ).fetchall() users = conn.execute( - "SELECT id, username, role, created_at FROM users ORDER BY id ASC" + "SELECT user_uuid AS id, username, role, created_at FROM users ORDER BY id ASC" ).fetchall() else: sites = conn.execute( """ - SELECT id, slug, original_filename, created_at + SELECT site_uuid AS id, slug, original_filename, created_at FROM sites WHERE owner_user_id = ? ORDER BY id DESC """, - (g.current_user["id"],), + (g.current_user["internal_id"],), ).fetchall() users = [] @@ -896,6 +1060,7 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: site_root = find_site_root(extract_dir) slug = reserve_random_slug(connect_db) + site_uuid = str(uuid.uuid4()) storage_key = secrets.token_hex(24) final_dir = cfg.sites_dir / storage_key @@ -904,11 +1069,12 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: with connect_db() as conn: conn.execute( """ - INSERT INTO sites (owner_user_id, slug, storage_key, original_filename, created_at, updated_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO sites (site_uuid, owner_user_id, slug, storage_key, original_filename, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) """, ( - int(g.current_user["id"]), + site_uuid, + int(g.current_user["internal_id"]), slug, storage_key, Path(archive.filename).name, @@ -935,15 +1101,15 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: flash("site uploaded", "success") return redirect(url_for("dashboard")) - @app.post("/app/sites/<int:site_id>/delete") - def delete_site(site_id: int) -> Response: + @app.post("/app/sites/<site_id>/delete") + def delete_site(site_id: str) -> Response: auth_redirect = require_auth() if auth_redirect is not None: return auth_redirect with connect_db() as conn: site = conn.execute( - "SELECT id, owner_user_id, slug, storage_key FROM sites WHERE id = ?", + "SELECT site_uuid, owner_user_id, slug, storage_key FROM sites WHERE site_uuid = ?", (site_id,), ).fetchone() if site is None: @@ -951,11 +1117,11 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: return redirect(url_for("dashboard")) is_admin = g.current_user["role"] == "admin" - if not is_admin and int(site["owner_user_id"]) != int(g.current_user["id"]): + if not is_admin and int(site["owner_user_id"]) != int(g.current_user["internal_id"]): flash("not allowed", "error") return redirect(url_for("dashboard")) - conn.execute("DELETE FROM sites WHERE id = ?", (site_id,)) + conn.execute("DELETE FROM sites WHERE site_uuid = ?", (site_id,)) shutil.rmtree(cfg.sites_dir / site["storage_key"], ignore_errors=True) flash("site deleted", "success") @@ -975,28 +1141,31 @@ 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/<int:user_id>/delete") - def admin_delete_user(user_id: int) -> Response: + @app.post("/app/admin/users/<user_id>/delete") + def admin_delete_user(user_id: str) -> Response: admin_redirect = require_admin() if admin_redirect is not None: return admin_redirect - if int(g.current_user["id"]) == int(user_id): + if g.current_user["id"] == user_id: flash("you cannot delete your own account", "error") return redirect(url_for("dashboard")) with connect_db() as conn: - user = conn.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone() + user = conn.execute( + "SELECT id AS internal_id, user_uuid, username FROM users WHERE user_uuid = ?", + (user_id,), + ).fetchone() if user is None: flash("user not found", "error") return redirect(url_for("dashboard")) owned_site_keys = conn.execute( "SELECT storage_key FROM sites WHERE owner_user_id = ?", - (user_id,), + (user["internal_id"],), ).fetchall() - conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.execute("DELETE FROM users WHERE id = ?", (user["internal_id"],)) for row in owned_site_keys: shutil.rmtree(cfg.sites_dir / row["storage_key"], ignore_errors=True) @@ -1004,8 +1173,8 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: flash("user deleted", "success") return redirect(url_for("dashboard")) - @app.post("/app/admin/sites/<int:site_id>/slug") - def admin_update_slug(site_id: int) -> Response: + @app.post("/app/admin/sites/<site_id>/slug") + def admin_update_slug(site_id: str) -> Response: admin_redirect = require_admin() if admin_redirect is not None: return admin_redirect @@ -1021,7 +1190,7 @@ def create_app(base_dir: Optional[Path] = None) -> Flask: """ UPDATE sites SET slug = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? + WHERE site_uuid = ? """, (new_slug, site_id), ) |
