"""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' if context else '' page_url = self.server_root() + urlparse.quote(post.page_url()) return f""" {vis_title} {author}{self.server_root()}/u/{author} {page_url}{category} {atom_timestamp(post.ts_created)} {atom_timestamp(post.ts_edited)} {atom_escaped(gemtext_to_html(self.render_post(post, omit_title=True)))} """ 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