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. * **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å * **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. 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 * **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. 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}$") USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# Reserverte navn som ville kollidert med URL-strukturen # 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(): def _cfg():
@ -434,6 +444,7 @@ def users():
elif action == "delete": elif action == "delete":
db.delete_user(g.db, int(request.form.get("user_id", 0))) db.delete_user(g.db, int(request.form.get("user_id", 0)))
flash("Brukeren er slettet. Innleggene ligger fortsatt i git.", "ok") flash("Brukeren er slettet. Innleggene ligger fortsatt i git.", "ok")
build_site(_cfg()) # /brukere/ genereres fra brukertabellen
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
return render_template("admin/users.html", users=db.list_users(g.db)) 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): if email and not EMAIL_RE.match(email):
flash("Ugyldig e-postadresse.", "error") flash("Ugyldig e-postadresse.", "error")
else: 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 build_site(_cfg()) # signaturen ligger i de genererte sidene
flash("Profilen er lagret.", "ok") flash("Profilen er lagret.", "ok")
return redirect(url_for("admin.profile")) 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)) _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 / "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). # Statiske filer lagvis: innebygd først, eget tema oppå (overskriver).
for d in reversed(cfg.theme_dirs): for d in reversed(cfg.theme_dirs):
@ -136,6 +155,23 @@ def build_site(cfg: Config) -> int:
return len(posts) 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: def _resolve_authors(cfg: Config, posts: list[Post]) -> None:
"""Fyller author_name/author_email fra brukertabellen. """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 Finnes ikke brukeren (f.eks. innhold klonet uten brukerdatabase), vises
verdien fra frontmatter som den er. verdien fra frontmatter som den er.
""" """
conn = db.connect(cfg.db_path) users = _load_users(cfg)
try:
users = db.authors(conn)
finally:
conn.close()
for p in posts: for p in posts:
key = p.author or (p.guest or "") key = p.author or (p.guest or "")
user = users.get(key) 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) 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_name = user.display_name if user else (p.author or p.guest or "")
p.author_email = user.email if user else "" 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.""" """sitemap.xml med forsiden, alle innlegg og gjesteoversiktene."""
base = cfg.site_url + "/" 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(p.url_path, p.updated or p.date) for p in posts]
entries += [url(f"{g}/") for g in guests] entries += [url(f"{g}/") for g in guests]
entries += [url(f"tag/{t}/") for t in tags] entries += [url(f"tag/{t}/") for t in tags]
entries += [url("brukere/")] + [url(f"brukere/{u}/") for u in users]
return ( return (
'<?xml version="1.0" encoding="UTF-8"?>\n' '<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' '<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: str = "" # brukernavn (eldre innlegg kan ha visningsnavn her)
author_name: str = "" # visningsnavn, fylles ut ved bygging fra brukertabellen author_name: str = "" # visningsnavn, fylles ut ved bygging fra brukertabellen
author_email: str = "" 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" tags: list[str] = field(default_factory=list) # visningsform, f.eks. "Friluftsliv"
guest: str | None = None # None for hovedbruker, ellers gjestens brukernavn guest: str | None = None # None for hovedbruker, ellers gjestens brukernavn
path: Path | None = None path: Path | None = None

View file

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

View file

@ -8,6 +8,8 @@
<input name="display_name" value="{{ me.display_name }}" aria-describedby="dn-hint"></label> <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> <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> <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> <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> <button type="submit" class="primary">Lagre</button>
</form> </form>

View file

@ -33,7 +33,7 @@
<form method="post" class="stack narrow"> <form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}"> <input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="action" value="create"> <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>Visningsnavn <input name="display_name"></label>
<label>E-post (valgfritt, vises offentlig) <input type="email" name="email"></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> <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); } .tags a:hover, .tags a:focus-visible { border-color: var(--accent); }
.tag-cloud { margin-top: 1rem; } .tag-cloud a { font-size: 1rem; } .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; } .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 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; } .body pre { background: var(--code-bg); padding: 1rem; overflow-x: auto; border-radius: 4px; }

View file

@ -28,7 +28,7 @@
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<nav aria-label="Bunnmeny"> <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> </nav>
{# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #} {# AGPL §13: brukere av tjenesten skal kunne hente kildekoden. #}
<p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p> <p class="colophon">Drevet av <a href="{{ source_url }}">Soapbox</a> (AGPL-3.0)</p>

View file

@ -32,7 +32,7 @@
</ul> </ul>
{% endif %} {% endif %}
{% if post.author_name %} {% 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> · <a href="mailto:{{ post.author_email }}">{{ post.author_email }}</a>{% endif %}</p>
{% endif %} {% endif %}
</article> </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}", f"/admin/edit/{post_id}",
data={"csrf": token, "title": "Sign", "body": "x", "action": "publish"}, 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 assert (cfg.content_dir / "posts/sign/index.md").read_text().count('author: "eier"') == 1
r = client.post( r = client.post(
@ -331,7 +333,7 @@ def test_signature_from_profile(client, cfg):
) )
assert r.status_code == 302 assert r.status_code == 302
html = client.get("/sign/").text 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 "<dc:creator>Ole M.</dc:creator>" in client.get("/feed.xml").text
assert ( assert (
client.post( client.post(
@ -362,7 +364,12 @@ def test_tags(client, cfg):
assert 'href="../tag/friluftsliv/"' in client.get("/en/").text assert 'href="../tag/friluftsliv/"' in client.get("/en/").text
page = client.get("/tag/friluftsliv/").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/skog/").status_code == 200
assert client.get("/tag/").status_code == 200 and "(2)" in client.get("/tag/").text assert client.get("/tag/").status_code == 200 and "(2)" in client.get("/tag/").text
assert "<category>skog</category>" in client.get("/feed.xml").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 # "tag" er reservert som slug
publish("tag", "") publish("tag", "")
assert not (cfg.content_dir / "posts/tag").exists() 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()