From 6c731b04d375baf72249f81fbf09c6aa92dc26e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Sun, 29 Jun 2025 18:17:04 +0300 Subject: [PATCH] initial commit --- .gitignore | 6 + app/__init__.py | 23 ++++ app/auth.py | 12 ++ app/db.py | 218 +++++++++++++++++++++++++++++++ app/migrations.py | 30 +++++ app/models.py | 1 + app/routes/app.py | 7 + app/run.py | 10 ++ app/schema.py | 13 ++ data/static/avatars/default.webp | Bin 0 -> 4188 bytes requirements.txt | 3 + 11 files changed, 323 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/db.py create mode 100644 app/migrations.py create mode 100644 app/models.py create mode 100644 app/routes/app.py create mode 100644 app/run.py create mode 100644 app/schema.py create mode 100644 data/static/avatars/default.webp create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cda462 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv +**/*.pyc + +data/db/* +data/static/avatars/* +!data/static/avatars/default.webp diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..bd0f573 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,23 @@ +from flask import Flask +import os + +def create_app(): + app = Flask(__name__) + + app.config["DB_PATH"] = "data/db/db.sqlite" + + os.makedirs(os.path.dirname(app.config["DB_PATH"]), exist_ok = True) + with app.app_context(): + from .schema import create as create_tables + from .migrations import run_migrations + create_tables() + run_migrations() + + if os.getenv("PYROM_PROD") is None: + app.static_folder = os.path.join(os.path.dirname(__file__), "../data/static") + app.debug = True + + from app.routes.app import bp as app_bp + app.register_blueprint(app_bp) + + return app diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..47a5d28 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,12 @@ +from argon2 import PasswordHasher + +ph = PasswordHasher() + +def hash_password(password): + return ph.hash(password) + +def verify(expected, given): + try: + return ph.verify(expected, given) + except: + return False diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..db8d9ba --- /dev/null +++ b/app/db.py @@ -0,0 +1,218 @@ +import sqlite3 +from contextlib import contextmanager +from flask import current_app + +class DB: + def __init__(self): + self._transaction_depth = 0 + self._connection = None + + + @contextmanager + def _get_connection(self): + if self._connection and self._transaction_depth > 0: + yield self._connection + return + + conn = sqlite3.connect(current_app.config["DB_PATH"]) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA FOREIGN_KEYS = 1") + + try: + yield conn + finally: + if self._transaction_depth == 0: + conn.close() + + + @contextmanager + def transaction(self): + """Transaction context.""" + self.begin() + try: + yield + self.commit() + except Exception: + self.rollback() + raise + + + def begin(self): + """Begins a new transaction.""" + if self._transaction_depth == 0: + if not self._connection: + self._connection = sqlite3.connect(current_app.config["DB_PATH"]) + self._connection.row_factory = sqlite3.Row + self._connection.execute("PRAGMA FOREIGN_KEYS = 1") + self._connection.execute("BEGIN") + self._transaction_depth += 1 + + + def commit(self): + """Commits the current transaction.""" + if self._transaction_depth > 0: + self._transaction_depth -= 1 + if self._transaction_depth == 0: + self._connection.commit() + + + def rollback(self): + """Rolls back the current transaction.""" + if self._transaction_depth > 0: + self._transaction_depth = 0 + self._connection.rollback() + + + def query(self, sql, *args): + """Executes a query and returns a list of dictionaries.""" + with self._get_connection() as conn: + rows = conn.execute(sql, args).fetchall() + return [dict(row) for row in rows] + + + def insert(self, table, columns, *values): + if isinstance(columns, (list, tuple)): + columns = ", ".join(columns) + + placeholders = ", ".join(["?"] * len(values)) + sql = f""" + INSERT INTO {table} ({columns}) + VALUES ({placeholders}) + RETURNING * + """ + + with self._get_connection() as conn: + result = conn.execute(sql, values).fetchone() + conn.commit() + return dict(result) if result else None + + + def execute(self, sql, *args): + """Executes a query without returning.""" + with self._get_connection() as conn: + conn.execute(sql, args) + conn.commit() + + + def fetch_one(self, sql, *args): + """Grabs the first row of a query.""" + with self._get_connection() as conn: + row = conn.execute(sql, args).fetchone() + return dict(row) if row else None + + + class QueryBuilder: + def __init__(self, table): + self.table = table + self._where = {} + self._select = "*" + self._params = [] + + + def select(self, columns = "*"): + self._select = columns + return self + + + def where(self, condition): + self._where.update(condition) + return self + + + def build_select(self): + sql = f"SELECT {self._select} FROM {self.table}" + if self._where: + conditions = " AND ".join(f"{k} = ?" for k in self._where.keys()) + sql += f" WHERE {conditions}" + return sql, list(self._where.values()) + + + def build_update(self, data): + columns = ", ".join(f"{k} = ?" for k in data.keys()) + sql = f"UPDATE {self.table} SET {columns}" + if self._where: + conditions = " AND ".join(f"{k} = ?" for k in self._where.keys()) + sql += f" WHERE {conditions}" + params = list(data.values()) + list(self._where.values()) + return sql, params + + + def build_delete(self): + sql = f"DELETE FROM {self.table}" + if self._where: + conditions = " AND ".join(f"{k} = ?" for k in self._where.keys()) + sql += f" WHERE {conditions}" + return sql, list(self._where.values()) + + + def first(self): + sql, params = self.build_select() + print(sql, params) + return db.fetch_one(f"{sql} LIMIT 1", *params) + + + def all(self): + sql, params = self.build_select() + return db.query(sql, *params) + + +class Model: + def __init__(self, table): + self.table = table + self._data = {} + + + def __getitem__(self, key): + return self._data[key] + + + def __getattr__(self, key): + try: + return self._data[key] + except KeyError: + raise AttributeError(f"No column '{key}'") + + + @classmethod + def find(cls, condition): + row = db.QueryBuilder(cls.table)\ + .where(condition)\ + .first() + if not row: + return None + instance = cls(cls.table) + instance._data = dict(row) + return instance + + + @classmethod + def create(cls, values): + if not values: + return None + + columns = list(values.keys()) + row = db.insert(cls.table, columns, *values.values()) + + if row: + instance = cls(cls.table) + instance._data = row + return instance + return None + + + def update(self, data): + qb = db.QueryBuilder(self.table)\ + .where({"id": self._data["id"]}) + sql, params = qb.build_update(data) + db.execute(sql, *params) + self._data.update(data) + + + def delete(self): + qb = db.QueryBuilder(self.table)\ + .where({"id": self._data["id"]}) + sql, params = qb.build_delete() + db.execute(sql, *params) + self._data = {} + +db = DB() diff --git a/app/migrations.py b/app/migrations.py new file mode 100644 index 0000000..07a7eca --- /dev/null +++ b/app/migrations.py @@ -0,0 +1,30 @@ +from .db import db + +# format: {integer: str|list} +MIGRATIONS = { + +} + +def run_migrations(): + print("Running migrations...") + ran = 0 + db.execute(""" + CREATE TABLE IF NOT EXISTS _migrations( + id INTEGER PRIMARY KEY + ) + """) + completed = [row["id"] for row in db.query("SELECT id FROM _migrations")] + for migration_id in sorted(MIGRATIONS.keys()): + if migration_id not in completed: + print(f"Running migration #{migration_id}") + ran += 1 + statements = MIGRATIONS[migration_id] + # support both strings and lists + if isinstance(statements, str): + statements = [statements] + + for sql in statements: + db.execute(sql) + + db.execute("INSERT INTO _migrations (id) VALUES (?)", migration_id) + print(f"Ran {ran} migrations.") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..f0987c4 --- /dev/null +++ b/app/models.py @@ -0,0 +1 @@ +from .db import Model diff --git a/app/routes/app.py b/app/routes/app.py new file mode 100644 index 0000000..67b0e87 --- /dev/null +++ b/app/routes/app.py @@ -0,0 +1,7 @@ +from flask import Blueprint + +bp = Blueprint("app", __name__, url_prefix = "/") + +@bp.route("/") +def hello_world(): + return f"" diff --git a/app/run.py b/app/run.py new file mode 100644 index 0000000..eb66f73 --- /dev/null +++ b/app/run.py @@ -0,0 +1,10 @@ +from app import create_app +import os + +app = create_app() + +if __name__ == "__main__": + app.run( + host = "127.0.0.1", + port = 8080 + ) diff --git a/app/schema.py b/app/schema.py new file mode 100644 index 0000000..a856aff --- /dev/null +++ b/app/schema.py @@ -0,0 +1,13 @@ +from .db import db + +# list of statements +SCHEMA = [ + +] + +def create(): + print("Creating schema...") + with db.transaction(): + for stmt in SCHEMA: + db.execute(stmt) + print("Schema completed.") diff --git a/data/static/avatars/default.webp b/data/static/avatars/default.webp new file mode 100644 index 0000000000000000000000000000000000000000..69329feddccde9eca6232c228808834ba85c7fdb GIT binary patch literal 4188 zcmV-i5Tox>Nk&Fg5C8yIMM6+kP&il$0000G000300093006|PpNHGWi00FRuZIfxa zTJ62Z%A8D`QEc0`ZQGnHwrx9^*fx%B#I~Ibcl}sv_d3&EUF-7~5fcFa_y51=mYA|? z(U>M-V%ghIS+*i3+#UsON7KYp-9yZx#{Pn)aWQtt|7#R{ySh$9&UQzWQE-k8T8Sy# z#`ZMFlztgyb9`>57{=P1ftd2i1#OMb0|{`at!afaCT6q=#fOin;j(sG3LIfAxYdNd z;oNerK}7tkyp<@o9G5Kheu;>2otZTlO%vK$7=)7M6LYLU)5QF|x@Lxnjm0j=GFEIJ zdYsQHCwh)=CK8e)%U2?DyCE?zBlrc&NpUv?hI^l*WCE}}^ancDJ9!3a{-9#L6L{_#_=bo@4&ZtC zS4g9K7BIcUW27=UhG&IdAfdB47J36ooyf3QB(@{FO1Y_dUw~VC(Bj2yOJn~~3%_^q zDqW)3p8R0Uwau*(4&YU16pil$o}0Z_=}`cywg|NY(I#$%Z~&|R64?ym%T@Y?Q{G7e z$A@6AXA8LF*kvN|{kvja}zET%&+;iXvm8Z*Bc7){t-Rop?X)3(O zuA8V;lX3XBzBqgy)$5}i4xbM=78?r<}~?2reV}v)ttK}7~7)E>xpqCYHl1DQ$3XGdtx%9 z-sgeo7L@uKF{QQU9u20wQ1Sp;TIlRm6ELm#QMR144At1-a4^mH0%fmC%XDp>C=2F= zAENXfX<4YJD>L(g$I7oz{*kn-R`1>)-<+0u|K^FUB0;ccJSsSqmRO~p@99$_7?jR< z0F_)o%RY7D_ty6U#l8B!LN!;@a!8f9<&mJev&CgpHI|lLiX4gp)pJe1hwAR9WwR2` znu5~4g<3DWf=chBWt9rwWdYAV6^E@k_0qplax(nq>Y4lphBE$Vv)EsbP5-~yv#SR!hC1TDoRyBY#UpY5piHne1v z>ti7>`X5KVm!riQFV$EuI`^RF!)bv(WV-BYG8Z+U;38(HObx+U->thVK46)P3^W9}^|uoU-XckJLow8UM~d1? zQq#~te;-p(6qY1MSwma>?F5p)kfXGri~btXG)khb+I!(kQ?_J2EdipB@+m)6b=h-h*c)^l zTB1>#o>tRI_oAoz?V6Mh18z!n7i{UjRXofpvzINAJK}%p`S8rv{n{-9fN!-8Nj5EU zuoKJ+Ua@7#O+Ya9pLT2#2@iyGkRhq)ig&%c<5X{pzqV06n{-IpzCAA#5ua z@=)LmuzI{ouX2ev_oHAVUIjf=2;T>Ld5rnx`jeO$6F|vPz<=e zUM)mSeinzqV}ad17gV!)*u^gh`Q^1enoKbGWJ*bSugZhD68L|PB?s|8Zqkz0PZ$ax&X ztQfQ&f9Xewez_QD4JY!-dB?)@on5oKAX=jQ;Ee9H?Wu*wUaX2h;m;ep!1T5>PaR-% z);lkR*;!Rw+IX{A)>m0r3ofnf>Ho6-54li0;@=z+#Lf<1IkQ`ea1BoE4bu-FQTw zY+yM)YmqCXie)Ymb5Z17d9cAnFPc4FTg?GRN2QgI&90RMTkO+f!KzvPD!4$@RkqKp zRSR4D!T#>MHx&HUK%jkN~?wsJ=voiO}bDx~A?>?aX!u)MLP5FrZqxHG;cm6lj1N$dX-=GKW zzo$>`hr)mNPy1e#`LKWC-qD;2@VLSNanrmj@itWpz~F+4ap1 z40u9IlITafh&kH2#6X>NqqOSUj)v@9j80m!9bdxqw-*Y>zh4J-nl%V4r26w;*!#%m z#lo@g-VA(zzCmgpHr8-MQ?5TBK+B%iBJnS-@Ee`G})KcHqWXFJ^VIa z4kD`cu9L46F6BzFjD)?v79@WFbv4&6-WFR;SHLwbcOz+A>c{tA^8XRkjZ-+a0aAF} zxskR*Wqacw%z=7vOOmY&S%8f=`<){CJIQn+HpFIYAozHdhSt=CUn&KUm7DhYwRxV% zL~Ulm;gb-zfi~WM)s8qf^bSj)9X+NDV{9uP{;EJf-pXo9`r~OhD;K{qAwK7IH|wDv z0T*Qna>~5ffB^pOPXGV_aQaLAriCN(Q`t+DbcMn5Yd!_$@Vq;pFPRdEFbXPF75-)* zB=Dv>SR*SpoYV~faF>`d2CmAhuM3|=)>=W>v-XQwt0*V-8?@Sk%UJg%`{mEcGH_0o z_0|xwiv7x%2F49oIQazTKpC{1dsM}0Z;)X?{PZEdPqJnGNy~?CmU#Yqs_!qpS^Lbw z{zWrAb}Zb08BV}i?&aL^&X>sD9vSFa8U9JB1ZaO2D4GL*?vI#H;0UnpJ9Fpvh>|2iA)c?X_y7zlf5R65 zPO#u<&V)3Zlfj)rk07*xk5U9}2w)G38eAY&6@2|nwb6bi=r;*9RK z^azU#5D0Wv_jtw3g{cInzpFeFxjt?ptKST*I1}BxWEvKu?W6!U?B>L{DIVB1+j}@j zW0RIQ^Nq648M|_07Zo_b#mmZGSlam4lX??xNHND>$LseF^_6GxFDxXWIyD7^ z2;QB)QAK`ps>6#yDM~XlSU~URZMP5MTo9ZWGIrqDvLAtL{8O3WGUl}~{X551# z;a|#m5G`~T<_J1}u%tx@x2fF6=lP|3Jq8ElM4&XO4x*q97E^?-6Tfq1`eT3Au z{+4(3zSuV{56Rq2)Q=SrB2E#V53;p>=rT3?c5p6o(f#-iPI7@}xdR`{y~(u$_l#-ElkH1Zv&N7V9Tf(cGCP` z0Bpmxc()HEfxjEM0o%j;398_EUgY~yrEn*^F@9%pi-*$-e|4Z4qkBX@0SSA~SywIK z3OFnTu&8WwMo-AzL@{A> zR^%`&C*A*Y(5aKsA!CL;x7*^6aF4kXfO$Lxngx8MG^jbr(^qQKu1 zcilM$=YDbqVdDH&x0F0(-uNlQ`j@frQ-|~_s?W{XhwM|y!`9%gbG8&PYls_||cIH{XR9p(#%Fmhzno!P%b1n%TNZ-g*_05TLfG9(jXLI4QZ z7f5Dz$5;o15PA>$K8V$7k3aw722aoTl@_JH!LRlZ4?*ipf1v>M9+%ibph6gbpjL$D zh=2fnh&*fBSX$`#kokSbQzR_&?vRd-Y>CyOawo)aeFB@uF65y25KZ12Ts4r9p~b`_tf-RB<5hjTZr}RdANj~c!x(!sY|mS=oPQ8C zx|#z4-I$1&8~*r0?rXU27OWZ1zs_9kSq=%8vjAV(Q+f1VSdq($JDN=0AoI_`McI*V zT=M8uf&z}lNic24k6^atX0jS!!nHYusM{}rW+#mC=jVr`lHHD4%1R1-g^%LE?Zomy zY1??8ttK@XQozeP_NFT5H<=P1=#03IL#G*sl0jc^9xyTXGPlqs~frW8XMY2jd(w} zE%W_%|Ms}w|6swEyzi<>kQny@z-#~4C2jRq!YI-uZ6D#+Y>(cKnIa$n000000002#KqFQF literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..06db0fc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask +argon2-cffi +wand