Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions jottit/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,111 @@ def update_site(
conn.execute(update(sites).where(sites.c.id == site_id).values(**values))


def change_public_url(conn: Connection, *, site_id: int, public_url: str | None) -> None:
"""Set (or clear, with `None`) the public subdomain slug for a site.

The `sites.public_url` column has an index but no uniqueness constraint —
callers (admin views) are responsible for checking availability first.
"""
conn.execute(update(sites).where(sites.c.id == site_id).values(public_url=public_url or None))


def is_public_url_available(conn: Connection, *, public_url: str) -> bool:
"""True if `public_url` is free to claim as a subdomain slug.

Reserved names (`www`, `signin`, etc.) and existing site rows both make
a slug unavailable; the admin's "is this URL available?" probe and the
settings POST both fan in here.
"""
if public_url in RESERVED_PUBLIC_URLS:
return False
existing = conn.execute(select(sites.c.id).where(sites.c.public_url == public_url)).first()
return existing is None


def delete_site(conn: Connection, *, site_id: int) -> None:
"""Soft-delete: marks the site `deleted=true` and frees its public_url.

Pages, revisions, and drafts are left intact so a future admin path
could revive the site. Clearing public_url so the subdomain slug can
be re-claimed mirrors the original behavior.
"""
conn.execute(update(sites).where(sites.c.id == site_id).values(deleted=True, public_url=None))


# ---- Design ----


def get_design(conn: Connection, *, site_id: int) -> Row | None:
return conn.execute(select(designs).where(designs.c.site_id == site_id).limit(1)).first()


_DESIGN_FIELDS = (
"title_font",
"subtitle_font",
"headings_font",
"content_font",
"header_color",
"title_color",
"subtitle_color",
"title_size",
"subtitle_size",
"headings_size",
"content_size",
"hue",
"brightness",
)


def update_design(conn: Connection, *, site_id: int, **fields: object) -> None:
"""Patch the design row for a site. Unknown keys are ignored."""
values = {k: v for k, v in fields.items() if k in _DESIGN_FIELDS and v is not None}
if not values:
return
conn.execute(update(designs).where(designs.c.site_id == site_id).values(**values))


# ---- Export ----


def get_pages_for_export(conn: Connection, *, site_id: int) -> list[Row]:
"""Latest non-deleted pages with their latest revision content, for export.

Returns rows shaped like (page_name, content, updated). Used by the
/admin/export endpoint to build the markdown bundle.
"""
latest_revision = (
select(
revisions.c.page_id,
func.max(revisions.c.revision).label("max_revision"),
)
.group_by(revisions.c.page_id)
.subquery()
)
stmt = (
select(
pages.c.name.label("page_name"),
revisions.c.content,
revisions.c.created.label("updated"),
)
.select_from(
pages.join(latest_revision, latest_revision.c.page_id == pages.c.id).join(
revisions,
(revisions.c.page_id == pages.c.id)
& (revisions.c.revision == latest_revision.c.max_revision),
)
)
.where(pages.c.site_id == site_id, pages.c.deleted.is_(False))
.order_by(pages.c.name)
)
return list(conn.execute(stmt).all())


# Reserved slugs that can't be used as subdomain public_urls (would shadow
# apex routes or are otherwise sensitive). Mirrors the 2007 list.
RESERVED_PUBLIC_URLS = frozenset({"www", "internal", "new", "signin"})


def new_site(
conn: Connection,
*,
Expand Down
8 changes: 6 additions & 2 deletions jottit/site_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def resolve_site() -> None:
return

if request.blueprint == "secret":
g.site = get_site(conn, secret_url=g.site_slug)
site = get_site(conn, secret_url=g.site_slug)
else:
g.site = get_site(conn, public_url=g.site_slug)
site = get_site(conn, public_url=g.site_slug)
# Deleted sites stay in the database (so an admin restore path is
# possible later) but are invisible to the resolver — every request
# for one hits the same 404 as a never-existed slug.
g.site = site if site is not None and not site.deleted else None
25 changes: 25 additions & 0 deletions jottit/templates/admin_change_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Change password</title>
</head>
<body>
<h1>Change password</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post">
<p>
<label>Current password<br>
<input type="password" name="current_password" required autofocus>
</label>
</p>
<p>
<label>New password<br>
<input type="password" name="new_password" required>
</label>
</p>
<p><button type="submit">Change password</button></p>
</form>
<p><a href="settings">Back to settings</a></p>
</body>
</html>
21 changes: 21 additions & 0 deletions jottit/templates/admin_change_site_address.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Change site address</title>
</head>
<body>
<h1>Change site address</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post">
<p>
<label>Site address<br>
<input type="text" name="public_url" value="{{ public_url }}" autofocus>
</label>
<small>Letters, numbers, and dashes. Leave blank to remove the subdomain.</small>
</p>
<p><button type="submit">Save</button></p>
</form>
<p><a href="settings">Back to settings</a></p>
</body>
</html>
15 changes: 15 additions & 0 deletions jottit/templates/admin_delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Delete site</title>
</head>
<body>
<h1>Delete site</h1>
<p>This will remove the site from public listings and free its subdomain. Pages and revisions stay on file in case we ever add a restore path.</p>
<form method="post">
<p><button type="submit">Delete this site</button></p>
</form>
<p><a href="settings">Cancel</a></p>
</body>
</html>
44 changes: 44 additions & 0 deletions jottit/templates/admin_design.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Design</title>
</head>
<body>
<h1>Design</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post">
<fieldset>
<legend>Fonts</legend>
<p><label>Title font <input type="text" name="title_font" value="{{ design.title_font }}"></label></p>
<p><label>Subtitle font <input type="text" name="subtitle_font" value="{{ design.subtitle_font }}"></label></p>
<p><label>Headings font <input type="text" name="headings_font" value="{{ design.headings_font }}"></label></p>
<p><label>Content font <input type="text" name="content_font" value="{{ design.content_font }}"></label></p>
</fieldset>

<fieldset>
<legend>Colors</legend>
<p><label>Header color <input type="text" name="header_color" value="{{ design.header_color }}" placeholder="#003452"></label></p>
<p><label>Title color <input type="text" name="title_color" value="{{ design.title_color }}" placeholder="#fff"></label></p>
<p><label>Subtitle color <input type="text" name="subtitle_color" value="{{ design.subtitle_color }}" placeholder="#bfe8ff"></label></p>
</fieldset>

<fieldset>
<legend>Sizes (percent)</legend>
<p><label>Title size <input type="number" name="title_size" value="{{ design.title_size }}" min="25" max="500"></label></p>
<p><label>Subtitle size <input type="number" name="subtitle_size" value="{{ design.subtitle_size }}" min="25" max="500"></label></p>
<p><label>Headings size <input type="number" name="headings_size" value="{{ design.headings_size }}" min="25" max="500"></label></p>
<p><label>Content size <input type="number" name="content_size" value="{{ design.content_size }}" min="25" max="500"></label></p>
</fieldset>

<fieldset>
<legend>Background</legend>
<p><label>Hue (0–360) <input type="number" name="hue" value="{{ design.hue }}" min="0" max="360"></label></p>
<p><label>Brightness (0–300) <input type="number" name="brightness" value="{{ design.brightness }}" min="0" max="300"></label></p>
</fieldset>

<p><button type="submit">Save design</button></p>
</form>
<p><a href="settings">Back to settings</a></p>
</body>
</html>
37 changes: 37 additions & 0 deletions jottit/templates/admin_settings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Settings</title>
</head>
<body>
<h1>Settings</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post">
<p>
<label>Title<br>
<input type="text" name="title" value="{{ title }}">
</label>
</p>
<p>
<label>Subtitle<br>
<input type="text" name="subtitle" value="{{ subtitle }}">
</label>
</p>
<p>
<label>Email<br>
<input type="email" name="email" value="{{ email }}">
</label>
</p>
<fieldset>
<legend>Security</legend>
<p><label><input type="radio" name="security" value="private" {% if security == "private" %}checked{% endif %}> Private — only you can view or edit</label></p>
<p><label><input type="radio" name="security" value="public" {% if security == "public" %}checked{% endif %}> Public — anyone can view, only you can edit</label></p>
<p><label><input type="radio" name="security" value="open" {% if security == "open" %}checked{% endif %}> Open — anyone can view or edit</label></p>
</fieldset>
<p><button type="submit">Save settings</button></p>
</form>
<p><a href="change-site-address">Site address: {{ public_url or "(none)" }}</a></p>
<p><a href="change-password">Change password</a></p>
</body>
</html>
Loading
Loading