From 4d721ddafa7df36e8f4f94b4f30df187a20b41ea Mon Sep 17 00:00:00 2001 From: "Peter J. Holzer" Date: Sun, 27 Apr 2025 19:10:30 +0200 Subject: [PATCH] Implement basics --- .gitignore | 2 + Notes | 23 ++++ README.md | 0 add_zettel | 74 ++++++++++++ app.py | 219 +++++++++++++++++++++++++++++++++++ hugo2zettel | 70 +++++++++++ requirements.txt | 8 ++ schema.procrusql | 14 +++ templates/base.html | 83 +++++++++++++ templates/login_confirm.html | 16 +++ templates/login_form.html | 16 +++ templates/mail_sent.html | 11 ++ templates/no_token.html | 15 +++ templates/posts.html | 39 +++++++ templates/welcome.html | 13 +++ 15 files changed, 603 insertions(+) create mode 100644 .gitignore create mode 100644 Notes create mode 100644 README.md create mode 100644 add_zettel create mode 100644 app.py create mode 100644 hugo2zettel create mode 100644 requirements.txt create mode 100644 schema.procrusql create mode 100644 templates/base.html create mode 100644 templates/login_confirm.html create mode 100644 templates/login_form.html create mode 100644 templates/mail_sent.html create mode 100644 templates/no_token.html create mode 100644 templates/posts.html create mode 100644 templates/welcome.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3afd512 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +config.py diff --git a/Notes b/Notes new file mode 100644 index 0000000..07203b9 --- /dev/null +++ b/Notes @@ -0,0 +1,23 @@ +Wieder mal ein Anlauf + +Eintragen muss ganz einfach gehen. +Mehrere Wege: + Webformular + CLI + Mobile? + +Jede Seite bekommt ID (hash?) +ID ändert sich, wenn editiert wird, aber per default gibt es redirect +auf aktuelle Version, wenn man auf alte ID zugreift (es gibt einen +speziellen Permalink, der das verhindert). + +Außerdem Zugriff über Tags: Wenn die Tags eindeutig sind, bekommt man +die eine Seite, sonst eine Liste von Seiten (und weiteren Tags) + +Ich hätte außerdem gerne eindeutige (HTML-)Ids für jeden Absatz. Aber +das schlägt sich mit dem oben geschilderten dynamischen Verhalten, d das +Fragment nicht an den Server geschickt wird und daher nicht in einem +Redirekt erhalten werden kann. + +Oder man kann einen Bereich markieren und bekommt dann einen Link auf +diesen Bereich? Feature-Creep-Alert! diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/add_zettel b/add_zettel new file mode 100644 index 0000000..9c6b82c --- /dev/null +++ b/add_zettel @@ -0,0 +1,74 @@ +#!/usr/bin/python3 + +import argparse +import html +import subprocess +import sys +import tempfile + +import yaml +import psycopg +import requests + +from bs4 import BeautifulSoup + +ap = argparse.ArgumentParser() +ap.add_argument("--author", default="hjp") +ap.add_argument("--content_type", default="text/plain") +ap.add_argument("--tags", nargs="+") +g = ap.add_mutually_exclusive_group(required=True) +g.add_argument("--content") +g.add_argument("--url") +g.add_argument("--file") +g.add_argument("--editor", action="store_true") +args = ap.parse_args() + +if args.content: + content = args.content +elif args.file: + with open(args.file) as fh: + content = fh.read() +elif args.url: + r = requests.get(args.url) + if r.status_code != 200: + print(f"GETting {args.url} failed with {r.status_code} {r.message}", + file=sys.stderr) + exit(1) + soup = BeautifulSoup(r.content, "lxml") + title = soup.find("title") + title = title.text + content = f"[{title}]({args.url})" + args.content_type = "text/markdown" +elif args.editor: + with tempfile.NamedTemporaryFile(delete_on_close=False) as tfh: + tfh.close() + subprocess.run(["sensible-editor", tfh.name]) + with open(tfh.name, "r") as fh: + content = fh.read() + if len(content) == 0: + print("Content is empty. Aborting.", file=sys.stderr) + +conn = psycopg.connect(dbname="zettel") +csr = conn.cursor(row_factory=psycopg.rows.namedtuple_row) +csr.execute("select * from role where name = %s", (args.author,)) +author_id = csr.fetchone().id +csr.execute( + """ + insert into post(text, ts, content_type, author) values(%s, now(), %s, %s) + returning id + """, + (content, args.content_type, author_id,)) +post_id = csr.fetchone().id +for tag in args.tags: + csr.execute("select * from tag where lower(name) = lower(%s)", + (tag,)) # XXX - use citext? + r = csr.fetchone() + if not r: + csr.execute("insert into tag(name) values(%s) returning id", + (tag,)) + r = csr.fetchone() + tag_id = r.id + csr.execute("insert into tag_post(tag, post) values(%s, %s)", + (tag_id, post_id,)) +csr.execute("insert into acl(post, role) values(%s, %s)", (post_id, author_id,)) +conn.commit() diff --git a/app.py b/app.py new file mode 100644 index 0000000..7671371 --- /dev/null +++ b/app.py @@ -0,0 +1,219 @@ +import base64 +import email.message +import os +import smtplib + +import markdown +import psycopg + +from flask import Flask, render_template, g, url_for, session, request +from markupsafe import Markup + +import config + +app = Flask(__name__) +app.config.from_object(config) + +#----------------------------------------------------------------------- + +# HTTP endpoints + +@app.get("/") +def index(): + return show_posts("") + +@app.get("/") +def show_posts(p): + roles = get_roles() + app.logger.info(f"{p}") + tags = p.split("/") + csr = get_cursor() + csr.execute("select * from tag where lower(name) = any(%s)", (tags,)) + tag_ids = [r.id for r in csr] + # XXX - Check that tag_ids has the same length as tags. + # Otherwise + # - return 404? + # - redirect? + q = "select distinct p.* from post p" + for tag_id in tag_ids: + # we got tag_id from the database so we know it's an int + q += f" join tag_post t{tag_id} on p.id = t{tag_id}.post and t{tag_id}.tag = {tag_id}" + # and the user_id is an int, too + q += f" join acl on p.id = acl.post and acl.role = any(%s)" + q += " order by p.ts desc" + csr.execute(q, (roles,)) + posts = csr.fetchall() + # XXX find all tags for each post + post_ids = [p.id for p in posts] + csr.execute( + "select * from tag_post tp join tag t on tp.tag = t.id where post = any(%s) order by name", + (post_ids,)) + tags_by_id = {} + tags_by_post = {} + for r in csr: + if r.id not in tags_by_id: + selected = r.id in tag_ids + if selected: + # XXX - should really use id + new_tags = [t for t in tags if t != r.name.lower()] + else: + new_tags = tags + [r.name.lower()] + link = url_for("show_posts", p="/".join(new_tags)) + tags_by_id[r.id] = { "id": r.id, "name": r.name, "selected": selected, "link": link } + tags_by_post.setdefault(r.post, []).append(tags_by_id[r.id]) + tags = tags_by_id.values() + + + + return render_template( + "posts.html", + posts=posts, tags=tags, tags_by_post=tags_by_post, + ) + + +# XXX - do we need a prefix for paths? +# Or use a separate domain for the api (no) +# Or use an impossible prefix for the api (what would that be?) +#@get("/api") + +#@get("/@/add_post") +#@post("/@/add_post") + # also json + +#@get("/@/tag/") + # plus one or more posts for operations, like editing implies, bulk + # updates, etc. + +@app.get("/@/login") +def login_form(): + # could duplicate as registration + return render_template("login_form.html") + +@app.post("/@/login") +def login_step1(): + csr = get_cursor() + email = request.form["email"].strip() + csr.execute("select * from role where email = %s", (email,)) + r = csr.fetchone() + if not r: + csr.execute("insert into role(email) values(%s) returning *", (email,)) + r = csr.fetchone() + user_id = r.id + key = base64.urlsafe_b64encode(os.urandom(15)).decode('us-ascii') + csr.execute("insert into confirm_token(role, token) values(%s, %s) returning *", (user_id, key,)) + r = csr.fetchone() + csr.connection.commit() + send_mail(email, request.root_url + url_for("login_confirm", key=key)) + return render_template("mail_sent.html") + +@app.get("/@/confirm") +def login_confirm(): + # XXX - Check that key isn't expired + return render_template("login_confirm.html") + +@app.post("/@/confirm") +def do_confirm(): + csr = get_cursor() + csr.execute("delete from confirm_token where expires <= now()") + csr.execute( + """ + select id, name from confirm_token t join role r on t.role = r.id + where token = %s + """, + (request.form["key"],)) + r = csr.fetchone() + if r: + csr.execute( + """ + update role + set first_confirmed = coalesce(first_confirmed, now()), + last_confirmed = now() + where id = %s + """, + (r.id,)) + + session["user_id"] = r.id + session["user_name"] = r.name + return render_template("welcome.html") + else: + return render_template("no_token.html") + +@app.route("/@/logout", methods=["GET", "POST"]) +def logout(): + del session["user_id"] + del session["user_name"] + return redirect("/", code=303) + +#@get("/~user") + # maybe too oldskool? use @user? +#@get("/~user/token") + +#----------------------------------------------------------------------- + +# Content generation + +@app.template_filter("markdown") +def markdown_to_html(m): + return Markup(markdown.markdown(m)) + +def send_mail(email_address, confirmation_url): + msg = email.message.EmailMessage() + msg["From"] ="noreply@hjp.at" + msg["To"] = email_address + msg["Subject"] = "Zettel confirmation" + body = confirmation_url # Really minimalistic + msg.set_content(body) + mta = smtplib.SMTP(host="localhost") + mta.send_message(msg) + +#----------------------------------------------------------------------- + +# Plumbing + +def get_roles(): + user_id = session.get("user_id") + csr = get_cursor() + if not user_id: + csr.execute("select * from role where name = %s", ("public",)) + r = csr.fetchone() + user_id = session["user_id"] = r.id + session["user_name"] = r.name + assert type(user_id) == int + roles = [user_id] + csr.execute( + """ + with recursive r as ( + select group_role from role_member where member_role = %s + union + select role_member.group_role from role_member join r on role_member.member_role = r.group_role + ) + select * from r + """, + (user_id,)) + for r in csr: + roles.append(r.group_role) + return roles + +def get_cursor(): + db = get_db() + csr = db.cursor(row_factory=psycopg.rows.namedtuple_row) + return csr + +def get_db(): + if "db" not in g: + g.db = psycopg.connect(dbname=config.dbname, user=config.dbuser) + return g.db + +@app.teardown_appcontext +def teardown_db(exception): + app.logger.debug("in teardown_db: exception = %s", exception) + db = g.pop('db', None) + if db is not None: + if not exception: + try: + db.commit() + except: + pass + db.rollback() + db.close() + diff --git a/hugo2zettel b/hugo2zettel new file mode 100644 index 0000000..07dc747 --- /dev/null +++ b/hugo2zettel @@ -0,0 +1,70 @@ +#!/usr/bin/python3 + +import argparse +import html + +import yaml +import psycopg + +ap = argparse.ArgumentParser() +ap.add_argument("--author",default="hjp") +ap.add_argument("file") +args = ap.parse_args() +if args.file.endswith(".md"): + file_type = "markdown" +elif args.file.endswith(".html"): + file_type = "html" +else: + file_type = "plain" + +with open(args.file) as fh: + section = None + frontmatter = "" + body = "" + for ln in fh: + if section is None and ln == "---\n": + section = "frontmatter" + elif section == "frontmatter" and ln == "---\n": + section = "body" + elif section == "frontmatter": + frontmatter += ln + elif section == "body": + body += ln + else: + raise RuntimeError("oops") + frontmatter = yaml.safe_load(frontmatter) + + # We don't have a dedicated title. Just prepend it to the body + if file_type == "html": + body = "

" + html.escape(frontmatter["title"]) + "

" + "\n" \ + + body + else: + body = frontmatter["title"] + "\n" \ + + "=" * len(frontmatter["title"]) + "\n" \ + + "\n" \ + + body + ts = frontmatter.get("lastmod") or frontmatter.get("date") + + conn = psycopg.connect(dbname="zettel") + csr = conn.cursor(row_factory=psycopg.rows.namedtuple_row) + csr.execute("select * from role where name = %s", (args.author,)) + author_id = csr.fetchone().id + csr.execute( + """ + insert into post(text, ts, content_type, author) values(%s, %s, %s, %s) + returning id + """, + (body, ts, "text/" + file_type, author_id,)) + post_id = csr.fetchone().id + for tag in frontmatter["tags"]: + csr.execute("select * from tag where lower(name) = lower(%s)", + (tag,)) # XXX - use citext? + r = csr.fetchone() + if not r: + csr.execute("insert into tag(name) values(%s) returning id", + (tag,)) + r = csr.fetchone() + tag_id = r.id + csr.execute("insert into tag_post(tag, post) values(%s, %s)", + (tag_id, post_id,)) + conn.commit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8142863 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +flask +ProcruSQL +psycopg +pyyaml +markdown +requests +beautifulsoup4 +lxml diff --git a/schema.procrusql b/schema.procrusql new file mode 100644 index 0000000..0cb30ea --- /dev/null +++ b/schema.procrusql @@ -0,0 +1,14 @@ +table post +column post id + +table attachment + +table tag + +table implies + +table tag_post + +table role + +table acl diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..1692be5 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + {% block content %} + {% endblock %} + + diff --git a/templates/login_confirm.html b/templates/login_confirm.html new file mode 100644 index 0000000..831d262 --- /dev/null +++ b/templates/login_confirm.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ session }} + {{ session.user_name }} +
+
+
+ + +
+
+{% endblock %} + diff --git a/templates/login_form.html b/templates/login_form.html new file mode 100644 index 0000000..d15495d --- /dev/null +++ b/templates/login_form.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ session }} + {{ session.user_name }} +
+
+
+ + +
+
+{% endblock %} diff --git a/templates/mail_sent.html b/templates/mail_sent.html new file mode 100644 index 0000000..9ae367a --- /dev/null +++ b/templates/mail_sent.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ session }} + {{ session.user_name }} +
+
+ Mail sent. +
+{% endblock %} + diff --git a/templates/no_token.html b/templates/no_token.html new file mode 100644 index 0000000..98e4087 --- /dev/null +++ b/templates/no_token.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ session }} + {{ session.user_name }} +
+
+ Token could not be found, Maybe it has already expired? + Go Home! (J/K) + Try again +
+{% endblock %} + + + diff --git a/templates/posts.html b/templates/posts.html new file mode 100644 index 0000000..f26baf9 --- /dev/null +++ b/templates/posts.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ session }} + {{ session.user_name }} + {% if session.user_id == 1 %} + Login + {% else %} + Logout + {% endif %} +
+
+ + {% for tag in tags %} + {{tag['name']}} + {% endfor %} +
+
+

{{posts | length}} posts:

+ + {% for post in posts %} +
+
+ {% for tag in tags_by_post[post.id] %} + {{tag['name']}} + {% endfor %} +
+ {% if post.content_type == "text/html" %} + + {{ post.text | safe }} + {% elif post.content_type == "text/markdown" %} + {{post.text | markdown}} + {% else %} +
{{post.text}}
+ {% endif %} +
+ {% endfor %} +
+{% endblock content %} diff --git a/templates/welcome.html b/templates/welcome.html new file mode 100644 index 0000000..ad7735c --- /dev/null +++ b/templates/welcome.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} +
+ {{ session }} + {{ session.user_name }} +
+
+ Welcome, {{ session.user_name }}! + Go Home! (J/K) +
+{% endblock %} + +