bubble/50_bubble.py

1013 lines
50 KiB
Python

"""Bubble - Bulletin Boards for Gemini"""
import os
import re
import shlex
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__ = '8.7'
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.user_role_limited = cfg.getboolean('user.role.limited', True)
self.rate_register = max(1, cfg.getint('rate.register', 20)) # per hour, any remote
self.rate_post = max(1, cfg.getint('rate.post', 10)) # per hour, per remote
self.admin_certpass = cfg.get('admin.certpass', '')
self.antenna_urls = cfg.get('antenna.url', 'gemini://warmedal.se/~antenna/submit').split()
self.antenna_labels = shlex.split(cfg.get('antenna.label', '"📡 Antenna"'))
self.antenna_abouts = cfg.get('antenna.about', 'gemini://warmedal.se/~antenna/about.gmi').split()
if len(self.antenna_urls) != len(self.antenna_labels) or \
len(self.antenna_urls) != len(self.antenna_abouts):
raise Exception("Config error: Antenna submission URLs, about URLs and labels are mismatched")
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 antenna_links(self, kind, feed_url):
links = []
for i, url in enumerate(self.antenna_urls):
links.append(f'=> {url}?{urlparse.quote(feed_url)} Submit {kind} to {self.antenna_labels[i]}\n')
return links
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 = """## Tips for Getting Started
* Regular Gemtext syntax can be used in posts and comments, e.g., `=>` for links.
* You can edit posted comments using the Actions menu on the comment page. You can get there by opening the comment author/timestamp link or via a notification.
* You can add text to an existing post or comment to make it as long as you want.
* If you run out of space while typing a comment, end it with a backslash and the entered text becomes a draft comment. You can publish the comment after you've added all the needed text.
* In the draft composer, you must first preview a post/comment before publishing it. The preview shows how a post appears as a feed entry and as a full page.
* Try different sorting modes to see activity in older posts. For example, "Sort by activity" in the Options menu in the bottom of a feed."""
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 object
self.context_mods = [] # User objects
self.context_mod_ids = [] # just the ID numbers
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 get_mods(self, subspace_id):
self.context_mods = self.db.get_mods(subspace=subspace_id)
self.context_mod_ids = [mod.id for mod in self.context_mods]
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_moderated(self, post: Post):
if not self.user:
return False
if self.user.role == User.ADMIN:
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_lockable(self, post: Post):
return self.is_deletable(post)
def is_title_editable(self, post: Post):
# Moderators can edit post titles.
return self.is_deletable(post)
def is_commenting_enabled(self, post: Post):
if not post:
return False
if self.user.role == User.ADMIN:
return True
if post.flags & Post.LOCKED_FLAG:
return False
return True
def is_antenna_enabled(self):
if self.user:
return self.user.role != User.LIMITED
return False
def is_rotation_enabled(self):
if self.user:
return (self.user.flags & User.DISABLE_ROTATION_FLAG) == 0
return True
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, is_comment=None)
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_flair(self, post, context):
if self.user and self.user.flags & User.HIDE_FLAIRS_FLAG:
# Node flairs in feeds.
return ''
flair = User.render_flair(post.poster_flair,
context,
abbreviate=True,
user_mod=post.user in self.context_mod_ids)
return f' [{flair}]' if flair else ''
def feed_entry(self, post, context=None, omit_rotate_info=False, is_activity_feed=False):
is_issue_tracker = self.is_context_tracker
is_comment = post.parent != 0 # Flat feeds intermingle comments with posts.
# Collect the metadata first.
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 and not is_comment else \
'' if post.num_cmts == 0 else \
f'{post.num_cmts} comment{plural_s(post.num_cmts)}'
if is_activity_feed:
age = ago_text(post.ts_comment, tz=self.tz)
else:
age = post.age(tz=self.tz)
bell = ' 🔔' if post.num_notifs else ''
SHORT_PREVIEW_LEN = 160
if is_issue_tracker:
# Issue tracker entries have a different appearance with just a single
# link to the entry.
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'
meta = []
meta.append(post.poster_name)
if cmt:
meta.append(cmt)
if likes:
meta.append(likes)
meta.append(age)
src += f'{post.poster_avatar} {" · ".join(meta)}{bell}\n'
else:
# Regular feeds may have subspace posts, user posts, and comments.
is_deleted = False
reply_label, reply_path = None, None
if is_comment:
post_icon = '🌒' if not post.sub_owner else ''
post_label = ("s/" if not post.sub_owner else "u/") + post.sub_name
post_path = '/' + post_label
meta_icon = post.poster_avatar
parent_post = self.db.get_post(id=post.parent)
if parent_post:
reply_label = f"Re: {parent_post.quoted_title(max_len=60)}"
reply_path = parent_post.page_url()
else:
reply_label = "Re: (deleted post)"
reply_path = post.page_url()
is_deleted = True
elif sub:
post_icon = '🌒'
post_label = sub
post_path = f'/{sub}'
meta_icon = '💬'
else:
post_icon = post.poster_avatar
post_label = post.poster_name
if not (is_user_post and context and context.id == post.subspace):
post_label += self.feed_flair(post, context)
post_path = f'/u/{post.poster_name}'
meta_icon = '💬'
# Grouped posts are shown in a rotating group.
rotation = ''
if not is_comment and not omit_rotate_info:
per_day = post.num_per_day
if per_day and per_day > 1:
n = per_day - 1
rotation = f" (+{n} other post{plural_s(n)})"
post_path = post.page_url() + "/group"
# Activity feeds use ts_comment timestamps, so the post author's name is at the top
# because the meta line is associated with the latest comment instead.
if is_activity_feed and sub:
post_label += f' · {post.poster_avatar} {post.poster_name}{self.feed_flair(post, context)}'
src = f'=> {post_path} {post_icon} {post_label}{rotation}\n'
if reply_label:
src += f'=> {reply_path} {INNER_LINK_PREFIX} {reply_label}\n'
if not is_deleted:
src += post.summary if not self.is_short_preview \
else shorten_text(post.summary, SHORT_PREVIEW_LEN) + '\n'
else:
src += "(only visible to author)\n"
# Last line in the metadata.
meta = []
if is_comment or (sub and not is_activity_feed):
meta.append(post.poster_name + self.feed_flair(post, context))
if cmt:
meta.append(cmt)
if likes:
meta.append(likes)
if len(meta) == 0:
meta.append('View post')
meta.append(age)
src += f'=> {post.page_url()} {meta_icon} {" · ".join(meta)}{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.feed_title()
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.feed_title()
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(strip_invalid(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
is_user_poll_author = self.user and self.user.id == post.user
if is_user_poll_author:
# The poll author always sees the results in addition to being able to vote.
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
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 else '## Poll\n'
if not show_results or is_user_poll_author:
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 or
is_user_poll_author) else ''
src += f'{vote_link}{opt_num}. {icon}{option.content}\n'
if cur_vote or show_results:
# 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.startswith(self.path + 'register/'):
if not session.bubble.user_register:
return 61, 'User registration is closed'
if db.get_access_rate(3600, None, LogEntry.ACCOUNT_CREATED) >= session.bubble.rate_register:
return 44, "Rate limit exceeded, try again later"
if not db.verify_token(session.user, req.path[len(self.path + 'register/'):]):
return 61, 'Expired'
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,
User.LIMITED if session.bubble.user_role_limited else \
User.BASIC)
db.add_log_entry(req.remote_address, LogEntry.ACCOUNT_CREATED)
page = f'# Welcome, {username}!\n\n'
if session.bubble.user_role_limited:
page += 'Your account has been created with limited access rights. Feel free to set up your profile while waiting for the administrator to review the account and grant full access:\n\n'
else:
page += 'Your account has been created. Now 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"
page += f"=> /settings ⚙️ Settings\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/{session.get_token()} 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 and req.query == 'register':
if not session.user:
return 60, "Activate a client certificate to create an account"
return 30, self.path
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
=> {self.path}?register Sign up for a new account here.
Alternatively, you can always navigate to the capsule front page and activate a client certificate manually. In this case, please check that the certificate is activated 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.
## Flairs
You may notice that some users have labels that appears after their name, for example "[admin]". These are called "flairs". They let you personalize your appearance in specific subspaces or site-wide, acting as a sort of "body language" that is otherwise missing in a text-based medium. You can configure your flairs in Settings:
=> /settings/flair Settings > Profile > Flairs
Flairs can be used for various purposes:
* A flair can signal the user's role or status.
* The automatic flairs "OP" and "mod" appear next to the original poster of a discussion thread, and a moderator of the current subspace.
* You can give additional details about yourself that may be relevant in a particular subspace.
* The flair can be used as a "soft" moderation tool to assign persistent labels on users, for instance if they have violated the Code of Conduct.
* A flair can help manage expectations about communicating with a user, for example if they prefer to speak a particular language, or if they have a communication style that is easy for others to misunderstand.
The moderator of a subspace can add and remove subspace flairs on a user. Moderators may ensure that flairs are used in the appropriate manner for a particular subspace. The administrator can assign flairs that regular users are unable to edit or remove.
## 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 lock and unlock comment threads, 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.
Moderators can add and remove flairs on users that apply to the moderated subspace. Moderators have the ability to add the "Note from moderator" flairs that may relate to moderation actions that occur as a response to rule breaking, for example.
### 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 because users cannot delete other users' content. After a post has been deleted, comments you have made about it are still visible to you 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|lock|unlock|notif|thanks|react|unreact|report|remind|transmit)/.*',
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'
elif req.path == self.path + 'stats':
return make_stats_page(session)
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