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:
parent
f0fcb875c5
commit
5263d3011d
13 changed files with 191 additions and 20 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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">'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
27
soapbox/themes/green/templates/user.html
Normal file
27
soapbox/themes/green/templates/user.html
Normal 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 %}
|
||||
14
soapbox/themes/green/templates/users.html
Normal file
14
soapbox/themes/green/templates/users.html
Normal 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 %}
|
||||
|
|
@ -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 "<b>" 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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue