commit 6dcb020bb5a37500d454755877f01532f474e2b3 Author: 0x01FE Date: Wed Jul 9 09:50:01 2025 -0500 init 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 0000000..4144ec0 Binary files /dev/null and b/main2.db differ diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..65c36d9 --- /dev/null +++ b/schema.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS "messages" ( + "id" INTEGER NOT NULL UNIQUE, + "user" INTEGER NOT NULL, + "thread" INTEGER NOT NULL, + "content" TEXT NOT NULL, + PRIMARY KEY("id"), + FOREIGN KEY ("user") REFERENCES "users"("id") + ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY ("thread") REFERENCES "threads"("id") + ON UPDATE NO ACTION ON DELETE NO ACTION +); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" INTEGER NOT NULL UNIQUE, + "name" TEXT NOT NULL, + PRIMARY KEY("id") +); + +CREATE TABLE IF NOT EXISTS "forums" ( + "id" INTEGER NOT NULL UNIQUE, + "name" TEXT NOT NULL, + PRIMARY KEY("id") +); + +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 ("forum_id") REFERENCES "forums"("id") + ON UPDATE NO ACTION ON DELETE NO ACTION, + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON UPDATE NO ACTION ON DELETE NO ACTION +); + +CREATE TABLE IF NOT EXISTS "threads" ( + "id" INTEGER NOT NULL UNIQUE, + "name" TEXT NOT NULL, + "forum" INTEGER NOT NULL, + PRIMARY KEY("id"), + FOREIGN KEY ("forum") REFERENCES "forums"("id") + ON UPDATE NO ACTION ON DELETE NO ACTION +); + diff --git a/server/api/api.py b/server/api/api.py new file mode 100644 index 0000000..f5775f4 --- /dev/null +++ b/server/api/api.py @@ -0,0 +1,14 @@ +import flask + +from . import user_api +from . import forum_api +from . import thread_api + +api = flask.Blueprint( + name='api', + import_name=__name__, + url_prefix='/api/' +) +api.register_blueprint(user_api.user) +api.register_blueprint(forum_api.forum) +api.register_blueprint(thread_api.thread) diff --git a/server/api/forum_api.py b/server/api/forum_api.py new file mode 100644 index 0000000..8d1a594 --- /dev/null +++ b/server/api/forum_api.py @@ -0,0 +1,47 @@ +import time + +import flask +import flask_login + +from . import helpers +import database + + +forum = flask.Blueprint( + name='forum_api', + import_name=__name__, + url_prefix='/forum/' +) + + +@forum.route('/forum/send_message', methods=['POST']) +@flask_login.login_required +def send_message() -> 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