import datetime import random import re from model import User, Database, Subspace from utils import clean_query, is_valid_name, is_empty_query, plural_s def admin_actions(session): user = session.user db = session.db req = session.req page = f'# Administration\n' page += session.dashboard_link() + '\n' if not user: return 60, "Login required" if user.role != User.ADMIN: return 61, "Not authorized" if req.path.startswith(session.path + 'admin/review-users/'): token = session.get_token() if req.path == session.path + f'admin/review-users/set-basic/{token}': name = clean_query(req) user = db.get_user(name=name) if not user: return 51, 'Not found' db.update_user(user, role=User.BASIC) db.unset_post_omit_flags(user) db.notify_role(user) page += f'User "{name}" (ID: {user.id}) has been given a Basic role.\n\n' page += f"=> /admin/review-users/ Continue Review\n" return page if req.path == session.path + f'admin/review-users/set-limited/{token}': name = clean_query(req) user = db.get_user(name=name) if not user: return 51, 'Not found' db.update_user(user, role=User.LIMITED, flags=user.flags | User.HIDE_REVIEW_FLAG) page += f'User "{name}" (ID: {user.id}) has been given a Limited role.\n\n' page += f"=> /admin/review-users/ Continue Review\n" return page if req.path == session.path + f'admin/review-users/batch-delete/{token}': if is_empty_query(req): return 10, 'Max age (hours) and max post count for batch deletion?' args = clean_query(req).split() seconds = int(args[0]) * 60 * 60 max_posts = int(args[1]) db.batch_delete_limited_users(seconds, max_posts) return 31, '/admin/review-users/' page += f'=> /admin/review-users/batch-delete/{token} 💥 Batch delete\n\n' limited_users = db.get_users(role=User.LIMITED) page += '## Promote Limited Users\n' for u in limited_users: if not u.flags & User.HIDE_REVIEW_FLAG: page += f"=> /u/{u.name} {u.avatar} {u.name}\n" page += f"=> set-basic/{token}?{u.name} 👍 Promote {u.name} to Basic role\n" page += f"=> set-limited/{token}?{u.name} Keep {u.name} as Limited\n\n" page += '## Delete Limited Users\n' for u in limited_users: if not u.flags & User.HIDE_REVIEW_FLAG: page += f"=> /u/{u.name} {u.avatar} {u.name}\n" page += f"=> /admin/delete-user/{token}?{u.name} ⚠️ DELETE {u.avatar} {u.name}\n\n" return page if req.path.startswith(session.path + 'admin/dormant-mods/'): token = session.get_token() if req.path == session.path + f'admin/dormant-mods/remove/{token}': if is_empty_query(req): return 10, 'Moderator to remove (in the form "user:subspace"):' parts = clean_query(req).split(':') if len(parts) != 2: return 59, 'Bad request' mod_name, sub_name = parts mod_user = db.get_user(name=mod_name) if not mod_user: return 51, 'User not found' sub = db.get_subspace(name=sub_name) if not sub: return 51, 'Subspace not found' db.modify_mods(sub, actor=user, remove=mod_user) page += f'Removed {mod_name} as moderator of s/{sub_name}.\n\n' page += f'=> {session.path}admin/dormant-mods/ Back to Dormant Moderators\n' return page dormant = db.get_dormant_mods(365) if not dormant: page += '## 🙂 Dormant Moderators\n\n' page += 'No dormant moderators found.\n' else: page += '## 😴 Dormant Moderators\n\n' # Look up all moderators of the listed subspaces. sub_mods = {} for _, _, sub in dormant: if not sub.id in sub_mods: sub_mods[sub.id] = db.get_mods(sub.id) # Print the list (descending). page += 'Remove a dormant moderator:\n' for dormancy_days, mod, sub in dormant: mod_count = len(sub_mods[sub.id]) months = dormancy_days // 30 years = months // 12 rem_months = months % 12 durs = [ f'{years} year{plural_s(years)}' if years > 0 else '', f'{rem_months} month{plural_s(rem_months)}' if rem_months > 0 else '' ] duration = ' '.join([d for d in durs if d]).strip() page += f'\n=> remove/{token}?{mod.name}:{sub.name} ❌ s/{sub.name}: Remove {mod.avatar} {mod.name}: {duration}\n=> /s/{sub.name} View subspace ({mod_count} moderator{plural_s(mod_count)})\n' return page if req.path == session.path + 'admin/flair': if is_empty_query(req): return 10, "Edit flairs of user:" return 30, session.path + 'settings/flair/' + clean_query(req) + '/' if req.path == session.path + 'admin/': token = session.get_token() page += "## Users\n\n" page += f'=> review-users/ ✔️ Review limited users\n' page += f'=> flair 📛 Edit user flairs\n' page += f'=> password/{token} 🔑 Generate a random password for user\n' page += f'=> revoke/{token} 🛂 Revoke certificates of user\n' page += f'=> unconfirm-email/{token} ✉️ Mark email as unconfirmed\n' page += f'=> create-user/{token} 👤 Create new user\n' page += f'=> delete-user/{token} ❌ Delete user\n' page += "\n## Subspaces\n\n" page += f'=> dormant-mods/ 😴 Dormant moderators\n' page += f"=> omit-subspace/{token} Omit a subspace from All Posts\n" page += f"=> include-subspace/{token} Include a subspace in All Posts\n" page += f'=> delete-subspace/{token} Delete a subspace\n' page += '\n### Locked\n' locked_subs = db.get_subspaces(locked=True) if not locked_subs: page += 'No locked subspaces.\n' for locked in locked_subs: page += f'=> /{locked.title()} 🔒 {locked.title()}\n' return page found = re.search(r'^admin/(create-user|password|revoke|flair|delete-user|unconfirm-email|omit-subspace|include-subspace|delete-subspace)/([0-9a-zA-Z]{10})$', req.path[len(session.path):]) if not found: return 59, "Bad request" name = clean_query(req) action = found[1] req_token = found[2] if not db.verify_token(user, req_token): return 61, "Not authorized" if action == 'create-user': if not is_valid_name(name): return 10, "Enter name of user to create: " + session.NAME_HINT user_id = db.create_user(name, None) page += f'User "{name}" (ID: {user_id}) has been created, with no registered certificates.\n' elif action == 'password': prompt = 'Generate a certificate password for which user? ' + \ '(Optionally, followed by how many days the password is valid: "username 3". ' + \ 'Default is 7 days.)' if is_empty_query(req): return 10, prompt try: parts = clean_query(req).split() if len(parts) == 2: name, days = parts days = int(days) else: name = parts[0] days = 7 user = db.get_user(name=name) if not user: return 10, prompt # Generate a reasonably secure password. pass_chars = [i for i in Database.TOKEN_CHARS] pass_chars.remove('O') pass_chars.remove('0') pass_chars.remove('l') pass_chars.remove('I') pass_chars.remove('1') pass_chars += ['@', '%', '&'] passwd = ''.join([pass_chars[random.randint(0, len(pass_chars) - 1)] for _ in range(15)]) passwd = passwd[:5] + '-' + passwd[5:10] + '-' + passwd[10:] expire_off = max(0, (days * 24 - 1) * 60) # normal expiration is one hour db.update_user(user, password=passwd, password_expiration_offset_minutes=expire_off) page += f'User "{name}" (ID: {user.id}) password has been set to:\n' page += f'```\n{passwd}\n```\n' dt = datetime.datetime.now() + datetime.timedelta(minutes=expire_off + 60) page += f'It expires on {dt.strftime("%Y-%m-%d %H:%M:%S")}.\n' except Exception as x: import traceback traceback.print_tb(x.__traceback__) print(x) return 10, prompt elif action == 'revoke': if not is_valid_name(name): return 10, 'Enter name of user whose certificates to revoke:' user = db.get_user(name=name) if not user: return 51, 'Not found' if user.role == User.ADMIN: return 50, 'Admin certificates cannot be revoked' db.remove_certificate(user, None) page += f'Certificates of user "{name}" (ID: {user.id}) have been unregistered.\n' # elif action == 'flair': # try: # parts = clean_query(req).split() # name = parts[0] # if not is_valid_name(name): # return 10, 'Enter user name followed by flair (e.g., "john Friendly"):' # except: # return 10, 'Enter user name followed by flair (e.g., "john Friendly"):' # flair = clean_query(req)[len(name):].strip() # user = db.get_user(name=name) # if not user: # return 51, 'Not found' # db.update_user(user, flair=flair) # page += f'Flair of user "{name}" (ID: {user.id}) has been set to: [{flair}]\n' elif action == 'unconfirm-email': if not is_valid_name(name): return 10, 'Enter name of user whose email to mark as unconfirmed:' target = db.get_user(name=name) if not target: return 51, 'Not found' if not target.email: return 50, f'User "{name}" has no email address set' db.update_user(target, flags=target.flags & ~User.EMAIL_CONFIRMED_FLAG) page += f'Email address of user "{name}" (ID: {target.id}) has been marked as unconfirmed.\n' elif action == 'delete-user': if not is_valid_name(name): return 10, "Enter user to delete: (NOTE: All of their posts and comments will be deleted.)" deleting = db.get_user(name=name) if not deleting: return 51, 'Not found' db.destroy_user(deleting) page += f'User "{deleting.name}" (ID: {deleting.id}) has been deleted.\n' elif action == 'omit-subspace': if not is_valid_name(name): return 10, "Enter subspace to omit from All Posts:" sub = db.get_subspace(name=name) if not sub: return 51, 'Not found' db.update_subspace(sub, flags=sub.flags | Subspace.OMIT_FROM_ALL_FLAG | Subspace.HIDE_OMIT_SETTING_FLAG) page += f'Subspace "{sub.name}" (ID: {sub.id}) is now omitted from All Posts.\n' elif action == 'include-subspace': if not is_valid_name(name): return 10, "Enter subspace to include in All Posts:" sub = db.get_subspace(name=name) if not sub: return 51, 'Not found' db.update_subspace(sub, flags=sub.flags & ~(Subspace.OMIT_FROM_ALL_FLAG | Subspace.HIDE_OMIT_SETTING_FLAG)) page += f'Subspace "{sub.name}" (ID: {sub.id}) is now included in All Posts.\n' elif action == 'delete-subspace': if not is_valid_name(name): return 10, "Enter subspace to delete: (NOTE: All posts and comments will be deleted.)" deleting = db.get_subspace(name=name) if not deleting: return 51, 'Not found' if deleting.owner: return 59, 'User subspaces cannot be separately deleted' db.destroy_subspace(deleting) page += f'Subspace "{deleting.name}" (ID: {deleting.id}) has been deleted.\n' else: return 59, "Invalid admin action" page += f'\n=> {session.path}admin/ Back to Administration\n' return page def make_stats_page(session): db = session.db page = '# Statistics\n\n' stats = db.get_statistics() page += f"""```Table: Accounts and activity Total accounts │ {stats['total']:4d} Total posters │ {stats['posters']:4d} Total commenters │ {stats['commenters']:4d} │ New accounts <= 30 days │ {stats['m_new']:4d} Visited <= 30 days │ {stats['m_visited']:4d} Post/comment <= 30 days │ {stats['m_post_cmt']:4d} Posted <= 30 days │ {stats['m_post']:4d} Commented <= 30 days │ {stats['m_cmt']:4d} │ New accounts <= 6 mos │ {stats['hy_new']:4d} Visited <= 6 mos │ {stats['hy_visited']:4d} Post/comment <= 6 mos │ {stats['hy_post_cmt']:4d} Posted <= 6 mos │ {stats['hy_post']:4d} Commented <= 6 mos │ {stats['hy_cmt']:4d} ``` => {session.path} Back to front page\n """ return page