Historikk per innlegg i admin, og presisert flytte-avsnitt i README

Redigeringssiden lenker til /admin/history/<id> som lister commits som
har rørt innleggets mappe; klikk på en commit viser diffen fargelagt.
Samme tilgangsregler som editoren (gjester ser bare egne innlegg).

README skiller nå tydelig mellom å kopiere hele data-volumet (ingen nye
brukere) og å klone bare innholdsrepoet (brukere må opprettes på nytt).

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 13:21:32 +02:00
commit fa8b980edd
7 changed files with 133 additions and 5 deletions

View file

@ -23,8 +23,9 @@ data/
både på `blog.domene.no/` og `annet-domene.com/blog/` uten rebuild.
* **Bilder** ligger i samme mappe som innlegget og refereres som `![](bilde.jpg)`. Ved opplasting
skaleres de ned (maks 2000 px) og EXIF fjernes (ingen GPS-posisjon publiseres).
* **Hver lagring er en git-commit** med brukeren som forfatter. Se historikken under
Innstillinger, eller `git -C data/content log`.
* **Hver lagring er en git-commit** med brukeren som forfatter. Hvert innlegg har en
«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.
## Kjøre lokalt
@ -70,9 +71,25 @@ prefikset appen monteres selv under `SOAPBOX_BASE_PATH`.
## Flytte bloggen (nytt domene og/eller sub-path)
1. Kopier `data/`-volumet (eller bare `git clone` `data/content` og opprett brukerne på nytt).
2. Sett `SOAPBOX_SITE_URL` og eventuelt `SOAPBOX_BASE_PATH` til de nye verdiene.
3. Start siden bygges på nytt. Ingen innhold trenger endring.
Ingen innhold trenger endring alle lenker er relative. Velg én av to måter:
**A. Kopier hele `data/`-volumet.** Tar med innhold, brukere og cookie-nøkkel. Ingenting må
opprettes på nytt.
```sh
podman volume export soapbox-data | ssh ny-server 'podman volume import soapbox-data -'
```
**B. Klon bare innholdsrepoet.** Brukere ligger i sqlite, ikke i git, så de må opprettes på nytt
(hovedbruker via `SOAPBOX_OWNER_USER/_PASSWORD` eller `soapbox create-user`, gjester i admin med
**samme brukernavn** som før brukernavnet er URL-prefikset `/gjest/`).
```sh
git clone gammel-server:/sti/til/data/content ./data/content
```
Deretter, uansett metode: sett `SOAPBOX_SITE_URL` og eventuelt `SOAPBOX_BASE_PATH` til de nye
verdiene og start. Siden bygges på nytt ved oppstart.
## Tema

View file

@ -266,6 +266,23 @@ def preview(post_id: str):
return re.sub(r'src="([^"]+)"', fix, html)
@bp.route("/history/<path:post_id>")
@login_required
def history(post_id: str):
"""Commit-liste for innlegget, og diff for én commit hvis ``sha`` er gitt."""
cfg = _cfg()
post = _load_or_404(post_id)
rel = str(post.path.parent.relative_to(cfg.content_dir))
entries = gitstore.path_log(cfg.content_dir, rel)
sha = request.args.get("sha")
diff = None
if sha:
diff = gitstore.path_diff(cfg.content_dir, sha, rel)
if diff is None:
abort(404)
return render_template("admin/history.html", post=post, entries=entries, sha=sha, diff=diff)
@bp.route("/media/<path:post_id>/<filename>")
@login_required
def media(post_id: str, filename: str):

View file

@ -43,3 +43,33 @@ def commit_all(repo: Path, message: str, author: str) -> bool:
def log(repo: Path, limit: int = 20) -> list[str]:
out = _git(repo, "log", f"-{limit}", "--format=%h %ad %an: %s", "--date=short", check=False)
return out.stdout.splitlines() if out.returncode == 0 else []
def path_log(repo: Path, path: str, limit: int = 50) -> list[dict]:
"""Commits som har rørt ``path`` (relativ til repo), nyeste først.
Merk: en katalog som ble omdøpt (slug endret mens innlegget var utkast)
mister historikken fra før omdøpingen git følger bare filer, ikke mapper.
"""
out = _git(
repo,
"log",
f"-{limit}",
"--format=%H%x1f%aI%x1f%an%x1f%s",
"--",
path,
check=False,
)
entries = []
for line in out.stdout.splitlines():
sha, date, author, subject = line.split("\x1f", 3)
entries.append({"sha": sha, "date": date, "author": author, "subject": subject})
return entries
def path_diff(repo: Path, sha: str, path: str) -> str | None:
"""Diff for ``path`` i én commit, eller None hvis commit-en ikke finnes."""
if not all(c in "0123456789abcdef" for c in sha) or len(sha) < 7:
return None
out = _git(repo, "show", "--format=", "--stat", "--patch", sha, "--", path, check=False)
return out.stdout if out.returncode == 0 else None

View file

@ -69,3 +69,15 @@ textarea#body.dragover { outline: 3px dashed var(--accent); }
.danger-zone { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1rem; }
pre.log { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; }
dl.meta dt { font-weight: 600; margin-top: .5rem; } dl.meta dd { margin: 0; }
.history { padding-left: 1.2rem; }
.history li { padding: .4rem 0; border-bottom: 1px solid var(--border); }
.history li[aria-current] { font-weight: 600; }
.history .meta { display: block; font-weight: normal; }
pre.diff { background: var(--code-bg); padding: 1rem; overflow-x: auto; font-size: .85rem; line-height: 1.4; }
pre.diff .add { color: #14632c; background: #d8f2df; display: inline-block; width: 100%; }
pre.diff .del { color: #9a1c1c; background: #f8dcdc; display: inline-block; width: 100%; }
pre.diff .hunk { color: var(--muted); }
@media (prefers-color-scheme: dark) {
pre.diff .add { color: #b8f0c6; background: #163d21; }
pre.diff .del { color: #f5b5b5; background: #4a1c1c; }
}

View file

@ -67,6 +67,8 @@
</details>
{% endif %}
<p><a href="{{ url_for('admin.history', post_id=post.id) }}">Historikk for dette innlegget</a></p>
<form method="post" action="{{ url_for('admin.delete', post_id=post.id) }}" class="danger-zone" data-confirm="Slette hele innlegget «{{ post.title }}»?">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit" class="link danger">Slett innlegget</button>

View file

@ -0,0 +1,23 @@
{% extends "admin/base.html" %}
{% block title %}Historikk {{ post.title }}{% endblock %}
{% block content %}
<h1>Historikk: {{ post.title }}</h1>
<p><a href="{{ url_for('admin.edit', post_id=post.id) }}">← Tilbake til editoren</a></p>
{% if not entries %}<p>Ingen historikk ennå.</p>{% endif %}
<ol class="history">
{% for e in entries %}
<li{% if e.sha == sha %} aria-current="true"{% endif %}>
<a href="{{ url_for('admin.history', post_id=post.id, sha=e.sha) }}">{{ e.subject }}</a>
<span class="meta"><time datetime="{{ e.date }}">{{ e.date[:16].replace('T', ' ') }}</time> · {{ e.author }} · <code>{{ e.sha[:7] }}</code></span>
</li>
{% endfor %}
</ol>
{% if diff is not none %}
<h2>Endring <code>{{ sha[:7] }}</code></h2>
{# Diffen vises linje for linje slik at + og kan farges uten å tolke innholdet som HTML. #}
<pre class="diff">{% for line in diff.splitlines() %}<span class="{% if line.startswith('+') and not line.startswith('+++') %}add{% elif line.startswith('-') and not line.startswith('---') %}del{% elif line.startswith('@@') %}hunk{% endif %}">{{ line }}</span>
{% endfor %}</pre>
{% endif %}
{% endblock %}

View file

@ -162,3 +162,30 @@ def test_no_inline_js_with_content(client, cfg):
html = client.get(r.headers["Location"]).text
assert "onsubmit=" not in html and "onclick=" not in html
assert "Ola&#39;s" in html
def test_post_history(client, cfg):
post_id = _create_post(client)
token = csrf(client)
r = client.post(
f"/admin/edit/{post_id}",
data={"csrf": token, "title": "Hist", "body": "første", "action": "publish"},
)
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "Hist", "body": "andre", "action": "save"},
)
html = client.get(f"/admin/history/{pid}").text
assert "Publiserte: Hist" in html and "Lagret: Hist" in html
sha = re.search(r"sha=([0-9a-f]{40})", html).group(1)
diff = client.get(f"/admin/history/{pid}?sha={sha}").text
assert '<span class="add">+andre</span>' in diff and '<span class="del">-første</span>' in diff
assert client.get(f"/admin/history/{pid}?sha=deadbeef").status_code == 404
assert client.get(f"/admin/history/{pid}?sha=;rm").status_code == 404
# Gjest får ikke se eierens historikk
client.post("/admin/logout", data={"csrf": token})
login(client, "gjest1")
assert client.get(f"/admin/history/{pid}").status_code == 403