bubble/settings.py

605 lines
25 KiB
Python

import math
import pytz
from utils import *
from model import Notification, User, 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 make_settings_page(session):
req = session.req
db = session.db
user = session.user
page = ''
if not user:
return 60, 'Login required'
if req.path == session.path + 'settings/avatar':
AVATARS = [
'Human',
('👤', 'silhouette of person'),
('🙂', 'smily face'),
('😎', 'smiling face with sunglasses'),
('🤠', 'cowboy face'),
('😈', 'smiling face with horns'),
('👻', 'ghost'),
('💀', 'skull'),
('❤️', 'red heart'),
'Animals',
('😺', 'cat face'),
('🐶', 'dog face'),
('🐸', 'frog face'),
('🐵', 'monkey face'),
('🐷', 'pig face'),
('🐻', 'bear face'),
('🐦', 'bird'),
('🐧', 'penguin'),
('🐝', 'bee'),
('🦋', 'butterfly'),
('🦀', 'crab'),
('🐐', 'goat'),
('🦥', 'sloth'),
('🦎', 'lizard'),
('🦂', 'scorpion'),
('🐉', 'dragon'),
'Nature',
('🍀', 'four leaf clover'),
('🌻', 'sunflower'),
('🌝', 'full moon with face'),
('🌙', 'crescent moon'),
('☀️', 'sun'),
('🌧️', 'cloud with rain'),
('🌲', 'evergreen tree'),
('🍄', 'mushroom'),
('🌊', 'wave'),
('🔥', 'fire'),
('', 'snowflake'),
'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'),
'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',
('👺', '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 == 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':
if req.query == None:
return 10, 'Enter profile description:'
db.update_user(session.user, info=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':
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 Avatar: {session.user.avatar}\n'
page += f'=> /settings/feed-title Feed title: {user_sub.info if user_sub.info else user.name}\n'
page += '\n### Description\n'
page += (session.user.info if session.user.info else '(no description)') + '\n'
page += f'=> /settings/info 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 Edit\n'
return page
elif req.path == session.path + 'settings/sort-feed':
db.update_user(session.user,
sort_post=(User.SORT_POST_HOTNESS
if session.user.sort_post == User.SORT_POST_RECENT else
User.SORT_POST_RECENT))
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(session.user, flags=(session.user.flags ^ User.ASCII_ICONS_FLAG))
return 30, './display'
elif req.path == session.path + 'settings/short-preview':
db.update_user(session.user, flags=(session.user.flags ^ User.SHORT_PREVIEW_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.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]
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'
page += f'=> /settings/notif?{nt} {session.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 = 0xffff
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)
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/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 {session.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 {session.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 {session.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':
db.update_user(session.user, flags=session.user.flags ^ User.HOME_FOLLOWED_FEED_FLAG)
return 30, '/settings'
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)
fusers.append(f'=> /u/{fu.name} {fu.avatar} {fu.name}\n')
elif type == type_subspace:
fs = db.get_subspace(id=target)
fsubs.append(f'=> /{fs.title()} {fs.title()}\n')
else:
fp = db.get_post(id=target)
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)
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 {session.CHECKS[nonzero(session.user.flags & User.SHORT_PREVIEW_FLAG)]} Short post previews\n'
page += f'\n=> /settings/timezone Time zone: {user.timezone}\n'
page += f'=> /settings/ascii Display icons as: {ICON_MODE[nonzero(session.user.flags & User.ASCII_ICONS_FLAG)]}\n'
return page
elif req.path == session.path + 'settings' or \
req.path == session.path + 'settings/':
page = f'# {session.user.name}: Settings\n'
page += f'\n=> /dashboard Back to Dashboard\n'
page += f'=> / Back to front page\n\n'
SORT_POST = { 'r': '🕑 Most recent', 'h': '🔥 Hotness' }
SORT_COMMENT = { 'o': '⬇ Oldest first', 'n': '⬆ Newest first' }
HOME_FEED = [ 'All Posts', 'Followed' ]
user_space = db.get_subspace(owner=session.user.id)
page += f'=> /settings/homefeed Home feed: {HOME_FEED[nonzero(session.user.flags & User.HOME_FOLLOWED_FEED_FLAG)]}\n'
page += f'=> /settings/sort-feed Sort posts: {SORT_POST[session.user.sort_post]}\n'
page += f'=> /settings/sort-cmt Sort comments: {SORT_COMMENT[session.user.sort_cmt]}\n'
page += f'\n=> /settings/omit-all {session.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 {session.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 Gemini/Atom feeds using the 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/{session.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/{session.user.name}.\n'
page += f'\n=> /settings/delete-account/{session.get_token()} ⚠️ Delete user {session.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'