bubble/settings.py

917 lines
38 KiB
Python

import math
import pytz
from utils import *
from model import Notification, User, Flair, Subspace, FOLLOW_USER, FOLLOW_SUBSPACE, \
MUTE_USER, MUTE_SUBSPACE
def modify_reactions(db, user, user_flag, notif_type):
notif = user.notif
if user.flags & user_flag:
notif |= notif_type
else:
notif &= ~notif_type
db.update_user(user, flags=user.flags ^ user_flag, notif=notif)
def space_separated(a, b):
return a + ' ' + b if a and b else a + b
def make_flair_composer_page(session):
# The flair composer can be used as a user editing their own flairs,
# a moderator removing subspace flairs that they moderate, or an
# admin editing flairs of any user without restrictions.
req = session.req
db = session.db
user = session.user
user_mod = False
page = ''
TEXT_LIMIT = 50
TEXT_PROMPT = f'Enter flair text (max. {TEXT_LIMIT} charecters):'
found = re.search(r'/settings/flair/([\w-]+)', req.path)
flair_user = found[1] if found else user.name
base_url = session.path + f'settings/flair/' + flair_user
moderated_subs = db.get_moderated_subspace_ids(user)
if not user:
return 60, 'Login required'
if user.role == User.LIMITED:
return 61, 'Not authorized'
if user.name != flair_user and user.role != User.ADMIN:
if len(moderated_subs):
user_mod = True
else:
return 61, 'Not authorized'
flair_user = db.get_user(name=flair_user)
if not flair_user:
return 51, 'Not found'
page += f"# {flair_user.name}: Flairs\n"
# Action to add a new flair. This is a multi-step wizard.
if req.path.startswith(base_url + '/add/'):
found = re.search(r'/add/(\d+|-)?(/([\w-]+)(/(edit|[A-Za-z0-9]+))?)?$', req.path)
flair_type = found[1]
subspace_name = found[3]
subspace_urlpart = '/' + subspace_name if subspace_name else ''
action = found[5]
if not flair_type or flair_type == '-':
page += '\nSelect type of flair to add:\n\n'
index = 0
for index, (icon, desc) in enumerate(Flair.TYPES):
if index != Flair.MODERATOR_NOTE or (user.role == User.ADMIN or user_mod):
page += f'=> {base_url}/add/{index}{subspace_urlpart} {space_separated(icon, desc)}\n'
index += 1
#page += f'=> {base_url}/add/note{subspace_urlpart} Custom note\n'
page += '\nCustom notes are only visible on post and user pages. The icons of other flairs are visible in feed entries, too.\n'
page += '\n=> /settings/flair Back to flairs\n'
return page
# Flair type is set but subspace may not be.
try:
subspace = None
is_global = False
sub_title = ''
if subspace_name:
if subspace_name == '-':
is_global = True
sub_title = ' in all subspaces'
if user_mod:
return 61, 'Not authorized'
else:
subspace = db.get_subspace(name=subspace_name)
sub_title = f' in {subspace.title()}'
if user_mod and subspace.id not in moderated_subs:
return 61, 'Not authorized'
icon, desc = Flair.TYPES[int(flair_type)]
page += f'\n## {space_separated(icon, desc)}{sub_title}\n\n'
base_url = base_url + f'/add/{flair_type}'
# Select the subspace.
if not subspace and not is_global:
page += 'Select subspace where the flair is displayed:\n\n'
if int(flair_type) != Flair.CUSTOM_NOTE or user.role == User.ADMIN:
page += f'=> {base_url}/- All subspaces\n'
page += f'=> {base_url}/{user.name} {flair_user.avatar} u/{flair_user.name}\n\n'
for sub in db.get_subspaces(owner=0):
page += f'=> {base_url}/{sub.name} {sub.title()}\n'
page += '\n=> /settings/flair Back to flairs\n'
return page
base_url += f'/{subspace_name}'
if action == 'edit':
if is_empty_query(req):
return 10, TEXT_PROMPT
return 30, base_url + '?' + req.query
elif action and db.verify_token(user, action):
if int(flair_type) == Flair.MODERATOR_NOTE and user.role != User.ADMIN and not user_mod:
return 61, 'Not authorized'
db.add_flair(flair_user,
Flair(subspace.id if subspace else 0,
int(flair_type),
clean_query(req)[:TEXT_LIMIT],
is_admin_assigned=(user.role == User.ADMIN)),
user)
return 30, session.path + f'settings/flair/' + flair_user.name
# Specify the flair text.
page += '### Flair text\n'
text = clean_query(req)
page += '(not set)' if not text else text
page += f'\n=> {base_url}/edit ✏️ Edit\n\n'
if text:
page += f'=> {base_url}/{session.get_token()}?{req.query} ✔️ Add the flair\n\n'
page += '=> /settings/flair Cancel\n'
return page
except GeminiError as gr:
raise gr
except Exception as x:
import traceback
traceback.print_tb(x.__traceback__)
print(x)
return 59, 'Bad request'
flairs = User.parse_flair(flair_user.flair)
# Edit and remove actions.
found = re.search(r'settings/flair/[\w-]+/(edit|remove)/(\d+)(/([A-Za-z0-9]+))?$', req.path)
if found:
action = found[1]
flair_index = int(found[2])
token = found[4]
if token and not db.verify_token(user, token):
return 61, 'Expired'
if flair_index < 0 or flair_index >= len(flairs):
return 51, 'Not found'
target = flairs[flair_index]
if target.is_admin_assigned and user.role != User.ADMIN:
return 61, 'Not authorized'
if target.type == Flair.MODERATOR_NOTE and (user.role != User.ADMIN and not user_mod):
return 61, 'Not authorized'
if action == 'remove':
db.remove_flair(flair_user, target, user)
elif action == 'edit':
if user_mod:
return 61, 'Not authorized'
if is_empty_query(req):
return 10, TEXT_PROMPT
target.label = clean_query(req)[:TEXT_LIMIT]
db.add_flair(flair_user, target, user)
return 30, base_url
# Compose the flair list page.
if user.id == flair_user.id:
page += f'=> /settings/profile ⚙️ Back to Profile\n'
page += f'=> /u/{flair_user.name} {flair_user.avatar} Back to u/{flair_user.name}\n\n'
elif user.role == User.ADMIN:
page += '=> /admin/ Back to Administration\n'
page += f'=> /u/{flair_user.name} {flair_user.avatar} u/{flair_user.name}\n\n'
elif user_mod:
page += '\nModerators can only edit flairs related to the moderated subspaces:\n'
for sid in moderated_subs:
msub = db.get_subspace(id=sid)
if msub:
page += f'=> {session.path}{msub.title()} {msub.title()}\n'
if not user_mod:
page += 'Flairs are labels that you can choose to show next to your name. They let you personalize your appearance in specific subspaces, acting as a sort of "body language" that is otherwise missing in a text-based medium. The administrator may also assign flairs if necessary.\n'
page += f'\n=> {base_url}/add/ Add a flair\n'
if len(flairs):
index = 0
for flair in flairs:
if flair.scope:
sub = db.get_subspace(id=flair.scope)
subspace = sub.title() if sub else 'a deleted subspace'
if user_mod and flair.scope not in moderated_subs:
continue
else:
subspace = 'all subspaces'
page += '\n'
if flair.icon():
item = f'{flair.icon()} {flair.description()}: '
else:
item = '📛 '
item += f'{flair.label}'
if flair.is_admin_assigned and user.role != User.ADMIN:
page += f'{item}\n'
page += f'In: {subspace}\n'
page += '(assigned by administrator)\n'
else:
if not user_mod:
page += f'=> {base_url}/edit/{index} {item}\n'
else:
page += f'{item}\n'
page += f'In: {subspace}\n'
page += f'=> {base_url}/remove/{index}/{session.get_token()} ❌ Remove\n'
index += 1
else:
page += '\nYou have no flairs.\n'
return page
def make_settings_page(session):
req = session.req
db = session.db
user = session.user
page = ''
if not user:
return 60, 'Login required'
token = session.get_token()
CHECKS = session.CHECKS
SORT_POST = {
'r': '🕑 Most recent',
'a': '🗣️ Activity',
'h': '🔥 Hotness',
'f': '🗪 Unified timeline'
}
if req.path == session.path + 'settings/avatar/' + token:
AVATARS = [
'Human',
('👤', 'silhouette of person'),
('🙂', 'smily face'),
('😎', 'smiling face with sunglasses'),
('🤠', 'cowboy face'),
('😇', 'smiling face with halo'),
('😈', 'smiling face with horns'),
('👻', 'ghost'),
('💀', 'skull'),
('❤️', 'red heart'),
'Animal Faces',
('🐻', 'bear face'),
('😺', 'cat face'),
('🐶', 'dog face'),
('🐸', 'frog face'),
('🐵', 'monkey face'),
('🐰', 'rabbit face'),
('🐷', 'pig face'),
'Animals',
('🐝', 'bee'),
('🐦', 'bird'),
('🦋', 'butterfly'),
('🦀', 'crab'),
('🐉', 'dragon'),
('🐐', 'goat'),
('🦎', 'lizard'),
('🐁', 'mouse'),
('🐙', 'octopus'),
('🦉', 'owl'),
('🐧', 'penguin'),
('🦝', 'raccoon'),
('🦂', 'scorpion'),
('🦥', 'sloth'),
('🕷', 'spider'),
'Nature',
('🍀', 'four leaf clover'),
('🌻', 'sunflower'),
('🍄', 'mushroom'),
('🕸', 'spider web'),
('🌝', 'full moon with face'),
('🌙', 'crescent moon'),
('☀️', 'sun'),
('🌧️', 'cloud with rain'),
('', 'snowflake'),
('🌲', 'evergreen tree'),
('⛰️', 'mountain'),
('🌊', 'wave'),
('🔥', 'fire'),
'Space',
('🪐', 'planet'),
('🛰️', 'satellite'),
('🚀', 'rocket'),
('🛸', 'UFO'),
('👽', 'alien'),
('👾', 'alien monster'),
('🔭', 'telescope'),
'Tech',
('🤖', 'robot face'),
('🖥️', 'desktop computer'),
('📱', 'mobile phone'),
('📷', 'camera'),
('📻', 'radio'),
('🎧', 'headphones'),
('🕹️', 'joystick'),
('🎮', 'video game controller'),
('📡', 'satellite antenna'),
('♊️', 'Gemini'),
'Food',
('☕️', 'hot beverage'),
('🍵', 'teacup without handle'),
('🍺', 'beer mug'),
('🍎', 'red apple'),
('🍭', 'lollipop'),
('🧇', 'waffle'),
('🍩', 'doughnut'),
'Hobbies',
('🎵', 'musical note'),
('🧩', 'puzzle piece'),
('🎲', 'game die'),
('🎱', 'billiards'),
('⚽️', 'soccer ball'),
('🏈', 'American football'),
('🧶', 'yarn'),
('🏕️', 'camping'),
('⛄️', 'snowman'),
'Travel',
('🛞', 'wheel'),
('🚲', 'bicycle'),
('🏍️', 'motorcycle'),
('🚗', 'car'),
('⛵️', 'sailboat'),
('✈️', 'airplane'),
('🚂', 'steam locomotive'),
('⛱️', 'beach umbrella'),
'Miscellaneous',
('💎', 'gemstone'),
('👺', 'goblin'),
('☯️', 'yin yang'),
]
if not is_empty_query(req):
try:
idx = int(req.query)
if not isinstance(AVATARS[idx], tuple):
return 51, 'Not found'
db.update_user(session.user, avatar=AVATARS[idx][0])
return 30, '/settings/profile'
except Exception as x:
import traceback; traceback.print_tb(x.__traceback__)
print(x)
return 51, 'Invalid avatar index'
page = f'Select avatar for u/{session.user.name}:\n\n'
for idx, avatar in enumerate(AVATARS):
if isinstance(avatar, str):
page += '### ' + avatar + '\n'
else:
avatar, name = avatar
page += f'=> ?{idx} {avatar} {name}\n'
note = session.bubble.cfg.get('avatar.note', '')
if note:
page += '\n' + note
return page
elif req.path.startswith(session.path + 'settings/flair'):
return make_flair_composer_page(session)
elif req.path == session.path + 'settings/feed-title':
if req.query is None:
return 10, f'Enter title for the u/{user.name} Gemini and Tinylog feeds:'
user_sub = db.get_subspace(owner=user)
print(user_sub.id)
db.update_subspace(user_sub, info=clean_query(req))
return 30, '/settings/profile'
elif req.path == session.path + 'settings/info/' + token:
if req.query == None:
return 10, 'Enter profile description:'
db.update_user(session.user, info=clean_description(clean_query(req)))
return 30, '/settings/profile'
elif req.path == session.path + 'settings/email':
if req.query == None:
return 10, 'Enter email to send notifications to:'
email = clean_query(req)
if email and not '@' in email:
return 50, 'Invalid email address'
db.update_user(session.user, email=email)
return 30, '/settings/notif'
elif req.path == session.path + 'settings/email-inter':
prompt = 'Interval for sending emails: (Examples: "30", "15 mins", "4h")'
if req.query == None:
return 10, prompt
found = re.search(r'^(\d+)\s*(\w*)$', clean_query(req))
if not found:
return 10, prompt
value = int(found[1])
unit = found[2][0] if found[2] else 'm'
if unit.lower() == 'h':
value *= 60
elif unit.lower() != 'm':
return 10, prompt
db.update_user(session.user, email_inter=value)
return 30, '/settings/notif'
elif req.path == session.path + 'settings/email-range':
prompt = f'{session.tz.localize(datetime.datetime.now()).tzname()} hour range (inclusive) when emails are not sent: (Examples: "0-6", "21-3")'
pattern = re.compile(r'(\d+)-(\d+)')
if req.query == None:
return 10, prompt
found = pattern.search(clean_query(req))
if not found:
return 10, prompt
begin, end = int(found[1]), int(found[2])
begin = max(0, begin)
begin = min(23, begin)
end = max(0, end)
end = min(23, end)
# Convert from user's time zone to UTC. The actual date doesn't matter,
# we are just using the hours.
dt_begin = session.tz.localize(datetime.datetime(2023, 6, 19, begin, 0, 0))
dt_end = session.tz.localize(datetime.datetime(2023, 6, 19, end, 0, 0))
begin = dt_begin.astimezone(pytz.utc).hour
end = dt_end.astimezone(pytz.utc).hour
db.update_user(session.user, email_range=f"{begin}-{end}")
return 30, '/settings/notif'
elif req.path == session.path + 'settings/url/' + token:
if req.query == None:
return 10, 'Featured link: (URL followed by label, separate with space)'
try:
link = form_link(parse_link_segment_query(req))
except:
link = ''
db.update_user(session.user, url=link)
return 30, '/settings/profile'
elif req.path == session.path + 'settings/profile':
user_sub = db.get_subspace(owner=user)
page += f'# {session.user.name}: Settings\n'
page += '=> /settings ⚙️ Go back\n'
page += '\n## Profile\n\n'
page += f'=> /settings/avatar/{token} Avatar: {session.user.avatar}\n'
page += f'=> /settings/feed-title Feed title: {user_sub.info if user_sub.info else user.name}\n'
page += f'=> /settings/flair 📛 Flairs\n'
page += '\n### Description\n'
page += (session.user.info if session.user.info else '(no description)') + '\n'
page += f'=> /settings/info/{token} Edit\n'
page += '\n### Featured Link\n'
page += (f'=> {session.user.url}' if session.user.url else '(no featured link)') + '\n'
page += f'=> /settings/url/{token} Edit\n'
return page
elif req.path == session.path + 'settings/sort-feed':
arg = clean_query(req)
if not arg:
page = 'Sort posts by:\n\n'
for key, label in SORT_POST.items():
page += f"=> ?{key} {label}\n"
page += '\n"Most recent" sorts posts by their original creation time. "Activity" is affected by the time of the latest comment: posts will be bumped to the top of a feed whenever a new comment is added. "Hotness" sorts posts by a score calculated based on time of latest comment, number of people in the discussion thread, number of likes, and the age of the post. "Unified timeline" combines posts and comments in one feed in reverse chronological order.'
return page
if not arg in SORT_POST:
return 50, 'Invalid sort order'
db.update_user(session.user, sort_post=arg)
return 30, '/settings'
elif req.path == session.path + 'settings/sort-cmt':
db.update_user(session.user,
sort_cmt=(User.SORT_COMMENT_OLDEST
if session.user.sort_cmt == User.SORT_COMMENT_NEWEST else
User.SORT_COMMENT_NEWEST))
return 30, '/settings'
elif req.path == session.path + 'settings/ascii':
db.update_user(user, flags=(user.flags ^ User.ASCII_ICONS_FLAG))
return 30, './display'
elif req.path == session.path + 'settings/short-preview':
db.update_user(user, flags=(user.flags ^ User.SHORT_PREVIEW_FLAG))
return 30, './display'
elif req.path == session.path + 'settings/hide-flairs':
db.update_user(user, flags=(user.flags ^ User.HIDE_FLAIRS_FLAG))
return 30, './display'
elif req.path == session.path + 'settings/flat':
db.update_user(user, flags=(user.flags ^ User.HOME_FLAT_FEED_FLAG))
return 30, './display'
elif req.path == session.path + 'settings/all-rotation':
db.update_user(session.user, flags=(session.user.flags ^ User.DISABLE_ROTATION_FLAG))
return 30, './display'
elif req.path == session.path + 'settings/notif':
notif = session.user.notif
if req.query is None:
NOTIFS = {
Notification.MENTION: f'@{session.user.name} is mentioned',
Notification.COMMENT: 'Comment on your post',
Notification.LIKE: 'Your post is liked',
Notification.THANKS: 'You receive thanks',
Notification.REACTION: 'Reaction on your post',
Notification.ISSUE_CLOSED: 'Your issue is closed',
Notification.ADDED_AS_MODERATOR: 'Added as moderator',
Notification.REMOVED_AS_MODERATOR: 'Removed as moderator',
Notification.REPORT: 'Reported content (moderator)',
Notification.USER_FLAIR_CHANGED: 'User changed flairs (moderator)',
Notification.COMMENT_ON_COMMENTED: 'Reply in discussion',
Notification.COMMENT_ON_FOLLOWED_POST: 'Comment in followed post',
Notification.POST_BY_FOLLOWED_USER: 'Post by followed user',
Notification.COMMENT_BY_FOLLOWED_USER: 'Comment by followed user',
Notification.POST_IN_FOLLOWED_SUBSPACE: 'Post in followed subspace',
Notification.COMMENT_IN_FOLLOWED_SUBSPACE: 'Comment in followed subspace',
Notification.NEW_POLL: 'New poll is posted',
}
if not session.is_likes_enabled():
del NOTIFS[Notification.LIKE]
if not session.is_reactions_enabled():
del NOTIFS[Notification.REACTION]
if not session.is_thanks_enabled():
del NOTIFS[Notification.THANKS]
if session.user.role == User.ADMIN:
NOTIFS[Notification.USER_CREATED] = 'New user created'
NOTIFS[Notification.USER_RENAMED] = 'User renamed'
NOTIFS[Notification.SUBSPACE_CREATED] = 'New subspace created'
NOTIFS[Notification.SUBSPACE_INFO_UPDATED] = 'Subspace info updated'
page += f'# {session.user.name}: Settings\n'
page += '=> /settings ⚙️ Go back\n'
page += '\n## Notifications\n\n'
page += '### Involving You\n'
for nt in NOTIFS:
if nt == Notification.COMMENT_ON_COMMENTED:
page += '\n### Followed\n'
elif nt == Notification.ADDED_AS_MODERATOR:
page += '\n'
page += f'=> /settings/notif?{nt} {CHECKS[nonzero(session.user.notif & nt)]} {NOTIFS[nt]}\n'
page += '\n=> /settings/notif?all Enable all\n'
page += '=> /settings/notif?none Disable all\n'
page += '\n### Email\n'
page += f'=> /settings/email 📧 Send to: {session.user.email if session.user.email else "(not set)"}\n'
page += f'\n=> /settings/email-inter Interval: {session.user.email_inter} minutes\n'
#email_range = "(not set)" if not session.user.email_range else session.user.email_range
# Convert the range to local hours.
if session.user.email_range:
begin, end = map(int, session.user.email_range.split('-'))
# The date doesn't matter, we are just using the hours.
dt_begin = datetime.datetime(2023, 6, 19, begin, 0, 0, tzinfo=UTC)
dt_end = datetime.datetime(2023, 6, 19, end, 0, 0, tzinfo=UTC)
dt_begin = dt_begin.astimezone(session.tz)
dt_end = dt_end.astimezone(session.tz)
email_range = f"{dt_begin.hour}-{dt_end.hour}"
else:
email_range = "(not set)"
page += f'=> /settings/email-range "Do not disturb" range: {email_range}\n'
return page
q = clean_query(req)
if q == 'all':
notif = Notification.ALL_MASK
elif q == 'none':
notif = 0
else:
notif ^= max(0, int(clean_query(req)))
db.update_user(session.user, notif=notif)
return 30, '/settings/notif'
elif req.path == session.path + 'settings/omit-all':
user_space = db.get_subspace(owner=session.user.id)
if user_space.flags & Subspace.HIDE_OMIT_SETTING_FLAG:
return 61, 'Not authorized'
db.update_subspace(user_space, flags=user_space.flags ^ Subspace.OMIT_FROM_ALL_FLAG)
return 30, '/settings'
elif req.path == session.path + 'settings/omit-feed':
user_space = db.get_subspace(owner=session.user.id)
db.update_subspace(user_space, flags=user_space.flags ^ Subspace.OMIT_FROM_FEED_BY_DEFAULT)
return 30, '/settings'
elif req.path == session.path + 'settings/autosplit':
db.update_user(user, flags=(user.flags ^ User.COMPOSER_SPLIT_FLAG))
return 30, '/settings'
elif req.path == session.path + 'settings/likes':
modify_reactions(db, user, User.HIDE_LIKES_FLAG, Notification.LIKE)
return 30, '/settings/feedback'
elif req.path == session.path + 'settings/thanks':
modify_reactions(db, user, User.HIDE_THANKS_FLAG, Notification.THANKS)
return 30, '/settings/feedback'
elif req.path == session.path + 'settings/reactions':
modify_reactions(db, user, User.HIDE_REACTIONS_FLAG, Notification.REACTION)
return 30, '/settings/feedback'
elif req.path == session.path + 'settings/feedback':
page += f'# {session.user.name}: Settings\n'
page += '=> /settings ⚙️ Go back\n\n'
page += '## Feedback\n\n'
if session.bubble.thanks_enabled:
page += f'=> /settings/thanks {CHECKS[is_zero(user.flags & User.HIDE_THANKS_FLAG)]} Enable 🙏 Thanks\n'
page += 'Thanks are a way of showing appreciation privately. The recipient just receives a Thanks notification.\n'
if session.bubble.likes_enabled:
page += f'=> /settings/likes {CHECKS[is_zero(user.flags & User.HIDE_LIKES_FLAG)]} Enable 👍 Likes\n'
page += 'Likes are public and the names of likers are visible to everyone. Like counts are shown in feeds. '
page += 'When disabled, you will not see likes on any post nor will you be notified of likes. Others may still like your posts.\n'
if len(session.bubble.user_reactions):
page += f'=> /settings/reactions {CHECKS[is_zero(user.flags & User.HIDE_REACTIONS_FLAG)]} Enable 😃 Reactions\n'
page += 'Reactions are anonymous and the per-reaction totals are shown on post pages. You are notified of new reactions on your posts. The administrator chooses which Emoji can be used.\n'
page += f'\nAvailable reactions: {session.bubble.user_reactions}\n'
return page
elif req.path == session.path + 'settings/homefeed':
uflags = user.flags & ~User.FEED_MASK
arg = clean_query(req)
if arg == 'all':
db.update_user(user, flags=uflags)
return 30, '/settings'
if arg == 'nouser':
db.update_user(user, flags=uflags | User.HOME_NO_USERS_FEED_FLAG)
return 30, '/settings'
if arg == 'user':
db.update_user(user, flags=uflags | User.HOME_USERS_FEED_FLAG)
return 30, '/settings'
if arg == 'followed':
db.update_user(user, flags=uflags | User.HOME_FOLLOWED_FEED_FLAG)
return 30, '/settings'
page = 'Which feed to show on the front page?\n\n'
page += '=> ?all All Posts\n'
page += '=> ?nouser Subspace Posts\n'
page += '=> ?user User Posts\n'
page += '=> ?followed Followed\n'
return page
elif req.path == session.path + 'settings/follow':
page += f'# {session.user.name}: Settings\n'
page += '=> /settings ⚙️ Go back\n\n'
def make_follow_mute_list(user, is_follow):
follows = db.get_follows(user) if is_follow else db.get_mutes(user)
type_user = FOLLOW_USER if is_follow else MUTE_USER
type_subspace = FOLLOW_SUBSPACE if is_follow else MUTE_SUBSPACE
fusers = []
fsubs = []
fposts = []
label = 'followed' if is_follow else 'muted'
page = ''
for type, target in follows:
if type == type_user:
fu = db.get_user(id=target)
if fu:
fusers.append(f'=> /u/{fu.name} {fu.avatar} {fu.name}\n')
elif type == type_subspace:
fs = db.get_subspace(id=target)
if fs:
fsubs.append(f'=> /{fs.title()} {fs.title()}\n')
else:
fp = db.get_post(id=target)
if fp:
fposts.append(session.gemini_feed_entry(fp))
page += '\n### Users\n'
if not fusers:
page += f'No {label} users.\n'
for u in sorted(fusers, key=str.lower):
page += u
page += '\n### Subspaces\n'
if not fsubs:
page += f'No {label} subspaces.\n'
for s in sorted(fsubs, key=str.lower):
page += s
page += '\n### Posts\n'
if not fposts:
page += f'No {label} posts.\n'
for p in sorted(fposts, reverse=True):
page += p
return page
page += '## Followed\n' + make_follow_mute_list(session.user, True)
page += '\n## Muted\n' + make_follow_mute_list(session.user, False)
return page
elif req.path.startswith(session.path + 'settings/rename/'):
found = re.search(r'/([0-9a-zA-Z]{10})$', req.path)
if not found:
return 59, 'Bad request'
token = found[1]
if not db.verify_token(user, token):
return 61, 'Not authorized'
prompt = f'New name for user account "{session.user.name}"? (Warning: Links to /u/{session.user.name} will break!)'
if is_empty_query(req):
return 10, prompt
new_name = clean_query(req)
if not is_valid_name(new_name):
return prompt
try:
db.update_user(session.user, name=new_name)
db.notify_admin(Notification.USER_RENAMED, session.user.id)
except:
return 10, prompt
return 30, '/settings'
elif req.path.startswith(session.path + 'settings/delete-account/'):
if user.role == User.ADMIN:
return 61, 'Admin users cannot be deleted'
found = re.search(r'/([0-9a-zA-Z]{10})$', req.path)
if not found:
return 59, 'Bad request'
token = found[1]
if not db.verify_token(user, token):
return 61, 'Not authorized'
if is_empty_query(req):
return 10, 'Really delete your user account? All posts and comments will be deleted. (Enter DELETE to confirm.)'
if req.query == 'DELETE':
db.destroy_user(user)
page = '# User Deleted\n'
page += f'The user account "{user.name}" has now been deleted.\n\nAll posts and comments made with that account have been removed. You should now deactivate your client certificate.\n'
page += '\n=> / Back to front page\n'
return page
return 30, '/settings'
elif req.path == session.path + 'settings/password':
if not user:
return 60, 'Login required'
PROMPT = "Enter certificate password: (Valid for one hour.)"
if req.query is None:
return 11, PROMPT
else:
pwd = urlparse.unquote(req.query)
if pwd == '' or len(pwd) >= 4:
db.update_user(user, password=pwd)
else:
return 11, "That is too short. " + PROMPT
return 30, '/settings/certs'
elif req.path == session.path + 'settings/recovery':
if req.query is None:
return 10, 'Enter certificate recovery URL:'
url = clean_query(req)
if not url.startswith('gemini://'):
url = 'gemini://' + url
db.update_user(session.user, recovery=url)
return 30, '/settings/certs'
elif req.path.startswith(session.path + 'settings/remove-cert/'):
if not session.user:
return 60, 'Login required'
m = re.search(r'/remove-cert/([0-9a-zA-Z]{10})/(\w+)$', req.path)
if not m:
return 59, 'Bad request'
token = m[1]
fp = m[2]
if not session.db.verify_token(session.user, token):
return 61, 'Not authorized'
if req.query is None:
return 10, f'Really remove certificate {fp[:10].upper()}? (Enter YES to confirm.)'
if req.query == 'YES':
db.remove_certificate(session.user, fp)
return 30, '/settings/certs'
elif req.path == session.path + 'settings/certs':
page += f'# {session.user.name}: Settings\n'
page += '=> /settings ⚙️ Go back\n\n'
page += '## Certificates\n'
exp_mins = math.ceil(user.password_expiry() / 60) if user.ts_password else 0
page += '\n=> /settings/password 🔑 Certificate password: '
if exp_mins > 0 and user.password:
page += f"(valid for {exp_mins} minute{plural_s(exp_mins)})"
else:
page += '(not set)'
page += '\nEnables registering additional certificates to this account.\n'
page += f'\n=> /settings/recovery 🛟 Recovery URL: {user.recovery if user.recovery else "(not set)"}\n'
page += "A PEM certificate at this URL can be registered to your account when needed. Set this URL to point at a file on a host you control. You should keep the URL always configured, but only serve the file temporarily when you want to recover access to your account.\n"
page += f'\n### Registered to {user.name}'
certs = db.get_certificates(user)
for fp, subject, expiry in certs:
page += f'\n{fp[:10].upper()} · Expires {expiry.strftime("%Y-%m-%d") if expiry else "?"} · Subject: {subject}\n'
if fp != req.identity.fp_cert:
page += f'=> /settings/remove-cert/{db.get_token(user)}/{fp} ❌ Remove\n'
else:
page += '\n'
return page
elif req.path == session.path + 'settings/timezone':
if is_empty_query(req):
page = 'Select a time zone:\n\n'
for tz in pytz.all_timezones:
page += f'=> ?{tz} {tz}\n'
return page
tz = clean_query(req)
if not tz in pytz.all_timezones:
return 50, 'Invalid time zone'
db.update_user(session.user, timezone=tz)
return 30, '/settings/display'
elif req.path == session.path + 'settings/display':
page += f'# {session.user.name}: Settings\n'
page += '=> /settings ⚙️ Go back\n\n'
ICON_MODE = [ 'Unicode/Emoji' , 'ASCII' ]
page += '## Display\n\n'
page += f'=> /settings/short-preview {CHECKS[nonzero(user.flags & User.SHORT_PREVIEW_FLAG)]} Short post previews\n'
page += f'=> /settings/hide-flairs {CHECKS[nonzero(user.flags & User.HIDE_FLAIRS_FLAG)]} Hide flairs in feeds\n'
page += f"\n=> /settings/all-rotation {CHECKS[is_zero(user.flags & User.DISABLE_ROTATION_FLAG)]} Rotate posts by subspace in All Posts\n"
if user.flags & User.DISABLE_ROTATION_FLAG:
page += 'The All Posts feed shows every post individually, even when one subspace has several posts per day.\n'
else:
page += 'The All Posts feed groups posts by subspace and rotates them throughout the day.\n'
page += f'\n=> /settings/timezone Time zone: {user.timezone}\n'
page += f'=> /settings/ascii Display icons as: {ICON_MODE[nonzero(user.flags & User.ASCII_ICONS_FLAG)]}\n'
return page
elif req.path == session.path + 'settings' or \
req.path == session.path + 'settings/':
page = f'# {user.name}: Settings\n'
page += f'\n=> /dashboard Back to Dashboard\n'
page += f'=> / Back to front page\n\n'
SORT_COMMENT = {
'o': '⬇ Oldest first',
'n': '⬆ Newest first'
}
HOME_FEED = {
0: 'All Posts',
User.HOME_NO_USERS_FEED_FLAG: 'Subspace Posts',
User.HOME_USERS_FEED_FLAG: 'User Posts',
User.HOME_FOLLOWED_FEED_FLAG: 'Followed',
}
user_space = db.get_subspace(owner=user.id)
page += f'=> /settings/homefeed Home feed: {HOME_FEED[user.flags & User.FEED_MASK]}\n'
page += f'=> /settings/sort-feed Sort posts: {SORT_POST[user.sort_post]}\n'
page += f'=> /settings/sort-cmt Sort comments: {SORT_COMMENT[user.sort_cmt]}\n'
page += '\n'
if not user_space.flags & Subspace.HIDE_OMIT_SETTING_FLAG:
page += f'=> /settings/omit-all {CHECKS[nonzero(user_space.flags & Subspace.OMIT_FROM_ALL_FLAG)]} ' + \
f'Omit u/{session.user.name} from All Posts\n'
page += f'=> /settings/omit-feed {CHECKS[nonzero(user_space.flags & Subspace.OMIT_FROM_FEED_BY_DEFAULT)]} ' + \
'Omit posts from Gemini/Atom feed by default\n'
page += 'Individual posts can be included or excluded from All Posts and Gemini/Atom feeds using the composer.\n'
page += f'=> /settings/autosplit {CHECKS[nonzero(user.flags & User.COMPOSER_SPLIT_FLAG)]} Auto-split paragraphs in composer\n'
page += '\n=> /settings/profile ⚙️ Profile\n'
page += '=> /settings/display ⚙️ Display\n'
page += '=> /settings/notif ⚙️ Notifications\n'
page += '=> /settings/feedback ⚙️ Feedback\n'
page += '=> /settings/follow ⚙️ Followed and muted\n'
page += '=> /settings/certs ⚙️ Certificates\n'
page += '\n## Account\n'
page += f'\n=> /export/{user.name}.gpub 📤 Export data archive\n'
page += 'Download a ZIP archive containing all posts and comments you have made. You can extract the contents and serve them as a static Gemini capsule. The archive has Gempub metadata so it can also be viewed in a Gempub reader. \n'
page += f'\n=> /settings/rename/{session.get_token()} Rename user\n'
page += f'Renaming the account will break links pointing to u/{user.name}.\n'
page += f'\n=> /settings/delete-account/{session.get_token()} ⚠️ Delete user {user.name}\n'
page += 'All of your posts and comments will be deleted.\n'
page += f'\n💬 Bubble v{session.bubble.version} by @jk@skyjake.fi\n'
return page
return 51, 'Not found'