Soapbox: statisk blogg med mobilvennlig markdown-editor
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 /<slug>/, gjester under /<bruker>/<slug>/. 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
commit
7ab2320be6
35 changed files with 2726 additions and 0 deletions
46
tests/conftest.py
Normal file
46
tests/conftest.py
Normal file
|
|
@ -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()
|
||||
136
tests/test_app.py
Normal file
136
tests/test_app.py
Normal file
|
|
@ -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": " [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\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 "<strong>verden</strong>" 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
|
||||
56
tests/test_core.py
Normal file
56
tests/test_core.py
Normal file
|
|
@ -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("<script>x</script>\n\n[a](/post2/#x) ", "g/p/")
|
||||
assert "<script>" not in html
|
||||
assert 'href="../../post2/#x"' in html
|
||||
assert 'src="b.jpg"' in html
|
||||
assert "//example.com" in render_markdown("[x](//example.com)")
|
||||
|
||||
|
||||
def test_image_downscale():
|
||||
data, ext = process_image(png_bytes((3000, 1500)), 2000)
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
assert ext == ".png" and img.size == (2000, 1000)
|
||||
|
||||
|
||||
def test_password_hashing(tmp_path):
|
||||
conn = db.connect(tmp_path / "x.sqlite")
|
||||
db.create_user(conn, "a", "passord123", db.ROLE_OWNER)
|
||||
assert db.authenticate(conn, "a", "passord123")
|
||||
assert db.authenticate(conn, "a", "feil") is None
|
||||
assert db.has_owner(conn)
|
||||
assert datetime.now(UTC) # sanity: UTC alias tilgjengelig
|
||||
Loading…
Add table
Add a link
Reference in a new issue