From 08d2ebb1b750e2b7eaf323517af06a49758a5abc Mon Sep 17 00:00:00 2001 From: Beesquit Date: Fri, 25 Jul 2025 18:38:24 +0300 Subject: [PATCH] users table endpoints. auth to fix --- .dockerignore | 11 ++ .gitea/workflows/docker-build-push.yaml | 48 ++++++++ .gitignore | 87 +++++++++++++ README.md | 11 +- compose.yml | 45 +++++++ dockerfile | 17 +++ requirements.txt | 19 +++ src/__main__.py | 7 ++ src/api/auth.py | 33 +++++ src/api/models.py | 28 +++++ src/api/status.py | 8 ++ src/api/tests.py | 21 ++++ src/api/users.py | 33 +++++ src/api/utils.py | 88 +++++++++++++ src/create_app.py | 30 +++++ src/db/internal.py | 44 +++++++ src/db/models.py | 8 ++ src/db/users.py | 157 ++++++++++++++++++++++++ src/settings/consts.py | 0 src/settings/settings.py | 22 ++++ tables.sql | 19 +++ 21 files changed, 734 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/docker-build-push.yaml create mode 100644 .gitignore create mode 100644 compose.yml create mode 100644 dockerfile create mode 100644 requirements.txt create mode 100644 src/__main__.py create mode 100644 src/api/auth.py create mode 100644 src/api/models.py create mode 100644 src/api/status.py create mode 100644 src/api/tests.py create mode 100644 src/api/users.py create mode 100644 src/api/utils.py create mode 100644 src/create_app.py create mode 100644 src/db/internal.py create mode 100644 src/db/models.py create mode 100644 src/db/users.py create mode 100644 src/settings/consts.py create mode 100644 src/settings/settings.py create mode 100644 tables.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd5b5fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +#.dockerignore +# Docker +.dockerignore +dockerfile +compose.yaml +compose.yml + +# Git +.gitignore +*.md +example.env diff --git a/.gitea/workflows/docker-build-push.yaml b/.gitea/workflows/docker-build-push.yaml new file mode 100644 index 0000000..59daabe --- /dev/null +++ b/.gitea/workflows/docker-build-push.yaml @@ -0,0 +1,48 @@ +name: Build and Push Docker Image + +on: + release: + types: [published] + +env: + REGISTRY: git.frik.su + IMAGE_NAME: ${{ gitea.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install Docker + run: curl -fsSL https://get.docker.com | sh + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Extract Docker tags from release + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9994306 --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +env/ +venv/ +*.venv + +# Rope project settings +.ropeproject + +# mypy +.mypy_cache/ + +# VSCode +.vscode/ + +# JetBrains +.idea/ + +# .env +.env + +# testing +test.py +test.sql +test/ diff --git a/README.md b/README.md index 1afbe55..f996734 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ -# picrinth-server +# Picrinth server -Server part for the Picrinth app \ No newline at end of file +This is a Server part for the Picrinth app. + +This version is written in python and there are plans to rewrite it to the Golang so stay tuned! + +Generate key: +``` sh +openssl rand -hex 32 +``` diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..49f515d --- /dev/null +++ b/compose.yml @@ -0,0 +1,45 @@ +services: + picrinth-server: + image: git.frik.su/Beesquit/picrinth-server:latest + container_name: prcrinth-server + environment: + # This should be configured + access_token_expiration_time: 10080 + secret_key: "your-key" + swagger_enabled: true + # This part should not be touched. Probably + algorithm: "HS256" + db_host: "127.0.0.1" # review this later + db_port: 5434 + db_name: "picrinth" + db_user: "postgres" + db_password: "postgres" + volumes: + - ./data:/app/data + restart: unless-stopped + + postgres: + image: postgres:latest + container_name: picrinth_postgres + environment: + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + POSTGRES_DB: "picrinth" + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256" + ports: + - 5432:5432 + volumes: + - ./pgdata:/var/lib/postgresql/data/pgdata + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$postgres_user $$postgres_db" ] + interval: 30s + timeout: 10s + retries: 5 + restart: unless-stopped + tty: true + stdin_open: true + +volumes: + pgdata: + driver: local diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..09f792b --- /dev/null +++ b/dockerfile @@ -0,0 +1,17 @@ +# Используем официальный образ Python 3 +FROM python:3.13-slim + +# Устанавливаем рабочую директорию внутри контейнера +WORKDIR /app + +# Копируем requirements.txt в контейнер для установки зависимостей +COPY requirements.txt ./ + +# Устанавливаем зависимости +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем остальные файлы проекта в контейнер +COPY . . + +# Определяем команду запуска приложения +CMD ["python", "src/__main__.py"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b748448 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +annotated-types==0.7.0 +anyio==4.9.0 +bcrypt==4.3.0 +click==8.2.1 +fastapi==0.116.1 +h11==0.16.0 +idna==3.10 +loguru==0.7.3 +passlib==1.7.4 +pydantic==2.11.7 +pydantic_core==2.33.2 +PyJWT==2.10.1 +python-decouple==3.8 +python-multipart==0.0.20 +sniffio==1.3.1 +starlette==0.47.2 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +uvicorn==0.35.0 diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..82c4d3f --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,7 @@ +import uvicorn + +from create_app import create_app + +if __name__ == "__main__": + app = create_app() + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..19d103f --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,33 @@ +from datetime import timedelta +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +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 settings import settings + +auth_router = APIRouter(prefix="/api", tags=["auth"]) + + +@auth_router.post("/token") +async def login( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + conn: Annotated[connection, Depends(get_db_connection)] +) -> Token: + user = authenticate_user(conn, form_data.username, form_data.password) # change db + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expire_time = timedelta(minutes=settings.access_token_expiration_time) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expire_time + ) + return Token(access_token=access_token, token_type="bearer") diff --git a/src/api/models.py b/src/api/models.py new file mode 100644 index 0000000..3b0035d --- /dev/null +++ b/src/api/models.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str + + +class User(BaseModel): + def fill(self, params): + self.username = params['username'] + self.password = params['password'] + 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 = '' + disabled: bool = False + groups_ids: list[str] | None = None + last_seen_at: datetime | None = None + created_at: datetime | None = None diff --git a/src/api/status.py b/src/api/status.py new file mode 100644 index 0000000..cd2535e --- /dev/null +++ b/src/api/status.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +status_router = APIRouter(prefix="/api", tags=["status"]) + + +@status_router.get('/ping') +async def ping(): + return {'ok'} diff --git a/src/api/tests.py b/src/api/tests.py new file mode 100644 index 0000000..5e51621 --- /dev/null +++ b/src/api/tests.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from api.models import User +from api.utils import get_current_user + +test_router = APIRouter(prefix="/api", tags=["test"]) + + +@test_router.get('/test-private') +async def test_private_func(token: Annotated[User, Depends(get_current_user)]): + return {'private nya'} + + +@test_router.post('/test') +async def test_func(text: str): + print(text) + if text == 'thighs': + raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED) + return {'nya'} diff --git a/src/api/users.py b/src/api/users.py new file mode 100644 index 0000000..8f65153 --- /dev/null +++ b/src/api/users.py @@ -0,0 +1,33 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends +from psycopg2._psycopg import connection + +import db.users as db +from api.models import User +from api.utils import get_current_user +from db.internal import get_db_connection + +users_router = APIRouter(prefix="/api/users", tags=["users"]) + + +@users_router.get("/me") +async def read_users_me(current_user: Annotated[User, Depends(get_current_user)]): + return current_user + + +@users_router.post("/user") +async def read_users_any(username: str, conn: Annotated[connection, Depends(get_db_connection)]): + user = User() + user.fill(db.get_user(conn, username)) + return user + + +@users_router.post("/add") +async def add_user(username: str, password: str, conn: Annotated[connection, Depends(get_db_connection)]): + return db.create_user(conn, username, password) + + +@users_router.post("/delete") +async def delete_user(username: str, conn: Annotated[connection, Depends(get_db_connection)]): + return db.delete_user(conn, username) diff --git a/src/api/utils.py b/src/api/utils.py new file mode 100644 index 0000000..ec9b0e7 --- /dev/null +++ b/src/api/utils.py @@ -0,0 +1,88 @@ +from datetime import datetime, timedelta, timezone +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jwt.exceptions import InvalidTokenError +from passlib.context import CryptContext +from psycopg2._psycopg import connection + +import db.users +import settings.settings as settings +from api.models import TokenData, User +from db.internal import get_db_connection + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +def decode_token(token): + return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + +def encode_token(payload): + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + + +def authenticate_user( + conn: connection, + username: str, + password: str +): + user = User() + userdata = db.users.get_user(conn, username) + if not userdata: + return False + if not verify_password(password, user.password): + return False + user.fill(userdata) + return user + +def create_access_token( + data: dict, + expires_delta: timedelta +): + encode_payload = data.copy() + expire_moment = datetime.now(timezone.utc) + expires_delta + encode_payload.update({"exp": expire_moment}) + encoded_jwt = encode_token(encode_payload) + return encoded_jwt + + +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + conn: Annotated[connection, Depends(get_db_connection)] +): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = decode_token(token) + print(payload) + username = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except InvalidTokenError: + raise credentials_exception + + user = User() + user.fill(db.users.get_user(conn, username=token_data.username)) + if user is None: + raise credentials_exception + + if user.disabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user" + ) + + return user diff --git a/src/create_app.py b/src/create_app.py new file mode 100644 index 0000000..a040070 --- /dev/null +++ b/src/create_app.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI + +from api.auth import auth_router +from api.status import status_router +from api.tests import test_router +from api.users import users_router +from db.internal import connect_db, disconnect_db +from settings import settings + +docs_url = None +if settings.swagger_enabled: + docs_url = "/api/docs" + +app = FastAPI( + redoc_url=None, + docs_url=docs_url, +) + + +def create_app(): + app.add_event_handler("startup", connect_db) + + app.include_router(status_router) + app.include_router(auth_router) + app.include_router(users_router) + app.include_router(test_router) + + app.add_event_handler("shutdown", disconnect_db) + + return app diff --git a/src/db/internal.py b/src/db/internal.py new file mode 100644 index 0000000..3e99018 --- /dev/null +++ b/src/db/internal.py @@ -0,0 +1,44 @@ +import sys + +import psycopg2 +from loguru import logger + +from db.models import database +from settings import settings + + +def connect_db(): + logger.info("Initializing DB connection") + try: + database.conn = psycopg2.connect( + dbname=settings.db_name, + user=settings.db_user, + password=settings.db_password, + host=settings.db_host, + port=settings.db_port, + ) + except Exception as e: + logger.error(f"Failed to initialize DB connection: {e}") + sys.exit(1) + logger.success("Successfully initialized DB connection") + + +def disconnect_db(): + logger.info("Closing DB connection") + if database.conn: + try: + database.conn.close() + except Exception as e: + logger.error(f"Failed to disconnect from DB: {e}") + return + else: + logger.error("Failed to disconnect from DB: no connection") + logger.success("Successfully closed DB connection") + + +def get_db_connection(): + if database.conn is not None: + yield database.conn + else: + logger.error("No connection pool") + sys.exit(1) diff --git a/src/db/models.py b/src/db/models.py new file mode 100644 index 0000000..99a3c2e --- /dev/null +++ b/src/db/models.py @@ -0,0 +1,8 @@ +from psycopg2._psycopg import connection + + +class DataBase: + conn: connection | None = None + + +database = DataBase() diff --git a/src/db/users.py b/src/db/users.py new file mode 100644 index 0000000..00d6091 --- /dev/null +++ b/src/db/users.py @@ -0,0 +1,157 @@ +import psycopg2.extras +from psycopg2._psycopg import connection + +# user create and delete + +def create_user( + conn: connection, + username: str, + password: str +): + with conn.cursor() as cur: + cur.execute( + """ + insert into picrinth.users + (username, password, disabled, created_at) + values (%s, %s, false, now()) + """, + (username, password), + ) + conn.commit() + return cur.rowcount > 0 + + +def delete_user( + conn: connection, + username: str +): + with conn.cursor() as cur: + cur.execute( + """ + delete from picrinth.users + where username = %s + """, + (username,), + ) + conn.commit() + return cur.rowcount > 0 + + +# user checks + +def check_user_existence( + conn: connection, + username: str +): + with conn.cursor() as cur: + cur.execute( + """ + select exists( + select 1 + from picrinth.users + where username = %s + ); + """, + (username,), + ) + return cur.fetchone() + +def check_user_disabled( + conn: connection, + username: str +): + with conn.cursor() as cur: + cur.execute( + """ + select disabled + from picrinth.users + where username = %s; + """, + (username,), + ) + return cur.fetchone() + + +# user updates + +def update_user_password( + conn: connection, + username: str, + password: str +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.users + set password = %s + where username = %s + """, + (password, username), + ) + conn.commit() + + +def update_user_username( + conn: connection, + username: str, + newUsername: str +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.users + set username = %s + where username = %s; + """, + (newUsername, username), + ) + conn.commit() + +def update_user_last_seen( + conn: connection, + username: str +): + with conn.cursor() as cur: + cur.execute( + """ + update picrinth.users + set last_seen_at = now() + where username = %s + """, + (username,), + ) + conn.commit() + + +# user receiving + +def get_user( + conn: connection, + username: str +): + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + cur.execute( + """ + select username, password, disabled, + groups_ids, last_seen_at, created_at + from picrinth.users + where username = %s + """, + (username,), + ) + return cur.fetchone() + +def get_user_password( + conn: connection, + username: str +): + with conn.cursor() as cur: + cur.execute( + """ + select password + from picrinth.users + where username = %s + """, + (username,), + ) + return cur.fetchone() diff --git a/src/settings/consts.py b/src/settings/consts.py new file mode 100644 index 0000000..e69de29 diff --git a/src/settings/settings.py b/src/settings/settings.py new file mode 100644 index 0000000..fed0c83 --- /dev/null +++ b/src/settings/settings.py @@ -0,0 +1,22 @@ +from decouple import config + + +def str_to_bool(string: str) -> bool: + 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')) + +# auth +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'))) diff --git a/tables.sql b/tables.sql new file mode 100644 index 0000000..4461383 --- /dev/null +++ b/tables.sql @@ -0,0 +1,19 @@ +CREATE TABLE public.users ( + id serial NOT NULL, + username text NOT NULL, + "password" text NOT NULL, + groups_ids integer[] NULL, + last_seen_at timestamp with time zone NULL, + created_at timestamp with time zone NULL, + CONSTRAINT userid_pk PRIMARY KEY (id), + CONSTRAINT username_unique UNIQUE (username) +); + +CREATE TABLE public.groups ( + id serial NOT NULL, + groupname text NOT NULL, + join_code text NOT NULL, + users_ids integer[] NULL, + created_at timestamp with time zone NULL, + CONSTRAINT groupname_unique UNIQUE (username) +);