Working on post preview; file attachments

This commit is contained in:
Jaakko Keränen 2023-05-02 22:28:53 +03:00
parent efb8645afa
commit 5bafb9116a
No known key found for this signature in database
GPG Key ID: BACCFCFB98DB2EDC
2 changed files with 308 additions and 59 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
/__pycache__ /__pycache__
bubble.ini bubble.ini
server.sh server.sh
.certs/
.vscode/

View File

@ -1,7 +1,9 @@
"""Bubble - Bulletin Boards for Gemini""" """Bubble - Bulletin Boards for Gemini"""
import datetime
import mariadb import mariadb
import re import re
import time
import urllib.parse as urlparse import urllib.parse as urlparse
@ -90,11 +92,26 @@ class Post:
return self.title return self.title
else: else:
return '(untitled post)' return '(untitled post)'
def ymd_date(self):
dt = datetime.datetime.fromtimestamp(self.ts_created)
return dt.strftime('%Y-%m-%d')
class File:
def __init__(self, id, segment, user, name, mimetype, data):
self.id = id
self.segment = segment
self.user = user
self.name = name
self.mimetype = mimetype
self.data = data
class Database: class Database:
def __init__(self, cfg): def __init__(self, cfg):
self.cfg = cfg self.cfg = cfg
self.max_summary = 500
self.conn = mariadb.connect( self.conn = mariadb.connect(
user=cfg.get('db.user'), user=cfg.get('db.user'),
password=cfg.get('db.password'), password=cfg.get('db.password'),
@ -118,6 +135,7 @@ class Database:
db.execute("DROP TABLE IF EXISTS segments") db.execute("DROP TABLE IF EXISTS segments")
db.execute("DROP TABLE IF EXISTS notifs") db.execute("DROP TABLE IF EXISTS notifs")
db.execute("DROP TABLE IF EXISTS follow") db.execute("DROP TABLE IF EXISTS follow")
db.execute("DROP TABLE IF EXISTS files")
db.execute("""CREATE TABLE users ( db.execute("""CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
@ -195,6 +213,15 @@ class Database:
UNIQUE KEY (user, type, target) UNIQUE KEY (user, type, target)
)""") )""")
db.execute("""CREATE TABLE files (
id INT PRIMARY KEY AUTO_INCREMENT,
segment INT,
user INT NOT NULL,
name VARCHAR(1000) DEFAULT '',
mimetype VARCHAR(1000),
data MEDIUMBLOB
)""")
db.execute('INSERT INTO users (name, avatar, role) VALUES (?, ?, ?)', db.execute('INSERT INTO users (name, avatar, role) VALUES (?, ?, ?)',
('admin', '🚀', User.ADMIN)) ('admin', '🚀', User.ADMIN))
db.execute('INSERT INTO subspaces (name, info, owner) VALUES (?, ?, 1)', db.execute('INSERT INTO subspaces (name, info, owner) VALUES (?, ?, 1)',
@ -222,13 +249,47 @@ class Database:
def destroy_user(): def destroy_user():
pass pass
def create_file(self, user: User, name: str, mimetype: str, data: bytes) -> int:
cur = self.conn.cursor()
cur.execute("""
INSERT INTO files (segment, user, name, mimetype, data)
VALUES (?, ?, ?, ?, ?)""", (0, user.id, name, mimetype, data))
self.commit()
return cur.lastrowid
def get_file(self, id):
cur = self.conn.cursor()
cur.execute("""
SELECT segment, user, name, mimetype, data
FROM files
WHERE id=?""", (id,))
for (segment, user, name, mimetype, data) in cur:
return File(id, segment, user, name, mimetype, data)
return None
def set_file_segment(self, file_id, segment_id):
print('set_file_segment', segment_id, file_id)
cur = self.conn.cursor()
cur.execute("UPDATE files SET segment=? WHERE id=?", (segment_id, file_id))
self.commit()
def destroy_post(self, post):
cur = self.conn.cursor()
cur.execute('DELETE FROM files WHERE segment IN (SELECT id FROM segments WHERE post=?)',
(post.id,))
cur.execute('DELETE FROM segments WHERE post=?', (post.id,))
cur.execute('DELETE FROM posts WHERE id=?', (post.id,))
self.commit()
def create_subspace(): def create_subspace():
pass pass
def destroy_subspace(): def destroy_subspace():
pass pass
def create_post(self, user, subspace_id, title=''): def create_post(self, user: User, subspace_id: int, title=''):
assert type(user) is User assert type(user) is User
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(""" cur.execute("""
@ -238,7 +299,48 @@ class Database:
self.commit() self.commit()
return cur.lastrowid return cur.lastrowid
def create_segment(self, post, type, content=None, url=None): def update_post_summary(self, post):
# Render a concise version of the segments to be displayed in feeds,
# save in the `render` field.
segments = self.get_segments(post)
render = ''
# Use only the first link/attachment.
for seg in filter(lambda s: s.type in [Segment.LINK,
Segment.ATTACHMENT], segments):
render += f'=> {seg.url}{seg.content}\n'
break
if len(post.title):
render += post.title
first = True
for text in filter(lambda s: s.type == Segment.TEXT, segments):
str = clean_title(text.content)
if len(str) == 0: continue
if len(post.title) and first:
# Separate title from the body text.
render += ''
first = False
if len(str) > self.max_summary:
render += str[:self.max_summary] + "..."
break
render += str + ' '
for seg in filter(lambda s: s.type == Segment.IMAGE, segments):
render += f'\n=> {seg.url} {seg.content}\n'
break
if len(render) and not render.endswith('\n'):
render += '\n'
post.render = render
cur = self.conn.cursor()
cur.execute('UPDATE posts SET render=? WHERE id=?', (render, post.id))
self.commit()
def create_segment(self, post, type, content=None, url=None) -> int:
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(""" cur.execute("""
INSERT INTO segments (post, type, content, url) INSERT INTO segments (post, type, content, url)
@ -249,17 +351,22 @@ class Database:
cur.execute("UPDATE segments SET pos=(SELECT MAX(pos) FROM segments) + 1 WHERE id=?", cur.execute("UPDATE segments SET pos=(SELECT MAX(pos) FROM segments) + 1 WHERE id=?",
(seg_id,)) (seg_id,))
self.commit() self.commit()
return cur.lastrowid return seg_id
def get_user(self, identity=None, name=None): def get_user(self, id=None, identity=None, name=None):
cur = self.conn.cursor() cur = self.conn.cursor()
cond = 'fp_cert=?' if identity else 'name=?' if id != None:
cond_value = identity.fp_cert if identity else name cond = 'id=?'
cond_value = id
else:
cond = 'fp_cert=?' if identity else 'name=?'
cond_value = identity.fp_cert if identity else name
cur.execute(f""" cur.execute(f"""
SELECT SELECT
id, name, info, avatar, role, flags, id, name, info, avatar, role, flags,
notif, email, email_notif, notif, email, email_notif,
ts_created, ts_active, UNIX_TIMESTAMP(ts_created),
UNIX_TIMESTAMP(ts_active),
num_notifs, sort_post, sort_cmt num_notifs, sort_post, sort_cmt
FROM users FROM users
WHERE {cond}""", (cond_value,)) WHERE {cond}""", (cond_value,))
@ -271,7 +378,7 @@ class Database:
return None return None
def get_posts(self, id=None, user=None, draft=False): def get_posts(self, id=None, user=None, draft=None):
cur = self.conn.cursor() cur = self.conn.cursor()
where_stm = [] where_stm = []
values = [] values = []
@ -281,18 +388,19 @@ class Database:
if user != None: if user != None:
where_stm.append('user=?') where_stm.append('user=?')
values.append(user.id) values.append(user.id)
where_stm.append('draft=?') if draft != None:
values.append(draft) where_stm.append('is_draft=?')
values.append(draft)
cur.execute(f""" cur.execute(f"""
SELECT SELECT
subspace, parent, user, title, is_draft, is_pinned, ts_created, render id, subspace, parent, user, title, is_draft, is_pinned, UNIX_TIMESTAMP(ts_created), render
FROM posts FROM posts
WHERE {' AND '.join(where_stm)} WHERE {' AND '.join(where_stm)}
ORDER BY ts_created ORDER BY ts_created
""", tuple(values)) """, tuple(values))
posts = [] posts = []
for (subspace, parent, user, title, is_draft, is_pinned, ts_created, render) in cur: for (id, subspace, parent, user, title, is_draft, is_pinned, ts_created, render) in cur:
posts.append(Post(id, subspace, parent, user, title, is_draft, is_pinned, posts.append(Post(id, subspace, parent, user, title, is_draft, is_pinned,
ts_created, render)) ts_created, render))
return posts return posts
@ -361,8 +469,9 @@ class Database:
return segments return segments
def delete_segment(self, segment): def destroy_segment(self, segment):
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute('DELETE FROM files WHERE segment=?', (segment.id,))
cur.execute('DELETE FROM segments WHERE id=?', (segment.id,)) cur.execute('DELETE FROM segments WHERE id=?', (segment.id,))
self.commit() self.commit()
@ -389,7 +498,7 @@ class Database:
cond_value = name cond_value = name
cur.execute(f""" cur.execute(f"""
SELECT id, name, info, flags, owner, ts_created, ts_active SELECT id, name, info, flags, owner, UNIX_TIMESTAMP(ts_created), UNIX_TIMESTAMP(ts_active)
FROM subspaces FROM subspaces
WHERE {cond}""", WHERE {cond}""",
(cond_value,)) (cond_value,))
@ -406,6 +515,10 @@ def is_valid_name(name):
return re.match(r'^[\w-]+$', name) != None return re.match(r'^[\w-]+$', name) != None
def plural_s(i):
return '' if i == 1 else 's'
def clean_text(text): def clean_text(text):
# TODO: Clean up the text. No => links, # H1s. # TODO: Clean up the text. No => links, # H1s.
@ -417,11 +530,28 @@ def clean_text(text):
def clean_title(title): def clean_title(title):
title = title.replace('\n', ' ').replace('\t', ' ').strip() # Strip `=>` and other Gemini syntax.
if len(title) and title[0] in '>#*': cleaned = []
title = title[1:].strip() syntax = re.compile(r'^(\s*=>\s*|\* |>\s*|##?#?)')
if len(title) and title[:2] == '=>': pre = False
title = title[2:].strip() for line in title.split('\n'):
if line[:3] == '```':
if not pre:
pre_label = line[3:].strip()
if len(pre_label) == 0:
pre_label = 'preformatted'
line = f'[{pre_label}]'
cleaned.append(line)
pre = not pre
continue
if pre:
continue
found = syntax.match(line)
if found:
line = line[found.end():]
line = line.replace('\t', ' ')
cleaned.append(line)
title = ' '.join(cleaned).strip()
return title return title
@ -450,9 +580,11 @@ class Bubble:
self.hostname = hostname self.hostname = hostname
self.path = path # ends with / self.path = path # ends with /
# TODO: Read these from the configuration.
self.site_name = 'Geminispace.org' self.site_name = 'Geminispace.org'
self.site_info = 'A Small Social Hub' self.site_info = 'A Small Social Hub'
self.site_icon = '🌒' self.site_icon = '🌒'
self.max_file_size = 100 * 1024
def __call__(self, req): def __call__(self, req):
db = Database(self.cfg) db = Database(self.cfg)
@ -460,6 +592,64 @@ class Bubble:
db.close() db.close()
return response return response
def dashboard_link(self, db, user):
#notifs = ' — 0 notifications'
notifs = ''
num_drafts = db.count_posts(user=user, draft=True)
if num_drafts > 0:
notifs += f' — ✏️ {num_drafts} draft{plural_s(num_drafts)}'
return f'=> /dashboard {user.avatar} {user.name}{notifs}\n'
def feed_entry(self, db, post, user, context=None):
poster = db.get_user(id=post.user) # Pre-fetch the poster info.
src = f'=> /u/{poster.name} {poster.avatar} {poster.name}\n'
src += post.render
cmt = 'Comment'
if post.is_draft:
age = 'Now'
else:
age_seconds = max(0, int(time.time()) - post.ts_created)
age = f'{age_seconds} seconds'
sub = ''
if not context or context.id != post.subspace:
sub = ' · ' + db.get_subspace(id=post.subspace).title()
src += f'=> /u/{poster.name}/{post.id} 💬 {cmt}{sub} · {age}\n'
# TODO: Show if there if a notification related to this.
return src
def render_post(self, db, post):
"""Render the final full presentation of the post, with all segments included."""
src = ''
if len(post.title):
src += f'# {post.title}\n\n'
# TODO: Metadata?
last_type = None
for segment in db.get_segments(post):
# Optionally, separate by newline.
if last_type != None:
if last_type != segment.type or (last_type == Segment.TEXT and
segment.type == Segment.TEXT):
src += '\n'
if segment.type == Segment.TEXT:
src += segment.content + '\n'
last_type = segment.type
elif segment.type in [Segment.LINK, Segment.IMAGE, Segment.ATTACHMENT]:
src += f'=> {segment.url} {segment.content}\n'
last_type = segment.type
return src
def respond(self, db, req): def respond(self, db, req):
user = None user = None
@ -521,7 +711,7 @@ class Bubble:
if not user: if not user:
return 60, 'Must be signed in to edit posts' return 60, 'Must be signed in to edit posts'
found = re.match(r'^(edit|move)-segment/(\d)$', req.path[len(self.path):]) found = re.match(r'^(edit|move)-segment/(\d+)$', req.path[len(self.path):])
seg_action = found.group(1) seg_action = found.group(1)
seg_id = int(found.group(2)) seg_id = int(found.group(2))
segment = db.get_segment(seg_id) segment = db.get_segment(seg_id)
@ -548,11 +738,15 @@ class Bubble:
seg_text = clean_text(urlparse.unquote(req.query)) seg_text = clean_text(urlparse.unquote(req.query))
else: else:
seg_text = clean_text(req.content.decode('utf-8')) seg_text = clean_text(req.content.decode('utf-8'))
db.update_segment(segment, content=seg_text) db.update_segment(segment, content=seg_text)
elif segment.type == Segment.IMAGE or segment.type == Segment.ATTACHMENT:
seg_text = clean_title(urlparse.unquote(req.query))
db.update_segment(segment, content=seg_text)
else: else:
arg = urlparse.unquote(req.query).strip() arg = urlparse.unquote(req.query).strip()
if arg.upper() == 'X': if arg.upper() == 'X':
db.delete_segment(segment) db.destroy_segment(segment)
else: else:
db.move_segment(post, segment, int(arg) - 1) db.move_segment(post, segment, int(arg) - 1)
@ -568,7 +762,7 @@ class Bubble:
elif req.path.startswith(self.path + 'edit/'): elif req.path.startswith(self.path + 'edit/'):
try: try:
found = re.match(r'^(\d)(/([\w-]+))?$', req.path[len(self.path + 'edit/'):]) found = re.match(r'^(\d+)(/([\w-]+))?$', req.path[len(self.path + 'edit/'):])
post_id = int(found.group(1)) post_id = int(found.group(1))
post_action = found.group(3) post_action = found.group(3)
post = db.get_post(post_id) post = db.get_post(post_id)
@ -600,18 +794,76 @@ class Bubble:
return 30, link return 30, link
return 10, 'Add link: (URL followed by label, separated with space)' return 10, 'Add link: (URL followed by label, separated with space)'
if post_action == 'add-file' and is_titan:
if len(req.content) > self.max_file_size:
return 50, f'File attachments must be less than {int(self.max_file_size / 1024)} KB'
if req.content_token:
fn = req.content_token.strip()
else:
fn = ''
mime = 'application/octet-stream'
if req.content_mime:
mime = req.content_mime.lower().split(';')[0]
file_id = db.create_file(user, fn, mime, req.content)
#fn = str(file_id)
is_image = mime.startswith('image/')
#if is_image:
url_path = '/u/' + user.name + '/'
url_path += 'image' if is_image else 'file'
url_path += f'/{file_id}'
if len(fn):
# TODO: Clean up the filename.
url_path += '/' + fn
EXTENSIONS = {
'image/jpeg': '.jpeg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif'
}
if len(fn) == 0 and mime in EXTENSIONS:
url_path += EXTENSIONS[mime]
segment_id = db.create_segment(post,
Segment.IMAGE if is_image else Segment.ATTACHMENT,
url=url_path, content=fn)
db.set_file_segment(file_id, segment_id)
return 30, gemini_link
if post_action == 'title': if post_action == 'title':
if len(req.query): if len(req.query):
title_text = clean_title(urlparse.unquote(req.query)) title_text = clean_title(urlparse.unquote(req.query))
db.update_post(post, title=title_text) db.update_post(post, title=title_text)
return 30, link return 30, link
return 10, 'Enter post title:' return 10, 'Enter post title:'
if post_action == 'preview':
db.update_post_summary(post)
page = f'=> {link}/publish ➠ Publish now\n'
page += f'=> {link} Edit draft\n'
page += '\n# Feed Preview\n'
page += self.feed_entry(db, post, user)
if not post.title:
page += '\n# Post Preview\n'
else:
page += '\n\n'
page += self.render_post(db, post)
return page
if post_action == 'delete':
if req.query.upper() == 'YES':
dst = '/dashboard' if post.is_draft else '/u/' + user.name
db.destroy_post(post)
return 30, dst
elif len(req.query) > 0:
return 30, f'/edit/{post.id}'
return 10, f'Delete post {post.id}: {post.ymd_date()} {post.title_text()}? (Enter YES to confirm)'
# The Post Composer. # The Post Composer.
page = f'# {post.title_text()}\n' page = self.dashboard_link(db, user)
page += f'=> {link}/title Edit title\n' page += f'=> {link}/preview 👁️ Preview post\n'
page += f'=> /{subspace.title()} {subspace.title()}\n\n' page += f'\n# {post.title_text()}\n'
page += f'=> {link}/title Edit post title\n'
# Segments. # Segments.
##SEPARATOR = '\n\n' ##SEPARATOR = '\n\n'
segments = db.get_segments(post) segments = db.get_segments(post)
@ -621,7 +873,7 @@ class Bubble:
sid += 1 sid += 1
if len(segments) > 1: if len(segments) > 1:
page += f'\n—— § {sid}\n\n' page += f'\n## — § {sid} \n\n'
else: else:
page += '\n' page += '\n'
@ -634,12 +886,12 @@ class Bubble:
page += f"=> {seg_link}/{segment.id} Edit link\n" page += f"=> {seg_link}/{segment.id} Edit link\n"
elif segment.type == Segment.IMAGE: elif segment.type == Segment.IMAGE:
page += '\n(image)\n' page += f'=> {segment.url} {segment.content}\n'
page += f"=> titan://{self.hostname}/{seg_link}/{segment.id} Replace image\n" page += f'=> {seg_link}/{segment.id} Edit caption\n'
elif segment.type == Segment.ATTACHMENT: elif segment.type == Segment.ATTACHMENT:
page += '\n(attachment\n' page += f'=> {segment.url} {segment.content}\n'
page += f"=> titan://{self.hostname}/{seg_link}/{segment.id} Replace attachment\n" page += f'=> {seg_link}/{segment.id} Edit label\n'
elif segment.type == Segment.POLL: elif segment.type == Segment.POLL:
page += f'\n* {segment.content}\n' page += f'\n* {segment.content}\n'
@ -654,11 +906,9 @@ class Bubble:
page += f'=> {link}/add-text Add text\n' page += f'=> {link}/add-text Add text\n'
page += f'=> titan://{self.hostname}/edit/{post.id}/add-text Add long text\n' page += f'=> titan://{self.hostname}/edit/{post.id}/add-text Add long text\n'
page += f'=> {link}/add-link Add link\n' page += f'=> {link}/add-link Add link\n'
page += f'=> titan://{self.hostname}/edit/{post.id}/add-image Add image\n' page += f'=> titan://{self.hostname}/edit/{post.id}/add-file Add image or file attachment\nOptionally, you can set a filename with the token field.\n'
page += f'=> titan://{self.hostname}/edit/{post.id}/add-file Add attachment\n' page += f'=> {link}/add-poll Add poll option\n'
page += f'=> {link}/add-link Add poll option\n'
page += f'\n=> {link}/publish Preview and publish\n'
page += f'\n=> {link}/delete ❌ Delete post\n' page += f'\n=> {link}/delete ❌ Delete post\n'
return page return page
@ -671,26 +921,27 @@ class Bubble:
return 51, 'Not found' return 51, 'Not found'
elif req.path == self.path + 'dashboard': elif req.path == self.path + 'dashboard':
page = f'# {user.name}\n' page = f'# {user.avatar} {user.name}\n'
page += f'\n## Notifications\n'
page += 'No notifications.\n' page += f'\n## 0 Notifications\n'
page += f'\n## Drafts\n'
drafts = db.get_posts(user=user, draft=True) drafts = db.get_posts(user=user, draft=True)
n = len(drafts)
page += f'\n## {n} Draft{plural_s(n)}\n'
if len(drafts) > 0: if len(drafts) > 0:
for post in drafts: for post in drafts:
page += f'=> /edit/{post.id} {post.ymd_date()} {post.title_text()}\n' page += f'=> /edit/{post.id} {post.ymd_date()} {post.title_text()}\n'
else: page += f'\n=> /u/{user.name} u/{user.name}\n'
page += 'No drafts.\n'
page += f'=> /u/{user.name} u/{user.name}\n'
return page return page
elif req.path.startswith(self.path + 'u/'): elif req.path.startswith(self.path + 'u/'):
try: try:
# User feed. # User feed.
path = req.path[len(self.path):] path = req.path[len(self.path):]
found = re.match(r'u/([\w-]+)(/(post))?', path) found = re.match(r'u/([\w-]+)(/(post|image|file))?(/(\d+).*)?', path)
u_name = found.group(1) u_name = found.group(1)
u_action = found.group(3) u_action = found.group(3)
arg = found.group(5)
c_user = db.get_user(name=u_name) c_user = db.get_user(name=u_name)
context = db.get_subspace(owner=c_user.id) context = db.get_subspace(owner=c_user.id)
@ -698,6 +949,10 @@ class Bubble:
draft_id = db.create_post(user, context.id) draft_id = db.create_post(user, context.id)
return 30, '/edit/%d' % draft_id return 30, '/edit/%d' % draft_id
if u_action == 'image' or u_action == 'file':
file = db.get_file(int(arg))
return 20, file.mimetype, file.data
page += f'# {context.title()}\n' page += f'# {context.title()}\n'
except Exception as x: except Exception as x:
@ -735,14 +990,7 @@ class Bubble:
page += f'=> /s/ {self.site_icon} Subspaces\n' page += f'=> /s/ {self.site_icon} Subspaces\n'
page += FOOTER_MENU page += FOOTER_MENU
else: else:
#notifs = ' — 0 notifications' page += self.dashboard_link(db, user)
notifs = ''
num_drafts = db.count_posts(user=user, draft=True)
if num_drafts > 0:
notifs += f' — ✏️ {num_drafts} draft{"s" if num_drafts != 1 else ""}'
page += f'=> /u/{user.name}/dashboard {user.avatar} {user.name}{notifs}\n'
if c_user and c_user.id == user.id: if c_user and c_user.id == user.id:
page += f'=> /u/{user.name}/post ✏️ New post\n' page += f'=> /u/{user.name}/post ✏️ New post\n'
elif context and context.owner == 0: elif context and context.owner == 0:
@ -775,16 +1023,15 @@ class Bubble:
def init(capsule): def init(capsule):
cfg = capsule.config() cfg = capsule.config()
try: try:
mod_cfg = cfg.section('bubble') mod_cfg = cfg.section('bubble')
hostname = mod_cfg.get('host', fallback=cfg.hostnames()[0]) path = mod_cfg.get('path', fallback='/')
path = mod_cfg.get('path', fallback='/')
if not path.endswith('/'): path += '/' if not path.endswith('/'): path += '/'
responder = Bubble(hostname, path, mod_cfg) for hostname in cfg.hostnames():
capsule.add(path + '*', responder, hostname, protocol='gemini') responder = Bubble(hostname, path, mod_cfg)
capsule.add(path + '*', responder, hostname, protocol='titan') capsule.add(path + '*', responder, hostname, protocol='gemini')
capsule.add(path + '*', responder, hostname, protocol='titan')
except KeyError: except KeyError:
pass pass