aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-04-03 01:27:11 -0400
committerkj_sh6042026-04-03 01:27:11 -0400
commitebfd27170bdb4426af2a284a85d195a3cbe9611b (patch)
tree208658569e128312e818610139986fe570951803
parentb5b1a685bf9c2dd172545091c049717360a23648 (diff)
refactor: usability and styling simplicity
-rw-r--r--auth_backend.py7
-rw-r--r--noir-overrides.css80
-rw-r--r--shim_app.py305
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&&current.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&&current.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),
)