Julian Marcos
f3dff8f3a4
I have modified the code to make sure the creator always sees the results. I think this is the desired behaviour of the issue #83. I have tested that this works, but I haven't tested if it might interfere with some other functionality. I hope not. Closes #83.
846 lines
41 KiB
Python
846 lines
41 KiB
Python
"""Bubble - Bulletin Boards for Gemini"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.parse as urlparse
|
|
|
|
sys.path.append(os.path.dirname(__file__)) # import from module directory
|
|
from admin import *
|
|
from composer import *
|
|
from feeds import *
|
|
from model import *
|
|
from settings import *
|
|
from subspace import *
|
|
from user import *
|
|
from utils import *
|
|
from worker import *
|
|
|
|
|
|
__version__ = '6.9'
|
|
|
|
|
|
class Bubble:
|
|
def __init__(self, capsule, hostname, port, path, cfg):
|
|
self.capsule = capsule
|
|
self.cfg = cfg # [bubble] in the server configuration
|
|
self.hostname = hostname
|
|
self.port = port
|
|
self.path = path # ends with /
|
|
|
|
self.site_icon = cfg.get('icon', '💬')
|
|
self.site_name = cfg.get('name', 'Bubble')
|
|
self.site_info = cfg.get('info', "Bulletin Boards for Gemini")
|
|
self.site_info_nouser = cfg.get('info.nouser', self.site_info)
|
|
self.likes_enabled = cfg.getboolean('user.likes', True)
|
|
self.thanks_enabled = cfg.getboolean('user.thanks', True)
|
|
self.user_reactions = cfg.get('user.reactions', '😄 ❤️ 🙏 🙁 🤔 🔥').strip() # list of allowed Emoji
|
|
self.user_register = cfg.getboolean('user.register', True)
|
|
self.user_subspaces = cfg.getboolean('user.subspaces', True)
|
|
self.admin_certpass = cfg.get('admin.certpass', '')
|
|
self.antenna_url = cfg.get('antenna.url', 'gemini://warmedal.se/~antenna/submit')
|
|
self.version = __version__
|
|
|
|
# TODO: Could just check capsule Config's titan.upload_limit?
|
|
self.max_file_size = cfg.getint('file.maxsize', 100 * 1024)
|
|
|
|
# Create database if needed.
|
|
db = Database(self.cfg)
|
|
db.create_tables(self.admin_certpass)
|
|
db.close()
|
|
|
|
def is_feedback_enabled(self):
|
|
return len(self.user_reactions) or self.thanks_enabled or self.likes_enabled
|
|
|
|
def __call__(self, req):
|
|
started_at = time.time()
|
|
db = Database(self.cfg)
|
|
try:
|
|
return self.respond(db, req)
|
|
except GeminiError as error:
|
|
return error.code, str(error)
|
|
except Exception as fatal:
|
|
import traceback
|
|
traceback.print_tb(fatal.__traceback__)
|
|
print(fatal)
|
|
return 42, 'Bubble Error: ' + str(fatal)[:500].replace('\n', ' ')
|
|
finally:
|
|
db.close()
|
|
# Slow request monitoring.
|
|
ended_at = time.time()
|
|
millisecs = (ended_at - started_at) * 1000
|
|
if millisecs > 50:
|
|
print(" (took %01.3f ms)" % millisecs)
|
|
|
|
class Session:
|
|
def __init__(self, bubble, db, req):
|
|
self.BANNER = f'# {bubble.site_icon} {bubble.site_name}\n'
|
|
self.FOOTER_MENU = "=> /help 📖 Help\n=> /conduct ❤️ Code of conduct\n"
|
|
self.NAME_HINT = '(You may use letters, digits, underscores, and dashes.)'
|
|
self.CHECKS = ['☐', '☑️']
|
|
self.ABOUT = f"""# 💬 Bubble
|
|
## Version {bubble.version}
|
|
=> https://skyjake.fi/@jk by @jk@skyjake.fi
|
|
|
|
Bubble is a Gemini bulletin board system with many influences from station.martinrue.com, Reddit, WordPress, and issue trackers like GitHub Issues. It supports both single-user and multi-user instances and provides personal blog feeds, moderated per-topic groups, and a common feed that includes all posts. It runs on Python and requires the GmCapsule Gemini server.
|
|
|
|
Bubble is open source:
|
|
=> gemini://git.skyjake.fi/bubble/main/ Bubble Git Repository
|
|
"""
|
|
self.EMPTY_FEED_PLACEHOLDER = "Emptiness is a boundless canvas, an unconstrained beginning: an opportunity for the courageous to create and the curious to explore."
|
|
|
|
self.bubble = bubble
|
|
self.path = bubble.path
|
|
self.db = db
|
|
self.req = req
|
|
self.user = None
|
|
self.is_titan = req.scheme == 'titan'
|
|
self.is_gemini = not self.is_titan
|
|
self.is_user_mod = False
|
|
self.feed_type = None
|
|
self.feed_mode = 'all'
|
|
self.feed_tag_filter = None
|
|
self.context = None # Subspace
|
|
self.context_mods = []
|
|
self.is_context_locked = False
|
|
self.is_context_tracker = False
|
|
self.user_follows = set()
|
|
self.user_mutes = set()
|
|
self.c_user = None # User associated with the context subspace, if any
|
|
self.token = None
|
|
self.notif_count = None
|
|
self.is_short_preview = False
|
|
self.is_archive = False
|
|
self.tz = pytz.timezone('UTC')
|
|
|
|
def set_user(self, user):
|
|
self.user = user
|
|
if user:
|
|
try:
|
|
self.tz = pytz.timezone(user.timezone)
|
|
except:
|
|
pass
|
|
if user.flags & User.ASCII_ICONS_FLAG:
|
|
self.CHECKS = [ '[_]', '[x]' ]
|
|
# TODO: Add more of these.
|
|
self.is_short_preview = (user.flags & User.SHORT_PREVIEW_FLAG) != 0
|
|
|
|
def is_likes_enabled(self):
|
|
enabled = self.bubble.likes_enabled
|
|
if self.user:
|
|
enabled = enabled and (self.user.flags & User.HIDE_LIKES_FLAG) == 0
|
|
return enabled
|
|
|
|
def is_thanks_enabled(self):
|
|
enabled = self.bubble.thanks_enabled
|
|
if self.user:
|
|
enabled = enabled and (self.user.flags & User.HIDE_THANKS_FLAG) == 0
|
|
return enabled
|
|
|
|
def is_reactions_enabled(self):
|
|
enabled = len(self.bubble.user_reactions) > 0
|
|
if self.user:
|
|
enabled = enabled and (self.user.flags & User.HIDE_REACTIONS_FLAG) == 0
|
|
return enabled
|
|
|
|
def is_editable(self, post: Post):
|
|
if self.user:
|
|
return self.user.role == User.ADMIN or post.user == self.user.id
|
|
return False
|
|
|
|
def is_unpublishable(self, post: Post):
|
|
return post.num_cmts == 0 and self.is_editable(post)
|
|
|
|
def is_deletable(self, post: Post):
|
|
if not self.user:
|
|
return False
|
|
if self.is_editable(post):
|
|
return True
|
|
return post.subspace in self.user.moderated_subspace_ids
|
|
|
|
def is_movable(self, post: Post):
|
|
if post.issueid:
|
|
return False
|
|
return self.is_deletable(post)
|
|
|
|
def is_title_editable(self, post: Post):
|
|
# Moderators can edit post titles.
|
|
return self.is_deletable(post)
|
|
|
|
def get_token(self):
|
|
if not self.token:
|
|
self.token = self.db.get_token(self.user)
|
|
return self.token
|
|
|
|
def feed_title(self):
|
|
if self.c_user:
|
|
# User's feed can have a custom title.
|
|
if self.context.info:
|
|
return self.context.info
|
|
return self.c_user.name
|
|
if self.context:
|
|
return self.context.title()
|
|
return self.bubble.site_name
|
|
|
|
def num_notifs(self):
|
|
if not self.user:
|
|
return 0
|
|
if self.notif_count is None:
|
|
self.notif_count = self.db.count_notifications(self.user)
|
|
return self.notif_count
|
|
|
|
def dashboard_link(self):
|
|
notifs = ''
|
|
|
|
num_notifs = self.num_notifs()
|
|
if num_notifs > 0:
|
|
notifs += f' — 🔔 {num_notifs} notification{plural_s(num_notifs)}'
|
|
|
|
num_drafts = self.db.count_posts(user=self.user, draft=True)
|
|
if num_drafts > 0:
|
|
notifs += f' — ✏️ {num_drafts} draft{plural_s(num_drafts)}'
|
|
|
|
if len(notifs) == 0:
|
|
notifs = ': Dashboard'
|
|
|
|
mode = ' — ★ ADMIN ' if self.user.role == User.ADMIN else ''
|
|
|
|
return f'=> /dashboard {self.user.avatar} {self.user.name}{notifs}{mode}\n'
|
|
|
|
def feed_entry(self, post, context=None):
|
|
# Check the metadata first.
|
|
is_issue_tracker = self.is_context_tracker
|
|
tag = ' · ' + post.tags if post.tags else ''
|
|
sub = ''
|
|
is_user_post = (post.sub_owner == post.user)
|
|
if not is_user_post:
|
|
if not context or context.id != post.subspace:
|
|
sub = f's/{post.sub_name}' if not is_user_post else post.sub_name
|
|
likes = ''
|
|
if self.is_likes_enabled() and post.num_likes > 0:
|
|
likes = f'{post.num_likes} like{plural_s(post.num_likes)}'
|
|
if is_issue_tracker:
|
|
cmt = ''
|
|
if post.num_cmts > 0:
|
|
cmt += f'{post.num_cmts} comment{plural_s(post.num_cmts)}'
|
|
else:
|
|
cmt = 'View post' if post.num_cmts == 0 and is_user_post else \
|
|
'' if post.num_cmts == 0 else \
|
|
f'{post.num_cmts} comment{plural_s(post.num_cmts)}'
|
|
age = post.age(tz=self.tz)
|
|
bell = ' 🔔' if post.num_notifs else ''
|
|
|
|
author = '🌒 ' + sub if sub else (post.poster_avatar + ' ' + post.poster_name)
|
|
author_link = f'/{sub}' if sub else f'/u/{post.poster_name}'
|
|
|
|
SHORT_PREVIEW_LEN = 160
|
|
|
|
if is_issue_tracker:
|
|
src = f'=> {post.page_url()} 🐞 [#{post.issueid if post.issueid else 0}] {post.title}{tag}\n'
|
|
src += shorten_text(post.summary,
|
|
200 if not self.is_short_preview else SHORT_PREVIEW_LEN) + '\n'
|
|
parts = []
|
|
parts.append(post.poster_name)
|
|
if cmt: parts.append(cmt)
|
|
if likes: parts.append(likes)
|
|
parts.append(age)
|
|
src += f'{post.poster_avatar} {" · ".join(parts)}{bell}\n'
|
|
else:
|
|
src = f'=> {author_link} {author}\n'
|
|
src += post.summary if not self.is_short_preview \
|
|
else shorten_text(post.summary, SHORT_PREVIEW_LEN) + '\n'
|
|
parts = []
|
|
if sub: parts.append(post.poster_name)
|
|
if cmt: parts.append(cmt)
|
|
if likes: parts.append(likes)
|
|
if len(parts) == 0: parts.append('View post')
|
|
parts.append(age)
|
|
src += f'=> {post.page_url()} 💬 {" · ".join(parts)}{bell}{tag}\n'
|
|
|
|
return src
|
|
|
|
def server_root(self, protocol='gemini'):
|
|
url = protocol + '://' + self.bubble.hostname
|
|
if self.bubble.port != 1965:
|
|
url += f':{self.bubble.port}'
|
|
return url
|
|
|
|
def gemini_feed_entry(self, post, context=None):
|
|
vis_title = post.title
|
|
if len(vis_title) == 0:
|
|
vis_title = shorten_text(clean_title(post.summary), 100)
|
|
|
|
if self.is_context_tracker:
|
|
vis_title = f'[#{post.issueid if post.issueid else 0}] ' + vis_title
|
|
|
|
if not context or context.id != post.subspace:
|
|
author = f'{post.poster_name}: '
|
|
else:
|
|
author = ''
|
|
|
|
sub = ''
|
|
if self.is_context_tracker:
|
|
sub = f' · {post.poster_avatar} {post.poster_name}'
|
|
else:
|
|
if not context and post.sub_owner != post.user:
|
|
sub = ' · s/' + post.sub_name
|
|
elif post.sub_owner != post.user:
|
|
# In a subspace feed show the poster as a suffix. The "author" (title) of
|
|
# the feed is the subspace title.
|
|
sub = f' · {post.poster_avatar} {post.poster_name}'
|
|
|
|
src = f'=> {self.server_root()}{post.page_url()} {post.ymd_date()} {author}{vis_title}{sub}\n'
|
|
return src
|
|
|
|
def atom_feed_entry(self, post, context=None):
|
|
vis_title = post.title
|
|
if len(vis_title) == 0:
|
|
vis_title = shorten_text(clean_title(post.summary), 100)
|
|
if self.is_context_tracker:
|
|
vis_title = f'[#{post.issueid if post.issueid else 0}] ' + vis_title
|
|
author = post.poster_name
|
|
category = f'\n<category term="{context.title()}" />' if context else ''
|
|
page_url = self.server_root() + urlparse.quote(post.page_url())
|
|
|
|
return f"""
|
|
<entry>
|
|
<title>{vis_title}</title>
|
|
<author><name>{author}</name><uri>{self.server_root()}/u/{author}</uri></author>
|
|
<link href="{page_url}" />
|
|
<id>{page_url}</id>{category}
|
|
<published>{atom_timestamp(post.ts_created)}</published>
|
|
<updated>{atom_timestamp(post.ts_edited)}</updated>
|
|
<content type="html">{atom_escaped(gemtext_to_html(self.render_post(post, omit_title=True)))}</content>
|
|
</entry>
|
|
"""
|
|
|
|
def tinylog_entry(self, post):
|
|
src = f'## {post.ymd_hm_tz()}\n\n'
|
|
|
|
# Full-content feed.
|
|
page = self.render_post(post)
|
|
|
|
# If there is a poll, include a link to the original page.
|
|
has_poll = ('🗳️' in post.tags) # fastest way to check
|
|
if has_poll or post.num_cmts:
|
|
page += f'\n=> {self.server_root()}{post.page_url()} View {"poll" if has_poll else "comments"}\n'
|
|
|
|
src += clean_tinylog(page)
|
|
|
|
return src
|
|
|
|
def render_post(self, post, omit_title=False):
|
|
"""Render the final full presentation of the post, with all segments included."""
|
|
src = ''
|
|
if len(post.title) and not omit_title:
|
|
title_prefix = f'[#{post.issueid}] ' if post.issueid else ''
|
|
src += f'# {title_prefix}{post.title}\n\n'
|
|
|
|
# In comments, differentiate between content links and UI links for clarity.
|
|
link_prefix = INNER_LINK_PREFIX if post.parent else ''
|
|
|
|
last_type = None
|
|
for segment in self.db.get_segments(post, poll=False):
|
|
if segment.type == Segment.TEXT and segment.content.strip() == '':
|
|
# Ignore any blank segments. The composer should not let these be
|
|
# created in the first place...
|
|
continue
|
|
|
|
# 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 += prefix_links(segment.content, link_prefix) + '\n'
|
|
last_type = segment.type
|
|
|
|
elif segment.type in [Segment.LINK, Segment.IMAGE, Segment.ATTACHMENT]:
|
|
label = segment.content
|
|
if len(label) == 0:
|
|
# No label; show the URL sans `gemini`` scheme.
|
|
label = segment.url
|
|
if label.startswith('gemini://'):
|
|
label = label[9:]
|
|
src += f'=> {segment.url} {link_prefix}{label}\n'
|
|
last_type = segment.type
|
|
|
|
return src.rstrip() + '\n'
|
|
|
|
def render_poll(self, post, show_results=False):
|
|
options = self.db.get_segments(post, poll=True)
|
|
if not options:
|
|
return ''
|
|
|
|
if not self.user:
|
|
show_results = True
|
|
|
|
cur_vote = self.db.get_vote(self.user, post) if self.user else None
|
|
cur_vote_id = cur_vote.id if cur_vote else 0
|
|
show_results_poll_creator = self.user.id == post.user if self.user else False
|
|
total_votes = 0
|
|
for option in options:
|
|
total_votes += option.counter
|
|
|
|
total_votes_msg = ''
|
|
if total_votes == 0:
|
|
total_votes_msg = 'No votes have been cast.\n'
|
|
else:
|
|
total_votes_msg = f'{total_votes} vote{plural_s(total_votes)} {("was" if total_votes==1 else "were") if show_results else ("have been" if total_votes != 1 else "has been")} cast.\n'
|
|
|
|
src = '## Poll Results\n' if cur_vote or show_results or show_results_poll_creator else '## Poll\n'
|
|
|
|
if not show_results:
|
|
if not cur_vote:
|
|
src += "\nVote for an option:\n"
|
|
elif total_votes == 1 and cur_vote:
|
|
src += '\nYou have cast the first vote! 🎉\n\n'
|
|
total_votes_msg = ''
|
|
else:
|
|
src += '\n'
|
|
else:
|
|
src += '\n'
|
|
|
|
opt_num = 1
|
|
token = self.db.get_token(self.user) if self.user else None
|
|
|
|
for option in options:
|
|
if cur_vote_id == option.id:
|
|
icon = '✔︎ '
|
|
else:
|
|
icon = ''
|
|
vote_link = f'=> /vote/{option.id}/{token} ' if not show_results else ''
|
|
src += f'{vote_link}{opt_num}. {icon}{option.content}\n'
|
|
|
|
if cur_vote or show_results or show_results_poll_creator:
|
|
# Bar graph.
|
|
FG = '\u2588'
|
|
BG = '\u2581'
|
|
WIDTH = 24
|
|
|
|
share = option.counter / total_votes if total_votes else 0
|
|
pos = round(share * WIDTH)
|
|
|
|
src += FG * pos + BG * (WIDTH - pos) + (' %d%%' % round(share * 100)) + '\n'
|
|
|
|
opt_num += 1
|
|
|
|
if total_votes_msg:
|
|
src += '\n' + total_votes_msg
|
|
|
|
return src
|
|
|
|
def respond(self, db, req):
|
|
session = Bubble.Session(self, db, req)
|
|
page = ''
|
|
|
|
if req.identity:
|
|
# Find the user account.
|
|
session.set_user(db.get_user(identity=req.identity))
|
|
if session.user is None:
|
|
if req.path == self.path + 'register':
|
|
if not session.bubble.user_register:
|
|
return 61, 'User registration is closed'
|
|
try:
|
|
username = clean_query(req)
|
|
if not is_valid_name(username):
|
|
return 10, 'What is the name for the new account? ' + session.NAME_HINT
|
|
|
|
db.create_user(username, req.identity)
|
|
|
|
page = f'# Welcome, {username}!\n\n'
|
|
page += 'Your account has been created.\n\nNow you can start posting in subspaces and commenting on posts. You may want to check out the user profile settings on your account page:\n\n'
|
|
page += f"=> /u/{username}\n"
|
|
return page
|
|
|
|
except Exception as x:
|
|
import traceback
|
|
traceback.print_tb(x.__traceback__)
|
|
return 10, 'That name is already taken. ' + session.NAME_HINT
|
|
|
|
fp_cert = req.identity.fp_cert[:10].upper()
|
|
|
|
if req.path == self.path + 'add-cert':
|
|
if is_empty_query(req):
|
|
return 10, 'Enter your user name:'
|
|
return 30, '/add-cert/' + clean_query(req)
|
|
|
|
if req.path.startswith(self.path + 'add-cert/'):
|
|
m = re.search('/add-cert/([\w%-]+)$', req.path)
|
|
if not m:
|
|
return 59, 'Bad request'
|
|
|
|
if is_empty_query(req):
|
|
return 11, 'Password:'
|
|
|
|
password = urlparse.unquote(req.query)
|
|
user = db.get_user(name=urlparse.unquote(m[1]), password=password)
|
|
|
|
if not user or user.password_expiry() == 0:
|
|
return 51, 'Not found'
|
|
db.add_certificate(user, req.identity)
|
|
return 30, '/'
|
|
|
|
if req.path == self.path + 'recover-cert':
|
|
if is_empty_query(req):
|
|
return 10, f'Enter your user name: (Recovery URL must already be configured and match your client certificate {fp_cert}.)'
|
|
user = db.get_user(name=clean_query(req))
|
|
if not user or not user.recovery:
|
|
return 51, 'Not found'
|
|
try:
|
|
db.recover_certificate(user, req.identity)
|
|
return 30, '/'
|
|
except:
|
|
return 50, 'Recovery failed'
|
|
|
|
if req.path != self.path:
|
|
return 61, "Please go to the front page to register a certificate"
|
|
|
|
# Registration page.
|
|
page = f'{session.BANNER}\n## New Certificate Detected\n\n'
|
|
if session.bubble.user_register:
|
|
page += f'You can create a new user account with this certificate ({fp_cert}). '
|
|
else:
|
|
page += f'The fingerprint is {fp_cert}. '
|
|
page += 'If you already have an account, you can register this certificate as an alternative.\n\n'
|
|
if session.bubble.user_register:
|
|
page += f"=> /register Create account\n"
|
|
page += "\n=> /add-cert Add alternative certificate\n"
|
|
page += "=> /recover-cert Recover certificate\n"
|
|
return page
|
|
|
|
if not session.user and self.cfg.get('frontpage', None):
|
|
return unescape_ini_gemtext(self.cfg.get('frontpage'))
|
|
|
|
if req.path == self.path + 'conduct':
|
|
return '# Code of Conduct\n\n' + \
|
|
unescape_ini_gemtext(self.cfg.get('conduct', 'Contact the administrator for details.'))
|
|
|
|
if req.path == self.path + 'help':
|
|
page = f'# Help\n\n'
|
|
|
|
intro = self.cfg.get('help.intro', None)
|
|
if intro:
|
|
page += unescape_ini_gemtext(intro) + '\n\n'
|
|
|
|
page += f"""## User Registration
|
|
|
|
To create a new account, navigate to the capsule front page and activate a client certificate. Please check that the certificate is in effect for the entire domain:
|
|
|
|
=> {self.path} {session.bubble.site_icon} {session.bubble.site_name}
|
|
|
|
After registering your account, you should visit Settings to configure a few things:
|
|
|
|
=> /settings/profile Set up your user profile.
|
|
You can select an Emoji avatar, describe who you are, and include one featured link that appears at the top of your "u/" page.
|
|
=> /settings/display Set your time zone.
|
|
Otherwise, all displayed times are shown in UTC. Your time zone is not displayed publicly for privacy reasons.
|
|
=> /settings/certs Configure a Recovery URL.
|
|
See the "Account Recovery" section for more details.
|
|
=> /settings/notif Set an email address for notifications.
|
|
|
|
Don't forget to check out the "Posting and Commenting" section for details about making posts. The main points you should know is that Gemtext formatting is fully supported and comments can be drafted, too.
|
|
|
|
### Adding and Updating Certificates
|
|
|
|
* Go to /settings/certs and set a certificate password. It will be valid for an hour.
|
|
* Activate your new certificate on the front page.
|
|
* Pick the "add alternative" option and enter your username and password when prompted.
|
|
|
|
Now the new certificate is linked to your account in addition to any existing certificates you had previously. You can remove old/expired certificates as long as at least one certificate remains linked to your account.
|
|
"""
|
|
page += """
|
|
## Posting and Commenting
|
|
|
|
Posts and comments are composed of "segments". There can be any number of segments, and the order of existing segments can be freely changed in the composer. There are four types of segments:
|
|
|
|
* Text — Gemtext with all the line types supported.
|
|
* Link — A single URI with an optional label. The first link segment will appear in feed previews as a clickable link.
|
|
* File Attachment — Link to a (small) file stored in the Bubble database.
|
|
* Poll Option
|
|
|
|
When viewing the post page, each of these segments are visible in their entirety. In various other places, a shortened "feed preview" is shown instead. The composer shows a preview of both the shortened version and the full page contents before you can publish the post.
|
|
|
|
When making a new post, what you enter in the "New post" prompt becomes the body text of the post and the title will be left blank. You can then edit the post to add a title, if necessary. However, if you begin the text entered into the prompt with a level 1 heading (#), that first line will be extracted and used as the post title. In issue tracker subspaces, the first line is always used as the issue title.
|
|
|
|
### Mentioning Users, Issues and Commits
|
|
|
|
You can use an "@username" mention to notify another user about your post or comment. Multiple users can be notified in the same message. Usernames are case-insensitive, so you don't have to capitalize the @-mention exactly like the user's name is capitalized.
|
|
|
|
While Bubble does not support private/direct messages, you can make a post in your "u/" subspace, flag it as omitted from All Posts and feeds, and mention one or more users who you wish to communicate with. This way the conversation at least does not clutter the front page or subspace feeds.
|
|
|
|
In an issue tracker, you can refer to other issues by their ID number, for instance "#123". A link to the mentioned issue appears automatically, and a cross reference will also appear in the mentioned issue's comment history.
|
|
|
|
If an issue tracker is linked with a Git repository, you can also refer to individual Git commits by their SHA hash simply by writing out (the beginning of) the hash.
|
|
|
|
### File Attachments
|
|
|
|
Because Gemini limits the amount of data per request to 1024 bytes, it is unsuitable for uploading files to the server. Therefore, Bubble supports a protocol called Titan for uploading files.
|
|
|
|
> The Titan protocol is an add-on for Gemini clients and servers. It is used to upload data to servers, making external tools like ftp or other protocols like the web unnecessary.
|
|
=> gemini://transjovian.org/titan/page/The%20Titan%20Specification — The Titan Specification
|
|
|
|
In practice, you need Gemini client that supports Titan uploads (e.g., Lagrange), or a standalone Titan upload tool:
|
|
=> gemini://transjovian.org/titan/page/Known%20clients Known Titan clients
|
|
|
|
### Special Commands
|
|
|
|
In the "New post" and "Comment" prompts, there are special commands available:
|
|
|
|
* Entering a period (.) will create an empty draft.
|
|
* Entering a colon (:) switches to Titan to submit a longer text.
|
|
* Ending the message in a single backslash (\) will create a draft out of the entered text.
|
|
|
|
In the "Edit text" prompts of the draft composer:
|
|
|
|
* Entering a colon (:) opens a Titan upload to update the segment text.
|
|
* Entering a backslash (\) will open the text segment in a raw view so it can be edited via Titan directly, if the client supports page editing.
|
|
|
|
Titan uploads always deal with the actual contents of the post/segments, so special commands are not available when using Titan.
|
|
|
|
### Undoing Mistakes
|
|
|
|
When editing segments in the draft composer, you may sometimes accidentally submit the input and overwrite the contents, losing some text that you meant to keep in there. While Bubble does not currently keep a history of past edited versions, your client may be able to help you. If you navigate backwards, the old contents of the segment may be found in a cached copy of the page.
|
|
|
|
## Notifications
|
|
|
|
In addition to the usual @-mentions, comments and likes, you can be notified about new posts in a thread you were part of, and you can follow users, subspaces, and individual posts to be notified of new comments and/or posts in/by them. There are also notifications for new polls and issues that have been closed. Each of these notification types can be individually enabled or disabled.
|
|
=> /settings/notif Check out the list of options in Settings.
|
|
|
|
When clicking on a notification in Dashboard or elsewhere, it is automatically hidden. However, the notification "/notif/" links remain valid for up to one week, during which you can access a particular notification multiple times.
|
|
=> /notif/history Past notifications can be viewed in the Notification History.
|
|
|
|
### Emails and "Do Not Disturb"
|
|
|
|
Bubble supports email notifications to keep you informed about activity in a timely fashion. Enabling this is optional, but it can be argued that an integral part of a communication tool is proactively keeping people in the loop about noteworthy events.
|
|
|
|
The emailing frequency is user-configurable, so you can choose how often emails about unseen notifications are sent. Furthermore, to prevent emails from being sent at inconvenient times, you can set an exclusion range. For example, a reasonable night hour range could be "23-05" (inclusive; the first emails would start arriving at 6 in the morning).
|
|
|
|
### Per-Post Notifications
|
|
|
|
Feed entries sometimes have a 🔔 icon. This means at least one of your unread notifications is about that post. When viewing such a post, the relevant notifications are listed right there under Actions, so you check them off and/or clear them more conveniently.
|
|
|
|
## Thanks, Likes, and Reactions
|
|
|
|
* "Thanks" are used for showing appreciation privately, in an ephemeral manner. The recipient will see a notification about the thanks. The notification will remain visible in Notification History for a few days, after which all record of it disappears.
|
|
* "Likes" are a way of giving positive feedback in public. The names of people who have liked a post is visible to everyone. Likes can be disabled per-user or site-wide in the Bubble configuration.
|
|
* "Reactions" are Emoji-based and anonymous to everyone apart from the recipient. The administrator chooses the available set of reaction Emoji, and users can select one of them for any given post. Everyone can see the total number of each type of reaction when viewing a post.
|
|
|
|
## Feeds
|
|
|
|
The front page's feed can be switched between "All Posts" and "Followed" in your Settings.
|
|
|
|
Sorting by 🔥 Hotness orders the posts based on how much time has passed since the latest comment in the discussion thread and how many different people have commented. The actual number of comments has no influence. The idea is to keep active discussion threads near the top of the feed.
|
|
|
|
### Tag Filtering
|
|
|
|
The front page feed, subspaces, user feeds, and issue trackers can all be filtered based on tags applied on posts.
|
|
|
|
### Gemlogging with Gemini/Atom Feeds
|
|
|
|
User subspaces ("u/" prefix) are intended for personal posts. Each individual post can be either included in or omitted from Gemini/Atom feeds using the toggles in the composer. In Settings, you can set which mode is the default. By omitting a post from Gemini/Atom feeds, it effectively becomes a microblog post that only appears in Tinylogs and on Bubble itself. This way, you can publish your "u/" Gemini/Atom feed as a gemlog that contains longer-form posts, while still being able to have short-form posts, too.
|
|
|
|
You can also use tag filtering to have more fine-grained control over feeds. You are free to tag your posts however you like.
|
|
|
|
=> /settings/profile In your user profile, you can configure the title of your "u/" feed.
|
|
By default, your username is the feed title.
|
|
|
|
The post title that is set in the composer will be used as-is in Gemini/Atom feeds. If the title is missing, a truncated version of the post contents are used instead.
|
|
|
|
A convenient action link is provided for submitting your "u/" Gemini feed and your individual posts to the Antenna aggregator. Antenna submission does not happen automatically so you retain control of when and if the submission is done.
|
|
|
|
The Atom feed generated by Bubble contains the full contents of each post as HTML. Any comments about the post are not included.
|
|
|
|
### Tinylogs
|
|
|
|
The formatting of posts is altered when viewing them via Tinylog: all headings are converted to level 3.
|
|
|
|
## Following and Muting
|
|
|
|
You can follow and mute posts, subspaces, and users to customize their visibility and notifications about them. In Settings, you can choose whether the front page feed shows all posts — except mutes ones — or just posts from followed subspaces and users.
|
|
|
|
### Follows
|
|
|
|
Follows are entirely private in Bubble: only you see what you follow, and there are no counters or statistics about them visible anywhere. Followers do not affect the "hotness" sorting order, either.
|
|
|
|
When you vote in a poll, the post is automatically marked as followed so you will be notified of discussion related to it even though you haven't commented on the poll yourself.
|
|
|
|
### Muting
|
|
|
|
Muting is private, too. No one can see what you have muted, or who has muted them. You can mute individual posts, entire subspaces, and all posts, comments by a particular user. Muting generally prevents notifications from being received regarding the target of the mute.
|
|
|
|
* Muting a post silences notifications about the post but you can still view the post normally. @-mentions in the post or its comments will still be delivered to you. This is useful for long threads that you are no longer interested in actively following.
|
|
* Muting a subspace hides it in All Posts and disables notifications about activity in the subspace. However, your own posts in that subspace are not affected, nor are @-mentions of you in the subspace. This could be used to prevent a busy subspace from dominating All Posts.
|
|
* Muting a user hides the user's posts in every feed, including in All Posts and the muted user's "u/"-feed, and hides their comments in every post. Notifications from the user are not shown, either. It is as if the user does not exist.
|
|
|
|
## Subspaces
|
|
|
|
### Moderation
|
|
|
|
Every subspace must have at least one moderator assigned to it, or otherwise the subspace is locked into read-only mode.
|
|
|
|
Moderators are able to delete posts and comments, and edit titles of posts. They cannot edit the contents of posts, though.
|
|
|
|
Posts can be moved between subspaces. Moderators can move any post in their subspace to another subspace, and post authors can always move their own posts elsewhere. Moving a post does not break URLs because post IDs are unique, and a URL that uses the old subspace name will be redirected to the new location. However, issue tracker posts cannot be moved due to per-subspace issue ID numbering.
|
|
|
|
A moderator of a subspace can assign any other user as an additional moderator of the subspace. Each moderator has the same access rights, regardless of who originally created the subspace.
|
|
|
|
### Issue Trackers
|
|
|
|
Individual subspaces can be used for project issue tracking instead of regular posting. Issue trackers differ from regular subspaces in that each post will be assigned a unique ID number, and each issue has an open/closed status. One can also reference issues in the same tracker just by writing the ID number preceded by a hash. This automatically creates a bidirectional cross reference.
|
|
|
|
> Also see #123.
|
|
|
|
Subspace moderators can enable or disable issue tracking mode in an empty subspace.
|
|
|
|
Bubble instance administrators are able to attach a Git repository to an issue tracker. This causes a bare clone of the repository to be fetched and periodically updated. Git commits that reference issues in their log messages will automatically appear in the relevant issue's discussion history, and when Git commit hashes are detected in issues and issue tracker comments, links to the commits are automatically displayed.
|
|
|
|
Use of tags is recommended in issue trackers, for example to make a distinction between features and bugs. The list of issues can be filtered based on tags for easier navigation.
|
|
|
|
## Account Recovery
|
|
|
|
Occasionally people lose their client certificates and thus also lose access to their Bubble account. Contacting the administrator is fine for account recovery but it also has some significant drawbacks. For example, the administrator may not be able to verify that the account actually belongs to you. To facilitate account recovery, there is an automated certificate recovery feature.
|
|
=> /settings/certs You can go to your account settings and set a Recovery URL.
|
|
Then, when disaster has struck and you've lost your registered certificates, you can do the following:
|
|
|
|
* 1. Make your new certificate available as a PEM file at the previously configured URL. Do NOT include the private key — that is not meant to be published under any conditions.
|
|
* 2. Activate the same certificate in your Gemini client on the Bubble front page.
|
|
* 3. Select the "Recover certificate" option and enter your user name.
|
|
* 4. Bubble downloads the certificate from your recovery URL. If (and only if) it matches the client certificate active in your client, the certificate will be registered to the account.
|
|
* 5. For privacy reasons, remove the certificate at the recovery URL.
|
|
|
|
If you have a place where you can publish files on Gemini, such as your own capsule, it is recommended to always have the recovery URL configured. It's a good idea to use a directory and/or file name that doesn't currently exist and is unlikely to be easily guessed. In any case, this URL should only point to an existing file while you are recovering your account. You probably don't want to keep your client certificate published otherwise.
|
|
|
|
## Restricting Access
|
|
|
|
An important use case for Bubble is providing individuals and small groups of people a personal publishing platform or a private space. For this purpose, user registration can be closed, and there are admin actions to create new users, generate random passwords, and revoke certificates.
|
|
|
|
The `frontpage` configuration option defines a static Gemtext page that is shown to unregistered visitors. This page is the only thing unregistered visitors are able to see.
|
|
|
|
The `user.subspaces` configuration option controls whether users can create new subspaces, in case the admin wants the subspaces to remain a fixed set of categories, for example.
|
|
"""
|
|
return page
|
|
|
|
if req.path == self.path + 'help/locked':
|
|
return """# Help
|
|
|
|
## Locked Subspace
|
|
|
|
Subspaces that have no moderators are locked into read-only mode.
|
|
No new posts or comments can be made. A subspace becomes unlocked
|
|
when the administrator assigns at least one moderator to it.
|
|
|
|
=> /help 📖 Back to Help"""
|
|
|
|
if req.path == self.path + 'help/deleted-post':
|
|
return """# Help
|
|
|
|
## Deleted Posts
|
|
|
|
Deleting a post does not delete its discussion thread, too, because the post author does not have the authority to delete other users' content. After a post has been deleted, comments about it are still accessible through the Dashboard comment index. You can find your orphaned comments in the index by searching for comments about a "Deleted post". Comments about deleted posts are not included in any feed.
|
|
|
|
Deleting a subspace will delete all posts and comments in the subspace, i.e., the full discussion threads will be deleted.
|
|
|
|
=> /help 📖 Back to Help"""
|
|
|
|
elif req.path == self.path + 'new-subspace':
|
|
return make_subspace(session)
|
|
|
|
elif req.path.startswith(self.path + 'admin/'):
|
|
return admin_actions(session)
|
|
|
|
elif req.path.startswith(self.path + 'edit-segment/') or \
|
|
req.path.startswith(self.path + 'move-segment/') or \
|
|
req.path.startswith(self.path + 'raw-segment/'):
|
|
return edit_segment(session)
|
|
|
|
elif req.path.startswith(self.path + 'edit/'):
|
|
return make_composer_page(session)
|
|
|
|
elif req.path.startswith(self.path + 'edit-tags/'):
|
|
return make_tags_page(session)
|
|
|
|
elif req.path.startswith(self.path + 'comment/'):
|
|
return make_comment(session)
|
|
|
|
elif re.match(r'^(like|unlike|vote|follow|unfollow|mute|unmute|notif|thanks|react|unreact)/.*',
|
|
req.path[len(self.path):]):
|
|
return user_actions(session)
|
|
|
|
elif req.path == self.path + 'dashboard':
|
|
return make_dashboard_page(session)
|
|
|
|
elif req.path.startswith(self.path + 'settings'):
|
|
return make_settings_page(session)
|
|
|
|
elif re.search(r'/search(/\d+)?$', req.path):
|
|
return make_search_page(session)
|
|
|
|
elif req.path.startswith(self.path + 'export/'):
|
|
return export_gempub_archive(session)
|
|
|
|
elif req.path == self.path + 'u/':
|
|
return 30, '/s/'
|
|
|
|
elif req.path == self.path + 's/':
|
|
return make_subspaces_page(session)
|
|
|
|
elif req.path.startswith(self.path + 'u/') or \
|
|
req.path.startswith(self.path + 's/') or \
|
|
req.path.startswith(self.path + 'tag'):
|
|
page = make_post_page_or_configure_feed(session)
|
|
if page:
|
|
return page
|
|
# NOTE: Fall through to feed generation below.
|
|
|
|
elif req.path == self.path + 'all':
|
|
session.feed_mode = 'all'
|
|
|
|
elif req.path == self.path + 'followed':
|
|
session.feed_mode = 'followed'
|
|
|
|
elif req.path == self.path:
|
|
session.feed_mode = 'followed' \
|
|
if session.user and session.user.flags & User.HOME_FOLLOWED_FEED_FLAG \
|
|
else 'all'
|
|
|
|
else:
|
|
return 51, "Not found"
|
|
|
|
# Write a feed page.
|
|
return make_feed_page(session)
|
|
|
|
|
|
def init(context):
|
|
cfg = context.config()
|
|
|
|
# TODO: Allow configuring multiple instances (bubble.*).
|
|
try:
|
|
mod_cfg = cfg.section('bubble')
|
|
|
|
path = mod_cfg.get('path', fallback='/')
|
|
if not path.endswith('/'): path += '/'
|
|
|
|
host = mod_cfg.get('host', None)
|
|
if host is None:
|
|
hostnames = cfg.hostnames() # all in the [server] section
|
|
else:
|
|
hostnames = [host]
|
|
|
|
port = cfg.section('server').getint('port', 1965)
|
|
|
|
for hostname in hostnames:
|
|
responder = Bubble(context, hostname, port, path, mod_cfg)
|
|
context.add(path + '*', responder, hostname, protocol='gemini')
|
|
context.add(path + '*', responder, hostname, protocol='titan')
|
|
|
|
if context.is_background_work_allowed():
|
|
emailer = Emailer(context, hostnames[0], port, mod_cfg)
|
|
emailer.start()
|
|
|
|
fetcher = RepoFetcher(context, mod_cfg)
|
|
fetcher.start()
|
|
|
|
except KeyError:
|
|
pass
|