added functional groups api + started pictures

This commit is contained in:
2025-07-30 19:10:10 +03:00
parent c203a890dc
commit 3341d68c7e
20 changed files with 1103 additions and 120 deletions

View File

@ -7,6 +7,7 @@ services:
access_token_expiration_time: 10080 access_token_expiration_time: 10080
secret_key: "your-key" secret_key: "your-key"
swagger_enabled: true swagger_enabled: true
join_code_length: 8
# This part should not be touched. Probably # This part should not be touched. Probably
algorithm: "HS256" algorithm: "HS256"
db_host: "127.0.0.1" # review this later db_host: "127.0.0.1" # review this later

View File

@ -22,6 +22,11 @@ async def add_admin(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not allowed", 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) hashed_password = get_password_hash(password)
return db.create_user(conn, username, hashed_password, "admin") return db.create_user(conn, username, hashed_password, "admin")
@ -36,5 +41,10 @@ async def add_user(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not allowed", 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) hashed_password = get_password_hash(password)
return db.create_user(conn, username, hashed_password, "user") return db.create_user(conn, username, hashed_password, "user")

View File

@ -8,6 +8,7 @@ from psycopg2._psycopg import connection
from api.models import Token from api.models import Token
from api.utils import authenticate_user, create_access_token from api.utils import authenticate_user, create_access_token
from db.internal import get_db_connection from db.internal import get_db_connection
from db.users import check_user_disabled
from settings import startup_settings from settings import startup_settings
auth_router = APIRouter(prefix="/api", tags=["auth"]) auth_router = APIRouter(prefix="/api", tags=["auth"])
@ -26,6 +27,14 @@ async def login(
headers={"WWW-Authenticate": "Bearer"}, 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_expire_time = timedelta(minutes=startup_settings.access_token_expiration_time)
access_token = create_access_token( access_token = create_access_token(
data={"sub": form_data.username}, expires_delta=access_token_expire_time data={"sub": form_data.username}, expires_delta=access_token_expire_time

0
src/api/feeds.py Normal file
View File

View File

@ -10,17 +10,22 @@ from settings.settings import load_settings, reset_settings, save_settings
general_router = APIRouter(prefix="/api", tags=["general"]) general_router = APIRouter(prefix="/api", tags=["general"])
@general_router.get('/ping') @general_router.get("/ping")
async def ping(): async def ping():
return {'ok'} return {"ok"}
@general_router.get('/settings/get') @general_router.get("/settings/get")
async def get_settings(): 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 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)]): async def update_settings(data: dict, current_user: Annotated[User, Depends(get_current_user)]):
if current_user.role not in settings.settings.admin_roles: if current_user.role not in settings.settings.admin_roles:
raise HTTPException( raise HTTPException(
@ -32,7 +37,7 @@ async def update_settings(data: dict, current_user: Annotated[User, Depends(get_
return settings.settings 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)]): async def reset_settings_api(current_user: Annotated[User, Depends(get_current_user)]):
if current_user.role not in settings.settings.admin_roles: if current_user.role not in settings.settings.admin_roles:
raise HTTPException( raise HTTPException(
@ -43,7 +48,7 @@ async def reset_settings_api(current_user: Annotated[User, Depends(get_current_u
return settings.settings 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)]): async def load_settings_api(current_user: Annotated[User, Depends(get_current_user)]):
if current_user.role not in settings.settings.admin_roles: if current_user.role not in settings.settings.admin_roles:
raise HTTPException( raise HTTPException(
@ -53,7 +58,7 @@ async def load_settings_api(current_user: Annotated[User, Depends(get_current_us
load_settings() load_settings()
return settings.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)]): async def save_settings_api(current_user: Annotated[User, Depends(get_current_user)]):
if current_user.role not in settings.settings.admin_roles: if current_user.role not in settings.settings.admin_roles:
raise HTTPException( raise HTTPException(

View File

@ -1,24 +1,24 @@
import secrets
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from psycopg2._psycopg import connection from psycopg2._psycopg import connection
import db.users as db import db.groups as db
import settings.settings as settings import settings.settings as settings
from api.models import User import settings.startup_settings as startup_settings
from api.utils import get_current_user 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.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 = APIRouter(prefix="/api/groups", tags=["groups"])
@groups_router.get("/my") @groups_router.post("/group")
async def read_users_groups(current_user: Annotated[User, Depends(get_current_user)]): async def read_any_group(
return current_user groupname: str,
@groups_router.post("/user")
async def read_users_any_groups(
username: str,
conn: Annotated[connection, Depends(get_db_connection)], conn: Annotated[connection, Depends(get_db_connection)],
current_user: Annotated[User, Depends(get_current_user)] current_user: Annotated[User, Depends(get_current_user)]
): ):
@ -27,46 +27,186 @@ async def read_users_any_groups(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not allowed", detail="Not allowed",
) )
user = User() group = Group()
user_data = db.get_user(conn, username) group_data = db.get_group(conn, groupname)
if user_data is None: if group_data is None:
return HTTPException( return HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="No such user", detail="No such group",
) )
user.fill(user_data) group.fill(group_data)
return user 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") @groups_router.post("/add")
async def add_group( async def add_group(
groupname: str,
conn: Annotated[connection, Depends(get_db_connection)], 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 if not settings.settings.allow_create_groups and current_user.role not in settings.settings.admin_roles:
pass raise HTTPException(
# if not settings.settings.allow_create_admins_by_admins: status_code=status.HTTP_403_FORBIDDEN,
# if current_user.role not in settings.settings.admin_roles: detail="Not allowed",
# raise HTTPException( )
# status_code=status.HTTP_403_FORBIDDEN,
# detail="Not allowed",
# )
# return db.create_user(conn, username, hashed_password, "admin")
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") @groups_router.post("/delete")
async def delete_user( async def delete_group(
groupname: str, groupname: str,
conn: Annotated[connection, Depends(get_db_connection)], conn: Annotated[connection, Depends(get_db_connection)],
current_user: Annotated[User, Depends(get_current_user)] current_user: Annotated[User, Depends(get_current_user)]
): ):
# TODO group = get_group_by_name(conn, groupname)
pass if current_user.role in settings.settings.admin_roles:
# if current_user.username == username or current_user.role in settings.settings.admin_roles: return db.delete_group(conn, groupname)
# return db.delete_user(conn, groupname) if current_user.username == group.author:
# else: return db.delete_group(conn, groupname)
# raise HTTPException( else:
# status_code=status.HTTP_403_FORBIDDEN, raise HTTPException(
# detail="Not allowed", 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",
)

117
src/api/memberships.py Normal file
View File

@ -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",
)

View File

@ -14,17 +14,61 @@ class TokenData(BaseModel):
class User(BaseModel): class User(BaseModel):
def fill(self, params): def fill(self, params):
self.username = params['username'] self.username = params["username"]
self.password = params['password'] self.password = params["password"]
self.role = params['role'] self.role = params["role"]
self.disabled = params['disabled'] self.disabled = params["disabled"]
self.groups_ids = params['groups_ids'] self.groups_ids = params["groups_ids"]
self.last_seen_at = params['last_seen_at'] self.last_seen_at = params["last_seen_at"]
self.created_at = params['created_at'] self.created_at = params["created_at"]
username: str = '' username: str = ""
password: str = '' password: str = ""
role: str = 'user' role: str = "user"
disabled: bool = False disabled: bool = False
groups_ids: list[str] | None = None groups_ids: list[str] | None = None
last_seen_at: datetime | None = None last_seen_at: datetime | None = None
created_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

103
src/api/pictures.py Normal file
View File

@ -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",
)

View File

@ -46,11 +46,20 @@ async def add_admin(
current_user: Annotated[User, Depends(get_current_user)] current_user: Annotated[User, Depends(get_current_user)]
): ):
if not settings.settings.allow_create_admins_by_admins: if not settings.settings.allow_create_admins_by_admins:
if current_user.role not in settings.settings.admin_roles: raise HTTPException(
raise HTTPException( status_code=status.HTTP_403_FORBIDDEN,
status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed",
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) hashed_password = get_password_hash(password)
return db.create_user(conn, username, hashed_password, "admin") return db.create_user(conn, username, hashed_password, "admin")
@ -61,11 +70,16 @@ async def add_user(
conn: Annotated[connection, Depends(get_db_connection)], conn: Annotated[connection, Depends(get_db_connection)],
current_user: Annotated[User, Depends(get_current_user)] 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not allowed", 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) hashed_password = get_password_hash(password)
return db.create_user(conn, username, hashed_password, "user") return db.create_user(conn, username, hashed_password, "user")
@ -119,12 +133,17 @@ async def update_role(
@users_router.post("/update/username") @users_router.post("/update/username")
async def update_username( async def update_username(
username: str, username: str,
password: str, new_username: str,
conn: Annotated[connection, Depends(get_db_connection)], conn: Annotated[connection, Depends(get_db_connection)],
current_user: Annotated[User, Depends(get_current_user)] 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: 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: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,

View File

@ -10,9 +10,10 @@ from jwt.exceptions import InvalidTokenError
# from passlib.context import CryptContext # from passlib.context import CryptContext
from psycopg2._psycopg import connection from psycopg2._psycopg import connection
import db.groups
import db.users import db.users
import settings.startup_settings as startup_settings 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 from db.internal import get_db_connection
# pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@ -38,9 +39,13 @@ def authenticate_user(
username: str, username: str,
user_password: str user_password: str
): ):
db_user_password = db.users.get_user_password(conn, username)
if not user_password: if not user_password:
return False 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): if not verify_password(user_password, db_user_password):
return False return False
return True return True
@ -64,7 +69,7 @@ async def get_current_user(
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"}
) )
try: try:
@ -90,3 +95,19 @@ async def get_current_user(
) )
return 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

View File

@ -7,6 +7,8 @@ from api.anon import anon_router
from api.auth import auth_router from api.auth import auth_router
from api.general import general_router from api.general import general_router
from api.groups import groups_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 api.users import users_router
from db.internal import connect_db, disconnect_db from db.internal import connect_db, disconnect_db
from settings import startup_settings from settings import startup_settings
@ -37,10 +39,12 @@ def create_app():
app.add_event_handler("startup", settings_up) app.add_event_handler("startup", settings_up)
app.include_router(general_router) app.include_router(general_router)
app.include_router(auth_router)
app.include_router(anon_router) app.include_router(anon_router)
app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(groups_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", disconnect_db)
app.add_event_handler("shutdown", settings_down) app.add_event_handler("shutdown", settings_down)

262
src/db/groups.py Normal file
View File

@ -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

93
src/db/memberships.py Normal file
View File

@ -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()

97
src/db/pictures.py Normal file
View File

@ -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()

View File

@ -55,7 +55,7 @@ def check_user_existence(
""", """,
(username,), (username,),
) )
return cur.fetchone() return cur.fetchone()[0] # type: ignore
def check_user_disabled( def check_user_disabled(
conn: connection, conn: connection,
@ -70,7 +70,10 @@ def check_user_disabled(
""", """,
(username,), (username,),
) )
return cur.fetchone() result = cur.fetchone()
if result is None:
return None
return result[0] # type: ignore
# user updates # user updates
@ -91,6 +94,7 @@ def update_user_disabled(
(disabled, username), (disabled, username),
) )
conn.commit() conn.commit()
return cur.rowcount > 0
def update_user_role( def update_user_role(
conn: connection, conn: connection,
@ -107,6 +111,7 @@ def update_user_role(
(role, username), (role, username),
) )
conn.commit() conn.commit()
return cur.rowcount > 0
def update_user_username( def update_user_username(
@ -124,6 +129,7 @@ def update_user_username(
(newUsername, username), (newUsername, username),
) )
conn.commit() conn.commit()
return cur.rowcount > 0
def update_user_password( def update_user_password(
conn: connection, conn: connection,
@ -140,6 +146,7 @@ def update_user_password(
(password, username), (password, username),
) )
conn.commit() conn.commit()
return cur.rowcount > 0
def update_user_last_seen( def update_user_last_seen(
@ -156,6 +163,7 @@ def update_user_last_seen(
(username,), (username,),
) )
conn.commit() conn.commit()
return cur.rowcount > 0
# user receiving # user receiving
@ -190,4 +198,7 @@ def get_user_password(
""", """,
(username,), (username,),
) )
return cur.fetchone()[0] # type: ignore result = cur.fetchone()
if result is None:
return None
return result[0] # type: ignore

View File

@ -0,0 +1 @@
JOIN_CODE_SYMBOLS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # No O + 0, I + 1

View File

@ -7,74 +7,78 @@ from pydantic import BaseModel
class Settings(BaseModel): class Settings(BaseModel):
def update(self, params): def update(self, params):
self.admin_roles = params['admin_roles'] self.admin_roles = params["admin_roles"]
self.allow_create_admins_by_admins = params['allow_create_admins_by_admins'] self.allow_create_admins_by_admins = params["allow_create_admins_by_admins"]
self.allow_create_admins = params['allow_create_admins'] self.allow_create_admins = params["allow_create_admins"]
self.allow_create_users = params['allow_create_users'] self.allow_create_users = params["allow_create_users"]
admin_roles: list[str] = ['admin'] 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_by_admins: bool = True
allow_create_admins: bool = True allow_create_admins: bool = True
allow_create_users: bool = True allow_create_users: bool = True
allow_create_groups: bool = True
allow_create_pictures: bool = False
json_path = 'data/' json_path = "data/"
json_settings_name = 'settings.json' json_settings_name = "settings.json"
settings = Settings() settings = Settings()
def settings_up(): def settings_up():
global settings, json_path, json_settings_name global settings, json_path, json_settings_name
logger.info('Configuring settings for startup') logger.info("Configuring settings for startup")
try: try:
if not(os.path.exists(json_path)): if not(os.path.exists(json_path)):
os.mkdir(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): if os.path.exists(json_path + json_settings_name):
load_settings() load_settings()
else: 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) 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")
logger.info('Successfully configured settings') logger.info("Successfully configured settings")
except Exception as e: 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 raise e
def settings_down(): def settings_down():
global settings, json_path, json_settings_name global settings, json_path, json_settings_name
logger.info('Saving settings for shutdown') logger.info("Saving settings for shutdown")
try: 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) 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")
logger.success('Successfully saved settings') logger.success("Successfully saved settings")
except Exception as e: 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(): def reset_settings():
global settings, json_path, json_settings_name global settings, json_path, json_settings_name
logger.info('Resetting settings') logger.info("Resetting settings")
print(settings) print(settings)
settings = Settings() settings = Settings()
print(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) 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(): def load_settings():
global settings, json_path, json_settings_name global settings, json_path, json_settings_name
logger.info('Loading settings') logger.info("Loading settings")
with open(json_path + json_settings_name, 'r') as f: with open(json_path + json_settings_name, "r") as f:
json_settings = json.load(f) json_settings = json.load(f)
settings = Settings.model_validate_json(json_settings) settings = Settings.model_validate_json(json_settings)
logger.info('Loaded settings from the JSON') logger.info("Loaded settings from the JSON")
def save_settings(): def save_settings():
global settings, json_path, json_settings_name 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) 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")

View File

@ -2,22 +2,23 @@ from decouple import config
def str_to_bool(string: str) -> bool: def str_to_bool(string: str) -> bool:
if string.lower() == 'true': if string.lower() == "true":
return True return True
return False return False
# database # database
db_host = str(config('db_host', default='127.0.0.1')) db_host = str(config("db_host", default="127.0.0.1"))
db_port = int(config('db_port', default=5432)) db_port = int(config("db_port", default=5432))
db_name = str(config('db_name', default='postgres')) db_name = str(config("db_name", default="postgres"))
db_user = str(config('db_user', default='postgres')) db_user = str(config("db_user", default="postgres"))
db_password = str(config('db_password', default='postgres')) db_password = str(config("db_password", default="postgres"))
# auth # auth
secret_key = str(config('secret_key')) secret_key = str(config("secret_key"))
algorithm = str(config('algorithm', 'HS256')) algorithm = str(config("algorithm", "HS256"))
access_token_expiration_time = int(config('access_token_expiration_time', default=10080)) access_token_expiration_time = int(config("access_token_expiration_time", default=10080))
# other settings # other settings
swagger_enabled = str_to_bool(str(config('swagger_enabled', 'false'))) swagger_enabled = str_to_bool(str(config("swagger_enabled", "false")))
log_level = str(config('log_level', default='INFO')) log_level = str(config("log_level", default="INFO"))
join_code_length = int(config("join_code_length", default=8))

View File

@ -1,28 +1,69 @@
CREATE TABLE picrinth.users ( create schema picrinth;
create table picrinth.users (
id serial not null, id serial not null,
username text not null, username text not null,
"password" text not null, "password" text not null,
"role" text not null default "user", "role" text not null default 'user',
"disabled" bool not null, "disabled" bool not null default false,
groups_ids integer[] NULL,
last_seen_at timestamp with time zone null, last_seen_at timestamp with time zone null,
created_at timestamp with time zone null, created_at timestamp with time zone default now(),
CONSTRAINT username_unique UNIQUE (username) constraint username_unique unique (username)
); );
CREATE TABLE picrinth.groups ( create table picrinth.groups (
id serial not null, id serial not null,
groupname text not null, groupname text not null,
invite_code text not null, invite_code text not null,
created_at timestamp with time zone null, author text null,
CONSTRAINT groupname_unique UNIQUE (groupname) 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, username text,
groupname text, groupname text,
joined_at timestamp with time zone null, joined_at timestamp with time zone null,
PRIMARY KEY (username, groupname) primary key (username, groupname),
FOREIGN KEY (username) REFERENCES users (username) on delete cascade on update cascade foreign key (username) references picrinth.users (username) on delete cascade on update cascade,
FOREIGN KEY (groupname) REFERENCES groups (groupname) 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)
); );