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:
parent
14bb9f97ab
commit
259465342b
12 changed files with 123 additions and 10 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 på 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>"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
14
soapbox/templates/admin/profile.html
Normal file
14
soapbox/templates/admin/profile.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue