From 7ab2320be68bad6fab6e2e38feb73a0f5c1200b0 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 26 Aug 2026 12:12:33 +0200 Subject: [PATCH] Soapbox: statisk blogg med mobilvennlig markdown-editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flask-app som genererer statiske sider til public/ og serverer dem sammen med et admin-grensesnitt (/admin). Innhold ligger som markdown og bilder i et git-repo (data/content), brukere i sqlite. - Editor med toolbar, live server-side forhåndsvisning, bildeopplasting via knapp, dra-og-slipp og lim inn. Bilder nedskaleres og EXIF fjernes. - Hovedbruker publiserer under //, gjester under ///. Slug lages fra tittelen og fryses ved publisering. - Alle interne lenker er relative, så siden kan flyttes mellom domener og sub-paths (SOAPBOX_BASE_PATH) uten rebuild. - Tema 'green' med CSS-variabler, mørk modus og WCAG-kontrast. - Containerfile (python:alpine + git), compose.yaml og Caddyfile-eksempel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR --- .gitignore | 9 + Caddyfile.example | 16 + Containerfile | 35 ++ README.md | 81 +++++ compose.yaml | 19 ++ pyproject.toml | 30 ++ soapbox/__init__.py | 1 + soapbox/admin.py | 393 ++++++++++++++++++++++ soapbox/app.py | 122 +++++++ soapbox/build.py | 134 ++++++++ soapbox/cli.py | 78 +++++ soapbox/config.py | 96 ++++++ soapbox/content.py | 173 ++++++++++ soapbox/db.py | 143 ++++++++ soapbox/gitstore.py | 45 +++ soapbox/images.py | 61 ++++ soapbox/render.py | 73 ++++ soapbox/slug.py | 31 ++ soapbox/static/admin/admin.css | 71 ++++ soapbox/static/admin/editor.js | 124 +++++++ soapbox/templates/admin/base.html | 36 ++ soapbox/templates/admin/edit.html | 77 +++++ soapbox/templates/admin/index.html | 24 ++ soapbox/templates/admin/login.html | 12 + soapbox/templates/admin/password.html | 11 + soapbox/templates/admin/settings.html | 28 ++ soapbox/templates/admin/users.html | 41 +++ soapbox/themes/green/static/style.css | 74 ++++ soapbox/themes/green/templates/base.html | 28 ++ soapbox/themes/green/templates/index.html | 15 + soapbox/themes/green/templates/post.html | 15 + tests/conftest.py | 46 +++ tests/test_app.py | 136 ++++++++ tests/test_core.py | 56 +++ uv.lock | 392 +++++++++++++++++++++ 35 files changed, 2726 insertions(+) create mode 100644 .gitignore create mode 100644 Caddyfile.example create mode 100644 Containerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 pyproject.toml create mode 100644 soapbox/__init__.py create mode 100644 soapbox/admin.py create mode 100644 soapbox/app.py create mode 100644 soapbox/build.py create mode 100644 soapbox/cli.py create mode 100644 soapbox/config.py create mode 100644 soapbox/content.py create mode 100644 soapbox/db.py create mode 100644 soapbox/gitstore.py create mode 100644 soapbox/images.py create mode 100644 soapbox/render.py create mode 100644 soapbox/slug.py create mode 100644 soapbox/static/admin/admin.css create mode 100644 soapbox/static/admin/editor.js create mode 100644 soapbox/templates/admin/base.html create mode 100644 soapbox/templates/admin/edit.html create mode 100644 soapbox/templates/admin/index.html create mode 100644 soapbox/templates/admin/login.html create mode 100644 soapbox/templates/admin/password.html create mode 100644 soapbox/templates/admin/settings.html create mode 100644 soapbox/templates/admin/users.html create mode 100644 soapbox/themes/green/static/style.css create mode 100644 soapbox/themes/green/templates/base.html create mode 100644 soapbox/themes/green/templates/index.html create mode 100644 soapbox/themes/green/templates/post.html create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py create mode 100644 tests/test_core.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61d0727 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +data/ +.pytest_cache/ +.ruff_cache/ +dist/ +.playwright-mcp/ +*.png diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..ff0474c --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,16 @@ +# Eksempel 1: bloggen på et eget domene (SOAPBOX_BASE_PATH tom) +blog.domene.no { + encode zstd gzip + reverse_proxy localhost:8080 +} + +# Eksempel 2: bloggen under en sub-path (SOAPBOX_BASE_PATH=/blog). +# Appen forventer å få hele stien inkl. /blog, så ingen strip_prefix her. +annet-domene.com { + encode zstd gzip + redir /blog /blog/ permanent + handle /blog/* { + reverse_proxy localhost:8080 + } + # … resten av nettstedet +} diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..e67a3b1 --- /dev/null +++ b/Containerfile @@ -0,0 +1,35 @@ +# Soapbox – lettest mulig image: python:alpine + git + appen. +# Bygg (fra repo-rot): +# BUILDAH_FORMAT=docker podman build \ +# --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ +# --build-arg GIT_REVISION="$(git describe --always --dirty)" \ +# -t soapbox . +FROM python:3.13-alpine AS build +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv +WORKDIR /app +ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-dev --no-install-project +COPY soapbox ./soapbox +RUN uv sync --frozen --no-dev + +FROM python:3.13-alpine +ARG BUILD_DATE +ARG GIT_REVISION +LABEL org.opencontainers.image.title="soapbox" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${GIT_REVISION}" +RUN apk add --no-cache git curl \ + && printf 'build_date=%s\ngit_revision=%s\n' "${BUILD_DATE}" "${GIT_REVISION}" > /etc/build-info \ + && adduser -D -u 1000 soapbox \ + && mkdir -p /data && chown soapbox:soapbox /data +WORKDIR /app +COPY --from=build --chown=soapbox:soapbox /app /app +USER soapbox +ENV PATH="/app/.venv/bin:$PATH" \ + SOAPBOX_DATA_DIR=/data \ + PYTHONUNBUFFERED=1 +VOLUME /data +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:8080/ >/dev/null || exit 1 +CMD ["soapbox", "serve"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c93888 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Soapbox + +En liten blogg: statiske sider, mobilvennlig Markdown-editor med bildeopplasting og live +forhåndsvisning, innhold versjonert i git. Én hovedbruker («soapbox») pluss valgfrie gjester. + +## Hvordan det henger sammen + +``` +data/ +├── content/ git-repo – alt innhold, kan klones/flyttes fritt +│ ├── site.toml tittel, beskrivelse, språk +│ ├── posts//index.md + bilder → // +│ └── guests///index.md → /// +├── public/ generert statisk side (bygges automatisk ved lagring) +├── soapbox.sqlite brukere og passord-hasher – det eneste som ikke ligger i git +└── secret_key cookie-signering (genereres automatisk) +``` + +* **Slug** lages av tittelen (`Grønt er skjønt` → `groent-er-skjoent`). Den følger tittelen så + lenge innlegget er utkast, og fryses ved publisering så lenker aldri brekker. +* **Alle interne lenker er relative.** Malene regner ut `../` ut fra sidens dybde, og absolutte + lenker du skriver i Markdown (`/annet-innlegg/`) skrives om. Derfor fungerer samme `public/` + 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`. +* **Gjester** ser og redigerer bare egne innlegg. Hovedbrukeren ser alt og administrerer gjester. + +## Kjøre lokalt + +```sh +uv sync +SOAPBOX_DATA_DIR=./data uv run soapbox create-user olemd --role owner +SOAPBOX_DATA_DIR=./data uv run soapbox serve --debug +# → http://localhost:8080/admin/ +``` + +Tester: `uv run pytest`. Lint: `uv run ruff check . && uv run ruff format .` + +## Konfigurasjon (miljøvariabler) + +| Variabel | Standard | Beskrivelse | +|---|---|---| +| `SOAPBOX_DATA_DIR` | `./data` (`/data` i container) | Alt persistent | +| `SOAPBOX_BASE_PATH` | `` | `/blog` hvis bloggen ligger under en sub-path | +| `SOAPBOX_SITE_URL` | `http://localhost:8080` | Absolutt URL til rot, brukes **bare** i `feed.xml` | +| `SOAPBOX_THEME` | `green` | Mappe under `soapbox/themes/` | +| `SOAPBOX_MAX_IMAGE_PX` | `2000` | Lengste side på opplastede bilder | +| `SOAPBOX_SECRET_KEY` | autogenerert | Overstyr hvis du vil ha nøkkelen utenfor volumet | +| `SOAPBOX_OWNER_USER` / `_PASSWORD` | – | Oppretter hovedbruker ved første start | + +## Container og Caddy + +```sh +BUILDAH_FORMAT=docker podman build \ + --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg GIT_REVISION="$(git describe --always --dirty)" \ + -t soapbox . +podman compose up -d # se compose.yaml +``` + +Caddy er ren reverse proxy, se `Caddyfile.example`. Ved sub-path skal Caddy **ikke** strippe +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. + +## Tema + +Et tema er en mappe under `soapbox/themes//` med `templates/` (`base.html`, `index.html`, +`post.html`) og `static/` (kopieres til `/theme/`). Enkleste tilpasning: kopier `green`, endre +CSS-variablene øverst i `style.css`. Malene får `site`, `posts`/`post`, `html` og `rel(sti)`. + +## Veien videre (ikke i denne omgangen) + +* Poste lenker til fediverset/Bluesky og vise svar som kommentarer. +* Push av innholdsrepoet til en ekstern git-remote. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c41af7d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,19 @@ +# podman-compose up -d (eller: podman compose up -d) +services: + soapbox: + image: localhost/soapbox:latest + container_name: soapbox + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + volumes: + - soapbox-data:/data + environment: + SOAPBOX_SITE_URL: https://blog.domene.no + SOAPBOX_BASE_PATH: "" + SOAPBOX_THEME: green + # Settes kun første gang; ignoreres når en hovedbruker finnes. + SOAPBOX_OWNER_USER: olemd + SOAPBOX_OWNER_PASSWORD: bytt-meg-nå +volumes: + soapbox-data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c42ac75 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "soapbox" +version = "0.1.0" +description = "Liten statisk blogg med mobilvennlig editor, innhold i git" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "flask>=3.1", + "linkify-it-py>=2.1.1", + "markdown-it-py>=3.0", + "mdit-py-plugins>=0.4", + "pillow>=11.0", + "waitress>=3.0", +] + +[project.scripts] +soapbox = "soapbox.cli:main" + +[dependency-groups] +dev = ["pytest>=8", "ruff>=0.8"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["soapbox"] + +[tool.ruff] +line-length = 100 diff --git a/soapbox/__init__.py b/soapbox/__init__.py new file mode 100644 index 0000000..f668e49 --- /dev/null +++ b/soapbox/__init__.py @@ -0,0 +1 @@ +"""Soapbox – en liten statisk blogg med mobilvennlig editor og innhold i git.""" diff --git a/soapbox/admin.py b/soapbox/admin.py new file mode 100644 index 0000000..3b2d53b --- /dev/null +++ b/soapbox/admin.py @@ -0,0 +1,393 @@ +"""Admin-blueprint: innlogging, editor, bilder, brukere og innstillinger. + +Tilgangsregler: +* Alle innloggede kan opprette/redigere/slette *egne* innlegg. +* Hovedbruker (owner) kan i tillegg redigere alle innlegg, administrere + gjestebrukere og endre side-innstillinger. + +CSRF: hvert skjema og hvert fetch-kall sender et token som ligger i +sesjonen. Enkelt, men tilstrekkelig siden cookien er SameSite=Lax. +""" + +from __future__ import annotations + +import json +import re +import secrets +import sqlite3 +from datetime import UTC, datetime +from functools import wraps + +from flask import ( + Blueprint, + abort, + current_app, + flash, + g, + jsonify, + redirect, + render_template, + request, + send_from_directory, + session, + url_for, +) + +from . import db, gitstore +from .build import build_site +from .content import ( + IMAGE_EXT, + Post, + delete_post, + existing_slugs, + get_post, + list_posts, + post_dir, + save_post, + split_id, +) +from .images import ImageError, process_image, safe_filename +from .render import render_markdown +from .slug import slugify, unique_slug + +bp = Blueprint( + "admin", + __name__, + template_folder="templates", + static_folder="static/admin", + static_url_path="/static", +) + +USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$") +# Reserverte navn som ville kollidert med URL-strukturen +RESERVED = {"admin", "theme", "feed.xml", "posts", "guests", "index.html"} + + +def _cfg(): + return current_app.soapbox # type: ignore[attr-defined] + + +def _now() -> datetime: + return datetime.now(UTC).replace(microsecond=0) + + +# ---------- auth ---------- + + +def current_user() -> db.User | None: + uid = session.get("uid") + return db.get_user(g.db, uid) if uid else None + + +def csrf_token() -> str: + if "csrf" not in session: + session["csrf"] = secrets.token_urlsafe(32) + return session["csrf"] + + +def login_required(view): + @wraps(view) + def wrapped(*a, **kw): + user = current_user() + if user is None: + return redirect(url_for("admin.login", next=request.path)) + g.user = user + if request.method == "POST": + token = request.form.get("csrf") or request.headers.get("X-CSRF") + if not token or token != session.get("csrf"): + abort(403) + return view(*a, **kw) + + return wrapped + + +def owner_required(view): + @wraps(view) + @login_required + def wrapped(*a, **kw): + if not g.user.is_owner: + abort(403) + return view(*a, **kw) + + return wrapped + + +@bp.context_processor +def _inject(): + return {"csrf": csrf_token(), "user": getattr(g, "user", None), "site": _cfg().site} + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = db.authenticate( + g.db, request.form.get("username", ""), request.form.get("password", "") + ) + if user: + session.clear() + session.permanent = True + session["uid"] = user.id + csrf_token() + nxt = request.form.get("next") or url_for("admin.index") + if not nxt.startswith("/"): + nxt = url_for("admin.index") + return redirect(nxt) + flash("Feil brukernavn eller passord.", "error") + return render_template("admin/login.html", next=request.args.get("next", "")) + + +@bp.route("/logout", methods=["POST"]) +@login_required +def logout(): + session.clear() + return redirect(url_for("admin.login")) + + +# ---------- innlegg ---------- + + +def _load_or_404(post_id: str) -> Post: + guest, slug = split_id(post_id) + post = get_post(_cfg(), guest, slug) + if post is None: + abort(404) + if not g.user.is_owner and guest != g.user.username: + abort(403) + return post + + +def _commit(message: str) -> None: + gitstore.commit_all(_cfg().content_dir, message, g.user.username) + + +@bp.route("/") +@login_required +def index(): + posts = list_posts(_cfg(), include_drafts=True) + if not g.user.is_owner: + posts = [p for p in posts if p.guest == g.user.username] + return render_template("admin/index.html", posts=posts) + + +@bp.route("/new", methods=["POST"]) +@login_required +def new(): + """Oppretter et tomt utkast med en gang, slik at bildeopplasting har en + mappe å lande i før første lagring.""" + cfg = _cfg() + guest = g.user.guest_prefix + slug = unique_slug("nytt-innlegg", existing_slugs(cfg, guest) | RESERVED) + post = Post( + slug=slug, + title="Nytt innlegg", + body="", + date=_now(), + draft=True, + author=g.user.display_name, + guest=guest, + ) + save_post(cfg, post) + _commit(f"Opprettet utkast: {post.title}") + return redirect(url_for("admin.edit", post_id=post.id)) + + +@bp.route("/edit/", methods=["GET", "POST"]) +@login_required +def edit(post_id: str): + cfg = _cfg() + post = _load_or_404(post_id) + if request.method == "POST": + title = request.form.get("title", "").strip() or "Uten tittel" + body = request.form.get("body", "").replace("\r\n", "\n") + action = request.form.get("action", "save") + was_draft = post.draft + + post.title = title + post.body = body + if action == "publish": + post.draft = False + elif action == "unpublish": + post.draft = True + + # Slug følger tittelen så lenge innlegget er utkast. Etter publisering + # fryses den, så eksterne lenker aldri brekker. + if was_draft: + wanted = slugify(title) + if wanted != post.slug: + taken = (existing_slugs(cfg, post.guest) - {post.slug}) | RESERVED + new_slug = unique_slug(wanted, taken) + post_dir(cfg, post.guest, post.slug).rename(post_dir(cfg, post.guest, new_slug)) + post.slug = new_slug + if was_draft and not post.draft: + post.date = _now() # publiseringstidspunkt, ikke utkastets + elif not post.draft: + post.updated = _now() + + save_post(cfg, post) + _commit(f"{'Publiserte' if action == 'publish' else 'Lagret'}: {post.title}") + build_site(cfg) + flash("Publisert." if action == "publish" else "Lagret.", "ok") + return redirect(url_for("admin.edit", post_id=post.id)) + return render_template("admin/edit.html", post=post) + + +@bp.route("/delete/", methods=["POST"]) +@login_required +def delete(post_id: str): + post = _load_or_404(post_id) + delete_post(_cfg(), post) + _commit(f"Slettet: {post.title}") + build_site(_cfg()) + flash("Innlegget er slettet.", "ok") + return redirect(url_for("admin.index")) + + +@bp.route("/preview/", methods=["POST"]) +@login_required +def preview(post_id: str): + """Server-side forhåndsvisning – identisk med publisert resultat. + + Relative bildereferanser pekes om til admin-ruten som serverer bilder rett + fra innholdskatalogen, siden utkast ikke finnes i ``public/``. + """ + post = _load_or_404(post_id) + text = request.get_json(silent=True, force=True) or {} + html = render_markdown(text.get("body", ""), post.url_path) + media = url_for("admin.media", post_id=post.id, filename="X").rsplit("X", 1)[0] + + def fix(m: re.Match) -> str: + src = m.group(1) + if "://" in src or src.startswith(("/", "../", "data:")): + return m.group(0) + return f'src="{media}{src}"' + + return re.sub(r'src="([^"]+)"', fix, html) + + +@bp.route("/media//") +@login_required +def media(post_id: str, filename: str): + post = _load_or_404(post_id) + return send_from_directory(post.path.parent, filename) + + +@bp.route("/upload/", methods=["POST"]) +@login_required +def upload(post_id: str): + post = _load_or_404(post_id) + f = request.files.get("file") + if f is None: + return jsonify(error="Ingen fil"), 400 + try: + data, ext = process_image(f.read(), _cfg().max_image_px) + except ImageError as e: + return jsonify(error=str(e)), 400 + taken = {p.name for p in post.path.parent.iterdir()} + name = safe_filename(f.filename or "bilde", ext, taken) + (post.path.parent / name).write_bytes(data) + _commit(f"Lastet opp {name} til {post.title}") + return jsonify( + filename=name, + markdown=f"![]({name})", + url=url_for("admin.media", post_id=post.id, filename=name), + ) + + +@bp.route("/image/delete/", methods=["POST"]) +@login_required +def delete_image(post_id: str): + post = _load_or_404(post_id) + name = request.form.get("filename", "") + target = post.path.parent / name + if "/" in name or target.suffix.lower() not in IMAGE_EXT or not target.exists(): + abort(404) + target.unlink() + _commit(f"Slettet {name} fra {post.title}") + flash(f"Slettet {name}.", "ok") + return redirect(url_for("admin.edit", post_id=post.id)) + + +# ---------- brukere og innstillinger ---------- + + +@bp.route("/users", methods=["GET", "POST"]) +@owner_required +def users(): + if request.method == "POST": + action = request.form.get("action") + if action == "create": + username = request.form.get("username", "").strip().lower() + password = request.form.get("password", "") + if not USERNAME_RE.match(username) or username in RESERVED: + flash("Ugyldig brukernavn (a–z, 0–9, bindestrek, 2–31 tegn).", "error") + elif len(password) < 8: + flash("Passordet må være minst 8 tegn.", "error") + else: + try: + db.create_user( + g.db, + username, + password, + db.ROLE_GUEST, + request.form.get("display_name", ""), + ) + flash(f"Gjestebruker {username} opprettet.", "ok") + except sqlite3.IntegrityError: + flash("Brukernavnet er allerede i bruk.", "error") + elif action == "password": + uid = int(request.form.get("user_id", 0)) + password = request.form.get("password", "") + if len(password) < 8: + flash("Passordet må være minst 8 tegn.", "error") + else: + db.set_password(g.db, uid, password) + flash("Passordet er endret.", "ok") + 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") + return redirect(url_for("admin.users")) + return render_template("admin/users.html", users=db.list_users(g.db)) + + +@bp.route("/password", methods=["GET", "POST"]) +@login_required +def password(): + if request.method == "POST": + if not db.authenticate(g.db, g.user.username, request.form.get("current", "")): + flash("Nåværende passord er feil.", "error") + elif len(request.form.get("password", "")) < 8: + flash("Passordet må være minst 8 tegn.", "error") + else: + db.set_password(g.db, g.user.id, request.form["password"]) + flash("Passordet er endret.", "ok") + return redirect(url_for("admin.index")) + return render_template("admin/password.html") + + +@bp.route("/settings", methods=["GET", "POST"]) +@owner_required +def settings(): + cfg = _cfg() + if request.method == "POST": + site = { + "title": request.form.get("title", "").strip() or "Soapbox", + "description": request.form.get("description", "").strip(), + "language": request.form.get("language", "nb").strip() or "nb", + } + # JSON-strenger er gyldige TOML basic strings for alt vi skriver her. + toml = "".join(f"{k} = {json.dumps(v, ensure_ascii=False)}\n" for k, v in site.items()) + (cfg.content_dir / "site.toml").write_text(toml, encoding="utf-8") + cfg.site = site + _commit("Endret innstillinger") + build_site(cfg) + flash("Innstillingene er lagret.", "ok") + return redirect(url_for("admin.settings")) + return render_template("admin/settings.html", log=gitstore.log(cfg.content_dir), cfg=cfg) + + +@bp.route("/rebuild", methods=["POST"]) +@owner_required +def rebuild(): + n = build_site(_cfg()) + flash(f"Bygde siden på nytt ({n} innlegg).", "ok") + return redirect(url_for("admin.settings")) diff --git a/soapbox/app.py b/soapbox/app.py new file mode 100644 index 0000000..81ebfda --- /dev/null +++ b/soapbox/app.py @@ -0,0 +1,122 @@ +"""Flask-appen: serverer den statiske bloggen og admin-grensesnittet. + +Ruter (alle under ``SOAPBOX_BASE_PATH``): + +* ``/admin/…`` – innlogging, editor, brukere, innstillinger +* alt annet – statiske filer fra ``public/`` (generert av :mod:`build`) + +Appen monteres under base-path via werkzeugs ``DispatcherMiddleware`` slik +at Flask selv ser ``SCRIPT_NAME`` og ``url_for`` gir riktige stier både på +``/`` og ``/blog``. +""" + +from __future__ import annotations + +import os +import sqlite3 + +from flask import Flask, abort, g, redirect, request, send_from_directory +from werkzeug.middleware.dispatcher import DispatcherMiddleware + +from . import db, gitstore +from .admin import bp as admin_bp +from .build import build_site +from .config import Config, load_config + + +def create_app(cfg: Config | None = None) -> Flask: + cfg = cfg or load_config() + app = Flask(__name__, static_folder=None) + app.config.update( + SECRET_KEY=cfg.secret_key, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=cfg.site_url.startswith("https://"), + SESSION_COOKIE_PATH=cfg.base_path or "/", + SESSION_COOKIE_NAME="soapbox_session", + MAX_CONTENT_LENGTH=32 * 1024 * 1024, + PERMANENT_SESSION_LIFETIME=60 * 60 * 24 * 30, + ) + app.soapbox = cfg # type: ignore[attr-defined] + + gitstore.ensure_repo(cfg.content_dir) + _bootstrap_owner(cfg) + if not cfg.public_dir.exists(): + build_site(cfg) + + @app.before_request + def _open_db(): + g.db = db.connect(cfg.db_path) + + @app.teardown_request + def _close_db(_exc): + conn = g.pop("db", None) + if conn is not None: + conn.close() + + @app.after_request + def _headers(resp): + resp.headers.setdefault("X-Content-Type-Options", "nosniff") + resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + if request.path.startswith("/admin"): + resp.headers["Cache-Control"] = "no-store" + resp.headers["X-Frame-Options"] = "DENY" + return resp + + app.register_blueprint(admin_bp, url_prefix="/admin") + + @app.route("/") + @app.route("/") + def public(path: str = ""): + return _serve_public(cfg, path) + + if cfg.base_path: + # Alt utenfor base-path svarer 404; Caddy ruter uansett bare base-path hit. + app.wsgi_app = DispatcherMiddleware(_not_found, {cfg.base_path: app.wsgi_app}) # type: ignore[assignment] + return app + + +def _not_found(environ, start_response): + start_response("404 NOT FOUND", [("Content-Type", "text/plain; charset=utf-8")]) + return [b"Not found"] + + +def _serve_public(cfg: Config, path: str): + root = cfg.public_dir + if not root.exists(): + abort(404) + target = (root / path).resolve() if path else root + if not str(target).startswith(str(root.resolve())): + abort(404) + if target.is_dir(): + # Katalog-URL-er må ha avsluttende skråstrek, ellers brekker relative + # bildelenker (``bilde.jpg`` relativt til ``/post1`` er ``/bilde.jpg``). + if path and not request.path.endswith("/"): + return redirect(request.path + "/", code=301) + path = f"{path}index.html" if path else "index.html" + resp = send_from_directory(root, path) + if path.startswith("theme/") or "." in os.path.basename(path) and not path.endswith(".html"): + resp.headers["Cache-Control"] = "public, max-age=3600" + else: + resp.headers["Cache-Control"] = "public, max-age=60" + return resp + + +def _bootstrap_owner(cfg: Config) -> None: + """Oppretter hovedbruker fra miljøvariabler ved første oppstart. + + Praktisk for containere: ``SOAPBOX_OWNER_USER`` / ``SOAPBOX_OWNER_PASSWORD`` + settes én gang; når en eier finnes, ignoreres variablene. + """ + user = os.environ.get("SOAPBOX_OWNER_USER") + password = os.environ.get("SOAPBOX_OWNER_PASSWORD") + if not (user and password): + return + conn = db.connect(cfg.db_path) + try: + if not db.has_owner(conn): + db.create_user(conn, user, password, db.ROLE_OWNER) + except sqlite3.IntegrityError: + pass + finally: + conn.close() diff --git a/soapbox/build.py b/soapbox/build.py new file mode 100644 index 0000000..a327d57 --- /dev/null +++ b/soapbox/build.py @@ -0,0 +1,134 @@ +"""Statisk generering av bloggen til ``public/``. + +Kjøres ved hver lagring i editoren (tar millisekunder for en vanlig blogg). +Utdata er rene filer som kan serveres av hva som helst – appen selv, Caddy +eller en CDN. + +Alle lenker i genererte sider er relative. Malene får en ``rel()``-funksjon +som regner ut stien fra *denne* siden til et mål, så ``rel("theme/style.css")`` +blir ``theme/style.css`` på forsiden og ``../../theme/style.css`` i et +gjesteinnlegg. Dermed kan hele ``public/`` flyttes til hvilken som helst +sub-path uten rebuild. Unntaket er ``feed.xml``, der RSS krever absolutte +URL-er; den bruker ``SOAPBOX_SITE_URL``. +""" + +from __future__ import annotations + +import shutil +from datetime import UTC, datetime +from email.utils import format_datetime +from pathlib import Path +from xml.sax.saxutils import escape + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from .config import Config, load_site_settings +from .content import IMAGE_EXT, Post, list_posts +from .render import excerpt, relative_to, render_markdown + + +def _env(cfg: Config) -> Environment: + env = Environment( + loader=FileSystemLoader(cfg.theme_dir / "templates"), + autoescape=select_autoescape(["html"]), + ) + env.filters["date"] = lambda d, fmt="%d.%m.%Y": d.strftime(fmt) + return env + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def build_site(cfg: Config) -> int: + """Genererer hele siden. Returnerer antall publiserte innlegg.""" + site = load_site_settings(cfg) + env = _env(cfg) + posts = list_posts(cfg) + out = cfg.public_dir + + # Bygg i en midlertidig katalog og bytt atomisk, så besøkende aldri ser + # en halvferdig side. + tmp = out.with_name(out.name + ".tmp") + if tmp.exists(): + shutil.rmtree(tmp) + tmp.mkdir(parents=True) + + def render(template: str, page_path: str, **ctx) -> str: + return env.get_template(template).render( + site=site, + page_path=page_path, + rel=lambda target: relative_to(page_path, target), + now=datetime.now(UTC), + **ctx, + ) + + guests: dict[str, list[Post]] = {} + for p in posts: + if p.guest: + guests.setdefault(p.guest, []).append(p) + + # Forside: alle innlegg, også gjestenes, nyeste først + _write(tmp / "index.html", render("index.html", "", posts=posts, heading=None)) + + # Innlegg + bilder + for p in posts: + html = render_markdown(p.body, p.url_path) + _write(tmp / p.url_path / "index.html", render("post.html", p.url_path, post=p, html=html)) + for f in p.path.parent.iterdir(): + if f.suffix.lower() in IMAGE_EXT: + shutil.copy2(f, tmp / p.url_path / f.name) + + # Én oversiktsside per gjest + for guest, gposts in guests.items(): + _write( + tmp / guest / "index.html", + render("index.html", f"{guest}/", posts=gposts, heading=gposts[0].author or guest), + ) + + _write(tmp / "feed.xml", _feed(cfg, site, posts[:20])) + + theme_static = cfg.theme_dir / "static" + if theme_static.exists(): + shutil.copytree(theme_static, tmp / "theme") + + if out.exists(): + old = out.with_name(out.name + ".old") + if old.exists(): + shutil.rmtree(old) + out.rename(old) + tmp.rename(out) + shutil.rmtree(old) + else: + tmp.rename(out) + return len(posts) + + +def _feed(cfg: Config, site: dict, posts: list[Post]) -> str: + base = cfg.site_url + "/" + items = [] + for p in posts: + link = base + p.url_path + items.append( + "" + f"{escape(p.title)}" + f"{escape(link)}" + f"{escape(link)}" + f"{format_datetime(p.date)}" + + (f"{escape(p.author)}" if p.author else "") + + f"{escape(excerpt(p.body))}" + "" + ) + return ( + '\n' + '' + f"{escape(site['title'])}" + f"{escape(base)}" + f'' + f"{escape(site.get('description', ''))}" + f"{escape(site.get('language', 'nb'))}" + + "".join(items) + + "\n" + ) diff --git a/soapbox/cli.py b/soapbox/cli.py new file mode 100644 index 0000000..81aedd4 --- /dev/null +++ b/soapbox/cli.py @@ -0,0 +1,78 @@ +"""Kommandolinje: ``soapbox serve|build|create-user|set-password``.""" + +from __future__ import annotations + +import argparse +import getpass +import sys + +from . import db +from .build import build_site +from .config import load_config + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="soapbox", description="Liten statisk blogg") + sub = parser.add_subparsers(dest="cmd", required=True) + + serve = sub.add_parser("serve", help="Start webserveren") + serve.add_argument("--host", default="0.0.0.0") + serve.add_argument("--port", type=int, default=8080) + serve.add_argument("--debug", action="store_true", help="Flask dev-server med reload") + + sub.add_parser("build", help="Generer statiske sider på nytt") + + cu = sub.add_parser("create-user", help="Opprett bruker") + cu.add_argument("username") + cu.add_argument("--role", choices=[db.ROLE_OWNER, db.ROLE_GUEST], default=db.ROLE_GUEST) + cu.add_argument("--display-name", default="") + cu.add_argument("--password", help="Leses interaktivt hvis utelatt") + + sp = sub.add_parser("set-password", help="Sett nytt passord") + sp.add_argument("username") + sp.add_argument("--password") + + args = parser.parse_args(argv) + cfg = load_config() + + if args.cmd == "serve": + from .app import create_app + + app = create_app(cfg) + if args.debug: + app.run(host=args.host, port=args.port, debug=True) + else: + from waitress import serve as waitress_serve + + print(f"Soapbox lytter på http://{args.host}:{args.port}{cfg.base_path}/admin/") + waitress_serve(app, host=args.host, port=args.port, threads=4) + return 0 + + if args.cmd == "build": + from . import gitstore + + gitstore.ensure_repo(cfg.content_dir) + n = build_site(cfg) + print(f"Bygde {n} innlegg til {cfg.public_dir}") + return 0 + + conn = db.connect(cfg.db_path) + password = args.password or getpass.getpass("Passord: ") + if len(password) < 8: + print("Passordet må være minst 8 tegn", file=sys.stderr) + return 1 + if args.cmd == "create-user": + db.create_user(conn, args.username, password, args.role, args.display_name) + print(f"Opprettet {args.role} '{args.username}'") + elif args.cmd == "set-password": + user = db.get_user_by_name(conn, args.username) + if not user: + print("Fant ikke brukeren", file=sys.stderr) + return 1 + db.set_password(conn, user.id, password) + print("Passord oppdatert") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/soapbox/config.py b/soapbox/config.py new file mode 100644 index 0000000..d4528b1 --- /dev/null +++ b/soapbox/config.py @@ -0,0 +1,96 @@ +"""Konfigurasjon lest fra miljøvariabler. + +Alt som varierer mellom installasjoner (katalog, base-path, tema) styres +via ``SOAPBOX_*``-variabler slik at samme container-image kan brukes overalt. +Innholdet i seg selv (tittel, forfatter) ligger i ``content/site.toml`` i +git-repoet, slik at det følger med ved migrering. +""" + +from __future__ import annotations + +import os +import secrets +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class Config: + data_dir: Path + base_path: str # "" eller "/blog" – aldri med avsluttende skråstrek + site_url: str # absolutt URL til bloggens rot, brukes kun i feed.xml + theme: str + secret_key: str + max_image_px: int = 2000 + site: dict = field(default_factory=dict) + + @property + def content_dir(self) -> Path: + return self.data_dir / "content" + + @property + def public_dir(self) -> Path: + return self.data_dir / "public" + + @property + def db_path(self) -> Path: + return self.data_dir / "soapbox.sqlite" + + @property + def theme_dir(self) -> Path: + return Path(__file__).parent / "themes" / self.theme + + +def _normalize_base_path(raw: str) -> str: + raw = raw.strip() + if raw in ("", "/"): + return "" + return "/" + raw.strip("/") + + +def load_config(env: dict[str, str] | None = None) -> Config: + env = env if env is not None else os.environ + data_dir = Path(env.get("SOAPBOX_DATA_DIR", "./data")).resolve() + data_dir.mkdir(parents=True, exist_ok=True) + + # Hemmelig nøkkel for cookie-signering. Genereres én gang og lagres på disk + # så sesjoner overlever restart uten at brukeren må konfigurere noe. + secret = env.get("SOAPBOX_SECRET_KEY") + if not secret: + key_file = data_dir / "secret_key" + if key_file.exists(): + secret = key_file.read_text().strip() + else: + secret = secrets.token_hex(32) + key_file.write_text(secret) + key_file.chmod(0o600) + + base_path = _normalize_base_path(env.get("SOAPBOX_BASE_PATH", "")) + site_url = env.get("SOAPBOX_SITE_URL", "http://localhost:8080" + base_path).rstrip("/") + + cfg = Config( + data_dir=data_dir, + base_path=base_path, + site_url=site_url, + theme=env.get("SOAPBOX_THEME", "green"), + secret_key=secret, + max_image_px=int(env.get("SOAPBOX_MAX_IMAGE_PX", "2000")), + ) + cfg.site = load_site_settings(cfg) + return cfg + + +DEFAULT_SITE = { + "title": "Soapbox", + "description": "", + "language": "nb", +} + + +def load_site_settings(cfg: Config) -> dict: + path = cfg.content_dir / "site.toml" + site = dict(DEFAULT_SITE) + if path.exists(): + site.update(tomllib.loads(path.read_text(encoding="utf-8"))) + return site diff --git a/soapbox/content.py b/soapbox/content.py new file mode 100644 index 0000000..1e99ed8 --- /dev/null +++ b/soapbox/content.py @@ -0,0 +1,173 @@ +"""Lesing og skriving av innlegg som markdown-filer med frontmatter. + +Katalogstruktur i ``content/``:: + + site.toml + posts//index.md # hovedbrukerens innlegg → // + posts//bilde.jpg + guests///index.md # gjesteinnlegg → /// + +Bilder ligger i samme mappe som innlegget og refereres relativt +(``![](bilde.jpg)``). Dermed er URL-strukturen identisk med mappestrukturen, +og alt kan flyttes mellom domener og sub-paths uten omskriving. + +Frontmatter er et lite ``key: value``-format der verdier skrives som JSON- +strenger. Det gjør parsing triviell uten YAML-avhengighet, og tåler kolon og +anførselstegn i titler. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +from .config import Config + +OWNER = "_owner" # sentinel: hovedbrukerens innlegg har ingen gjest-prefiks + + +@dataclass +class Post: + slug: str + title: str + body: str + date: datetime + updated: datetime | None = None + draft: bool = False + author: str = "" # brukernavn; tomt for hovedbruker + guest: str | None = None # None for hovedbruker, ellers gjestens brukernavn + path: Path | None = None + images: list[str] = field(default_factory=list) + + @property + def url_path(self) -> str: + """Sti relativt til bloggens rot, med avsluttende skråstrek.""" + return f"{self.guest}/{self.slug}/" if self.guest else f"{self.slug}/" + + @property + def id(self) -> str: + """Stabil identifikator brukt i admin-URL-er.""" + return f"{self.guest or OWNER}/{self.slug}" + + +_FM_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.DOTALL) + + +def parse_frontmatter(text: str) -> tuple[dict, str]: + m = _FM_RE.match(text) + if not m: + return {}, text + meta = {} + for line in m.group(1).splitlines(): + if ":" not in line: + continue + key, _, value = line.partition(":") + value = value.strip() + try: + meta[key.strip()] = json.loads(value) + except json.JSONDecodeError: + meta[key.strip()] = value + return meta, m.group(2) + + +def dump_frontmatter(meta: dict, body: str) -> str: + lines = ["---"] + for key, value in meta.items(): + if value is None or value == "": + continue + lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}") + lines.append("---") + return "\n".join(lines) + "\n\n" + body.strip() + "\n" + + +def _parse_dt(value) -> datetime | None: + if not value: + return None + dt = datetime.fromisoformat(value) + return dt if dt.tzinfo else dt.replace(tzinfo=UTC) + + +IMAGE_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg"} + + +def load_post(path: Path, guest: str | None) -> Post: + meta, body = parse_frontmatter(path.read_text(encoding="utf-8")) + slug = path.parent.name + images = sorted(p.name for p in path.parent.iterdir() if p.suffix.lower() in IMAGE_EXT) + return Post( + slug=slug, + title=meta.get("title") or slug, + body=body, + date=_parse_dt(meta.get("date")) or datetime.now(UTC), + updated=_parse_dt(meta.get("updated")), + draft=bool(meta.get("draft", False)), + author=meta.get("author", ""), + guest=guest, + path=path, + images=images, + ) + + +def post_dir(cfg: Config, guest: str | None, slug: str) -> Path: + if guest: + return cfg.content_dir / "guests" / guest / slug + return cfg.content_dir / "posts" / slug + + +def list_posts(cfg: Config, include_drafts: bool = False) -> list[Post]: + posts: list[Post] = [] + owner_dir = cfg.content_dir / "posts" + if owner_dir.exists(): + for md in owner_dir.glob("*/index.md"): + posts.append(load_post(md, None)) + guests_dir = cfg.content_dir / "guests" + if guests_dir.exists(): + for md in guests_dir.glob("*/*/index.md"): + posts.append(load_post(md, md.parent.parent.name)) + if not include_drafts: + posts = [p for p in posts if not p.draft] + posts.sort(key=lambda p: p.date, reverse=True) + return posts + + +def get_post(cfg: Config, guest: str | None, slug: str) -> Post | None: + md = post_dir(cfg, guest, slug) / "index.md" + return load_post(md, guest) if md.exists() else None + + +def existing_slugs(cfg: Config, guest: str | None) -> set[str]: + base = post_dir(cfg, guest, "").parent + return {p.name for p in base.iterdir() if p.is_dir()} if base.exists() else set() + + +def save_post(cfg: Config, post: Post) -> Path: + d = post_dir(cfg, post.guest, post.slug) + d.mkdir(parents=True, exist_ok=True) + meta = { + "title": post.title, + "date": post.date.isoformat(timespec="seconds"), + "updated": post.updated.isoformat(timespec="seconds") if post.updated else None, + "author": post.author, + "draft": True if post.draft else None, + } + path = d / "index.md" + path.write_text(dump_frontmatter(meta, post.body), encoding="utf-8") + post.path = path + return path + + +def delete_post(cfg: Config, post: Post) -> None: + import shutil + + d = post_dir(cfg, post.guest, post.slug) + if d.exists(): + shutil.rmtree(d) + + +def split_id(post_id: str) -> tuple[str | None, str]: + """``"_owner/slug"`` → (None, slug); ``"gjest/slug"`` → ("gjest", slug).""" + guest, _, slug = post_id.partition("/") + return (None if guest == OWNER else guest), slug diff --git a/soapbox/db.py b/soapbox/db.py new file mode 100644 index 0000000..a24d313 --- /dev/null +++ b/soapbox/db.py @@ -0,0 +1,143 @@ +"""Brukere i sqlite. + +Kun brukere og passord-hasher lever i databasen – alt innhold ligger i git. +Det gjør migrering enkel: klon innholdsrepoet og opprett brukerne på nytt. + +Passord hashes med ``hashlib.scrypt`` fra standardbiblioteket for å slippe +en ekstra avhengighet. Parametrene (n=2**15, r=8, p=1) tilsvarer OWASP- +anbefalingene. ``maxmem`` må settes eksplisitt fordi OpenSSL ellers avviser +kall som trenger mer enn 32 MB, som er akkurat det disse parametrene bruker. +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +import sqlite3 +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +ROLE_OWNER = "owner" +ROLE_GUEST = "guest" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'guest')), + created_at TEXT NOT NULL +); +""" + + +@dataclass +class User: + id: int + username: str + display_name: str + role: str + + @property + def is_owner(self) -> bool: + return self.role == ROLE_OWNER + + @property + def guest_prefix(self) -> str | None: + """Gjester publiserer under //, hovedbruker under /.""" + return None if self.is_owner else self.username + + +def connect(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.executescript(SCHEMA) + return conn + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.scrypt( + password.encode(), salt=salt, n=2**15, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024 + ) + return f"scrypt${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> bool: + try: + _, salt_hex, digest_hex = stored.split("$") + except ValueError: + return False + digest = hashlib.scrypt( + password.encode(), + salt=bytes.fromhex(salt_hex), + n=2**15, + r=8, + p=1, + dklen=32, + maxmem=64 * 1024 * 1024, + ) + return hmac.compare_digest(digest.hex(), digest_hex) + + +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"]) + + +def create_user( + conn: sqlite3.Connection, username: str, password: str, role: str, display_name: str = "" +) -> User: + conn.execute( + "INSERT INTO users (username, display_name, password_hash, role, created_at) VALUES (?,?,?,?,?)", + ( + username, + display_name or username, + hash_password(password), + role, + datetime.now(UTC).isoformat(timespec="seconds"), + ), + ) + conn.commit() + return get_user_by_name(conn, username) + + +def get_user(conn: sqlite3.Connection, user_id: int) -> User | None: + return _row_to_user(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()) + + +def get_user_by_name(conn: sqlite3.Connection, username: str) -> User | None: + return _row_to_user( + conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + ) + + +def authenticate(conn: sqlite3.Connection, username: str, password: str) -> User | None: + row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + if row and verify_password(password, row["password_hash"]): + return _row_to_user(row) + return None + + +def set_password(conn: sqlite3.Connection, user_id: int, password: str) -> None: + conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), user_id)) + conn.commit() + + +def list_users(conn: sqlite3.Connection) -> list[User]: + rows = conn.execute("SELECT * FROM users ORDER BY role, username").fetchall() + return [_row_to_user(r) for r in rows] + + +def delete_user(conn: sqlite3.Connection, user_id: int) -> None: + conn.execute("DELETE FROM users WHERE id=? AND role='guest'", (user_id,)) + conn.commit() + + +def has_owner(conn: sqlite3.Connection) -> bool: + return conn.execute("SELECT 1 FROM users WHERE role='owner' LIMIT 1").fetchone() is not None diff --git a/soapbox/gitstore.py b/soapbox/gitstore.py new file mode 100644 index 0000000..a384b69 --- /dev/null +++ b/soapbox/gitstore.py @@ -0,0 +1,45 @@ +"""Versjonering av innholdskatalogen med git. + +Vi kaller git-binæren direkte i stedet for å dra inn pygit2/dulwich: git +finnes uansett i containeren, og et lite subprocess-lag er lettere å forstå +og feilsøke. Hver lagring i editoren blir én commit med brukeren som forfatter, +så historikken i git er en fullverdig revisjonslogg for bloggen. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=check, + capture_output=True, + text=True, + ) + + +def ensure_repo(repo: Path) -> None: + repo.mkdir(parents=True, exist_ok=True) + if not (repo / ".git").exists(): + _git(repo, "init", "-q", "-b", "main") + # Uten identitet nekter git å committe. Vi setter en lokal fallback; + # hver commit overstyrer uansett forfatter med den innloggede brukeren. + _git(repo, "config", "user.name", "soapbox") + _git(repo, "config", "user.email", "soapbox@localhost") + + +def commit_all(repo: Path, message: str, author: str) -> bool: + """Legger til alle endringer og committer. Returnerer False hvis ingenting endret.""" + _git(repo, "add", "-A") + if _git(repo, "diff", "--cached", "--quiet", check=False).returncode == 0: + return False + _git(repo, "commit", "-q", "-m", message, f"--author={author} <{author}@soapbox>") + return True + + +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 [] diff --git a/soapbox/images.py b/soapbox/images.py new file mode 100644 index 0000000..81f725e --- /dev/null +++ b/soapbox/images.py @@ -0,0 +1,61 @@ +"""Behandling av opplastede bilder. + +Mobiltelefoner leverer gjerne 12 MP-bilder på 5 MB med GPS-koordinater i +EXIF. Vi skalerer ned til ``max_px`` på lengste side, roterer i henhold til +EXIF-orientering og lagrer *uten* metadata – både for filstørrelse og for +personvern (posisjonen der bildet ble tatt skal ikke publiseres). +""" + +from __future__ import annotations + +import io +import re + +from PIL import Image, ImageOps, UnidentifiedImageError + +from .slug import slugify + +ALLOWED = {"JPEG": ".jpg", "PNG": ".png", "GIF": ".gif", "WEBP": ".webp"} + + +class ImageError(ValueError): + pass + + +def safe_filename(name: str, ext: str, taken: set[str]) -> str: + stem = slugify(re.sub(r"\.[^.]+$", "", name)) or "bilde" + candidate = stem + ext + n = 2 + while candidate in taken: + candidate = f"{stem}-{n}{ext}" + n += 1 + return candidate + + +def process_image(data: bytes, max_px: int) -> tuple[bytes, str]: + """Returnerer (bytes, filendelse) for et nedskalert bilde uten EXIF.""" + try: + img = Image.open(io.BytesIO(data)) + img.load() + except (UnidentifiedImageError, OSError) as e: + raise ImageError("Filen er ikke et gyldig bilde") from e + fmt = img.format + if fmt not in ALLOWED: + raise ImageError(f"Bildeformatet {fmt} støttes ikke (bruk JPEG, PNG, GIF eller WebP)") + + if fmt == "GIF": + # Animerte GIF-er beholdes urørt; nedskalering ville drept animasjonen. + return data, ALLOWED[fmt] + + img = ImageOps.exif_transpose(img) # roter etter EXIF før vi kaster EXIF + img.thumbnail((max_px, max_px)) # bevarer aspekt, forstørrer aldri + + out = io.BytesIO() + if fmt == "JPEG": + img = img.convert("RGB") + img.save(out, "JPEG", quality=85, optimize=True, progressive=True) + elif fmt == "PNG": + img.save(out, "PNG", optimize=True) + else: + img.save(out, "WEBP", quality=85) + return out.getvalue(), ALLOWED[fmt] diff --git a/soapbox/render.py b/soapbox/render.py new file mode 100644 index 0000000..3f46755 --- /dev/null +++ b/soapbox/render.py @@ -0,0 +1,73 @@ +"""Markdown → HTML, med omskriving av interne lenker til relative stier. + +Sikkerhet: rå HTML i markdown er slått av (``html=False``) fordi gjestebrukere +skriver innhold. Alt som ikke er markdown blir escapet. + +Relative lenker: hvert innlegg vet sin egen dybde i URL-treet. Absolutte +interne lenker som forfatteren skriver (``/annet-innlegg/``) skrives om til +``../annet-innlegg/`` slik at siden fungerer både på ``domene.no/`` og +``domene.no/blog/`` uten rebuild. +""" + +from __future__ import annotations + +import posixpath +from urllib.parse import urlsplit + +from markdown_it import MarkdownIt + + +def make_markdown() -> MarkdownIt: + md = MarkdownIt("commonmark", {"html": False, "linkify": True, "typographer": False}) + md.enable(["table", "strikethrough", "linkify"]) + return md + + +_md = make_markdown() + + +def relative_to(page_path: str, target: str) -> str: + """Relativ sti fra siden ``page_path`` (f.eks. ``"gjest/post/"``) til ``target``. + + Begge er stier relativt til bloggens rot. Rot-siden har ``page_path == ""``. + Mapper (som ender med ``/``) beholder avsluttende skråstrek. + """ + page_dir = posixpath.dirname(page_path.rstrip("/") + "/x") if page_path else "" + # posixpath.relpath trenger absolutte-aktige stier for å regne riktig + rel = posixpath.relpath("/" + target.lstrip("/"), "/" + page_dir) + if target.endswith("/") or target in ("", "/"): + rel = rel + "/" if rel != "." else "./" + return rel + + +def rewrite_internal_links(html: str, page_path: str) -> str: + """Skriver om href/src som starter med ``/`` (men ikke ``//``) til relative.""" + import re + + def repl(m: re.Match) -> str: + attr, quote, url = m.group(1), m.group(2), m.group(3) + parts = urlsplit(url) + if parts.scheme or parts.netloc or not url.startswith("/") or url.startswith("//"): + return m.group(0) + new = relative_to(page_path, parts.path) + if parts.query: + new += "?" + parts.query + if parts.fragment: + new += "#" + parts.fragment + return f"{attr}={quote}{new}{quote}" + + return re.sub(r'\b(href|src)=(["\'])([^"\']*)\2', repl, html) + + +def render_markdown(text: str, page_path: str = "") -> str: + return rewrite_internal_links(_md.render(text), page_path) + + +def excerpt(text: str, limit: int = 300) -> str: + """Første avsnitt som ren tekst, til innholdslister og feed.""" + for para in text.split("\n\n"): + para = para.strip() + if para and not para.startswith(("#", "!", "```", "|")): + plain = " ".join(para.split()) + return plain if len(plain) <= limit else plain[:limit].rsplit(" ", 1)[0] + "…" + return "" diff --git a/soapbox/slug.py b/soapbox/slug.py new file mode 100644 index 0000000..8f9c125 --- /dev/null +++ b/soapbox/slug.py @@ -0,0 +1,31 @@ +"""Lager URL-vennlige slugs fra titler. + +Norske tegn translittereres eksplisitt (æ→ae, ø→oe, å→aa) fordi generisk +unicode-normalisering ville mistet dem helt ("blåbær" → "blbr"). +""" + +from __future__ import annotations + +import re +import unicodedata + +_NORDIC = str.maketrans( + {"æ": "ae", "ø": "oe", "å": "aa", "ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"} +) + + +def slugify(text: str) -> str: + text = text.lower().translate(_NORDIC) + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() + text = re.sub(r"[^a-z0-9]+", "-", text).strip("-") + return text or "post" + + +def unique_slug(base: str, taken: set[str]) -> str: + """Returnerer ``base`` eller ``base-2``, ``base-3`` … til den er ledig.""" + if base not in taken: + return base + n = 2 + while f"{base}-{n}" in taken: + n += 1 + return f"{base}-{n}" diff --git a/soapbox/static/admin/admin.css b/soapbox/static/admin/admin.css new file mode 100644 index 0000000..ac15afa --- /dev/null +++ b/soapbox/static/admin/admin.css @@ -0,0 +1,71 @@ +/* Admin-grensesnitt. Mobil først; editor og forhåndsvisning side om side fra 900px. */ +:root { + --bg: #f4f8f3; --fg: #1c2a1e; --muted: #4d6350; --accent: #1f6b3a; --accent-strong: #154d29; + --surface: #fff; --border: #c9dccb; --code-bg: #e6efe6; --danger: #a12626; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #101a12; --fg: #e4ede4; --muted: #a7bda9; --accent: #7fd39a; --accent-strong: #a9e6bb; + --surface: #182619; --border: #2f4532; --code-bg: #1e2f20; --danger: #ff8a8a; + } +} +@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } +* { box-sizing: border-box; } +body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.5; } +.skip { position: absolute; left: -999px; } .skip:focus { left: 0; background: var(--accent); color: #fff; padding: .5rem; z-index: 10; } +a { color: var(--accent); } +:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } +.topbar { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center; padding: .6rem 1rem; border-bottom: 3px solid var(--accent); } +.topbar nav { display: flex; flex-wrap: wrap; gap: .25rem .9rem; align-items: center; } +.brand { font-weight: 700; color: var(--accent-strong); text-decoration: none; font-size: 1.15rem; } +main { max-width: 70rem; margin: 0 auto; padding: 1rem; } +h1 { font-size: 1.5rem; margin: .5rem 0; } +.row { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; justify-content: space-between; } +.grow { flex: 1 1 18rem; } +.stack { display: flex; flex-direction: column; gap: .75rem; } +.narrow { max-width: 28rem; } +label { display: flex; flex-direction: column; gap: .25rem; font-weight: 600; } +input, textarea, button { font: inherit; } +input[type=text], input:not([type]), input[type=password], textarea { + padding: .6rem; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); color: var(--fg); width: 100%; +} +button { padding: .55rem .9rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--accent-strong); cursor: pointer; min-height: 44px; } +button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } +button.link { border: none; background: none; padding: .25rem; min-height: auto; text-decoration: underline; color: var(--accent); } +button.danger { color: var(--danger); } +.inline { display: inline; } +.meta { color: var(--muted); font-size: .9rem; } +.badge { background: var(--accent); color: #fff; padding: .05rem .45rem; border-radius: 999px; font-size: .8rem; } +.flash { padding: .6rem .9rem; border-radius: 4px; border-left: 4px solid var(--accent); background: var(--surface); } +.flash.error { border-color: var(--danger); } +.posts, .users { list-style: none; padding: 0; } +.posts li, .users li { padding: .6rem 0; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: baseline; } + +/* Editor */ +.tabs { display: flex; gap: .25rem; margin-top: 1rem; } +.tabs button { border-radius: 4px 4px 0 0; border-bottom: none; } +.tabs button[aria-selected=true] { background: var(--accent); color: #fff; } +.panes section { border: 1px solid var(--border); background: var(--surface); padding: .5rem; border-radius: 0 4px 4px 4px; } +.toolbar { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .5rem; } +.toolbar button { min-height: 40px; padding: .3rem .6rem; } +textarea#body { min-height: 50vh; resize: vertical; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 1rem; line-height: 1.5; } +textarea#body.dragover { outline: 3px dashed var(--accent); } +.preview { min-height: 50vh; padding: 0 .5rem; overflow-wrap: anywhere; } +.preview img { max-width: 100%; height: auto; } +.preview pre, .preview code { background: var(--code-bg); } +.preview pre { padding: 1rem; overflow-x: auto; } +.preview table { border-collapse: collapse; } .preview td, .preview th { border: 1px solid var(--border); padding: .3rem .5rem; } +.preview blockquote { border-left: 4px solid var(--accent); margin: 1rem 0; padding: .25rem 1rem; color: var(--muted); } +.actions { margin-top: 1rem; justify-content: flex-start; } +@media (min-width: 900px) { + .tabs { display: none; } + .panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem; } + .panes section { border-radius: 4px; } + .panes section[hidden] { display: block; } +} +.images ul { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 1rem; } +.images li { display: flex; flex-direction: column; gap: .25rem; font-size: .85rem; } +.images img { width: 100%; aspect-ratio: 4/3; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); } +.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; } diff --git a/soapbox/static/admin/editor.js b/soapbox/static/admin/editor.js new file mode 100644 index 0000000..9d03a1f --- /dev/null +++ b/soapbox/static/admin/editor.js @@ -0,0 +1,124 @@ +/* Markdown-editor: toolbar, live forhåndsvisning og bildeopplasting. + * Ingen avhengigheter. Forhåndsvisningen rendres på serveren (samme kode som + * publisert side) med ~400 ms debounce, så det som vises er det som publiseres. */ +(function () { + "use strict"; + const form = document.getElementById("editor"); + const ta = document.getElementById("body"); + const preview = document.getElementById("preview"); + const status = document.getElementById("upload-status"); + const csrf = form.dataset.csrf; + let dirty = false; + + // ---- tabs (kun synlige på små skjermer) ---- + const tabs = form.querySelectorAll('[role="tab"]'); + tabs.forEach((tab) => + tab.addEventListener("click", () => { + tabs.forEach((t) => { + const on = t === tab; + t.setAttribute("aria-selected", on); + document.getElementById(t.getAttribute("aria-controls")).hidden = !on; + }); + if (tab.id === "tab-preview") renderPreview(); + }) + ); + + // ---- forhåndsvisning ---- + let timer = null; + let lastBody = null; + async function renderPreview() { + if (ta.value === lastBody) return; + lastBody = ta.value; + try { + const res = await fetch(form.dataset.previewUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "X-CSRF": csrf }, + body: JSON.stringify({ body: ta.value }), + }); + preview.innerHTML = res.ok ? await res.text() : "

Kunne ikke lage forhåndsvisning.

"; + } catch (e) { + preview.innerHTML = "

Kunne ikke lage forhåndsvisning (frakoblet?).

"; + } + } + ta.addEventListener("input", () => { + dirty = true; + clearTimeout(timer); + timer = setTimeout(renderPreview, 400); + }); + renderPreview(); + + // ---- tekstinnsetting ---- + function insert(before, after, placeholder) { + const s = ta.selectionStart, e = ta.selectionEnd; + const sel = ta.value.slice(s, e) || placeholder || ""; + ta.setRangeText(before + sel + after, s, e, "select"); + ta.setSelectionRange(s + before.length, s + before.length + sel.length); + ta.focus(); + ta.dispatchEvent(new Event("input")); + } + function prefixLines(prefix) { + const s = ta.selectionStart, e = ta.selectionEnd; + const start = ta.value.lastIndexOf("\n", s - 1) + 1; + const block = ta.value.slice(start, e); + const out = block.split("\n").map((l) => (l.startsWith(prefix) ? l.slice(prefix.length) : prefix + l)).join("\n"); + ta.setRangeText(out, start, e, "end"); + ta.focus(); + ta.dispatchEvent(new Event("input")); + } + form.querySelectorAll(".toolbar button").forEach((b) => { + b.addEventListener("click", () => { + if (b.dataset.wrap !== undefined) insert(b.dataset.wrap, b.dataset.wrap, "tekst"); + else if (b.dataset.prefix !== undefined) prefixLines(b.dataset.prefix); + else if (b.dataset.link !== undefined) { + const url = prompt("Lenkeadresse (bruk /slug/ for interne lenker):", "https://"); + if (url) insert("[", "](" + url + ")", "lenketekst"); + } + }); + }); + ta.addEventListener("keydown", (ev) => { + if (!(ev.ctrlKey || ev.metaKey)) return; + if (ev.key === "b") { ev.preventDefault(); insert("**", "**", "tekst"); } + if (ev.key === "i") { ev.preventDefault(); insert("_", "_", "tekst"); } + }); + + // ---- bildeopplasting: knapp, dra-og-slipp og lim inn ---- + const fileInput = document.getElementById("file-input"); + document.getElementById("btn-image").addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", () => { uploadAll(fileInput.files); fileInput.value = ""; }); + ta.addEventListener("dragover", (ev) => { ev.preventDefault(); ta.classList.add("dragover"); }); + ta.addEventListener("dragleave", () => ta.classList.remove("dragover")); + ta.addEventListener("drop", (ev) => { + ev.preventDefault(); ta.classList.remove("dragover"); + uploadAll(ev.dataTransfer.files); + }); + ta.addEventListener("paste", (ev) => { + const files = [...(ev.clipboardData?.items || [])].filter((i) => i.kind === "file").map((i) => i.getAsFile()); + if (files.length) { ev.preventDefault(); uploadAll(files); } + }); + + async function uploadAll(files) { + for (const f of files) await upload(f); + } + async function upload(file) { + if (!file.type.startsWith("image/")) { status.textContent = "Bare bilder kan lastes opp."; return; } + status.textContent = "Laster opp " + file.name + " …"; + const fd = new FormData(); + fd.append("file", file, file.name); + try { + const res = await fetch(form.dataset.uploadUrl, { method: "POST", headers: { "X-CSRF": csrf }, body: fd }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || res.statusText); + // Bilder skal stå på egen linje for å bli egne avsnitt i Markdown. + const before = ta.value.slice(0, ta.selectionStart); + const lead = before === "" || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n"; + insert(lead + data.markdown + "\n", "", ""); + status.textContent = "Lastet opp " + data.filename + ". Husk å lagre."; + } catch (e) { + status.textContent = "Opplasting feilet: " + e.message; + } + } + + // ---- ikke mist tekst ved uhell ---- + form.addEventListener("submit", () => { dirty = false; }); + window.addEventListener("beforeunload", (ev) => { if (dirty) { ev.preventDefault(); ev.returnValue = ""; } }); +})(); diff --git a/soapbox/templates/admin/base.html b/soapbox/templates/admin/base.html new file mode 100644 index 0000000..7dd3d33 --- /dev/null +++ b/soapbox/templates/admin/base.html @@ -0,0 +1,36 @@ + + + + + +{% block title %}Admin{% endblock %} – {{ site.title }} + + + + +
+ {{ site.title }} + {% if user %} + + {% endif %} +
+
+{% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %}

{{ msg }}

{% endfor %} +{% endwith %} +{% block content %}{% endblock %} +
+ + diff --git a/soapbox/templates/admin/edit.html b/soapbox/templates/admin/edit.html new file mode 100644 index 0000000..4e6223e --- /dev/null +++ b/soapbox/templates/admin/edit.html @@ -0,0 +1,77 @@ +{% extends "admin/base.html" %} +{% block title %}{{ post.title }}{% endblock %} +{% block content %} +
+ +
+ + + {% if post.draft %}utkast URL: /{{ post.url_path }} (følger tittelen) + {% else %}Publisert på /{{ post.url_path }}{% endif %} + +
+ +
+ + +
+ +
+
+ + +

+
+ +
+ +
+ + {% if post.draft %} + + {% else %} + + {% endif %} +
+
+ +{% if post.images %} +
+ Bilder i dette innlegget ({{ post.images|length }}) +
    + {% for name in post.images %} +
  • + + ![]({{ name }}) +
    + + + +
    +
  • + {% endfor %} +
+
+{% endif %} + +
+ + +
+ +{% endblock %} diff --git a/soapbox/templates/admin/index.html b/soapbox/templates/admin/index.html new file mode 100644 index 0000000..e517313 --- /dev/null +++ b/soapbox/templates/admin/index.html @@ -0,0 +1,24 @@ +{% extends "admin/base.html" %} +{% block title %}Innlegg{% endblock %} +{% block content %} +
+

Innlegg

+
+ + +
+
+{% if not posts %}

Ingen innlegg ennå. Trykk «Nytt innlegg» for å komme i gang.

{% endif %} +
    +{% for p in posts %} +
  • + {{ p.title }} + + {% if p.draft %}utkast{% endif %} + {{ p.date.strftime('%d.%m.%Y') }} + {% if p.guest %}· {{ p.author or p.guest }} (/{{ p.guest }}/){% endif %} + +
  • +{% endfor %} +
+{% endblock %} diff --git a/soapbox/templates/admin/login.html b/soapbox/templates/admin/login.html new file mode 100644 index 0000000..e5d101c --- /dev/null +++ b/soapbox/templates/admin/login.html @@ -0,0 +1,12 @@ +{% extends "admin/base.html" %} +{% block title %}Logg inn{% endblock %} +{% block content %} +

Logg inn

+
+ + + + + +
+{% endblock %} diff --git a/soapbox/templates/admin/password.html b/soapbox/templates/admin/password.html new file mode 100644 index 0000000..d9b7c17 --- /dev/null +++ b/soapbox/templates/admin/password.html @@ -0,0 +1,11 @@ +{% extends "admin/base.html" %} +{% block title %}Bytt passord{% endblock %} +{% block content %} +

Bytt passord

+
+ + + + +
+{% endblock %} diff --git a/soapbox/templates/admin/settings.html b/soapbox/templates/admin/settings.html new file mode 100644 index 0000000..1730d25 --- /dev/null +++ b/soapbox/templates/admin/settings.html @@ -0,0 +1,28 @@ +{% extends "admin/base.html" %} +{% block title %}Innstillinger{% endblock %} +{% block content %} +

Innstillinger

+
+ + + + + +
+ +

Drift

+
+
Base-path
{{ cfg.base_path or '/' }}
+
Site-URL (for RSS)
{{ cfg.site_url }}
+
Tema
{{ cfg.theme }}
+
Innhold
{{ cfg.content_dir }} (git-repo)
+
+
+ + +
+ +

Siste endringer i git

+
{% for line in log %}{{ line }}
+{% endfor %}
+{% endblock %} diff --git a/soapbox/templates/admin/users.html b/soapbox/templates/admin/users.html new file mode 100644 index 0000000..e1c3c18 --- /dev/null +++ b/soapbox/templates/admin/users.html @@ -0,0 +1,41 @@ +{% extends "admin/base.html" %} +{% block title %}Brukere{% endblock %} +{% block content %} +

Brukere

+
    +{% for u in users %} +
  • + {{ u.display_name }} {{ u.username }} + {% if u.is_owner %}hovedbruker{% else %}/{{ u.username }}/{% endif %} + {% if not u.is_owner %} +
    + Endre +
    + + + + + +
    +
    + + + + +
    +
    + {% endif %} +
  • +{% endfor %} +
+ +

Ny gjestebruker

+
+ + + + + + +
+{% endblock %} diff --git a/soapbox/themes/green/static/style.css b/soapbox/themes/green/static/style.css new file mode 100644 index 0000000..e0c7ecb --- /dev/null +++ b/soapbox/themes/green/static/style.css @@ -0,0 +1,74 @@ +/* Tema: "grønt er skjønt". + Alle farger er CSS-variabler; et nytt tema kan nøye seg med å overstyre :root. + Kontrast: tekst/bakgrunn ≥ 7:1 (AAA), lenker ≥ 4.5:1 på begge skjemaer. */ +:root { + --bg: #f4f8f3; + --fg: #1c2a1e; + --muted: #4d6350; + --accent: #1f6b3a; + --accent-strong: #154d29; + --surface: #ffffff; + --border: #c9dccb; + --code-bg: #e6efe6; + --max-width: 42rem; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #101a12; + --fg: #e4ede4; + --muted: #a7bda9; + --accent: #7fd39a; + --accent-strong: #a9e6bb; + --surface: #182619; + --border: #2f4532; + --code-bg: #1e2f20; + } +} +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; } +} +* { box-sizing: border-box; } +html { font-size: 100%; } +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + line-height: 1.6; +} +.skip { + position: absolute; left: -999px; top: 0; + background: var(--accent); color: #fff; padding: .5rem 1rem; +} +.skip:focus { left: 0; z-index: 10; } +.site-header, main, .site-footer { + max-width: var(--max-width); + margin: 0 auto; + padding: 1rem; +} +.site-header { border-bottom: 3px solid var(--accent); padding-top: 1.5rem; } +.site-title { + font-size: 1.75rem; font-weight: 700; text-decoration: none; color: var(--accent-strong); +} +.site-description { margin: .25rem 0 0; color: var(--muted); } +a { color: var(--accent); } +a:hover { color: var(--accent-strong); } +a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible { + outline: 3px solid var(--accent); outline-offset: 2px; +} +h1 { font-size: 1.9rem; line-height: 1.2; margin: 1rem 0 .25rem; } +h2 { font-size: 1.35rem; margin: 1.25rem 0 .25rem; } +.meta { color: var(--muted); font-size: .9rem; margin: 0 0 1rem; } +.summary { padding: .5rem 0; border-bottom: 1px solid var(--border); } +.summary h2 a { text-decoration: none; } +.summary h2 a:hover { text-decoration: underline; } +.body img { max-width: 100%; height: auto; 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; } +.body pre code { background: none; padding: 0; } +.body blockquote { + margin: 1rem 0; padding: .25rem 1rem; border-left: 4px solid var(--accent); color: var(--muted); +} +.body table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; } +.body th, .body td { border: 1px solid var(--border); padding: .4rem .6rem; text-align: left; } +.site-footer { border-top: 1px solid var(--border); color: var(--muted); font-size: .9rem; margin-top: 2rem; } diff --git a/soapbox/themes/green/templates/base.html b/soapbox/themes/green/templates/base.html new file mode 100644 index 0000000..3b2b058 --- /dev/null +++ b/soapbox/themes/green/templates/base.html @@ -0,0 +1,28 @@ + + + + + +{% block title %}{{ site.title }}{% endblock %} +{% if site.description %}{% endif %} + + + + + + +
+{% block content %}{% endblock %} +
+ + + diff --git a/soapbox/themes/green/templates/index.html b/soapbox/themes/green/templates/index.html new file mode 100644 index 0000000..db3e4a2 --- /dev/null +++ b/soapbox/themes/green/templates/index.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}{% if heading %}{{ heading }} – {% endif %}{{ site.title }}{% endblock %} +{% block content %} +{% if heading %}

{{ heading }}

{% endif %} +{% if not posts %}

Ingen innlegg ennå.

{% endif %} +{% for post in posts %} + +{% endfor %} +{% endblock %} diff --git a/soapbox/themes/green/templates/post.html b/soapbox/themes/green/templates/post.html new file mode 100644 index 0000000..e622ddf --- /dev/null +++ b/soapbox/themes/green/templates/post.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}{{ post.title }} – {{ site.title }}{% endblock %} +{% block content %} +
+

{{ post.title }}

+

+ + {% if post.updated %} · oppdatert {% endif %} + {% if post.guest %} · {{ post.author or post.guest }}{% endif %} +

+
+ {{ html|safe }} +
+
+{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7be1786 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +import io + +import pytest +from PIL import Image + +from soapbox import db +from soapbox.app import create_app +from soapbox.config import load_config + + +@pytest.fixture +def cfg(tmp_path): + return load_config( + {"SOAPBOX_DATA_DIR": str(tmp_path / "data"), "SOAPBOX_SITE_URL": "https://blog.example.no"} + ) + + +@pytest.fixture +def app(cfg): + app = create_app(cfg) + app.config["TESTING"] = True + conn = db.connect(cfg.db_path) + db.create_user(conn, "eier", "hemmelig123", db.ROLE_OWNER, "Ole") + db.create_user(conn, "gjest1", "hemmelig123", db.ROLE_GUEST, "Gjest En") + conn.close() + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def login(client, username="eier", password="hemmelig123", prefix=""): + return client.post(f"{prefix}/admin/login", data={"username": username, "password": password}) + + +def csrf(client, prefix=""): + with client.session_transaction(path=f"{prefix}/admin/") as s: + return s["csrf"] + + +def png_bytes(size=(3000, 1500)): + buf = io.BytesIO() + Image.new("RGB", size, "green").save(buf, "PNG") + return buf.getvalue() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..a169890 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,136 @@ +import re + +from soapbox.app import create_app +from soapbox.config import load_config +from tests.conftest import csrf, login, png_bytes + + +def _create_post(client, prefix=""): + login(client, prefix=prefix) + r = client.post(f"{prefix}/admin/new", data={"csrf": csrf(client)}) + assert r.status_code == 302 + post_id = r.headers["Location"].split("/admin/edit/")[1] + return post_id + + +def test_login_required(client): + assert client.get("/admin/").status_code == 302 + assert client.get("/admin/login").status_code == 200 + assert client.get("/admin/static/admin.css").content_type.startswith("text/css") + assert client.get("/admin/static/editor.js").status_code == 200 + + +def test_full_flow_owner(client, cfg): + post_id = _create_post(client) + assert post_id == "_owner/nytt-innlegg" + token = csrf(client) + + # Last opp et bilde + r = client.post( + f"/admin/upload/{post_id}", + data={"file": (__import__("io").BytesIO(png_bytes()), "Mitt Bilde.PNG")}, + headers={"X-CSRF": token}, + content_type="multipart/form-data", + ) + assert r.status_code == 200, r.data + assert r.json["filename"] == "mitt-bilde.png" + + # Forhåndsvisning peker bildet til admin-media + r = client.post( + f"/admin/preview/{post_id}", + json={"body": "![](mitt-bilde.png) [x](/annet/)"}, + headers={"X-CSRF": token}, + ) + assert f'src="/admin/media/{post_id}/mitt-bilde.png"' in r.text + assert 'href="../annet/"' in r.text + + # Publiser: slug følger tittelen + body = "Hei **verden**\n\n![](mitt-bilde.png)\n\n[hjem](/)" + r = client.post( + f"/admin/edit/{post_id}", + data={"csrf": token, "title": "Grønt er skjønt", "body": body, "action": "publish"}, + ) + assert r.headers["Location"].endswith("/admin/edit/_owner/groent-er-skjoent") + assert (cfg.content_dir / "posts/groent-er-skjoent/index.md").exists() + assert (cfg.content_dir / "posts/groent-er-skjoent/mitt-bilde.png").exists() + + # Statisk output + r = client.get("/groent-er-skjoent") + assert r.status_code == 301 and r.headers["Location"].endswith("/groent-er-skjoent/") + html = client.get("/groent-er-skjoent/").text + assert "verden" in html + assert 'href="../theme/style.css"' in html + assert 'href="../"' in html # intern lenke omskrevet til relativ + assert client.get("/groent-er-skjoent/mitt-bilde.png").status_code == 200 + assert "Grønt er skjønt" in client.get("/").text + feed = client.get("/feed.xml").text + assert "https://blog.example.no/groent-er-skjoent/" in feed + + # Etter publisering fryses slug selv om tittelen endres + r = client.post( + "/admin/edit/_owner/groent-er-skjoent", + data={"csrf": token, "title": "Ny tittel", "body": body, "action": "save"}, + ) + assert r.headers["Location"].endswith("/admin/edit/_owner/groent-er-skjoent") + + # Git-historikk + from soapbox.gitstore import log + + lines = log(cfg.content_dir) + assert any("Publiserte: Grønt er skjønt" in line for line in lines) + assert all("eier:" in line for line in lines) + + +def test_guest_flow_and_isolation(client, cfg): + post_id = _create_post(client) # eierens utkast + client.post("/admin/logout", data={"csrf": csrf(client)}) + + login(client, "gjest1") + token = csrf(client) + # Gjest kan ikke røre eierens innlegg + assert client.get(f"/admin/edit/{post_id}").status_code == 403 + assert client.get("/admin/users").status_code == 403 + + r = client.post("/admin/new", data={"csrf": token}) + gid = r.headers["Location"].split("/admin/edit/")[1] + assert gid == "gjest1/nytt-innlegg" + client.post( + f"/admin/edit/{gid}", + data={"csrf": token, "title": "Post 1", "body": "hei", "action": "publish"}, + ) + assert (cfg.content_dir / "guests/gjest1/post-1/index.md").exists() + + html = client.get("/gjest1/post-1/").text + assert 'href="../../theme/style.css"' in html + assert "Gjest En" in html + assert "Post 1" in client.get("/gjest1/").text + assert "Post 1" in client.get("/").text # gjesteinnlegg vises også på forsiden + # eierens utkast er ikke publisert + assert client.get("/nytt-innlegg/").status_code == 404 + + +def test_csrf_enforced(client): + login(client) + assert client.post("/admin/new", data={}).status_code == 403 + + +def test_subpath_mount(tmp_path): + cfg = load_config({"SOAPBOX_DATA_DIR": str(tmp_path / "d"), "SOAPBOX_BASE_PATH": "/blog/"}) + assert cfg.base_path == "/blog" + app = create_app(cfg) + app.config["TESTING"] = True + from soapbox import db + + conn = db.connect(cfg.db_path) + db.create_user(conn, "eier", "hemmelig123", db.ROLE_OWNER) + conn.close() + client = app.test_client() + assert client.get("/admin/login").status_code == 404 + assert client.get("/blog/admin/login").status_code == 200 + login(client, prefix="/blog") + r = client.post("/blog/admin/new", data={"csrf": csrf(client, "/blog")}) + assert r.headers["Location"].startswith("/blog/admin/edit/") + html = client.get("/blog/").text + assert 'href="theme/style.css"' in html + assert re.search(r'href="admin/"', html) + assert client.get("/blog/theme/style.css").status_code == 200 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..f00586d --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,56 @@ +from datetime import UTC, datetime + +from soapbox import db +from soapbox.content import dump_frontmatter, parse_frontmatter +from soapbox.images import process_image +from soapbox.render import relative_to, render_markdown +from soapbox.slug import slugify, unique_slug +from tests.conftest import png_bytes + + +def test_slugify_norwegian(): + assert slugify("Grønt er skjønt: blåbær & øl!") == "groent-er-skjoent-blaabaer-oel" + assert slugify(" ") == "post" + assert unique_slug("a", {"a", "a-2"}) == "a-3" + + +def test_frontmatter_roundtrip(): + meta = {"title": 'Tittel: med "anførsel"', "date": "2026-01-01T00:00:00+00:00", "draft": True} + text = dump_frontmatter(meta, "# Hei\n\nbrødtekst") + parsed, body = parse_frontmatter(text) + assert parsed == meta + assert body.strip() == "# Hei\n\nbrødtekst" + + +def test_relative_paths(): + assert relative_to("", "post1/") == "post1/" + assert relative_to("post1/", "") == "../" + assert relative_to("gjest/post1/", "post2/") == "../../post2/" + assert relative_to("gjest/post1/", "theme/style.css") == "../../theme/style.css" + + +def test_markdown_safe_and_relative(): + html = render_markdown("\n\n[a](/post2/#x) ![](b.jpg)", "g/p/") + assert "