Implement basics
This commit is contained in:
commit
4d721ddafa
|
@ -0,0 +1,2 @@
|
|||
__pycache__
|
||||
config.py
|
|
@ -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!
|
|
@ -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()
|
|
@ -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("/<path:p>")
|
||||
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/<tag_name_or_id>")
|
||||
# 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()
|
||||
|
|
@ -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 = "<h1>" + html.escape(frontmatter["title"]) + "</h1>" + "\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()
|
|
@ -0,0 +1,8 @@
|
|||
flask
|
||||
ProcruSQL
|
||||
psycopg
|
||||
pyyaml
|
||||
markdown
|
||||
requests
|
||||
beautifulsoup4
|
||||
lxml
|
|
@ -0,0 +1,14 @@
|
|||
table post
|
||||
column post id
|
||||
|
||||
table attachment
|
||||
|
||||
table tag
|
||||
|
||||
table implies
|
||||
|
||||
table tag_post
|
||||
|
||||
table role
|
||||
|
||||
table acl
|
|
@ -0,0 +1,83 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
Replace with local copy (11)
|
||||
-->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Economica:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Economica:ital,wght@0,400;0,700;1,400;1,700&family=Josefin+Sans:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- end of (11) -->
|
||||
<style>
|
||||
body {
|
||||
font-family: "Josefin Sans", serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
|
||||
line-height: 1.5;
|
||||
background-color: oklch(0.9 0.025 200);
|
||||
display: grid;
|
||||
}
|
||||
.info {
|
||||
inline-size: fit-content;
|
||||
grid-row: 1/2;
|
||||
grid-column: 2/3;
|
||||
width: min-content;;
|
||||
text-align: right;
|
||||
justify-self: right;
|
||||
}
|
||||
h1 {
|
||||
font-family: "Economica", serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-size: 3rem;
|
||||
line-height: 1.0;
|
||||
}
|
||||
article {
|
||||
/* border: 1px solid oklch(0.1 0.5 200); */
|
||||
margin-block: 1rem;
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
.superceded {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.tagcloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
grid-row: 1/2;
|
||||
grid-column: 1/2;
|
||||
align-self: start;
|
||||
}
|
||||
.tag {
|
||||
border: 1px solid oklch(0.1 0.5 200);
|
||||
border-radius: 0.5em;
|
||||
padding: 0.25em 1.0em;
|
||||
font-size: 0.8125rem;
|
||||
text-decoration: none;
|
||||
color: oklch(0.3 0.5 200);
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
.tag.selected {
|
||||
background-color: oklch(0.3 0.5 200);
|
||||
color: oklch(0.95 0.5 200);
|
||||
}
|
||||
main {
|
||||
grid-row: 2/3;
|
||||
grid-column: 1/3;
|
||||
}
|
||||
p {
|
||||
max-width: 40em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="info">
|
||||
{{ session }}
|
||||
{{ session.user_name }}
|
||||
</div>
|
||||
<main>
|
||||
<form method="post">
|
||||
<label>
|
||||
<input type="text" name="key" value="{{request.args.key}}">
|
||||
</label>
|
||||
<input type="submit" value="Confirm">
|
||||
</form>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="info">
|
||||
{{ session }}
|
||||
{{ session.user_name }}
|
||||
</div>
|
||||
<main>
|
||||
<form method="post">
|
||||
<label>
|
||||
Email
|
||||
<input name="email" type="email">
|
||||
</label>
|
||||
<input type="submit" value="Login">
|
||||
</form>
|
||||
</main>
|
||||
{% endblock %}
|
|
@ -0,0 +1,11 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="info">
|
||||
{{ session }}
|
||||
{{ session.user_name }}
|
||||
</div>
|
||||
<main>
|
||||
Mail sent.
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="info">
|
||||
{{ session }}
|
||||
{{ session.user_name }}
|
||||
</div>
|
||||
<main>
|
||||
Token could not be found, Maybe it has already expired?
|
||||
<a href="/">Go Home!</a> (J/K)
|
||||
<a href="/@/login">Try again</a>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="info">
|
||||
{{ session }}
|
||||
{{ session.user_name }}
|
||||
{% if session.user_id == 1 %}
|
||||
<a href="{{url_for('login_form')}}">Login</a>
|
||||
{% else %}
|
||||
<a href="{{url_for('logout')}}">Logout</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tagcloud">
|
||||
|
||||
{% for tag in tags %}
|
||||
<a href="{{tag.link}}" class="tag {% if tag['selected'] %}selected{% endif %}">{{tag['name']}}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<main>
|
||||
<p>{{posts | length}} posts:</p>
|
||||
|
||||
{% for post in posts %}
|
||||
<article class="{%if post.superceded_by%}superceded{%endif%}">
|
||||
<div class="tagcloud">
|
||||
{% for tag in tags_by_post[post.id] %}
|
||||
<a href="{{tag.link}}" class="tag {% if tag['selected'] %}selected{% endif %}">{{tag['name']}}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if post.content_type == "text/html" %}
|
||||
<!-- for now I trust myself -->
|
||||
{{ post.text | safe }}
|
||||
{% elif post.content_type == "text/markdown" %}
|
||||
{{post.text | markdown}}
|
||||
{% else %}
|
||||
<pre>{{post.text}}</pre>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</main>
|
||||
{% endblock content %}
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="info">
|
||||
{{ session }}
|
||||
{{ session.user_name }}
|
||||
</div>
|
||||
<main>
|
||||
Welcome, {{ session.user_name }}!
|
||||
<a href="/">Go Home!</a> (J/K)
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
|
Loading…
Reference in New Issue