Visningsnavn og e-post i profil, signatur under innlegg

Alle brukere kan sette visningsnavn og valgfri e-post under Profil i
admin. Innlegg avsluttes med «– Visningsnavn», med e-post som mailto-
lenke hvis satt. Frontmatter lagrer nå brukernavnet (stabilt), og
visningsnavn/e-post slås opp i brukertabellen ved bygging, så en
endring gjelder alle innlegg. Eldre innlegg med visningsnavn i author-
feltet vises uendret. Brukertabellen migreres automatisk (email).

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:37:27 +02:00
commit 259465342b
12 changed files with 123 additions and 10 deletions

View file

@ -27,6 +27,8 @@ data/
«Historikk»-seksjon i editoren med diff per endring; hele loggen ligger under Innstillinger
(eller `git -C data/content log`).
* **Gjester** ser og redigerer bare egne innlegg. Hovedbrukeren ser alt og administrerer gjester.
* **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.
## Kjøre lokalt

View file

@ -61,6 +61,7 @@ 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", "feed.xml", "posts", "guests", "index.html"}
@ -244,7 +245,7 @@ def new():
body="",
date=_now(),
draft=True,
author=g.user.display_name,
author=g.user.username,
guest=guest,
)
save_post(cfg, post)
@ -407,13 +408,16 @@ def users():
flash("Passordet må være minst 8 tegn.", "error")
else:
try:
db.create_user(
user = db.create_user(
g.db,
username,
password,
db.ROLE_GUEST,
request.form.get("display_name", ""),
)
email = request.form.get("email", "").strip()
if email and EMAIL_RE.match(email):
db.update_profile(g.db, user.id, user.display_name, email)
flash(f"Gjestebruker {username} opprettet.", "ok")
except sqlite3.IntegrityError:
flash("Brukernavnet er allerede i bruk.", "error")
@ -448,6 +452,22 @@ def password():
return render_template("admin/password.html")
@bp.route("/profile", methods=["GET", "POST"])
@login_required
def profile():
"""Visningsnavn (signatur på innlegg) og e-post (vises offentlig hvis satt)."""
if request.method == "POST":
email = request.form.get("email", "").strip()
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)
build_site(_cfg()) # signaturen ligger i de genererte sidene
flash("Profilen er lagret.", "ok")
return redirect(url_for("admin.profile"))
return render_template("admin/profile.html", me=db.get_user(g.db, g.user.id))
@bp.route("/settings", methods=["GET", "POST"])
@owner_required
def settings():

View file

@ -23,6 +23,7 @@ from xml.sax.saxutils import escape
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
from . import db
from .config import Config, load_site_settings
from .content import IMAGE_EXT, Post, list_posts
from .render import excerpt, relative_to, render_markdown
@ -50,6 +51,7 @@ def build_site(cfg: Config) -> int:
site = load_site_settings(cfg)
env = _env(cfg)
posts = list_posts(cfg)
_resolve_authors(cfg, posts)
out = cfg.public_dir
# Bygg i en midlertidig katalog og bytt atomisk, så besøkende aldri ser
@ -90,7 +92,7 @@ def build_site(cfg: Config) -> int:
for guest, gposts in guests.items():
_write(
tmp / guest / "index.html",
render("index.html", f"{guest}/", posts=gposts, heading=gposts[0].author or guest),
render("index.html", f"{guest}/", posts=gposts, heading=gposts[0].author_name or guest),
)
_write(tmp / "feed.xml", _feed(cfg, site, posts[:20]))
@ -113,6 +115,28 @@ def build_site(cfg: Config) -> int:
return len(posts)
def _resolve_authors(cfg: Config, posts: list[Post]) -> None:
"""Fyller author_name/author_email fra brukertabellen.
Frontmatter lagrer brukernavnet (stabilt); visningsnavn og e-post kan
endres i profilen og slår da gjennom alle innlegg ved neste bygg.
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()
for p in posts:
key = p.author or (p.guest or "")
user = users.get(key)
if user is None and p.guest is 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 ""
def _sitemap(cfg: Config, posts: list[Post], guests: list[str]) -> str:
"""sitemap.xml med forsiden, alle innlegg og gjesteoversiktene."""
base = cfg.site_url + "/"
@ -145,7 +169,7 @@ def _feed(cfg: Config, site: dict, posts: list[Post]) -> str:
f"<link>{escape(link)}</link>"
f"<guid>{escape(link)}</guid>"
f"<pubDate>{format_datetime(p.date)}</pubDate>"
+ (f"<dc:creator>{escape(p.author)}</dc:creator>" if p.author else "")
+ (f"<dc:creator>{escape(p.author_name)}</dc:creator>" if p.author_name else "")
+ f"<description>{escape(excerpt(p.body))}</description>"
"</item>"
)

View file

@ -37,7 +37,9 @@ class Post:
date: datetime
updated: datetime | None = None
draft: bool = False
author: str = "" # brukernavn; tomt for hovedbruker
author: str = "" # brukernavn (eldre innlegg kan ha visningsnavn her)
author_name: str = "" # visningsnavn, fylles ut ved bygging fra brukertabellen
author_email: str = ""
guest: str | None = None # None for hovedbruker, ellers gjestens brukernavn
path: Path | None = None
images: list[str] = field(default_factory=list)

View file

@ -40,6 +40,7 @@ class User:
username: str
display_name: str
role: str
email: str = ""
@property
def is_owner(self) -> bool:
@ -56,6 +57,11 @@ def connect(path: Path) -> sqlite3.Connection:
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(SCHEMA)
# Enkel migrering: kolonner lagt til etter første versjon
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()
return conn
@ -87,7 +93,7 @@ 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"])
return User(row["id"], row["username"], row["display_name"], row["role"], row["email"])
def create_user(
@ -124,6 +130,19 @@ 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:
conn.execute(
"UPDATE users SET display_name=?, email=? WHERE id=?",
(display_name.strip(), email.strip(), user_id),
)
conn.commit()
def authors(conn: sqlite3.Connection) -> dict[str, User]:
"""Brukernavn → bruker, til oppslag av visningsnavn/e-post ved bygging."""
return {u.username: u for u in list_users(conn)}
def password_stamp(conn: sqlite3.Connection, user_id: int) -> str | None:
"""Kort, ikke-hemmelig fingeravtrykk av passord-hashen, lagret i sesjonen
slik at et passordbytte ugyldiggjør alle eksisterende sesjoner."""

View file

@ -18,6 +18,7 @@
<a href="{{ url_for('admin.users') }}">Brukere</a>
<a href="{{ url_for('admin.settings') }}">Innstillinger</a>
{% endif %}
<a href="{{ url_for('admin.profile') }}">Profil</a>
<a href="{{ url_for('admin.password') }}">Passord</a>
<a href="{{ url_for('public') }}">Se bloggen</a>
<form method="post" action="{{ url_for('admin.logout') }}" class="inline">

View file

@ -0,0 +1,14 @@
{% extends "admin/base.html" %}
{% block title %}Profil{% endblock %}
{% block content %}
<h1>Profil</h1>
<form method="post" class="stack narrow">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label>Visningsnavn <span class="hint" id="dn-hint">Brukes som signatur («– Navn») under innleggene dine.</span>
<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>
<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>
{% endblock %}

View file

@ -5,7 +5,7 @@
<ul class="users">
{% for u in users %}
<li>
<strong>{{ u.display_name }}</strong> <code>{{ u.username }}</code>
<strong>{{ u.display_name }}</strong> <code>{{ u.username }}</code>{% if u.email %} <span class="meta">{{ u.email }}</span>{% endif %}
{% if u.is_owner %}<span class="badge">hovedbruker</span>{% else %}<span class="meta">/{{ u.username }}/</span>{% endif %}
{% if not u.is_owner %}
<details>
@ -35,6 +35,7 @@
<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>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>
<button type="submit" class="primary">Opprett</button>
</form>

View file

@ -82,6 +82,7 @@ h2 { font-size: 1.4rem; line-height: 1.25; margin: 2rem 0 .5rem; }
.summary .meta { margin: 0; }
.summary .excerpt { margin: .5rem 0 0; color: var(--fg); }
.body p, .body ul, .body ol { margin: 0 0 1.1rem; }
.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; }
.body code { background: var(--code-bg); padding: .1em .3em; border-radius: 3px; font-size: .95em; overflow-wrap: anywhere; }

View file

@ -8,7 +8,7 @@
<h2><a href="{{ rel(post.url_path) }}">{{ post.title }}</a></h2>
<p class="meta">
<time datetime="{{ post.date.isoformat() }}">{{ post.date|date }}</time>
{% if post.guest %} · <a href="{{ rel(post.guest ~ '/') }}">{{ post.author or post.guest }}</a>{% endif %}
{% if post.guest %} · <a href="{{ rel(post.guest ~ '/') }}">{{ post.author_name or post.guest }}</a>{% endif %}
</p>
{% set text = post.body|excerpt(220) %}{% if text %}<p class="excerpt">{{ text }}</p>{% endif %}
</article>

View file

@ -12,7 +12,7 @@
{% if post.images %}<meta property="og:image" content="{{ site_url }}/{{ post.url_path }}{{ post.images[0] }}">{% endif %}
<meta property="article:published_time" content="{{ post.date.isoformat() }}">
{% if post.updated %}<meta property="article:modified_time" content="{{ post.updated.isoformat() }}">{% endif %}
{% if post.author %}<meta property="article:author" content="{{ post.author }}">{% endif %}
{% if post.author_name %}<meta property="article:author" content="{{ post.author_name }}">{% endif %}
<meta name="twitter:card" content="{{ 'summary_large_image' if post.images else 'summary' }}">
{% endblock %}
{% block content %}
@ -21,10 +21,14 @@
<p class="meta">
<time datetime="{{ post.date.isoformat() }}">{{ post.date|date }}</time>
{% if post.updated %} · oppdatert <time datetime="{{ post.updated.isoformat() }}">{{ post.updated|date }}</time>{% endif %}
{% if post.guest %} · <a href="{{ rel(post.guest ~ '/') }}">{{ post.author or post.guest }}</a>{% endif %}
{% if post.guest %} · <a href="{{ rel(post.guest ~ '/') }}">{{ post.author_name or post.guest }}</a>{% endif %}
</p>
<div class="body">
{{ html|safe }}
</div>
{% if post.author_name %}
<p class="signature"> {{ post.author_name }}{% if post.author_email %}
· <a href="mailto:{{ post.author_email }}">{{ post.author_email }}</a>{% endif %}</p>
{% endif %}
</article>
{% endblock %}

View file

@ -314,3 +314,28 @@ def test_base_path_derived_from_site_url(tmp_path):
}
)
assert cfg.base_path == ""
def test_signature_from_profile(client, cfg):
post_id = _create_post(client)
token = csrf(client)
client.post(
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 (cfg.content_dir / "posts/sign/index.md").read_text().count('author: "eier"') == 1
r = client.post(
"/admin/profile", data={"csrf": token, "display_name": "Ole M.", "email": "ole@example.no"}
)
assert r.status_code == 302
html = client.get("/sign/").text
assert " Ole M." 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(
"/admin/profile", data={"csrf": token, "display_name": "x", "email": "ikke-epost"}
).status_code
== 200
)