From b2765ce400f4021376b074328a0a4b9fd353e650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Pelletier?= Date: Fri, 23 Aug 2024 22:50:28 -0400 Subject: [PATCH 1/3] Ajout authentification OAuth2 --- .env.template | 3 + .gitignore | 2 + convert_video.py | 46 ++++++++++++ docker-run.sh | 2 +- main.py | 174 +++++++++++++++++++++++++++++++------------- models.py | 19 +++++ requirements.txt | 16 ++-- test_confiance.http | 12 +++ 8 files changed, 216 insertions(+), 58 deletions(-) create mode 100644 .env.template create mode 100644 convert_video.py create mode 100644 models.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..856b009 --- /dev/null +++ b/.env.template @@ -0,0 +1,3 @@ +SECRET_KEY= +USERNAME= +PASS_HASH= diff --git a/.gitignore b/.gitignore index 19132e2..8614305 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /fabriquedoc.iml /out/ *.DS_Store +/test_confiance_values.http +/.env diff --git a/convert_video.py b/convert_video.py new file mode 100644 index 0000000..cbe6639 --- /dev/null +++ b/convert_video.py @@ -0,0 +1,46 @@ +import logging +import os + +import cv2 + + +def convert_video(images_path, output_path, width, height, fps, stilltime): + """ + Convert images in output_path into a mp4 file usine OpenCV. + :param images_path: + :param output_path: + :param images_path: + :param width: + :param height: + :return: + """ + + # define a frame array + frame_array = [] + + # list all files in images_path + files = [f for f in os.listdir(images_path) if os.path.isfile(os.path.join(images_path, f))] + # sort the files + files.sort() + + # create a video writer object + + for i in range(len(files)): + file = os.path.join(images_path, files[i]) + logging.log(logging.INFO, f'Converting {file} to mp4') + img = cv2.imread(file) + for j in range(fps * stilltime): + frame_array.append(img) + + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + video_writer = cv2.VideoWriter(output_path, + fourcc, + fps, + (width, height)) + + for i in range(len(frame_array)): + # writing to a image array + video_writer.write(frame_array[i]) + + video_writer.release() + logging.log(logging.INFO, f'Finished converting {output_path}') diff --git a/docker-run.sh b/docker-run.sh index e529b44..ee5952c 100644 --- a/docker-run.sh +++ b/docker-run.sh @@ -2,4 +2,4 @@ docker stop fabriquedoc docker rm fabriquedoc docker network create fabriquedoc # Ce programme sert à lancer le job_dispatcher dans un docker localement pour tester -docker run -p 8000:8000 --name fabriquedoc --network fabriquedoc local/fabriquedoc \ No newline at end of file +docker run -p 8000:8000 --env-file .env --name fabriquedoc --network fabriquedoc local/fabriquedoc \ No newline at end of file diff --git a/main.py b/main.py index 167e26b..fd3d773 100644 --- a/main.py +++ b/main.py @@ -16,71 +16,141 @@ along with this program. If not, see . """ -import datetime +from datetime import datetime, timedelta, timezone import logging +from typing import Annotated -from fastapi import FastAPI, UploadFile +import jwt +from fastapi import FastAPI, UploadFile, Depends, HTTPException from fastapi.responses import FileResponse import pypandoc import json + +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.testclient import TestClient import os import shutil -import cv2 + +from passlib.exc import InvalidTokenError +from starlette import status from DocumentSpecs import DocumentSpecs from FormatParameters import FormatParameters from convert_pdf import convert_pdf +from convert_video import convert_video from extract_emojis import replace_emojis from list_dir import list_dir +from models import UserInDB, User, TokenData, Token from responses import Styles, Formats, App +from passlib.context import CryptContext -def convert_video(images_path, output_path, width, height, fps, stilltime): - """ - Convert images in output_path into a mp4 file usine OpenCV. - :param images_path: - :param output_path: - :param images_path: - :param width: - :param height: - :return: - """ - - # define a frame array - frame_array = [] - - # list all files in images_path - files = [f for f in os.listdir(images_path) if os.path.isfile(os.path.join(images_path, f))] - # sort the files - files.sort() - - # create a video writer object - - for i in range(len(files)): - file = os.path.join(images_path, files[i]) - logging.log(logging.INFO, f'Converting {file} to mp4') - img = cv2.imread(file) - for j in range(fps * stilltime): - frame_array.append(img) - - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - video_writer = cv2.VideoWriter(output_path, - fourcc, - fps, - (width, height)) - - for i in range(len(frame_array)): - # writing to a image array - video_writer.write(frame_array[i]) - - video_writer.release() - logging.log(logging.INFO, f'Finished converting {output_path}') +SECRET_KEY = os.getenv("SECRET_KEY") +USERNAME = os.getenv("USERNAME") +PASS_HASH = os.getenv("PASS_HASH") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days +fake_users_db = { + "francois": { + "username": f"{USERNAME}", + "hashed_password": f"{PASS_HASH}", + "disabled": False, + } +} +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") app = FastAPI() +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return pwd_context.hash(password) + + +def get_user(db, username: str): + if username in db: + user_dict = db[username] + return UserInDB(**user_dict) + + +def authenticate_user(fake_db, username: str, password: str): + user = get_user(fake_db, username) + if not user: + return False + if not verify_password(password, user.hashed_password): + return False + return user + + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except InvalidTokenError: + raise credentials_exception + user = get_user(fake_users_db, username=token_data.username) + if user is None: + raise credentials_exception + return user + + +async def get_current_active_user( + current_user: Annotated[User, Depends(get_current_user)], +): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +@app.post("/token") +async def login_for_access_token( + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], +) -> Token: + user = authenticate_user(fake_users_db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return Token(access_token=access_token, token_type="bearer") + + +@app.get("/users/me") +async def read_users_me( + current_user: Annotated[User, Depends(get_current_active_user)], +): + return current_user + + @app.get("/") async def get_root(): app = App(app='fabriquedoc') @@ -88,19 +158,19 @@ async def get_root(): @app.get("/styles/") -async def get_styles(): +async def get_styles(current_user: Annotated[User, Depends(get_current_active_user)]): styles = Styles(styles=list_dir("./styles")) return styles @app.get("/formats/{style}/") -async def get_formats(style: str): +async def get_formats(style: str, current_user: Annotated[User, Depends(get_current_active_user)]): formats = Formats(formats=list_dir(f"./styles/{style}/")) return formats @app.get("/format_parameters/{style}/{format}/") -async def get_format_parameters(style: str, format: str): +async def get_format_parameters(style: str, format: str, current_user: Annotated[User, Depends(get_current_active_user)]): # open styles/format_parameters.json as a dictionary with open(f"./styles/{style}/format_parameters.json", "r") as f: format_data = json.load(f).get(format) @@ -111,7 +181,7 @@ async def get_format_parameters(style: str, format: str): @app.get("/images/") -async def get_images(): +async def get_images(current_user: Annotated[User, Depends(get_current_active_user)]): # list all files in resources/images files = [f for f in os.listdir("./resources/images") if os.path.isfile(os.path.join("./resources/images", f))] # sort the files @@ -120,12 +190,12 @@ async def get_images(): @app.get("/images/{nom_image}") -async def get_image(nom_image: str): +async def get_image(nom_image: str, current_user: Annotated[User, Depends(get_current_active_user)]): return FileResponse(f"./resources/images/{nom_image}") @app.post("/images/") -async def ajouter_image(file: UploadFile): +async def ajouter_image(file: UploadFile, current_user: Annotated[User, Depends(get_current_active_user)]): """ Add an image to the images folder. :param file: @@ -145,7 +215,7 @@ async def ajouter_image(file: UploadFile): @app.delete("/images/{nom_image}") -async def supprimer_image(nom_image: str): +async def supprimer_image(nom_image: str, current_user: Annotated[User, Depends(get_current_active_user)]): """ Delete an image from the images folder. :param nom_image: @@ -161,10 +231,10 @@ async def supprimer_image(nom_image: str): @app.get("/generer/") -async def generer(specs: DocumentSpecs): +async def generer(specs: DocumentSpecs, current_user: Annotated[User, Depends(get_current_active_user)]): header_file = f'{os.getcwd()}/styles/{specs.style}/{specs.format}/header.tex' cover_file = f'{os.getcwd()}/styles/{specs.style}/{specs.format}/cover.tex' - datef = datetime.datetime.now().strftime("%Y-%m-%d") + datef = datetime.now().strftime("%Y-%m-%d") os.makedirs("out", exist_ok=True) filters = ['latex-emoji.lua', 'centered.lua'] pdoc_args = [ diff --git a/models.py b/models.py new file mode 100644 index 0000000..fd02e64 --- /dev/null +++ b/models.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str | None = None + + +class User(BaseModel): + username: str + disabled: bool | None = None + + +class UserInDB(User): + hashed_password: str diff --git a/requirements.txt b/requirements.txt index 61e6e5b..82196c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,12 @@ -emoji~=2.12.1 -fastapi~=0.111.0 -opencv-python~=4.10.0.84 -pydantic~=2.7.4 -pypandoc~=1.13 +emoji==2.12.1 +fastapi==0.112.1 +opencv_python==4.10.0.84 +passlib==1.7.4 +pydantic==2.8.2 +PyJWT==2.9.0 +pypandoc==1.13 +starlette==0.38.2 +uvicorn==0.30.6 Wand==0.6.13 +httpx==0.27.0 +python-multipart==0.0.9 diff --git a/test_confiance.http b/test_confiance.http index 78f0334..6bca795 100644 --- a/test_confiance.http +++ b/test_confiance.http @@ -1,8 +1,16 @@ # Test your FastAPI endpoints +POST http://127.0.0.1:8000/token/ +Content-Type: application/x-www-form-urlencoded + +username=USERNAME&password=PASSWORD + +### + GET http://127.0.0.1:8000/generer/ Content-Type: application/json Accept: application/zip +Authorization: Bearer TOKEN_HERE { "format": "instagram-fullscreen", @@ -27,6 +35,7 @@ Accept: application/zip GET http://127.0.0.1:8000/generer/ Content-Type: application/json Accept: video/* +Authorization: Bearer TOKEN_HERE { "format": "instagram-story", @@ -51,6 +60,7 @@ Accept: video/* GET http://127.0.0.1:8000/generer/ Content-Type: application/json Accept: application/pdf +Authorization: Bearer TOKEN_HERE { "format": "slide169", @@ -75,6 +85,7 @@ Accept: application/pdf GET http://127.0.0.1:8000/generer/ Content-Type: application/json Accept: application/pdf +Authorization: Bearer TOKEN_HERE { "format": "lettre", @@ -99,6 +110,7 @@ Accept: application/pdf GET http://127.0.0.1:8000/generer/ Content-Type: application/json Accept: application/pdf +Authorization: Bearer TOKEN_HERE { "format": "instagram-carre", -- 2.39.5 From 6ccbe23169c91a6e52ce2c6dca1c690d3a71a554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Pelletier?= Date: Sat, 24 Aug 2024 00:25:03 -0400 Subject: [PATCH 2/3] Le endpoint generer est un POST --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index fd3d773..0b70c7a 100644 --- a/main.py +++ b/main.py @@ -230,7 +230,7 @@ async def supprimer_image(nom_image: str, current_user: Annotated[User, Depends( return {"message": f"Successfully deleted {nom_image}"} -@app.get("/generer/") +@app.post("/generer/") async def generer(specs: DocumentSpecs, current_user: Annotated[User, Depends(get_current_active_user)]): header_file = f'{os.getcwd()}/styles/{specs.style}/{specs.format}/header.tex' cover_file = f'{os.getcwd()}/styles/{specs.style}/{specs.format}/cover.tex' -- 2.39.5 From f86421d9b17f56928b2734a7e6f027ec12571369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Pelletier?= Date: Sat, 24 Aug 2024 11:41:44 -0400 Subject: [PATCH 3/3] Le endpoint generer est un POST --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 0b70c7a..2e71dc1 100644 --- a/main.py +++ b/main.py @@ -126,7 +126,7 @@ async def get_current_active_user( return current_user -@app.post("/token") +@app.post("/token/") async def login_for_access_token( form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> Token: -- 2.39.5