Brukersider: /brukere/ og /brukere/<brukernavn>/ med beskrivelse

Alle brukere kan skrive en valgfri beskrivelse (Markdown, uten rå HTML)
under Profil. /brukere/ lister hovedbruker øverst og gjester alfabetisk
etter visningsnavn; brukersiden viser navn, beskrivelse, e-post og
innlegg. Signaturen under innlegg lenker til brukersiden. «brukere» er
reservert. Siden bygges nå også når brukere opprettes/slettes.
Retter også ugyldig pattern-regex for brukernavn i admin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
Ole-Morten Duesund 2026-08-26 15:48:15 +02:00
commit 5263d3011d
13 changed files with 191 additions and 20 deletions

View file

@ -29,6 +29,8 @@ data/
* **Gjester** ser og redigerer bare egne innlegg. Hovedbrukeren ser alt og administrerer gjester.
* **Tagger** settes kommaseparert i editoren. `/tag/<tagg>/` lister alle innlegg med taggen (også
gjesters), `/tag/` alle tagger. Tagger får slug på samme måte som titler.
* **Brukersider**: `/brukere/` lister hovedbruker øverst og gjester alfabetisk; `/brukere/<navn>/`
viser visningsnavn, beskrivelse (Markdown, settes under Profil), e-post og innlegg.
* **Signatur**: innlegg avsluttes med «– Visningsnavn», evt. med e-post som mailto-lenke. Begge
settes under Profil i admin og slås opp ved bygging, så en endring gjelder alle innlegg.

View file

@ -64,7 +64,17 @@ bp = Blueprint(
USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# Reserverte navn som ville kollidert med URL-strukturen
RESERVED = {"admin", "theme", "tag", "feed.xml", "sitemap.xml", "posts", "guests", "index.html"}
RESERVED = {
"admin",
"theme",
"tag",
"brukere",
"feed.xml",
"sitemap.xml",
"posts",
"guests",
"index.html",
}
def _cfg():
@ -434,6 +444,7 @@ def users():
elif action == "delete":
db.delete_user(g.db, int(request.form.get("user_id", 0)))
flash("Brukeren er slettet. Innleggene ligger fortsatt i git.", "ok")
build_site(_cfg()) # /brukere/ genereres fra brukertabellen
return redirect(url_for("admin.users"))
return render_template("admin/users.html", users=db.list_users(g.db))
@ -463,7 +474,13 @@ def profile():
if email and not EMAIL_RE.match(email):
flash("Ugyldig e-postadresse.", "error")
else:
db.update_profile(g.db, g.user.id, request.form.get("display_name", ""), email)
db.update_profile(
g.db,
g.user.id,
request.form.get("display_name", ""),
email,
request.form.get("bio", "").replace("\r\n", "\n"),
)
build_site(_cfg()) # signaturen ligger i de genererte sidene
flash("Profilen er lagret.", "ok")
return redirect(url_for("admin.profile"))

View file

@ -116,8 +116,27 @@ def build_site(cfg: Config) -> int:
)
_write(tmp / "tag" / "index.html", render("tags.html", "tag/", tags=tags))
# Brukere: /brukere/ (hovedbruker øverst) og /brukere/<brukernavn>/ med bio og innlegg
users = _users_sorted(_load_users(cfg))
_write(tmp / "brukere" / "index.html", render("users.html", "brukere/", users=users))
for u in users:
uposts = [p for p in posts if (p.guest or None) == (None if u.is_owner else u.username)]
_write(
tmp / "brukere" / u.username / "index.html",
render(
"user.html",
f"brukere/{u.username}/",
user=u,
posts=uposts,
bio_html=render_markdown(u.bio, f"brukere/{u.username}/") if u.bio else "",
),
)
_write(tmp / "feed.xml", _feed(cfg, site, posts[:20]))
_write(tmp / "sitemap.xml", _sitemap(cfg, posts, sorted(guests), sorted(tags)))
_write(
tmp / "sitemap.xml",
_sitemap(cfg, posts, sorted(guests), sorted(tags), [u.username for u in users]),
)
# Statiske filer lagvis: innebygd først, eget tema oppå (overskriver).
for d in reversed(cfg.theme_dirs):
@ -136,6 +155,23 @@ def build_site(cfg: Config) -> int:
return len(posts)
def _load_users(cfg: Config) -> dict[str, db.User]:
conn = db.connect(cfg.db_path)
try:
return db.authors(conn)
finally:
conn.close()
def _users_sorted(users: dict[str, db.User]) -> list[db.User]:
"""Hovedbruker først, deretter gjester alfabetisk etter visningsnavn."""
owners = [u for u in users.values() if u.is_owner]
guests = sorted(
(u for u in users.values() if not u.is_owner), key=lambda u: u.display_name.casefold()
)
return owners + guests
def _resolve_authors(cfg: Config, posts: list[Post]) -> None:
"""Fyller author_name/author_email fra brukertabellen.
@ -144,11 +180,7 @@ def _resolve_authors(cfg: Config, posts: list[Post]) -> None:
Finnes ikke brukeren (f.eks. innhold klonet uten brukerdatabase), vises
verdien fra frontmatter som den er.
"""
conn = db.connect(cfg.db_path)
try:
users = db.authors(conn)
finally:
conn.close()
users = _load_users(cfg)
for p in posts:
key = p.author or (p.guest or "")
user = users.get(key)
@ -156,9 +188,12 @@ def _resolve_authors(cfg: Config, posts: list[Post]) -> None:
user = next((u for u in users.values() if u.is_owner), None)
p.author_name = user.display_name if user else (p.author or p.guest or "")
p.author_email = user.email if user else ""
p.author_username = user.username if user else ""
def _sitemap(cfg: Config, posts: list[Post], guests: list[str], tags: list[str]) -> str:
def _sitemap(
cfg: Config, posts: list[Post], guests: list[str], tags: list[str], users: list[str]
) -> str:
"""sitemap.xml med forsiden, alle innlegg og gjesteoversiktene."""
base = cfg.site_url + "/"
@ -172,6 +207,7 @@ def _sitemap(cfg: Config, posts: list[Post], guests: list[str], tags: list[str])
entries += [url(p.url_path, p.updated or p.date) for p in posts]
entries += [url(f"{g}/") for g in guests]
entries += [url(f"tag/{t}/") for t in tags]
entries += [url("brukere/")] + [url(f"brukere/{u}/") for u in users]
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'

View file

@ -40,6 +40,7 @@ class Post:
author: str = "" # brukernavn (eldre innlegg kan ha visningsnavn her)
author_name: str = "" # visningsnavn, fylles ut ved bygging fra brukertabellen
author_email: str = ""
author_username: str = "" # for lenke til /brukere/<brukernavn>/, tomt hvis ukjent
tags: list[str] = field(default_factory=list) # visningsform, f.eks. "Friluftsliv"
guest: str | None = None # None for hovedbruker, ellers gjestens brukernavn
path: Path | None = None

View file

@ -41,6 +41,7 @@ class User:
display_name: str
role: str
email: str = ""
bio: str = ""
@property
def is_owner(self) -> bool:
@ -61,7 +62,9 @@ def connect(path: Path) -> sqlite3.Connection:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)")}
if "email" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''")
conn.commit()
if "bio" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN bio TEXT NOT NULL DEFAULT ''")
conn.commit()
return conn
@ -93,7 +96,9 @@ def verify_password(password: str, stored: str) -> bool:
def _row_to_user(row: sqlite3.Row | None) -> User | None:
if row is None:
return None
return User(row["id"], row["username"], row["display_name"], row["role"], row["email"])
return User(
row["id"], row["username"], row["display_name"], row["role"], row["email"], row["bio"]
)
def create_user(
@ -130,10 +135,12 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> User
return None
def update_profile(conn: sqlite3.Connection, user_id: int, display_name: str, email: str) -> None:
def update_profile(
conn: sqlite3.Connection, user_id: int, display_name: str, email: str, bio: str = ""
) -> None:
conn.execute(
"UPDATE users SET display_name=?, email=? WHERE id=?",
(display_name.strip(), email.strip(), user_id),
"UPDATE users SET display_name=?, email=?, bio=? WHERE id=?",
(display_name.strip(), email.strip(), bio.strip(), user_id),
)
conn.commit()

View file

@ -8,6 +8,8 @@
<input name="display_name" value="{{ me.display_name }}" aria-describedby="dn-hint"></label>
<label>E-post <span class="hint" id="em-hint">Valgfritt. Vises offentlig ved signaturen så lesere kan kontakte deg.</span>
<input type="email" name="email" value="{{ me.email }}" autocomplete="email" aria-describedby="em-hint"></label>
<label>Om deg <span class="hint" id="bio-hint">Valgfritt, Markdown. Vises på din side under /brukere/.</span>
<textarea name="bio" rows="5" aria-describedby="bio-hint">{{ me.bio }}</textarea></label>
<p class="meta">Brukernavn: <code>{{ me.username }}</code>{% if not me.is_owner %} (URL-prefiks /{{ me.username }}/){% endif %}</p>
<button type="submit" class="primary">Lagre</button>
</form>

View file

@ -33,7 +33,7 @@
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="action" value="create">
<label>Brukernavn (blir URL: /brukernavn/) <input name="username" pattern="[a-z0-9][a-z0-9-]{1,30}" required autocapitalize="none"></label>
<label>Brukernavn (blir URL: /brukernavn/) <input name="username" pattern="[a-z0-9][a-z0-9\-]{1,30}" required autocapitalize="none"></label>
<label>Visningsnavn <input name="display_name"></label>
<label>E-post (valgfritt, vises offentlig) <input type="email" name="email"></label>
<label>Passord <input type="password" name="password" minlength="8" required autocomplete="new-password"></label>

View file

@ -89,6 +89,13 @@ h2 { font-size: 1.4rem; line-height: 1.25; margin: 2rem 0 .5rem; }
}
.tags a:hover, .tags a:focus-visible { border-color: var(--accent); }
.tag-cloud { margin-top: 1rem; } .tag-cloud a { font-size: 1rem; }
.users { list-style: none; padding: 0; }
.users li { padding: 1rem 0; border-bottom: 1px solid var(--border); }
.users li a { font-size: 1.2rem; font-weight: 600; text-decoration: none; color: var(--accent-strong); }
.users .excerpt { margin: .25rem 0 0; }
.summary h3 { font-size: 1.2rem; margin: 0 0 .25rem; }
.summary h3 a { color: var(--accent-strong); text-decoration: none; }
.signature a { color: inherit; }
.signature { margin: 1.5rem 0 0; color: var(--muted); font-style: italic; }
.body img { max-width: 100%; height: auto; display: block; margin: 1.5rem 0; border-radius: 4px; }
.body pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; }

View file

@ -28,7 +28,7 @@
</main>
<footer class="site-footer">
<nav aria-label="Bunnmeny">
<a href="{{ rel('') }}">Forsiden</a><a href="{{ rel('tag/') }}">Tagger</a><a href="{{ rel('feed.xml') }}">RSS</a><a href="{{ rel('admin/') }}">Logg inn</a>
<a href="{{ rel('') }}">Forsiden</a><a href="{{ rel('tag/') }}">Tagger</a><a href="{{ rel('brukere/') }}">Brukere</a><a href="{{ rel('feed.xml') }}">RSS</a><a href="{{ rel('admin/') }}">Logg inn</a>
</nav>
{# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #}
<p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p>

View file

@ -32,7 +32,7 @@
</ul>
{% endif %}
{% if post.author_name %}
<p class="signature"> {{ post.author_name }}{% if post.author_email %}
<p class="signature"> {% if post.author_username %}<a href="{{ rel('brukere/' ~ post.author_username ~ '/') }}">{{ post.author_name }}</a>{% else %}{{ post.author_name }}{% endif %}{% if post.author_email %}
· <a href="mailto:{{ post.author_email }}">{{ post.author_email }}</a>{% endif %}</p>
{% endif %}
</article>

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}{{ user.display_name }} {{ site.title }}{% endblock %}
{% block og %}
<meta property="og:type" content="profile">
<meta property="og:site_name" content="{{ site.title }}">
<meta property="og:title" content="{{ user.display_name }}">
<meta property="og:url" content="{{ site_url }}/brukere/{{ user.username }}/">
{% if user.bio %}<meta property="og:description" content="{{ user.bio|excerpt(200) }}">{% endif %}
{% endblock %}
{% block content %}
<article class="profile">
<h1>{{ user.display_name }}</h1>
<p class="meta">
{% if user.is_owner %}Hovedbruker{% else %}Gjest{% endif %}
{% if user.email %} · <a href="mailto:{{ user.email }}">{{ user.email }}</a>{% endif %}
</p>
{% if bio_html %}<div class="body">{{ bio_html|safe }}</div>{% endif %}
</article>
<h2>Innlegg{% if posts %} ({{ posts|length }}){% endif %}</h2>
{% if not posts %}<p>Ingen innlegg ennå.</p>{% endif %}
{% for post in posts %}
<article class="summary">
<h3><a href="{{ rel(post.url_path) }}">{{ post.title }}</a></h3>
<p class="meta"><time datetime="{{ post.date.isoformat() }}">{{ post.date|date }}</time></p>
</article>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Brukere {{ site.title }}{% endblock %}
{% block content %}
<h1>Brukere</h1>
<ul class="users">
{% for u in users %}
<li>
<a href="{{ rel('brukere/' ~ u.username ~ '/') }}">{{ u.display_name }}</a>
{% if u.is_owner %}<span class="meta">· hovedbruker</span>{% endif %}
{% if u.bio %}<p class="excerpt">{{ u.bio|excerpt(160) }}</p>{% endif %}
</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -323,7 +323,9 @@ def test_signature_from_profile(client, cfg):
f"/admin/edit/{post_id}",
data={"csrf": token, "title": "Sign", "body": "x", "action": "publish"},
)
assert " Ole" in client.get("/sign/").text # visningsnavn fra brukertabellen
assert re.search(
r" <a[^>]*>Ole</a>", client.get("/sign/").text
) # visningsnavn fra brukertabellen
assert (cfg.content_dir / "posts/sign/index.md").read_text().count('author: "eier"') == 1
r = client.post(
@ -331,7 +333,7 @@ def test_signature_from_profile(client, cfg):
)
assert r.status_code == 302
html = client.get("/sign/").text
assert " Ole M." in html and 'href="mailto:ole@example.no"' in html
assert ">Ole M.</a>" in html and 'href="mailto:ole@example.no"' in html
assert "<dc:creator>Ole M.</dc:creator>" in client.get("/feed.xml").text
assert (
client.post(
@ -362,7 +364,12 @@ def test_tags(client, cfg):
assert 'href="../tag/friluftsliv/"' in client.get("/en/").text
page = client.get("/tag/friluftsliv/").text
assert "tagg: friluftsliv" in page.lower() and ">En<" in page and ">To<" in page and ">Tre<" not in page
assert (
"tagg: friluftsliv" in page.lower()
and ">En<" in page
and ">To<" in page
and ">Tre<" not in page
)
assert client.get("/tag/skog/").status_code == 200
assert client.get("/tag/").status_code == 200 and "(2)" in client.get("/tag/").text
assert "<category>skog</category>" in client.get("/feed.xml").text
@ -370,3 +377,54 @@ def test_tags(client, cfg):
# "tag" er reservert som slug
publish("tag", "")
assert not (cfg.content_dir / "posts/tag").exists()
def test_user_pages(client, cfg):
login(client)
token = csrf(client)
client.post(
"/admin/profile",
data={
"csrf": token,
"display_name": "Ole",
"email": "",
"bio": "Skriver om **skog**.\n\n<b>x</b>",
},
)
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "Mitt", "body": "x", "action": "publish"},
)
client.post(
"/admin/users",
data={
"csrf": token,
"action": "create",
"username": "aa",
"display_name": "Åse",
"password": "passord123",
},
)
lst = client.get("/brukere/").text
order = [n for n in ("Ole", "Gjest En", "Åse") if n in lst]
assert [lst.index(n) for n in order] == sorted(
lst.index(n) for n in order
) # eier, så Gjest En, Åse
assert lst.index("Ole") < lst.index("Gjest En") < lst.index("Åse")
page = client.get("/brukere/eier/").text
assert "<strong>skog</strong>" in page and "&lt;b&gt;" in page # markdown, ikke rå HTML
assert ">Mitt<" in page and "Hovedbruker" in page
assert "Ingen innlegg ennå" in client.get("/brukere/gjest1/").text
assert 'href="../brukere/eier/"' in client.get("/mitt/").text # signatur lenker til brukersiden
assert "https://blog.example.no/brukere/eier/" in client.get("/sitemap.xml").text
# "brukere" er reservert som slug
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "brukere", "body": "x", "action": "publish"},
)
assert not (cfg.content_dir / "posts/brukere").exists()