From fa8b980eddda6fec7d438bf08c03114a93386f4f Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 26 Aug 2026 13:21:32 +0200 Subject: [PATCH] Historikk per innlegg i admin, og presisert flytte-avsnitt i README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redigeringssiden lenker til /admin/history/ 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 Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR --- README.md | 27 ++++++++++++++++++++----- soapbox/admin.py | 17 ++++++++++++++++ soapbox/gitstore.py | 30 ++++++++++++++++++++++++++++ soapbox/static/admin/admin.css | 12 +++++++++++ soapbox/templates/admin/edit.html | 2 ++ soapbox/templates/admin/history.html | 23 +++++++++++++++++++++ tests/test_app.py | 27 +++++++++++++++++++++++++ 7 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 soapbox/templates/admin/history.html diff --git a/README.md b/README.md index b69216c..319634e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/soapbox/admin.py b/soapbox/admin.py index 4c4bc14..40dd48a 100644 --- a/soapbox/admin.py +++ b/soapbox/admin.py @@ -266,6 +266,23 @@ def preview(post_id: str): return re.sub(r'src="([^"]+)"', fix, html) +@bp.route("/history/") +@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//") @login_required def media(post_id: str, filename: str): diff --git a/soapbox/gitstore.py b/soapbox/gitstore.py index a384b69..0c3d2bb 100644 --- a/soapbox/gitstore.py +++ b/soapbox/gitstore.py @@ -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 diff --git a/soapbox/static/admin/admin.css b/soapbox/static/admin/admin.css index ac15afa..696154c 100644 --- a/soapbox/static/admin/admin.css +++ b/soapbox/static/admin/admin.css @@ -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; } +} diff --git a/soapbox/templates/admin/edit.html b/soapbox/templates/admin/edit.html index 3944193..265f7de 100644 --- a/soapbox/templates/admin/edit.html +++ b/soapbox/templates/admin/edit.html @@ -67,6 +67,8 @@ {% endif %} +

Historikk for dette innlegget

+
diff --git a/soapbox/templates/admin/history.html b/soapbox/templates/admin/history.html new file mode 100644 index 0000000..a64318a --- /dev/null +++ b/soapbox/templates/admin/history.html @@ -0,0 +1,23 @@ +{% extends "admin/base.html" %} +{% block title %}Historikk – {{ post.title }}{% endblock %} +{% block content %} +

Historikk: {{ post.title }}

+

← Tilbake til editoren

+ +{% if not entries %}

Ingen historikk ennå.

{% endif %} +
    +{% for e in entries %} + + {{ e.subject }} + · {{ e.author }} · {{ e.sha[:7] }} + +{% endfor %} +
+ +{% if diff is not none %} +

Endring {{ sha[:7] }}

+{# Diffen vises linje for linje slik at + og − kan farges uten å tolke innholdet som HTML. #} +
{% for line in diff.splitlines() %}{{ line }}
+{% endfor %}
+{% endif %} +{% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index 5b5b07d..f26db0a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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'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 '+andre' in diff and '-første' 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