mirror of
https://codeberg.org/Cyberpro123/AO3-DL.git
synced 2026-07-27 08:37:23 +00:00
321 lines
10 KiB
Python
Executable file
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)
|