From 3341d68c7e38d97a236efefe7975ff2c42e3cd50 Mon Sep 17 00:00:00 2001 From: Beesquit Date: Wed, 30 Jul 2025 19:10:10 +0300 Subject: [PATCH] added functional groups api + started pictures --- compose.yml | 1 + src/api/anon.py | 10 ++ src/api/auth.py | 9 ++ src/api/feeds.py | 0 src/api/general.py | 21 ++- src/api/groups.py | 214 ++++++++++++++++++++----- src/api/memberships.py | 117 ++++++++++++++ src/api/models.py | 64 ++++++-- src/api/pictures.py | 103 ++++++++++++ src/api/users.py | 35 ++++- src/api/utils.py | 27 +++- src/create_app.py | 6 +- src/db/groups.py | 262 +++++++++++++++++++++++++++++++ src/db/memberships.py | 93 +++++++++++ src/db/pictures.py | 97 ++++++++++++ src/db/users.py | 17 +- src/settings/consts.py | 1 + src/settings/settings.py | 56 ++++--- src/settings/startup_settings.py | 23 +-- tables.sql | 67 ++++++-- 20 files changed, 1103 insertions(+), 120 deletions(-) create mode 100644 src/api/feeds.py create mode 100644 src/api/memberships.py create mode 100644 src/api/pictures.py create mode 100644 src/db/groups.py create mode 100644 src/db/memberships.py create mode 100644 src/db/pictures.py diff --git a/compose.yml b/compose.yml index 49f515d..a1075e1 100644 --- a/compose.yml +++ b/compose.yml @@ -7,6 +7,7 @@ services: access_token_expiration_time: 10080 secret_key: "your-key" swagger_enabled: true + join_code_length: 8 # This part should not be touched. Probably algorithm: "HS256" db_host: "127.0.0.1" # review this later diff --git a/src/api/anon.py b/src/api/anon.py index 1445a7a..b2c3206 100644 --- a/src/api/anon.py +++ b/src/api/anon.py @@ -22,6 +22,11 @@ async def add_admin( status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed", ) + if db.check_user_existence(conn, username): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already exists", + ) hashed_password = get_password_hash(password) return db.create_user(conn, username, hashed_password, "admin") @@ -36,5 +41,10 @@ async def add_user( status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed", ) + if db.check_user_existence(conn, username): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already exists", + ) hashed_password = get_password_hash(password) return db.create_user(conn, username, hashed_password, "user") diff --git a/src/api/auth.py b/src/api/auth.py index 4e29d3f..e20736d 100644 --- a/src/api/auth.py +++ b/src/api/auth.py @@ -8,6 +8,7 @@ from psycopg2._psycopg import connection from api.models import Token from api.utils import authenticate_user, create_access_token from db.internal import get_db_connection +from db.users import check_user_disabled from settings import startup_settings auth_router = APIRouter(prefix="/api", tags=["auth"]) @@ -26,6 +27,14 @@ async def login( headers={"WWW-Authenticate": "Bearer"}, ) + user_disabled = check_user_disabled(conn, form_data.username) + if user_disabled: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User acoount is disabled", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expire_time = timedelta(minutes=startup_settings.access_token_expiration_time) access_token = create_access_token( data={"sub": form_data.username}, expires_delta=access_token_expire_time diff --git a/src/api/feeds.py b/src/api/feeds.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/general.py b/src/api/general.py index 6eba691..f154c29 100644 --- a/src/api/general.py +++ b/src/api/general.py @@ -10,17 +10,22 @@ from settings.settings import load_settings, reset_settings, save_settings general_router = APIRouter(prefix="/api", tags=["general"]) -@general_router.get('/ping') +@general_router.get("/ping") async def ping(): - return {'ok'} + return {"ok"} -@general_router.get('/settings/get') -async def get_settings(): +@general_router.get("/settings/get") +async def get_settings(current_user: Annotated[User, Depends(get_current_user)]): + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) return settings.settings -@general_router.post('/settings/update') +@general_router.post("/settings/update") async def update_settings(data: dict, current_user: Annotated[User, Depends(get_current_user)]): if current_user.role not in settings.settings.admin_roles: raise HTTPException( @@ -32,7 +37,7 @@ async def update_settings(data: dict, current_user: Annotated[User, Depends(get_ return settings.settings -@general_router.get('/settings/reset') +@general_router.get("/settings/reset") async def reset_settings_api(current_user: Annotated[User, Depends(get_current_user)]): if current_user.role not in settings.settings.admin_roles: raise HTTPException( @@ -43,7 +48,7 @@ async def reset_settings_api(current_user: Annotated[User, Depends(get_current_u return settings.settings -@general_router.get('/settings/load_from_file') +@general_router.get("/settings/load_from_file") async def load_settings_api(current_user: Annotated[User, Depends(get_current_user)]): if current_user.role not in settings.settings.admin_roles: raise HTTPException( @@ -53,7 +58,7 @@ async def load_settings_api(current_user: Annotated[User, Depends(get_current_us load_settings() return settings.settings -@general_router.get('/settings/save_to_file') +@general_router.get("/settings/save_to_file") async def save_settings_api(current_user: Annotated[User, Depends(get_current_user)]): if current_user.role not in settings.settings.admin_roles: raise HTTPException( diff --git a/src/api/groups.py b/src/api/groups.py index 5157cc6..3e96d6d 100644 --- a/src/api/groups.py +++ b/src/api/groups.py @@ -1,24 +1,24 @@ +import secrets from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from psycopg2._psycopg import connection -import db.users as db +import db.groups as db import settings.settings as settings -from api.models import User -from api.utils import get_current_user +import settings.startup_settings as startup_settings +from api.models import Group, User +from api.utils import get_current_user, get_group_by_name from db.internal import get_db_connection +from db.memberships import check_membership_exists +from settings.consts import JOIN_CODE_SYMBOLS groups_router = APIRouter(prefix="/api/groups", tags=["groups"]) -@groups_router.get("/my") -async def read_users_groups(current_user: Annotated[User, Depends(get_current_user)]): - return current_user - -@groups_router.post("/user") -async def read_users_any_groups( - username: str, +@groups_router.post("/group") +async def read_any_group( + groupname: str, conn: Annotated[connection, Depends(get_db_connection)], current_user: Annotated[User, Depends(get_current_user)] ): @@ -27,46 +27,186 @@ async def read_users_any_groups( status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed", ) - user = User() - user_data = db.get_user(conn, username) - if user_data is None: + group = Group() + group_data = db.get_group(conn, groupname) + if group_data is None: return HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No such user", + detail="No such group", ) - user.fill(user_data) - return user + group.fill(group_data) + return group + +@groups_router.post("/invite_code") +async def read_group_invite_code( + groupname: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if not check_membership_exists(conn, current_user.username, groupname) and current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + invite_code = db.get_group_invite_code(conn, groupname) + if invite_code is None: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No such group", + ) + return invite_code @groups_router.post("/add") async def add_group( - groupname: str, conn: Annotated[connection, Depends(get_db_connection)], - current_user: Annotated[User, Depends(get_current_user)] + current_user: Annotated[User, Depends(get_current_user)], + groupname: str, + allow_skips: bool = True, + feed_interval_minutes: int = 1440, ): - # TODO - pass - # if not settings.settings.allow_create_admins_by_admins: - # if current_user.role not in settings.settings.admin_roles: - # raise HTTPException( - # status_code=status.HTTP_403_FORBIDDEN, - # detail="Not allowed", - # ) - # return db.create_user(conn, username, hashed_password, "admin") + if not settings.settings.allow_create_groups and current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + if db.check_group_existence(conn, groupname): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Group already exists", + ) + + invite_code = "".join(secrets.choice(JOIN_CODE_SYMBOLS) for _ in range(startup_settings.join_code_length)) + while db.check_invite_code(conn, invite_code): + invite_code = "".join(secrets.choice(JOIN_CODE_SYMBOLS) for _ in range(startup_settings.join_code_length)) + return { + "result": db.create_group(conn, groupname, invite_code, current_user.username, allow_skips, feed_interval_minutes), + "invite code": invite_code + } @groups_router.post("/delete") -async def delete_user( +async def delete_group( groupname: str, conn: Annotated[connection, Depends(get_db_connection)], current_user: Annotated[User, Depends(get_current_user)] ): - # TODO - pass - # if current_user.username == username or current_user.role in settings.settings.admin_roles: - # return db.delete_user(conn, groupname) - # else: - # raise HTTPException( - # status_code=status.HTTP_403_FORBIDDEN, - # detail="Not allowed", - # ) + group = get_group_by_name(conn, groupname) + if current_user.role in settings.settings.admin_roles: + return db.delete_group(conn, groupname) + if current_user.username == group.author: + return db.delete_group(conn, groupname) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + +@groups_router.post("/update/groupname") +async def update_groupname( + groupname: str, + new_groupname: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if db.check_group_existence(conn, new_groupname): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Groupname is already taken", + ) + group = get_group_by_name(conn, groupname) + + if current_user.role in settings.settings.admin_roles: + return db.update_group_groupname(conn, groupname, new_groupname) + if current_user.username == group.author: + return db.update_group_groupname(conn, groupname, new_groupname) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + +@groups_router.post("/update/author") +async def update_author( + groupname: str, + new_author: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + group = get_group_by_name(conn, groupname) + if current_user.role in settings.settings.admin_roles: + return db.update_group_author(conn, groupname, new_author) + if current_user.username == group.author: + return db.update_group_author(conn, groupname, new_author) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + +@groups_router.get("/update/invite_code") +async def update_invite_code( + groupname: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + group = get_group_by_name(conn, groupname) + + invite_code = "".join(secrets.choice(JOIN_CODE_SYMBOLS) for _ in range(startup_settings.join_code_length)) + while db.check_invite_code(conn, invite_code): + invite_code = "".join(secrets.choice(JOIN_CODE_SYMBOLS) for _ in range(startup_settings.join_code_length)) + + if current_user.role in settings.settings.admin_roles: + return { + "result": db.update_group_invite_code(conn, groupname, invite_code), + "invite code": invite_code + } + if current_user.username == group.author: + return { + "result": db.update_group_invite_code(conn, groupname, invite_code), + "invite code": invite_code + } + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + +@groups_router.get("/update/allow_skips") +async def update_allow_skips( + groupname: str, + allow_skips: bool, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + group = get_group_by_name(conn, groupname) + if current_user.role in settings.settings.admin_roles: + return db.update_group_allow_skips(conn, groupname, allow_skips) + if current_user.username == group.author: + return db.update_group_allow_skips(conn, groupname, allow_skips) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + +@groups_router.get("/update/feed_interval") +async def update_feed_interval( + groupname: str, + feed_interval: int, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + group = get_group_by_name(conn, groupname) + if current_user.role in settings.settings.admin_roles: + return db.update_group_feed_interval(conn, groupname, feed_interval) + if current_user.username == group.author: + return db.update_group_feed_interval(conn, groupname, feed_interval) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) diff --git a/src/api/memberships.py b/src/api/memberships.py new file mode 100644 index 0000000..c69181e --- /dev/null +++ b/src/api/memberships.py @@ -0,0 +1,117 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from psycopg2._psycopg import connection + +import db.memberships as db +import settings.settings as settings +from api.models import User +from api.utils import get_current_user, get_group_by_name +from db.groups import check_group_existence, get_groupname_by_invite_code +from db.internal import get_db_connection +from db.users import check_user_existence + +memberships_router = APIRouter(prefix="/api/membership", tags=["memberships"]) + + +@memberships_router.get("/me") +async def read_users_groups( + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + return db.get_memberships_by_username(conn, current_user.username) + +@memberships_router.post("/user") +async def read_users_any_memberships( + username: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if not check_user_existence(conn, username): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User does not exist", + ) + + if not username == current_user.username and current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + return db.get_memberships_by_username(conn, username) + +@memberships_router.post("/group") +async def read_any_group_members( + groupname: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + user_is_in_group = db.check_membership_exists(conn, current_user.username, groupname) + if not user_is_in_group and current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + if not check_group_existence(conn, groupname): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No such group", + ) + + return db.get_memberships_by_groupname(conn, groupname) + + +@memberships_router.post("/add") +async def add_membership( + username: str, + invite_code: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if username != current_user.username and current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + groupname = get_groupname_by_invite_code(conn, invite_code) + if groupname is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invite code is incorrect", + ) + + if not check_user_existence(conn, username): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User does not exist", + ) + + if db.check_membership_exists(conn, username, groupname): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User is already a member", + ) + + return db.create_membership(conn, username, groupname) + +@memberships_router.post("/delete") +async def delete_membership( + username: str, + groupname: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.role in settings.settings.admin_roles: + return db.delete_membership(conn, username, groupname) + + group = get_group_by_name(conn, groupname) + if current_user.username == group.author: + return db.delete_membership(conn, username, groupname) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) diff --git a/src/api/models.py b/src/api/models.py index 306ea91..5b9e028 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -14,17 +14,61 @@ class TokenData(BaseModel): class User(BaseModel): def fill(self, params): - self.username = params['username'] - self.password = params['password'] - self.role = params['role'] - self.disabled = params['disabled'] - self.groups_ids = params['groups_ids'] - self.last_seen_at = params['last_seen_at'] - self.created_at = params['created_at'] - username: str = '' - password: str = '' - role: str = 'user' + self.username = params["username"] + self.password = params["password"] + self.role = params["role"] + self.disabled = params["disabled"] + self.groups_ids = params["groups_ids"] + self.last_seen_at = params["last_seen_at"] + self.created_at = params["created_at"] + username: str = "" + password: str = "" + role: str = "user" disabled: bool = False groups_ids: list[str] | None = None last_seen_at: datetime | None = None created_at: datetime | None = None + + +class Group(BaseModel): + def fill(self, params): + self.groupname = params["groupname"] + self.author = params["author"] + self.invite_code = params["invite_code"] + self.allow_skips = params["allow_skips"] + self.feed_interval_minutes = params["feed_interval_minutes"] + self.last_feed_id = params["last_feed_id"] + self.created_at = params["created_at"] + groupname: str = "" + author: str = "" + invite_code: str = "" + allow_skips: bool = True + feed_interval_minutes: int = 1440 + last_feed_id: int | None = None + created_at: datetime | None = None + + +class Membership(BaseModel): + def fill(self, params): + self.groupname = params["groupname"] + self.username = params["username"] + self.joined_at = params["joined_at"] + groupname: str = "" + username: str = "" + joined_at: datetime | None = None + + +class Picture(BaseModel): + def fill(self, params): + self.id = params["id"] + self.source = params["source"] + self.external_id = params["external_id"] + self.url = params["url"] + self.metadata = params["metadata"] + self.created_at = params["created_at"] + id: int = -1 + source: str = "" + external_id: str = "" + url: str = "" + metadata: dict | None = None + created_at: datetime | None = None diff --git a/src/api/pictures.py b/src/api/pictures.py new file mode 100644 index 0000000..141bde4 --- /dev/null +++ b/src/api/pictures.py @@ -0,0 +1,103 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from psycopg2._psycopg import connection + +import db.pictures as db +import settings.settings as settings +from api.models import Picture, User +from api.utils import get_current_user +from db.internal import get_db_connection + +pictures_router = APIRouter(prefix="/api/pictures", tags=["pictures"]) + + +# maybe to delete +@pictures_router.post("/picture/url") +async def read_picture_by_url( + groupname: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + picture = Picture() + picture_data = db.get_picture_by_url(conn, groupname) + if picture_data is None: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No such picture", + ) + picture.fill(picture_data) + return picture + + +@pictures_router.post("/picture/id") +async def read_picture_by_id( + groupname: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + picture = Picture() + picture_data = db.get_picture_by_id(conn, groupname) + if picture_data is None: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No such picture", + ) + picture.fill(picture_data) + return picture + + +@pictures_router.post("/add") +async def add_picture( + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)], + source: str, + external_id: str, + url: str, + metadata: dict +): + if not settings.settings.allow_create_pictures and current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + + # if db.check_picture_existence(conn, groupname): + # raise HTTPException( + # status_code=status.HTTP_409_CONFLICT, + # detail="Picture already exists", + # ) + + return { + "id": db.create_picture(conn, source, external_id, url, metadata) + } + + +# maybe to delete +@pictures_router.post("/delete/url") +async def delete_picture_by_url( + picture_url: str, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.role in settings.settings.admin_roles: + return db.delete_picture_by_url(conn, picture_url) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + +@pictures_router.post("/delete/id") +async def delete_picture_by_id( + picture_id: int, + conn: Annotated[connection, Depends(get_db_connection)], + current_user: Annotated[User, Depends(get_current_user)] +): + if current_user.role in settings.settings.admin_roles: + return db.delete_picture_by_id(conn, picture_id) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) diff --git a/src/api/users.py b/src/api/users.py index 30f48a5..4b56b50 100644 --- a/src/api/users.py +++ b/src/api/users.py @@ -46,11 +46,20 @@ async def add_admin( current_user: Annotated[User, Depends(get_current_user)] ): if not settings.settings.allow_create_admins_by_admins: - if current_user.role not in settings.settings.admin_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not allowed", - ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + if current_user.role not in settings.settings.admin_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not allowed", + ) + if db.check_user_existence(conn, username): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already exists", + ) hashed_password = get_password_hash(password) return db.create_user(conn, username, hashed_password, "admin") @@ -61,11 +70,16 @@ async def add_user( conn: Annotated[connection, Depends(get_db_connection)], current_user: Annotated[User, Depends(get_current_user)] ): - if not settings.settings.allow_create_users or current_user.role not in settings.settings.admin_roles: + if not settings.settings.allow_create_users and current_user.role not in settings.settings.admin_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed", ) + if db.check_user_existence(conn, username): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already exists", + ) hashed_password = get_password_hash(password) return db.create_user(conn, username, hashed_password, "user") @@ -119,12 +133,17 @@ async def update_role( @users_router.post("/update/username") async def update_username( username: str, - password: str, + new_username: str, conn: Annotated[connection, Depends(get_db_connection)], current_user: Annotated[User, Depends(get_current_user)] ): + if db.check_user_existence(conn, new_username): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username is already taken", + ) if current_user.username == username or current_user.role in settings.settings.admin_roles: - return db.update_user_username(conn, username, password) + return db.update_user_username(conn, username, new_username) else: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/src/api/utils.py b/src/api/utils.py index ebd90ba..08dc892 100644 --- a/src/api/utils.py +++ b/src/api/utils.py @@ -10,9 +10,10 @@ from jwt.exceptions import InvalidTokenError # from passlib.context import CryptContext from psycopg2._psycopg import connection +import db.groups import db.users import settings.startup_settings as startup_settings -from api.models import TokenData, User +from api.models import Group, TokenData, User from db.internal import get_db_connection # pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -38,9 +39,13 @@ def authenticate_user( username: str, user_password: str ): - db_user_password = db.users.get_user_password(conn, username) if not user_password: return False + + db_user_password = db.users.get_user_password(conn, username) + if db_user_password is None: + return False + if not verify_password(user_password, db_user_password): return False return True @@ -64,7 +69,7 @@ async def get_current_user( credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + headers={"WWW-Authenticate": "Bearer"} ) try: @@ -90,3 +95,19 @@ async def get_current_user( ) return user + +def get_group_by_name( + conn: connection, + groupname: str +): + group_exception = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No such group" + ) + group = Group() + group_data = db.groups.get_group(conn, groupname) + if group_data is None: + raise group_exception + + group.fill(group_data) + return group diff --git a/src/create_app.py b/src/create_app.py index 431e9ba..05439ec 100644 --- a/src/create_app.py +++ b/src/create_app.py @@ -7,6 +7,8 @@ from api.anon import anon_router from api.auth import auth_router from api.general import general_router from api.groups import groups_router +from api.memberships import memberships_router +from api.pictures import pictures_router from api.users import users_router from db.internal import connect_db, disconnect_db from settings import startup_settings @@ -37,10 +39,12 @@ def create_app(): app.add_event_handler("startup", settings_up) app.include_router(general_router) - app.include_router(auth_router) app.include_router(anon_router) + app.include_router(auth_router) app.include_router(users_router) app.include_router(groups_router) + app.include_router(memberships_router) + app.include_router(pictures_router) app.add_event_handler("shutdown", disconnect_db) app.add_event_handler("shutdown", settings_down) diff --git a/src/db/groups.py b/src/db/groups.py new file mode 100644 index 0000000..038597b --- /dev/null +++ b/src/db/groups.py @@ -0,0 +1,262 @@ +import psycopg2.extras +from psycopg2._psycopg import connection + +# group create and delete + +def create_group( + conn: connection, + groupname: str, + invite_code: str, + author: str, + allow_skips: bool = True, + feed_interval_minutes: int = 1440, +): + with conn.cursor() as cur: + cur.execute( + """ + insert into picrinth.groups + (groupname, invite_code, author, allow_skips, + feed_interval_minutes, created_at) + values (%s, %s, %s, %s, %s, now()) + """, + (groupname, invite_code, author, allow_skips, feed_interval_minutes), + ) + conn.commit() + return cur.rowcount > 0 + + +def delete_group( + conn: connection, + groupname: str +): + with conn.cursor() as cur: + cur.execute( + """ + delete from picrinth.groups + where groupname = %s + """, + (groupname,), + ) + conn.commit() + return cur.rowcount > 0 + + +# group checks + +def check_group_existence( + conn: connection, + groupname: str +): + with conn.cursor() as cur: + cur.execute( + """ + select exists( + select 1 + from picrinth.groups + where groupname = %s + ); + """, + (groupname,), + ) + return cur.fetchone()[0] # type: ignore + + +def check_invite_code( + conn: connection, + invite_code: str +): + with conn.cursor() as cur: + cur.execute( + """ + select exists( + select 1 + from picrinth.groups + where invite_code = %s + ); + """, + (invite_code,), + ) + return cur.fetchone()[0] # type: ignore + + +# group updates + +def update_group_groupname( + conn: connection, + groupname: str, + new_groupname: str +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.groups + set groupname = %s + where groupname = %s + """, + (new_groupname, groupname), + ) + conn.commit() + return cur.rowcount > 0 + +def update_group_author( + conn: connection, + groupname: str, + author: str +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.groups + set author = %s + where groupname = %s + """, + (author, groupname), + ) + conn.commit() + return cur.rowcount > 0 + +def update_group_invite_code( + conn: connection, + groupname: str, + invite_code: str +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.groups + set invite_code = %s + where groupname = %s + """, + (invite_code, groupname), + ) + conn.commit() + return cur.rowcount > 0 + + +def update_group_allow_skips( + conn: connection, + groupname: str, + allow_skips: bool +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.groups + set allow_skips = %s + where groupname = %s + """, + (allow_skips, groupname), + ) + conn.commit() + return cur.rowcount > 0 + +def update_group_feed_interval( + conn: connection, + groupname: str, + feed_interval: int +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.groups + set feed_interval_minutes = %s + where groupname = %s + """, + (feed_interval, groupname), + ) + conn.commit() + return cur.rowcount > 0 + +def update_group_last_feed( + conn: connection, + groupname: str, + last_feed_id: int +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.groups + set last_feed_id = %s + where groupname = %s + """, + (last_feed_id, groupname), + ) + conn.commit() + return cur.rowcount > 0 + + +# group receiving + +def get_group( + conn: connection, + groupname: str +): + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute( + """ + select groupname, author, + invite_code, allow_skips, + feed_interval_minutes, + last_feed_id, created_at + from picrinth.groups + where groupname = %s + """, + (groupname,), + ) + return cur.fetchone() + + +def get_group_by_invite_code( + conn: connection, + invite_code: str +): + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute( + """ + select groupname, author, + invite_code, allow_skips, + feed_interval_minutes, + last_feed_id, created_at + from picrinth.groups + where invite_code = %s + """, + (invite_code,), + ) + return cur.fetchone() + + +def get_groupname_by_invite_code( + conn: connection, + invite_code: str +): + with conn.cursor() as cur: + cur.execute( + """ + select groupname + from picrinth.groups + where invite_code = %s + """, + (invite_code,), + ) + result = cur.fetchone() + if result is None: + return None + return result[0] # type: ignore + +def get_group_invite_code( + conn: connection, + groupname: str +): + with conn.cursor() as cur: + cur.execute( + """ + select invite_code + from picrinth.groups + where groupname = %s + """, + (groupname,), + ) + result = cur.fetchone() + if result is None: + return None + return result[0] # type: ignore diff --git a/src/db/memberships.py b/src/db/memberships.py new file mode 100644 index 0000000..1544495 --- /dev/null +++ b/src/db/memberships.py @@ -0,0 +1,93 @@ +import psycopg2.extras +from psycopg2._psycopg import connection + +# membership create and delete + +def create_membership( + conn: connection, + username: str, + groupname: str +): + with conn.cursor() as cur: + cur.execute( + """ + insert into picrinth.memberships + (username, groupname, joined_at) + values (%s, %s, now()) + """, + (username, groupname), + ) + conn.commit() + return cur.rowcount > 0 + + +def delete_membership( + conn: connection, + username: str, + groupname: str +): + with conn.cursor() as cur: + cur.execute( + """ + delete from picrinth.memberships + where username = %s and groupname = %s + """, + (username, groupname), + ) + conn.commit() + return cur.rowcount > 0 + + +# membership checks + +def check_membership_exists( + conn: connection, + username: str, + groupname: str +): + with conn.cursor() as cur: + cur.execute( + """ + select exists( + select 1 + from picrinth.memberships + where username = %s and groupname = %s + ); + """, + (username, groupname), + ) + return cur.fetchone()[0] # type: ignore + + +# membership receiving + +def get_memberships_by_username( + conn: connection, + username: str +): + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute( + """ + select * + from picrinth.memberships + where username = %s + """, + (username,), + ) + return cur.fetchall() + + +def get_memberships_by_groupname( + conn: connection, + groupname: str +): + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute( + """ + select * + from picrinth.memberships + where groupname = %s + """, + (groupname,), + ) + return cur.fetchall() diff --git a/src/db/pictures.py b/src/db/pictures.py new file mode 100644 index 0000000..b90b2ab --- /dev/null +++ b/src/db/pictures.py @@ -0,0 +1,97 @@ +import json + +import psycopg2.extras +from psycopg2._psycopg import connection + +# picture create and delete + +def create_picture( + conn: connection, + source: str, + external_id: str, + url: str, + metadata: dict +): + with conn.cursor() as cur: + cur.execute( + """ + insert into picrinth.pictures + (source, external_id, url, metadata, created_at) + values (%s, %s, %s, %s, now()) + returning id + """, + (source, external_id, url, json.dumps(metadata)), + ) + result = cur.fetchone() + conn.commit() + if result is None: + return None + return result[0] + + +def delete_picture_by_url( + conn: connection, + url: str +): + with conn.cursor() as cur: + cur.execute( + """ + delete from picrinth.pictures + where url = %s + """, + (url,), + ) + conn.commit() + return cur.rowcount > 0 + +def delete_picture_by_id( + conn: connection, + id: int +): + with conn.cursor() as cur: + cur.execute( + """ + delete from picrinth.pictures + where id = %s + """, + (id,), + ) + conn.commit() + return cur.rowcount > 0 + + +# picture receiving + +def get_picture_by_url( + conn: connection, + url: str +): + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute( + """ + select id, source, + external_id, url, + metadata, created_at + from picrinth.pictures + where url = %s + """, + (url,), + ) + return cur.fetchone() + +def get_picture_by_id( + conn: connection, + id: str +): + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute( + """ + select id, source, + external_id, url, + metadata, created_at + from picrinth.pictures + where id = %s + """, + (id,), + ) + return cur.fetchone() diff --git a/src/db/users.py b/src/db/users.py index a295a00..37df2ef 100644 --- a/src/db/users.py +++ b/src/db/users.py @@ -55,7 +55,7 @@ def check_user_existence( """, (username,), ) - return cur.fetchone() + return cur.fetchone()[0] # type: ignore def check_user_disabled( conn: connection, @@ -70,7 +70,10 @@ def check_user_disabled( """, (username,), ) - return cur.fetchone() + result = cur.fetchone() + if result is None: + return None + return result[0] # type: ignore # user updates @@ -91,6 +94,7 @@ def update_user_disabled( (disabled, username), ) conn.commit() + return cur.rowcount > 0 def update_user_role( conn: connection, @@ -107,6 +111,7 @@ def update_user_role( (role, username), ) conn.commit() + return cur.rowcount > 0 def update_user_username( @@ -124,6 +129,7 @@ def update_user_username( (newUsername, username), ) conn.commit() + return cur.rowcount > 0 def update_user_password( conn: connection, @@ -140,6 +146,7 @@ def update_user_password( (password, username), ) conn.commit() + return cur.rowcount > 0 def update_user_last_seen( @@ -156,6 +163,7 @@ def update_user_last_seen( (username,), ) conn.commit() + return cur.rowcount > 0 # user receiving @@ -190,4 +198,7 @@ def get_user_password( """, (username,), ) - return cur.fetchone()[0] # type: ignore + result = cur.fetchone() + if result is None: + return None + return result[0] # type: ignore diff --git a/src/settings/consts.py b/src/settings/consts.py index e69de29..8a3aaad 100644 --- a/src/settings/consts.py +++ b/src/settings/consts.py @@ -0,0 +1 @@ +JOIN_CODE_SYMBOLS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No O + 0, I + 1 diff --git a/src/settings/settings.py b/src/settings/settings.py index f7cd1d5..c0969a4 100644 --- a/src/settings/settings.py +++ b/src/settings/settings.py @@ -7,74 +7,78 @@ from pydantic import BaseModel class Settings(BaseModel): def update(self, params): - self.admin_roles = params['admin_roles'] - self.allow_create_admins_by_admins = params['allow_create_admins_by_admins'] - self.allow_create_admins = params['allow_create_admins'] - self.allow_create_users = params['allow_create_users'] - admin_roles: list[str] = ['admin'] + self.admin_roles = params["admin_roles"] + self.allow_create_admins_by_admins = params["allow_create_admins_by_admins"] + self.allow_create_admins = params["allow_create_admins"] + self.allow_create_users = params["allow_create_users"] + self.allow_create_groups = params["allow_create_groups"] + self.allow_create_pictures = params["allow_create_groups"] + admin_roles: list[str] = ["admin"] allow_create_admins_by_admins: bool = True allow_create_admins: bool = True allow_create_users: bool = True + allow_create_groups: bool = True + allow_create_pictures: bool = False -json_path = 'data/' -json_settings_name = 'settings.json' +json_path = "data/" +json_settings_name = "settings.json" settings = Settings() def settings_up(): global settings, json_path, json_settings_name - logger.info('Configuring settings for startup') + logger.info("Configuring settings for startup") try: if not(os.path.exists(json_path)): os.mkdir(json_path) - logger.debug(f'Created "{json_path}" directory') + logger.debug(f"Created '{json_path}' directory") if os.path.exists(json_path + json_settings_name): load_settings() else: - with open(json_path + json_settings_name, 'w') as f: + with open(json_path + json_settings_name, "w") as f: json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) - logger.info('Wrote settings to the JSON') - logger.info('Successfully configured settings') + logger.info("Wrote settings to the JSON") + logger.info("Successfully configured settings") except Exception as e: - logger.error(f'Failed to configure settings during startup: {e}') + logger.error(f"Failed to configure settings during startup: {e}") raise e def settings_down(): global settings, json_path, json_settings_name - logger.info('Saving settings for shutdown') + logger.info("Saving settings for shutdown") try: - with open(json_path + json_settings_name, 'w') as f: + with open(json_path + json_settings_name, "w") as f: json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) - logger.info('Wrote settings to the JSON') - logger.success('Successfully saved settings') + logger.info("Wrote settings to the JSON") + logger.success("Successfully saved settings") except Exception as e: - logger.error(f'Failed to save settings during shutdown: {e}') + logger.error(f"Failed to save settings during shutdown: {e}") def reset_settings(): global settings, json_path, json_settings_name - logger.info('Resetting settings') + logger.info("Resetting settings") print(settings) settings = Settings() print(settings) - with open(json_path + json_settings_name, 'w') as f: + with open(json_path + json_settings_name, "w") as f: json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) - logger.info('Wrote settings to the JSON') + logger.info("Wrote settings to the JSON") def load_settings(): global settings, json_path, json_settings_name - logger.info('Loading settings') - with open(json_path + json_settings_name, 'r') as f: + logger.info("Loading settings") + with open(json_path + json_settings_name, "r") as f: json_settings = json.load(f) settings = Settings.model_validate_json(json_settings) - logger.info('Loaded settings from the JSON') + logger.info("Loaded settings from the JSON") def save_settings(): global settings, json_path, json_settings_name - with open(json_path + json_settings_name, 'w') as f: + with open(json_path + json_settings_name, "w") as f: json.dump(settings.model_dump_json(), f, ensure_ascii = False, indent=4) - logger.info('Wrote settings to the JSON') + logger.info("Wrote settings to the JSON") diff --git a/src/settings/startup_settings.py b/src/settings/startup_settings.py index ff5f6b5..524a9f8 100644 --- a/src/settings/startup_settings.py +++ b/src/settings/startup_settings.py @@ -2,22 +2,23 @@ from decouple import config def str_to_bool(string: str) -> bool: - if string.lower() == 'true': + if string.lower() == "true": return True return False # database -db_host = str(config('db_host', default='127.0.0.1')) -db_port = int(config('db_port', default=5432)) -db_name = str(config('db_name', default='postgres')) -db_user = str(config('db_user', default='postgres')) -db_password = str(config('db_password', default='postgres')) +db_host = str(config("db_host", default="127.0.0.1")) +db_port = int(config("db_port", default=5432)) +db_name = str(config("db_name", default="postgres")) +db_user = str(config("db_user", default="postgres")) +db_password = str(config("db_password", default="postgres")) # auth -secret_key = str(config('secret_key')) -algorithm = str(config('algorithm', 'HS256')) -access_token_expiration_time = int(config('access_token_expiration_time', default=10080)) +secret_key = str(config("secret_key")) +algorithm = str(config("algorithm", "HS256")) +access_token_expiration_time = int(config("access_token_expiration_time", default=10080)) # other settings -swagger_enabled = str_to_bool(str(config('swagger_enabled', 'false'))) -log_level = str(config('log_level', default='INFO')) +swagger_enabled = str_to_bool(str(config("swagger_enabled", "false"))) +log_level = str(config("log_level", default="INFO")) +join_code_length = int(config("join_code_length", default=8)) diff --git a/tables.sql b/tables.sql index 0698691..0901c0c 100644 --- a/tables.sql +++ b/tables.sql @@ -1,28 +1,69 @@ -CREATE TABLE picrinth.users ( +create schema picrinth; + +create table picrinth.users ( id serial not null, username text not null, "password" text not null, - "role" text not null default "user", - "disabled" bool not null, - groups_ids integer[] NULL, + "role" text not null default 'user', + "disabled" bool not null default false, last_seen_at timestamp with time zone null, - created_at timestamp with time zone null, - CONSTRAINT username_unique UNIQUE (username) + created_at timestamp with time zone default now(), + constraint username_unique unique (username) ); -CREATE TABLE picrinth.groups ( +create table picrinth.groups ( id serial not null, groupname text not null, invite_code text not null, - created_at timestamp with time zone null, - CONSTRAINT groupname_unique UNIQUE (groupname) + author text null, + allow_skips bool not null default true, + feed_interval_minutes integer null default 1440, + last_feed_id int null, + created_at timestamp with time zone default now(), + constraint groupname_unique unique (groupname), + constraint invite_code_unique unique (invite_code) ); -create table picrinth.group_members ( +create table picrinth.memberships ( username text, groupname text, joined_at timestamp with time zone null, - PRIMARY KEY (username, groupname) - FOREIGN KEY (username) REFERENCES users (username) on delete cascade on update cascade - FOREIGN KEY (groupname) REFERENCES groups (groupname) on delete cascade on update cascade + primary key (username, groupname), + foreign key (username) references picrinth.users (username) on delete cascade on update cascade, + foreign key (groupname) references picrinth.groups (groupname) on delete cascade on update cascade +); + +create table picrinth.pictures ( + id serial not null, + source text not null, + external_id text not null, + url text not null, + metadata jsonb null, + created_at timestamp with time zone default now(), + constraint pictures_pkey primary key (id), + constraint url_unique unique (url) +); + +create table picrinth.feeds ( + id serial not null, + groupname text not null, + image_ids integer[] not null, + created_at timestamp with time zone default now(), + constraint feeds_pkey primary key (id), + foreign key (groupname) references picrinth.groups (groupname) on delete cascade on update cascade, + constraint unique_feed_per_timestamp_per_group unique (groupname, created_at) +); + +create table picrinth.swipes ( + id serial not null, + username text not null, + feed_id integer not null, + picture_id integer not null, + swipe_value smallint not null, + swiped_at timestamp with time zone default now(), + primary key (id), + foreign key (username) references picrinth.users (username) on delete cascade on update cascade, + foreign key (feed_id) references picrinth.feeds (id) on delete cascade on update cascade, + foreign key (picture_id) references picrinth.pictures (id) on delete cascade on update cascade, + constraint swipes_unique unique (username, feed_id, picture_id) );