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__
bubble.ini
server.sh
.certs/
.vscode/

View file

@ -1,7 +1,9 @@
"""Bubble - Bulletin Boards for Gemini"""
import datetime
import mariadb
import re
import time
import urllib.parse as urlparse
@ -90,11 +92,26 @@ class Post:
return self.title
else:
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:
def __init__(self, cfg):
self.cfg = cfg
self.max_summary = 500
self.conn = mariadb.connect(
user=cfg.get('db.user'),
password=cfg.get('db.password'),
@ -118,6 +135,7 @@ class Database:
db.execute("DROP TABLE IF EXISTS segments")
db.execute("DROP TABLE IF EXISTS notifs")
db.execute("DROP TABLE IF EXISTS follow")
db.execute("DROP TABLE IF EXISTS files")
db.execute("""CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
@ -195,6 +213,15 @@ class Database:
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 (?, ?, ?)',
('admin', '🚀', User.ADMIN))
db.execute('INSERT INTO subspaces (name, info, owner) VALUES (?, ?, 1)',
@ -222,13 +249,47 @@ class Database:
def destroy_user():
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():
pass
def destroy_subspace():
pass
def create_post(self, user, subspace_id, title=''):
def create_post(self, user: User, subspace_id: int, title=''):
assert type(user) is User
cur = self.conn.cursor()
cur.execute("""
@ -238,7 +299,48 @@ class Database:
self.commit()
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.execute("""
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=?",
(seg_id,))
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()
cond = 'fp_cert=?' if identity else 'name=?'
cond_value = identity.fp_cert if identity else name
if id != None:
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"""
SELECT
id, name, info, avatar, role, flags,
notif, email, email_notif,
ts_created, ts_active,
UNIX_TIMESTAMP(ts_created),
UNIX_TIMESTAMP(ts_active),
num_notifs, sort_post, sort_cmt
FROM users
WHERE {cond}""", (cond_value,))
@ -271,7 +378,7 @@ class Database:
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()
where_stm = []
values = []
@ -281,18 +388,19 @@ class Database:
if user != None:
where_stm.append('user=?')
values.append(user.id)
where_stm.append('draft=?')
values.append(draft)
if draft != None:
where_stm.append('is_draft=?')
values.append(draft)
cur.execute(f"""
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
WHERE {' AND '.join(where_stm)}
ORDER BY ts_created
""", tuple(values))
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,
ts_created, render))
return posts
@ -361,8 +469,9 @@ class Database:
return segments
def delete_segment(self, segment):
def destroy_segment(self, segment):
cur = self.conn.cursor()
cur.execute('DELETE FROM files WHERE segment=?', (segment.id,))
cur.execute('DELETE FROM segments WHERE id=?', (segment.id,))
self.commit()
@ -389,7 +498,7 @@ class Database:
cond_value = name
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
WHERE {cond}""",
(cond_value,))
@ -406,6 +515,10 @@ def is_valid_name(name):
return re.match(r'^[\w-]+$', name) != None
def plural_s(i):
return '' if i == 1 else 's'
def clean_text(text):
# TODO: Clean up the text. No => links, # H1s.
@ -417,11 +530,28 @@ def clean_text(text):
def clean_title(title):
title = title.replace('\n', ' ').replace('\t', ' ').strip()
if len(title) and title[0] in '>#*':
title = title[1:].strip()
if len(title) and title[:2] == '=>':
title = title[2:].strip()
# Strip `=>` and other Gemini syntax.
cleaned = []
syntax = re.compile(r'^(\s*=>\s*|\* |>\s*|##?#?)')
pre = False
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
@ -450,9 +580,11 @@ class Bubble:
self.hostname = hostname
self.path = path # ends with /
# TODO: Read these from the configuration.
self.site_name = 'Geminispace.org'
self.site_info = 'A Small Social Hub'
self.site_icon = '🌒'
self.max_file_size = 100 * 1024
def __call__(self, req):
db = Database(self.cfg)
@ -460,6 +592,64 @@ class Bubble:
db.close()
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):
user = None
@ -521,7 +711,7 @@ class Bubble:
if not user:
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_id = int(found.group(2))
segment = db.get_segment(seg_id)
@ -548,11 +738,15 @@ class Bubble:
seg_text = clean_text(urlparse.unquote(req.query))
else:
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:
arg = urlparse.unquote(req.query).strip()
if arg.upper() == 'X':
db.delete_segment(segment)
db.destroy_segment(segment)
else:
db.move_segment(post, segment, int(arg) - 1)
@ -568,7 +762,7 @@ class Bubble:
elif req.path.startswith(self.path + 'edit/'):
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_action = found.group(3)
post = db.get_post(post_id)
@ -600,18 +794,76 @@ class Bubble:
return 30, link
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 len(req.query):
title_text = clean_title(urlparse.unquote(req.query))
db.update_post(post, title=title_text)
return 30, link
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.
page = f'# {post.title_text()}\n'
page += f'=> {link}/title Edit title\n'
page += f'=> /{subspace.title()} {subspace.title()}\n\n'
page = self.dashboard_link(db, user)
page += f'=> {link}/preview 👁️ Preview post\n'
page += f'\n# {post.title_text()}\n'
page += f'=> {link}/title Edit post title\n'
# Segments.
##SEPARATOR = '\n\n'
segments = db.get_segments(post)
@ -621,7 +873,7 @@ class Bubble:
sid += 1
if len(segments) > 1:
page += f'\n—— § {sid}\n\n'
page += f'\n## — § {sid} \n\n'
else:
page += '\n'
@ -634,12 +886,12 @@ class Bubble:
page += f"=> {seg_link}/{segment.id} Edit link\n"
elif segment.type == Segment.IMAGE:
page += '\n(image)\n'
page += f"=> titan://{self.hostname}/{seg_link}/{segment.id} Replace image\n"
page += f'=> {segment.url} {segment.content}\n'
page += f'=> {seg_link}/{segment.id} Edit caption\n'
elif segment.type == Segment.ATTACHMENT:
page += '\n(attachment\n'
page += f"=> titan://{self.hostname}/{seg_link}/{segment.id} Replace attachment\n"
page += f'=> {segment.url} {segment.content}\n'
page += f'=> {seg_link}/{segment.id} Edit label\n'
elif segment.type == Segment.POLL:
page += f'\n* {segment.content}\n'
@ -654,11 +906,9 @@ class Bubble:
page += f'=> {link}/add-text Add 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'=> titan://{self.hostname}/edit/{post.id}/add-image Add image\n'
page += f'=> titan://{self.hostname}/edit/{post.id}/add-file Add attachment\n'
page += f'=> {link}/add-link Add poll option\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'=> {link}/add-poll Add poll option\n'
page += f'\n=> {link}/publish Preview and publish\n'
page += f'\n=> {link}/delete ❌ Delete post\n'
return page
@ -671,26 +921,27 @@ class Bubble:
return 51, 'Not found'
elif req.path == self.path + 'dashboard':
page = f'# {user.name}\n'
page += f'\n## Notifications\n'
page += 'No notifications.\n'
page += f'\n## Drafts\n'
page = f'# {user.avatar} {user.name}\n'
page += f'\n## 0 Notifications\n'
drafts = db.get_posts(user=user, draft=True)
n = len(drafts)
page += f'\n## {n} Draft{plural_s(n)}\n'
if len(drafts) > 0:
for post in drafts:
page += f'=> /edit/{post.id} {post.ymd_date()} {post.title_text()}\n'
else:
page += 'No drafts.\n'
page += f'=> /u/{user.name} u/{user.name}\n'
page += f'\n=> /u/{user.name} u/{user.name}\n'
return page
elif req.path.startswith(self.path + 'u/'):
try:
# User feed.
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_action = found.group(3)
arg = found.group(5)
c_user = db.get_user(name=u_name)
context = db.get_subspace(owner=c_user.id)
@ -698,6 +949,10 @@ class Bubble:
draft_id = db.create_post(user, context.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'
except Exception as x:
@ -735,14 +990,7 @@ class Bubble:
page += f'=> /s/ {self.site_icon} Subspaces\n'
page += FOOTER_MENU
else:
#notifs = ' — 0 notifications'
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'
page += self.dashboard_link(db, user)
if c_user and c_user.id == user.id:
page += f'=> /u/{user.name}/post ✏️ New post\n'
elif context and context.owner == 0:
@ -775,16 +1023,15 @@ class Bubble:
def init(capsule):
cfg = capsule.config()
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 += '/'
responder = Bubble(hostname, path, mod_cfg)
capsule.add(path + '*', responder, hostname, protocol='gemini')
capsule.add(path + '*', responder, hostname, protocol='titan')
for hostname in cfg.hostnames():
responder = Bubble(hostname, path, mod_cfg)
capsule.add(path + '*', responder, hostname, protocol='gemini')
capsule.add(path + '*', responder, hostname, protocol='titan')
except KeyError:
pass