AO3-DL/python/webuiAdmin.py
2025-12-26 08:00:43 -08:00

321 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
# This file is part of AOMO, "Archive Of My Own", a collection of Python and PHP
# scripts designed act as an improved local backup system
# for works published on https://archiveofourown.org
#
# Copyright (c) 2025 Cyberpro123, except where otherwise noted.
#
# This project is available under the GNU General Public License (GPL) v2.0,
# available at https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html
# or in the 'LICENSE.md' file that should be distributed alongside this one.
#
# Report issues to https://codeberg.org/Cyberpro123/AOMO
# Contact author at cyberpro123@posteo.com
from collections.abc import Callable
import curses
import datetime
import textwrap
import time
import sys
import bcrypt
import common
import sql
class MenuItem:
def __init__(
self,
prompt: str,
actionType: str,
action: Callable | str = "",
key: str | int = "",
value: str | bool = "",
):
self.prompt = str(prompt)
self.actionType = str(actionType)
self.action = action
self.key = str(key)[:1]
self.value = value
self.defaultValue = value
def getHeight(self, width) -> int:
table = textwrap.wrap(
self.value, width=width, initial_indent=4, subsequent_indent=4
)
return max(1, len(table))
def render(self, width) -> list:
if self.key:
outStr = f"[{self.key}] "
else:
outStr = "[ ] "
outStr += self.prompt
if self.actionType in ("checkbox", "getTxt") and (not outStr.endswith(" ")):
outStr += " "
if self.actionType == "checkbox":
if self.value:
outStr += "[X]"
else:
outStr += "[ ]"
elif self.actionType == "getTxt":
if "password" in self.prompt.lower():
for i in self.value:
outStr += "*"
else:
outStr += self.value
return textwrap.wrap(outStr, width=width, subsequent_indent=" ")
class Menu:
def __init__(self, *args):
self.children = []
for i in args:
if isinstance(i, MenuItem):
self.children.append(i)
else:
raise TypeError(
f"Class 'Menu' only accepts 'MenuItem' args, not [{type(i)}]"
)
def getHeight(self, width) -> int:
sum = 0
for i in self.children:
sum += i.getHeight(width)
return sum
def render(self, width) -> list:
out = []
for i in self.children:
out.append(i.render(width))
return out
def getKeys(self) -> list:
out = []
for i in self.children:
if i.key:
out.append(i.key)
return out
def getMenuItemFromKey(self, key) -> MenuItem | None:
for i in self.children:
if i.key and i.key == key:
return i
def resetValues(self) -> None:
for i in self.children:
i.value = i.defaultValue
menus = {
"menu_main": Menu(
MenuItem(prompt="Manage Users", actionType="chMn", action="menu_users", key=1),
MenuItem(prompt="Close Menu", actionType="close", key=9),
),
"menu_users": Menu(
MenuItem(prompt="Add User", actionType="chMn", action="userAdd", key=1),
MenuItem(prompt="Change Password", actionType="chMn", action="chPass", key=2),
MenuItem(
prompt="Change Stylesheet",
actionType="chMn",
action="switchStylesheet",
key=3,
),
MenuItem(
prompt="Change Work Rating Blacklist",
actionType="chMn",
action="ratingBlacklist",
key=4,
),
MenuItem(prompt="Delete User", actionType="chMn", action="userDel", key=5),
MenuItem(
prompt="View Database Table",
actionType="showTable",
action="users",
key=6,
value=[
"id",
"username",
"password_hash",
"stylesheet",
"can_see_explicit_work",
],
),
MenuItem(prompt="Back", actionType="chMn", action="menu_main", key=9),
),
"chPass": Menu(),
}
header = ""
loaded = {"highestExistingUserID": None}
def doMenu(screen):
global header
exitMenu = False
char = 0
optionSelected = 0
optionsStart = 0
menu = menus["menu_main"]
while not exitMenu:
screen.erase()
skipInput = False
height, width = screen.getmaxyx()
headerList = textwrap.wrap(header, width=width)
optionsStart = len(headerList)
common.logger.debug(f"Char: [{char}]")
for num, line in enumerate(headerList):
screen.addstr(num, 0, line)
optionsStartLines = [optionsStart]
for menuItem in menu.render(width):
for num, line in enumerate(menuItem):
screen.addstr((num + optionsStartLines[-1]), 0, line)
optionsStartLines.append(len(menuItem) + optionsStartLines[-1])
optionsStartLines.pop(-1)
if (chr(char) in menu.getKeys()) or (
char in (curses.KEY_RIGHT, curses.KEY_ENTER, 10, 13)
):
# 10 = newline 13 = carriage return
if chr(char) in menu.getKeys():
item = menu.getMenuItemFromKey(chr(char))
else:
item = menu.children[optionSelected]
match item.actionType:
case "close":
sys.exit(0)
case "chMn":
menu.resetValues()
menu = menus[item.action]
case "checkbox":
item.value = not item.value
case "function":
header = item.action(menu)
menu.resetValues()
if item.actionType not in ("checkbox", "none"):
optionSelected = 0
skipInput = True
del item
elif char in (curses.KEY_DOWN, 9): # 9 is tab
optionSelected += 1
elif char in (curses.KEY_UP, curses.KEY_BTAB):
optionSelected -= 1
elif chr(char).isprintable() and char != curses.KEY_LEFT:
item = menu.children[optionSelected]
if char == curses.KEY_BACKSPACE:
item.value = item.value[:-1]
else:
item.value = item.value + chr(char)
del item
skipInput = True
if char in (curses.KEY_HOME, curses.KEY_PPAGE) or optionSelected == len(
menu.children
):
optionSelected = 0
elif char in (curses.KEY_END, curses.KEY_NPAGE) or optionSelected == -1:
optionSelected = len(menu.children) - 1
screen.move(optionsStartLines[optionSelected], 1)
if skipInput:
char = 0
else:
char = screen.getch()
def addUser(menu: MenuItem) -> str:
id = 1 + getHighestExistingUserID()
username = menu.children[0].value
pass1 = menu.children[1].value
pass2 = menu.children[2].value
preHashed = menu.children[3].value
if pass1 != pass2:
return "ERROR: Passwords don't match. User not added."
if len(username) > 100:
return "ERROR: Username too long (max 100 characters)"
if len(pass1) > 72:
return "ERROR: Password too long (max 72 characters)"
if preHashed:
hashed = pass1
else:
hashed = bcrypt.hashpw(pass1.encode(), bcrypt.gensalt()).decode()
if not bcrypt.checkpw(pass1.encode(), hashed.encode()):
return "ERROR: Password hashing failed"
try:
sql.execute(
commands=[
(
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)",
(id, username, hashed),
)
],
config=common.config,
logger=common.logger,
)
except sql.UniqueConstraintFailed:
return "ERROR: User with that name already exists."
else:
loaded["highestExistingUserID"] += 1
return f"Successfully added user [{username}] as ID [{id}]"
def changePassword(menu: MenuItem) -> str:
username = menu.children[0].value
pass1 = menu.children[1].value
pass2 = menu.children[2].value
preHashed = menu.children[3].value
if pass1 != pass2:
return "ERROR: Passwords don't match. User not added."
if len(username) > 100:
return "ERROR: Username too long (max 100 characters)"
if len(pass1) > 72:
return "ERROR: Password too long (max 72 characters)"
if preHashed:
hashed = pass1
else:
hashed = bcrypt.hashpw(pass1.encode(), bcrypt.gensalt()).decode()
if not bcrypt.checkpw(pass1.encode(), hashed.encode()):
return "ERROR: Password hashing failed"
sql.execute(
commands=[
(
"UPDATE users SET password_hash = ? WHERE username = ?",
(hashed, username),
)
],
config=common.config,
logger=common.logger,
)
return f"Successfully changed password for user [{username}]"
def getHighestExistingUserID() -> int:
if not loaded["highestExistingUserID"]:
loaded["highestExistingUserID"] = sql.execute(
commands=[("SELECT MAX(id) FROM users", ())],
config=common.config,
logger=common.logger,
)[0][0][0]
if not loaded["highestExistingUserID"]:
loaded["highestExistingUserID"] = 0
return loaded["highestExistingUserID"]
menus["userAdd"] = Menu(
MenuItem(prompt="Username: ", actionType="getTxt"),
MenuItem(prompt="Enter Password: ", actionType="getTxt"),
MenuItem(prompt="Confirm Password: ", actionType="getTxt"),
MenuItem(prompt="Password Pre-Hashed?", actionType="checkbox", value=False),
MenuItem(prompt="[Submit]", actionType="function", action=addUser),
MenuItem(prompt="Back", actionType="chMn", action="menu_users"),
)
menus["chPass"] = Menu(
MenuItem(prompt="Username: ", actionType="getTxt"),
MenuItem(prompt="Enter Password: ", actionType="getTxt"),
MenuItem(prompt="Confirm Password: ", actionType="getTxt"),
MenuItem(prompt="Password Pre-Hashed?", actionType="checkbox", value=False),
MenuItem(prompt="[Submit]", actionType="function", action=changePassword),
MenuItem(prompt="Back", actionType="chMn", action="menu_users"),
)
if __name__ == "__main__":
common.init()
curses.wrapper(doMenu)