first commit
This commit is contained in:
BIN
core/__pycache__/auth.cpython-312.pyc
Normal file
BIN
core/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/notifications.cpython-312.pyc
Normal file
BIN
core/__pycache__/notifications.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/postgres.cpython-312.pyc
Normal file
BIN
core/__pycache__/postgres.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/users.cpython-312.pyc
Normal file
BIN
core/__pycache__/users.cpython-312.pyc
Normal file
Binary file not shown.
58
core/auth.py
Normal file
58
core/auth.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import core.users as users
|
||||
import core.postgres as postgres
|
||||
import bcrypt
|
||||
import jwt
|
||||
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
||||
import datetime
|
||||
import os
|
||||
|
||||
|
||||
def verifyLoginToken(login_token, username=False, userUUID=False):
|
||||
if username:
|
||||
userUUID = users.getUserUUID(username)
|
||||
|
||||
if userUUID:
|
||||
try:
|
||||
decoded_token = jwt.decode(
|
||||
login_token, os.getenv("JWT_SECRET"), algorithms=["HS256"]
|
||||
)
|
||||
if decoded_token.get("sub") == str(userUUID):
|
||||
return True
|
||||
return False
|
||||
except (ExpiredSignatureError, InvalidTokenError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def getUserpasswordHash(userUUID):
|
||||
user = postgres.select_one("users", {"id": userUUID})
|
||||
if user:
|
||||
pw_hash = user.get("password_hashed")
|
||||
if isinstance(pw_hash, memoryview):
|
||||
return bytes(pw_hash)
|
||||
return pw_hash
|
||||
return None
|
||||
|
||||
|
||||
def getLoginToken(username, password):
|
||||
userUUID = users.getUserUUID(username)
|
||||
if userUUID:
|
||||
formatted_pass = password.encode("utf-8")
|
||||
users_hashed_pw = getUserpasswordHash(userUUID)
|
||||
if bcrypt.checkpw(formatted_pass, users_hashed_pw):
|
||||
payload = {
|
||||
"sub": userUUID,
|
||||
"name": users.getUserFirstName(userUUID),
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1),
|
||||
}
|
||||
return jwt.encode(payload, os.getenv("JWT_SECRET"), algorithm="HS256")
|
||||
return False
|
||||
|
||||
|
||||
def unregisterUser(userUUID, password):
|
||||
pw_hash = getUserpasswordHash(userUUID)
|
||||
if not pw_hash:
|
||||
return False
|
||||
if bcrypt.checkpw(password.encode("utf-8"), pw_hash):
|
||||
return users.deleteUser(userUUID)
|
||||
return False
|
||||
74
core/notifications.py
Normal file
74
core/notifications.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
notifications.py - Multi-channel notification routing
|
||||
|
||||
Supported channels: Discord webhook, ntfy
|
||||
"""
|
||||
|
||||
import core.postgres as postgres
|
||||
import uuid
|
||||
import requests
|
||||
import time
|
||||
|
||||
|
||||
def _sendToEnabledChannels(notif_settings, message):
|
||||
"""Send message to all enabled channels. Returns True if at least one succeeded."""
|
||||
sent = False
|
||||
|
||||
if notif_settings.get("discord_enabled") and notif_settings.get("discord_webhook"):
|
||||
if discord.send(notif_settings["discord_webhook"], message):
|
||||
sent = True
|
||||
|
||||
if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"):
|
||||
if ntfy.send(notif_settings["ntfy_topic"], message):
|
||||
sent = True
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
def getNotificationSettings(userUUID):
|
||||
settings = postgres.select_one("notifications", {"user_uuid": userUUID})
|
||||
if not settings:
|
||||
return False
|
||||
return settings
|
||||
|
||||
|
||||
def setNotificationSettings(userUUID, data_dict):
|
||||
existing = postgres.select_one("notifications", {"user_uuid": userUUID})
|
||||
allowed = [
|
||||
"discord_webhook",
|
||||
"discord_enabled",
|
||||
"ntfy_topic",
|
||||
"ntfy_enabled",
|
||||
]
|
||||
updates = {k: v for k, v in data_dict.items() if k in allowed}
|
||||
if not updates:
|
||||
return False
|
||||
if existing:
|
||||
postgres.update("notifications", updates, {"user_uuid": userUUID})
|
||||
else:
|
||||
updates["id"] = str(uuid.uuid4())
|
||||
updates["user_uuid"] = userUUID
|
||||
postgres.insert("notifications", updates)
|
||||
return True
|
||||
|
||||
|
||||
class discord:
|
||||
@staticmethod
|
||||
def send(webhook_url, message):
|
||||
try:
|
||||
response = requests.post(webhook_url, json={"content": message})
|
||||
return response.status_code == 204
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
class ntfy:
|
||||
@staticmethod
|
||||
def send(topic, message):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"https://ntfy.sh/{topic}", data=message.encode("utf-8")
|
||||
)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
264
core/postgres.py
Normal file
264
core/postgres.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
postgres.py - Generic PostgreSQL CRUD module
|
||||
|
||||
Requires: pip install psycopg2-binary
|
||||
|
||||
Connection config from environment:
|
||||
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
def _get_config():
|
||||
return {
|
||||
"host": os.environ.get("DB_HOST", "localhost"),
|
||||
"port": int(os.environ.get("DB_PORT", 5432)),
|
||||
"dbname": os.environ.get("DB_NAME", "app"),
|
||||
"user": os.environ.get("DB_USER", "app"),
|
||||
"password": os.environ.get("DB_PASS", ""),
|
||||
}
|
||||
|
||||
|
||||
def _safe_id(name):
|
||||
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
||||
raise ValueError(f"Invalid SQL identifier: {name}")
|
||||
return f'"{name}"'
|
||||
|
||||
|
||||
def _build_where(where, prefix=""):
|
||||
clauses = []
|
||||
params = {}
|
||||
for i, (col, val) in enumerate(where.items()):
|
||||
param_name = f"{prefix}{col}_{i}"
|
||||
safe_col = _safe_id(col)
|
||||
|
||||
if isinstance(val, tuple) and len(val) == 2:
|
||||
op, operand = val
|
||||
op = op.upper()
|
||||
allowed = {
|
||||
"=",
|
||||
"!=",
|
||||
"<",
|
||||
">",
|
||||
"<=",
|
||||
">=",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"IN",
|
||||
"IS",
|
||||
"IS NOT",
|
||||
}
|
||||
if op not in allowed:
|
||||
raise ValueError(f"Unsupported operator: {op}")
|
||||
if op == "IN":
|
||||
ph = ", ".join(f"%({param_name}_{j})s" for j in range(len(operand)))
|
||||
clauses.append(f"{safe_col} IN ({ph})")
|
||||
for j, item in enumerate(operand):
|
||||
params[f"{param_name}_{j}"] = item
|
||||
elif op in ("IS", "IS NOT"):
|
||||
clauses.append(f"{safe_col} {op} NULL")
|
||||
else:
|
||||
clauses.append(f"{safe_col} {op} %({param_name})s")
|
||||
params[param_name] = operand
|
||||
elif val is None:
|
||||
clauses.append(f"{safe_col} IS NULL")
|
||||
else:
|
||||
clauses.append(f"{safe_col} = %({param_name})s")
|
||||
params[param_name] = val
|
||||
|
||||
return " AND ".join(clauses), params
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection():
|
||||
conn = psycopg2.connect(**_get_config())
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_cursor(dict_cursor=True):
|
||||
with get_connection() as conn:
|
||||
factory = psycopg2.extras.RealDictCursor if dict_cursor else None
|
||||
cur = conn.cursor(cursor_factory=factory)
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def insert(table, data):
|
||||
columns = list(data.keys())
|
||||
placeholders = [f"%({col})s" for col in columns]
|
||||
safe_cols = [_safe_id(c) for c in columns]
|
||||
|
||||
query = f"""
|
||||
INSERT INTO {_safe_id(table)}
|
||||
({", ".join(safe_cols)})
|
||||
VALUES ({", ".join(placeholders)})
|
||||
RETURNING *
|
||||
"""
|
||||
with get_cursor() as cur:
|
||||
cur.execute(query, data)
|
||||
return dict(cur.fetchone()) if cur.rowcount else None
|
||||
|
||||
|
||||
def select(table, where=None, order_by=None, limit=None, offset=None):
|
||||
query = f"SELECT * FROM {_safe_id(table)}"
|
||||
params = {}
|
||||
|
||||
if where:
|
||||
clauses, params = _build_where(where)
|
||||
query += f" WHERE {clauses}"
|
||||
if order_by:
|
||||
if isinstance(order_by, list):
|
||||
order_by = ", ".join(order_by)
|
||||
query += f" ORDER BY {order_by}"
|
||||
if limit is not None:
|
||||
query += f" LIMIT {int(limit)}"
|
||||
if offset is not None:
|
||||
query += f" OFFSET {int(offset)}"
|
||||
|
||||
with get_cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def select_one(table, where):
|
||||
results = select(table, where=where, limit=1)
|
||||
return results[0] if results else None
|
||||
|
||||
|
||||
def update(table, data, where):
|
||||
set_columns = list(data.keys())
|
||||
set_clause = ", ".join(f"{_safe_id(col)} = %(set_{col})s" for col in set_columns)
|
||||
params = {f"set_{col}": val for col, val in data.items()}
|
||||
|
||||
where_clause, where_params = _build_where(where, prefix="where_")
|
||||
params.update(where_params)
|
||||
|
||||
query = f"""
|
||||
UPDATE {_safe_id(table)}
|
||||
SET {set_clause}
|
||||
WHERE {where_clause}
|
||||
RETURNING *
|
||||
"""
|
||||
with get_cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def delete(table, where):
|
||||
where_clause, params = _build_where(where)
|
||||
query = f"""
|
||||
DELETE FROM {_safe_id(table)}
|
||||
WHERE {where_clause}
|
||||
RETURNING *
|
||||
"""
|
||||
with get_cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def count(table, where=None):
|
||||
query = f"SELECT COUNT(*) as count FROM {_safe_id(table)}"
|
||||
params = {}
|
||||
if where:
|
||||
clauses, params = _build_where(where)
|
||||
query += f" WHERE {clauses}"
|
||||
with get_cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return cur.fetchone()["count"]
|
||||
|
||||
|
||||
def exists(table, where):
|
||||
return count(table, where) > 0
|
||||
|
||||
|
||||
def upsert(table, data, conflict_columns):
|
||||
columns = list(data.keys())
|
||||
placeholders = [f"%({col})s" for col in columns]
|
||||
safe_cols = [_safe_id(c) for c in columns]
|
||||
conflict_cols = [_safe_id(c) for c in conflict_columns]
|
||||
|
||||
update_cols = [c for c in columns if c not in conflict_columns]
|
||||
update_clause = ", ".join(
|
||||
f"{_safe_id(c)} = EXCLUDED.{_safe_id(c)}" for c in update_cols
|
||||
)
|
||||
|
||||
query = f"""
|
||||
INSERT INTO {_safe_id(table)}
|
||||
({", ".join(safe_cols)})
|
||||
VALUES ({", ".join(placeholders)})
|
||||
ON CONFLICT ({", ".join(conflict_cols)})
|
||||
DO UPDATE SET {update_clause}
|
||||
RETURNING *
|
||||
"""
|
||||
with get_cursor() as cur:
|
||||
cur.execute(query, data)
|
||||
return dict(cur.fetchone()) if cur.rowcount else None
|
||||
|
||||
|
||||
def insert_many(table, rows):
|
||||
if not rows:
|
||||
return 0
|
||||
columns = list(rows[0].keys())
|
||||
safe_cols = [_safe_id(c) for c in columns]
|
||||
query = f"""
|
||||
INSERT INTO {_safe_id(table)}
|
||||
({", ".join(safe_cols)})
|
||||
VALUES %s
|
||||
"""
|
||||
template = f"({', '.join(f'%({col})s' for col in columns)})"
|
||||
with get_cursor() as cur:
|
||||
psycopg2.extras.execute_values(
|
||||
cur, query, rows, template=template, page_size=100
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def execute(query, params=None):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(query, params or {})
|
||||
if cur.description:
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def table_exists(table):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %(table)s
|
||||
)
|
||||
""",
|
||||
{"table": table},
|
||||
)
|
||||
return cur.fetchone()["exists"]
|
||||
|
||||
|
||||
def get_columns(table):
|
||||
with get_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %(table)s
|
||||
ORDER BY ordinal_position
|
||||
""",
|
||||
{"table": table},
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
96
core/users.py
Normal file
96
core/users.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import uuid
|
||||
import core.postgres as postgres
|
||||
import bcrypt
|
||||
|
||||
|
||||
def getUserUUID(username):
|
||||
userRecord = postgres.select_one("users", {"username": username})
|
||||
if userRecord:
|
||||
return userRecord["id"]
|
||||
return False
|
||||
|
||||
|
||||
def getUserFirstName(userUUID):
|
||||
userRecord = postgres.select_one("users", {"id": userUUID})
|
||||
if userRecord:
|
||||
return userRecord.get("username")
|
||||
return None
|
||||
|
||||
|
||||
def isUsernameAvailable(username):
|
||||
return not postgres.exists("users", {"username": username})
|
||||
|
||||
|
||||
def doesUserUUIDExist(userUUID):
|
||||
return postgres.exists("users", {"id": userUUID})
|
||||
|
||||
|
||||
def registerUser(username, password, data=None):
|
||||
if isUsernameAvailable(username):
|
||||
hashed_pass = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||
user_data = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"username": username,
|
||||
"password_hashed": hashed_pass,
|
||||
}
|
||||
if data:
|
||||
user_data.update(data)
|
||||
createUser(user_data)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def updateUser(userUUID, data_dict):
|
||||
user = postgres.select_one("users", {"id": userUUID})
|
||||
if not user:
|
||||
return False
|
||||
blocked = {"id", "password_hashed", "created_at"}
|
||||
allowed = set(user.keys()) - blocked
|
||||
updates = {k: v for k, v in data_dict.items() if k in allowed}
|
||||
if not updates:
|
||||
return False
|
||||
postgres.update("users", updates, {"id": userUUID})
|
||||
return True
|
||||
|
||||
|
||||
def changePassword(userUUID, new_password):
|
||||
user = postgres.select_one("users", {"id": userUUID})
|
||||
if not user:
|
||||
return False
|
||||
hashed = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt())
|
||||
postgres.update("users", {"password_hashed": hashed}, {"id": userUUID})
|
||||
return True
|
||||
|
||||
|
||||
def deleteUser(userUUID):
|
||||
user = postgres.select_one("users", {"id": userUUID})
|
||||
if not user:
|
||||
return False
|
||||
postgres.delete("users", {"id": userUUID})
|
||||
return True
|
||||
|
||||
|
||||
def createUser(data_dict):
|
||||
user_schema = {
|
||||
"id": None,
|
||||
"username": None,
|
||||
"password_hashed": None,
|
||||
"created_at": None,
|
||||
}
|
||||
for key in user_schema:
|
||||
if key in data_dict:
|
||||
user_schema[key] = data_dict[key]
|
||||
|
||||
is_valid, errors = validateUser(user_schema)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid user data: {', '.join(errors)}")
|
||||
|
||||
postgres.insert("users", user_schema)
|
||||
|
||||
|
||||
def validateUser(user):
|
||||
required = ["id", "username", "password_hashed"]
|
||||
missing = [f for f in required if f not in user or user[f] is None]
|
||||
if missing:
|
||||
return False, missing
|
||||
return True, []
|
||||
Reference in New Issue
Block a user