From 6dcb020bb5a37500d454755877f01532f474e2b3 Mon Sep 17 00:00:00 2001 From: 0x01FE Date: Wed, 9 Jul 2025 09:50:01 -0500 Subject: [PATCH] init --- .gitignore | 1 + main2.db | Bin 0 -> 57344 bytes schema.sql | 43 ++++++ server/api/api.py | 14 ++ server/api/forum_api.py | 47 ++++++ server/api/helpers.py | 26 ++++ server/api/thread_api.py | 24 +++ server/api/user_api.py | 25 ++++ server/app.py | 41 ++++++ server/database.py | 193 +++++++++++++++++++++++++ server/example_jsons/send_message.json | 4 + server/mgU.py | 31 ++++ server/requirements.txt | 3 + server/sql/get_messages_by_thread.sql | 11 ++ test.py | 18 +++ test.sql | 7 + 16 files changed, 488 insertions(+) create mode 100644 .gitignore create mode 100644 main2.db create mode 100644 schema.sql create mode 100644 server/api/api.py create mode 100644 server/api/forum_api.py create mode 100644 server/api/helpers.py create mode 100644 server/api/thread_api.py create mode 100644 server/api/user_api.py create mode 100644 server/app.py create mode 100644 server/database.py create mode 100644 server/example_jsons/send_message.json create mode 100644 server/mgU.py create mode 100644 server/requirements.txt create mode 100644 server/sql/get_messages_by_thread.sql create mode 100644 test.py create mode 100644 test.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/main2.db b/main2.db new file mode 100644 index 0000000000000000000000000000000000000000..4144ec03faa425d008c186aa693e969c211d1f05 GIT binary patch literal 57344 zcmeI*&2QUe9KdlqX_Go_W~MQ$ZmML`N;IOQ&APUmLB>sC9(57_V4lYc^)Tu z>?XZ*ak1h#=CyjGTJp?Er^~SqLD200Id7H-Y4Kbi^1R zSMOf;N-OJ*dwqSy_MGa5?LW0=+7cpj1uI*$%wqQRqGhJq@>Aw$LQ7T3DQzKFw9Z-u zGnX%#xuwNLb1AoQamgAJ*_*D@XkS6*cxw%(w8zFW?`pl~IW;e(6|E~GWHYS4Ri*0e z8Sg@2;e58RY@V~0N99hX#oF`vg0*lq7o_=nk~Rz0yj8GrbJiu(-#IrGtZwFW=F){T zvckE%nVl;xhy-yvV=Y=c({_h#R--j=kk}z~a$Eh@Z-_u%xZaKp#*LvN^=^<-bzHZ! z>bT*2S3`u!_{*k#djF11n@Decnur@mkE*xN`v*m{8Jn~I*5e^;V{5Ch~!|L6BA4?uzc2M|^MXeQWM~>*e62MOp z$LpR1K?Utj*)2k0pfU5&R%z+D2bqlczo@mx4|k(AyiEO9Z8D|BjD_J&ZQ>UenA@2h zPZD3m;>O^hy0z>#O|VAr7;kNmAhFpNdpU=%<+80hbmEp~kg}pLy%~=g?+kX@?{Jrc zdEbFAmAGN3>DOY$tkG#3pU^(;&km#yKY3Lj#Eoqoc~t~A)LVxhc2n06(%UNGr|s)C z)n{mZYYLSF(niU3*GleM`YJO?FQ z*Q%fWX{a*$jh^?M_4WFgd86J~FTXA_ewE{YanWDVzt?|zt}DpSA%Fk^2q1s}0tg_0 z00Iag@bU?C9M21igW^1Z^6)&s6HW{?>Ez$?M_)chYK#B^2q1s}0tg_000IagfWV6^ z&>L0NNF*B7GnjH=T@&}H$J&Me{tpH$xO{XQ(R4mda3%~in#T5 zt%?+c00IagfB*srAbQV>7@0R#|0009ILKmY**5I~@-0*|i$KTz}sT`iiz5I_I{ z1Q0*~0R#|0009ILK%hGUQC0l@UvT}O@qc&7DFp!p5I_I{1Q0*~0R#|00D-Ox^z>-q z@BiOZ^m|<|p8^p;009ILKmY**5I_I{1Q0;r*$C)rk20*uFa96%)*4Q!%=iDF4Kz^^ zKmY**5I_I{1Q0*~0R#|uUIgU#|K<2UqW>lS|DSkZLI42-5I_I{1Q0*~0R#|00D%`s ypf{?jkw`fHSLOJh(f tuple[str, int]: + + data = flask.request.json + + if not data: + return '', 400 + + if any([not p in data for p in ['content', 'thread']]): + return '', 400 + + new_message = database.MessageORM( + user=flask_login.current_user.get_id(), + content=data['content'], + thread=data['thread'], + time=int(time.time()) + ) + + if not helpers.user_in_thread(flask_login.current_user.get_id(), data['thread']): + return '', 401 + + database.insert_message(new_message) + + return '', 201 + +@forum.route('/forum/get_threads') +@flask_login.login_required +def get_forum_threads() -> tuple[str, int]: + return '', 200 + diff --git a/server/api/helpers.py b/server/api/helpers.py new file mode 100644 index 0000000..8b19138 --- /dev/null +++ b/server/api/helpers.py @@ -0,0 +1,26 @@ +import logging + +import database + +def user_in_thread(user_id: int, thread_id: int) -> bool: + forums = database.get_user_forums(user_id) + + if not forums: + return False + + for forum in forums: + if not forum.id: + logging.error(f'Forum {forum.name} has no id but user is in it?') + continue + + threads = database.get_forum_threads(forum.id) + + for thread in threads: + if not thread.id: + logging.error(f'Thread {thread.name} has no id?') + continue + + if thread.id == thread_id: + return True + + return False diff --git a/server/api/thread_api.py b/server/api/thread_api.py new file mode 100644 index 0000000..7a1f8b5 --- /dev/null +++ b/server/api/thread_api.py @@ -0,0 +1,24 @@ +import dataclasses + +import flask +import flask_login + +import database + + +thread = flask.Blueprint( + name='thread_api', + import_name=__name__, + url_prefix='/thread/' +) + +@thread.route('/get_messages') +@flask_login.login_required +def get_thread_messages() -> tuple[flask.Response, int]: + thread_id = flask.request.args.get("thread", type=int) + if thread_id is None: + return flask.jsonify({"error": "Missing thread parameter"}), 400 + + messages = database.get_messages_by_thread(thread_id) + messages_dicts = {"messages" : [dataclasses.asdict(msg) for msg in messages]} + return flask.jsonify(messages_dicts), 200 diff --git a/server/api/user_api.py b/server/api/user_api.py new file mode 100644 index 0000000..c1bf8dc --- /dev/null +++ b/server/api/user_api.py @@ -0,0 +1,25 @@ +import dataclasses + +import flask +import flask_login + +import mgU +import database + + +user = flask.Blueprint( + name='user_api', + import_name=__name__, + url_prefix='/user/' +) + + +@user.route('/get_forums') +@flask_login.login_required +def get_user_forums() -> tuple[flask.Response, int]: + user_id: str = flask_login.current_user.get_id() + + forums: list[mgU.Forum] = database.get_user_forums(int(user_id)) + forums_dict = {"forums" : [dataclasses.asdict(forum) for forum in forums]} + + return flask.jsonify(forums_dict), 200 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..a9e7343 --- /dev/null +++ b/server/app.py @@ -0,0 +1,41 @@ +import base64 +import secrets +import logging + +import flask +import flask_login +import api.api as api +import database + +app = flask.Flask(__name__) +app.secret_key = secrets.token_hex(16) +app.register_blueprint(api.api) +logging.basicConfig( + format="%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s", + level=logging.DEBUG +) + +login_manager = flask_login.LoginManager() +login_manager.session_protection = "strong" +login_manager.init_app(app) + + +@login_manager.request_loader +def load_user(request: flask.Request) -> database.User | None: + api_key = request.headers.get('Authorization') + if api_key: + api_key = api_key.replace('Basic ', '', 1) + try: + api_key = base64.b64decode(api_key).decode() + except TypeError: + pass + + username, password = api_key.split(':') + + user = database.get_user(username, password) + return user + + return None + +if __name__ == "__main__": + app.run(debug=True) diff --git a/server/database.py b/server/database.py new file mode 100644 index 0000000..1f23d55 --- /dev/null +++ b/server/database.py @@ -0,0 +1,193 @@ +import sqlite3 +import logging +import dataclasses + +import argon2 +import flask_login + +from sqlalchemy import create_engine, Column, Integer, ForeignKey, Text, select +from sqlalchemy.orm import sessionmaker, DeclarativeBase +from sqlalchemy.exc import SQLAlchemyError + +import mgU + +DATABASE = "./main2.db" + +ph = argon2.PasswordHasher() + +class User(flask_login.UserMixin): + + def __init__(self, id: str, name: str): + self.name = name + self.id = id + + def get_id(self) -> str: + return self.id + +# My awful sqlite boilerplate because i hate orms + +def load_query(name: str) -> str: + path = f'server/sql/{name}.sql' + return open(path).read() + +class Opener(): + def __init__(self): + self.con = sqlite3.connect(DATABASE) + + def __enter__(self) -> tuple[sqlite3.Connection, sqlite3.Cursor]: + return self.con, self.con.cursor() + + def __exit__(self, type, value, traceback): + self.con.commit() + self.con.close() + +# orm garbage + +class Base(DeclarativeBase): + pass + +class MessageORM(Base): + __tablename__ = "messages" + id = Column(Integer, primary_key=True, autoincrement=True) + user = Column(Integer, ForeignKey("users.id"), nullable=False) + thread = Column(Integer, ForeignKey("threads.id"), nullable=False) + content = Column(Text, nullable=False) + time = Column(Integer, nullable=False) + +class UserORM(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Text, nullable=False, unique=True) + passhash = Column(Text, nullable=False) + +class ForumORM(Base): + __tablename__ = "forums" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Text, nullable=False) + +class UserForumsORM(Base): + __tablename__ = "user_forums" + user_id = Column(Integer, ForeignKey("users.id"), primary_key=True, nullable=False) + forum_id = Column(Integer, ForeignKey("forums.id"), primary_key=True, nullable=False) + +class ThreadORM(Base): + __tablename__ = "threads" + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(Text, nullable=False) + forum = Column(Integer, ForeignKey("forums.id"), nullable=False) + +# Raw SQL DTOs + +@dataclasses.dataclass +class UserDTO: + name: str + id: int | None = None + +@dataclasses.dataclass +class MessageDTO: + user: UserDTO + content: str + time: int + thread_id: int + id: int | None = None + +engine = create_engine(f"sqlite:///{DATABASE}", echo=False, future=True) +SessionLocal = sessionmaker(bind=engine) + +def insert_message(message: MessageORM) -> None: + """ + Insert a new message (from mgU.Message dataclass) and return a new mgU.Message with id, or None on failure. + """ + session = SessionLocal() + try: + session.add(message) + session.commit() + except SQLAlchemyError: + session.rollback() + return None + finally: + session.close() + +def get_messages_by_thread(thread_id: int) -> list[mgU.Message]: + """ + Return a list of mgU.Message objects for a given thread_id, ordered by id ascending. + """ + + with Opener() as (conn, cur): + q = load_query("get_messages_by_thread") + cur.execute(q, [thread_id]) + results = cur.fetchall() + return [ + mgU.Message( + user=mgU.User( + name=msg[0], + id=msg[1] + ), + thread=msg[4], + content=msg[2], + time=msg[3], + id=msg[5] + ) + for msg in results + ] + +def get_user_forums(user_id: int) -> list[mgU.Forum]: + session = SessionLocal() + try: + q = ( + select(ForumORM.id, ForumORM.name) + .join_from(ForumORM, UserForumsORM) + .where(UserForumsORM.user_id == user_id) + ) + results = session.execute(q) + return [ + mgU.Forum( + name=forum.name, + id=forum.id + ) + for forum in results + ] + finally: + session.close() + +def get_forum_threads(forum_id: int) -> list[mgU.Thread]: + session = SessionLocal() + try: + q = ( + select(ThreadORM.id, ThreadORM.name, ThreadORM.forum) + .where(ThreadORM.forum == forum_id) + ) + + results = session.execute(q) + return [ + mgU.Thread( + name=thread.name, + forum=thread.forum, + id=thread.id + ) + for thread in results + ] + finally: + session.close() + +# For flask_login +def get_user(username: str, api_key: str) -> User | None: + + session = SessionLocal() + try: + results = session.query(UserORM).where(UserORM.name == username) + finally: + session.close() + + if not results: + return None + + user_row = results[0] + + try: + ph.verify(user_row.passhash, api_key) + except argon2.exceptions.VerifyMismatchError: + return None + + return User(str(user_row.id), str(user_row.name)) + diff --git a/server/example_jsons/send_message.json b/server/example_jsons/send_message.json new file mode 100644 index 0000000..86e76e5 --- /dev/null +++ b/server/example_jsons/send_message.json @@ -0,0 +1,4 @@ +{ + "content" : "Hello, world!", + "thread" : 1 +} diff --git a/server/mgU.py b/server/mgU.py new file mode 100644 index 0000000..faa07d2 --- /dev/null +++ b/server/mgU.py @@ -0,0 +1,31 @@ +import typing +import dataclasses + +@dataclasses.dataclass(kw_only=True) +class User: + id: int | None = None + name: str + +@dataclasses.dataclass(kw_only=True) +class Forum: + id: int | None = None + name: str + +@dataclasses.dataclass(kw_only=True) +class Thread: + id: int | None = None + name: str + forum: int + +@dataclasses.dataclass(kw_only=True) +class UserForum: + user_id: int + forum_id: int + +@dataclasses.dataclass(kw_only=True) +class Message: + user: User + content: str + thread: int + time: int + id: int | None = None diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..e96345f --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +Flask==2.2.3 +Flask-Login==0.6.3 +SQLAlchemy==2.0.41 \ No newline at end of file diff --git a/server/sql/get_messages_by_thread.sql b/server/sql/get_messages_by_thread.sql new file mode 100644 index 0000000..a28f2c1 --- /dev/null +++ b/server/sql/get_messages_by_thread.sql @@ -0,0 +1,11 @@ +select + users.name as user_name, + users.id as user_id, + messages.content, + messages.time, + messages.thread, + messages.id as message_id +from messages +inner join users on messages.user = users.id +where messages.thread = ? +; diff --git a/test.py b/test.py new file mode 100644 index 0000000..8d4e3d1 --- /dev/null +++ b/test.py @@ -0,0 +1,18 @@ +import requests +import requests.auth + +session = requests.Session() + +URL = 'http://127.0.0.1:5000' + +r = requests.get( + URL + '/api/thread/get_messages?thread=1', + auth=requests.auth.HTTPBasicAuth( + "0x01FE", + "password" + ) +) + +print(r.content.decode()) + + diff --git a/test.sql b/test.sql new file mode 100644 index 0000000..9c25d4d --- /dev/null +++ b/test.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS "user_forums" ( + "user_id" INTEGER NOT NULL, + "forum_id" INTEGER NOT NULL, + PRIMARY KEY ("user_id", "forum_id"), + FOREIGN KEY ("user_id") REFERENCES "users"("id"), + FOREIGN KEY ("forum_id") REFERENCES "forums"("id") +); \ No newline at end of file