diff --git a/backend/.env.template b/backend/.env.template index 3c404bab560f96c67d262d1a72ff58a08b3851e9..c3c4ff2d5ab597aaaff83246f8b7729dbc4a4409 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -1,9 +1,14 @@ -MYSQL_DATABASE=eatfast -MYSQL_USER=user -MYSQL_PASSWORD=password -MYSQL_ROOT_PASSWORD=rootpassword - -DB_HOST=localhost -DB_PORT=3306 - -WEB_ROOT=http://localhost:3000 \ No newline at end of file +MYSQL_DATABASE= +MYSQL_USER= +MYSQL_PASSWORD= +MYSQL_ROOT_PASSWORD= + +DB_HOST=localhost +DB_PORT=3306 + +CLIENT_ID= +CLIENT_SECRET= + +API_ROOT=http://localhost:3001/api +WEB_ROOT=http://localhost:3000 +AUTH_ROOT=https://auth.viarezo.fr \ No newline at end of file diff --git a/backend/db/crud.py b/backend/db/crud.py index 77f677b9b3d1d48e545e484b5753f84df1ef1cf2..246b0f692a1fb2921da42ec12d37eb89659f5e64 100644 --- a/backend/db/crud.py +++ b/backend/db/crud.py @@ -4,6 +4,8 @@ Module to interact with the database from datetime import date, datetime, time, timedelta from sqlalchemy.orm import Session from sqlalchemy.sql import func +from uuid import uuid4 +import secrets import pytz from db import models, schemas @@ -177,31 +179,33 @@ def get_current_graph(place: str, db: Session): # Define CRUD operation for the comments def get_comments(place: str, page: int, db: Session): - """ Get the 10 last comments for the given place """ + """ Get the 20 last comments for the given place """ if page == 0: comments = db.query(models.Comments).order_by(models.Comments.published_at.desc(), models.Comments.id.desc()).all() else: comments = db.query( - models.Comments).filter( + models.Comments, + models.Users.username).join( + models.Users).filter( models.Comments.place == place).order_by( - models.Comments.published_at.desc(), - models.Comments.id.desc()).slice( - (page - - 1) * - 10, - page * - 10).all() - return comments + models.Comments.published_at.desc(), + models.Comments.id.desc()).slice( + (page - + 1) * + 20, + page * + 20).all() + return list(schemas.Comment(**comment.__dict__, username=username) for comment, username in comments) -def create_comment(place: str, new_comments: schemas.CommentBase, db: Session): +def create_comment(user: schemas.User, place: str, new_comments: schemas.CommentBase, db: Session): """ Add a new comment to the database """ date = datetime.now(tz=pytz.timezone("Europe/Paris")) - db_comment = models.Comments(**new_comments.dict(), published_at=date, place=place) + db_comment = models.Comments(**new_comments.dict(), published_at=date, place=place, user_id=user.id) db.add(db_comment) db.commit() db.refresh(db_comment) - return db_comment + return schemas.Comment(**db_comment.__dict__, username=user.username) def delete_comment(id: int, db: Session): @@ -337,3 +341,61 @@ def get_restaurants(db: Session): restaurants.append(restaurant) return restaurants + + +# Define CRUD operation for the authentication + +def init_user(db: Session): + """ Add a news to the database """ + cookie = uuid4() + state = secrets.token_urlsafe(30) + expiration_date = datetime.now(tz=pytz.timezone("Europe/Paris")) + timedelta(minutes=10) + db_user = models.Users(state=state, cookie=cookie, expiration_date=expiration_date) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def get_user(cookie: str, db: Session): + """ Get user infos """ + user = db.query(models.Users).filter(models.Users.cookie == cookie).one() + if pytz.timezone("Europe/Paris").localize(user.expiration_date) < datetime.now(tz=pytz.timezone("Europe/Paris")): + return + return user + + +def delete_state(user: schemas.User, db: Session): + """ Delete the state of a user """ + user.state = None + db.add(user) + db.commit() + + +def update_user(user: schemas.User, user_info: dict, db: Session): + full_name = f"{user_info['firstName']} {user_info['lastName']}" + expiration_date = datetime.now(tz=pytz.timezone("Europe/Paris")) + timedelta(days=3) + existing_user = db.query(models.Users).filter(models.Users.username == full_name).first() + if existing_user: + existing_user.cookie = user.cookie + existing_user.expiration_date = expiration_date + db.delete(user) + db.add(existing_user) + db.commit() + db.refresh(existing_user) + return existing_user + else: + user.username = full_name + user.expiration_date = expiration_date + db.add(user) + db.commit() + db.refresh(user) + return user + + +def end_session(cookie: str, db: Session): + user = db.query(models.Users).filter(models.Users.cookie == cookie).one() + user.expiration_date = datetime.now(tz=pytz.timezone("Europe/Paris")) + db.add(user) + db.commit() + return diff --git a/backend/db/models.py b/backend/db/models.py index c842be44111de48b2e165428e737e326fda42b64..ff985da4d23f28c5b5868fb0bd7ded7aedcb53dc 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -1,7 +1,8 @@ """ Models of the database for magasin app """ -from sqlalchemy import Column, Integer, DateTime, Float, Interval, String, Text, Boolean, Time +from sqlalchemy import Column, ForeignKey, Integer, DateTime, Float, Interval, String, Text, Boolean, Time +from sqlalchemy.orm import relationship from db.database import Base @@ -22,6 +23,7 @@ class Comments(Base): __tablename__ = "comments" id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) content = Column(Text) published_at = Column(DateTime) place = Column(String(30)) @@ -49,3 +51,15 @@ class OpeningHours(Base): timeslot = Column(Boolean) open_time = Column(Time) close_time = Column(Time) + + +class Users(Base): + """ User sql table model """ + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + state = Column(String(50)) + username = Column(String(50)) + cookie = Column(String(50)) + expiration_date = Column(DateTime) + comments = relationship("Comments") diff --git a/backend/db/schemas.py b/backend/db/schemas.py index e6107329523baece2bc414e6a1c9bbace35912f2..55e13ba87afc25f6a8e75611a18f6def36ab9e81 100644 --- a/backend/db/schemas.py +++ b/backend/db/schemas.py @@ -28,8 +28,9 @@ class CommentBase(BaseModel): class Comment(CommentBase): - """Database comments base schema""" + """Comments reading schema""" id: int + username: str = Field(..., title="Name of the user posting the comment") published_at: datetime = Field(..., title="Publication date of the comment") place: str = Field(..., title="Name of the RU corresponding the comment") @@ -69,3 +70,12 @@ class OpeningHours(OpeningHoursBase): class Config: orm_mode = True + + +class User(BaseModel): + """Database user base schema""" + id: int + state: str + username: str + cookie: str + expiration_date: datetime diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 1ee257b2850fa504d09ba2bd1e520c12c8c98faf..72494bd9d8628ccbe572d903be41568ff37a7c80 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,36 +1,36 @@ -version: "3.3" - -services: - db: - image: mysql:latest - container_name: "db" - restart: always - env_file: .env - command: ["mysqld", "--authentication-policy=mysql_native_password"] - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 10s - timeout: 1s - retries: 3 - ports: - - "3306:3306" - volumes: - - mysql-db:/var/lib/mysql - - app: - build: . - container_name: "app" - depends_on: - db: - condition: service_healthy - restart: always - ports: - - 8000:80 - env_file: .env - environment: - DB_HOST: db - links: - - db - -volumes: +version: "3.3" + +services: + db: + image: mysql:latest + container_name: "db" + restart: always + env_file: .env + command: ["mysqld", "--authentication-policy=mysql_native_password"] + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 1s + retries: 3 + ports: + - "3306:3306" + volumes: + - mysql-db:/var/lib/mysql + + app: + build: . + container_name: "app" + depends_on: + db: + condition: service_healthy + restart: always + ports: + - 8000:80 + env_file: .env + environment: + DB_HOST: db + links: + - db + +volumes: mysql-db: \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index cddefa099390a78b7e1a48f031ac0ffd29759944..c0ec2d235f673af74d2dada934a4785fbaef76d2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,7 +9,7 @@ from db import schemas from typing import List from db import database, models -from routers import stats, comments, news +from routers import * app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json") @@ -39,6 +39,7 @@ def on_startup(): app.include_router(stats.router) app.include_router(comments.router) app.include_router(news.router) +app.include_router(authentication.router) @app.get('/api/records', response_model=List[schemas.Record]) diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..b751487ca496e209213000e5a5d9e49653cafcbb 100644 --- a/backend/routers/__init__.py +++ b/backend/routers/__init__.py @@ -0,0 +1,5 @@ +from . import authentication +from . import comments +from . import news +from . import opening_hours +from . import stats diff --git a/backend/routers/authentication.py b/backend/routers/authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..df409dfcfe6f9caa9e336ae1a0dea47744f67f4f --- /dev/null +++ b/backend/routers/authentication.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Cookie, HTTPException, Depends +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from typing import Optional, Union +from requests import get, post +from urllib.parse import urlencode +from dotenv import load_dotenv +import os + +from db.database import get_db +from db import crud + +# load environment variables +load_dotenv("../.env") + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.get("/") +async def whoami(connect_id: str = Cookie(...), db: Session = Depends(get_db)): + user = crud.get_user(connect_id, db) + return user.username + + +@router.get("/login") +async def login(code: Optional[str] = None, state: Optional[str] = None, connect_id: Union[str, None] = Cookie(default=None), db: Session = Depends(get_db)): + + if not code: + init_user = crud.init_user(db) + params = urlencode({ + "client_id": os.getenv("CLIENT_ID"), + "redirect_uri": f"{os.getenv('API_ROOT')}/auth/login", + "response_type": "code", + "state": init_user.state, + "scope": "default"}) + response = RedirectResponse(f"{os.getenv('AUTH_ROOT')}/oauth/authorize?{params}") + response.set_cookie(key="connect_id", value=init_user.cookie) + return response + + if not connect_id or not state: + raise HTTPException(status_code=403, detail="Cookie Invalid") + user = crud.get_user(connect_id, db) + if not user: + raise HTTPException(status_code=599, detail="Timeout error") + if user.state != state: + raise HTTPException(status_code=403, detail="State Invalid") + crud.delete_state(user, db) + + headers = {"content-type": "application/x-www-form-urlencoded"} + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": f"{os.getenv('API_ROOT')}/auth/login", + "client_id": os.getenv("CLIENT_ID"), + "client_secret": os.getenv("CLIENT_SECRET"), + } + + token_response = post( + f"{os.getenv('AUTH_ROOT')}/oauth/token", + data=data, + headers=headers + ) + + access_token = token_response.json()["access_token"] + user_info = get( + f"{os.getenv('AUTH_ROOT')}/api/user/show/me", + headers={"Authorization": f"Bearer {access_token}"} + ) + + user = crud.update_user(user, user_info.json(), db) + return RedirectResponse(f"{os.getenv('WEB_ROOT')}?connected=true") + + +@router.get("/logout") +async def delete_session(connect_id: str = Cookie(...), db: Session = Depends(get_db)): + response = RedirectResponse(f"{os.getenv('AUTH_ROOT')}/logout?{urlencode({'redirect_logout': os.getenv('WEB_ROOT')})}") + response.delete_cookie(key="connect_id") + crud.end_session(connect_id, db) + return response diff --git a/backend/routers/comments.py b/backend/routers/comments.py index 2a88bbc5b18ff8ced7d0534757d614ead4d642c1..44033c9671aff30381ce830a132617c44378a63b 100644 --- a/backend/routers/comments.py +++ b/backend/routers/comments.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Body, Depends +from fastapi import APIRouter, Body, Cookie, Depends from sqlalchemy.orm import Session from typing import List @@ -15,8 +15,12 @@ async def get_comments(place: str, page: int = 1, db: Session = Depends(get_db)) @router.post('/{place}/comments', response_model=schemas.Comment) -async def create_comment(place: str, comment: schemas.CommentBase = Body(...), db: Session = Depends(get_db)): - return crud.create_comment(place, comment, db) +async def create_comment(place: str, connect_id: str = Cookie(...), comment: schemas.CommentBase = Body(...), db: Session = Depends(get_db)): + user = crud.get_user(connect_id, db) + if user: + return crud.create_comment(user, place, comment, db) + else: + raise Exception @router.delete('/comments/{id}', response_model=None) diff --git a/frontend/.env.template b/frontend/.env.template index 7a3df20b2047f40c8eb09fbff8a895ea57d0c2dd..4055e48c96107e4df5cbba1e7093d64bf957f722 100644 --- a/frontend/.env.template +++ b/frontend/.env.template @@ -1,2 +1 @@ -REACT_APP_BASE_URL_BACK=http://localhost:3001/api -REACT_APP_BASE_URL_FRONT=http://localhost:3000 \ No newline at end of file +REACT_APP_BASE_URL_BACK=http://localhost:3001/api \ No newline at end of file diff --git a/frontend/src/components/Comments.js b/frontend/src/components/Comments.js index 48953438d8a7b9d4d8b98573c04aed0bf0aab6b6..a195b65141808f36184612b33dde3fe87e4cfd2e 100644 --- a/frontend/src/components/Comments.js +++ b/frontend/src/components/Comments.js @@ -1,14 +1,16 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import axios from "axios"; import { AiOutlineInfoCircle } from "react-icons/ai"; import { BiSend } from "react-icons/bi"; import { BsChatText } from "react-icons/bs"; +import { User } from "../index"; import { getSiblings } from "../utils"; import "../styles/Comments.css"; export default function Messages({ place, infos }) { + const [user] = useContext(User); const [messages, setMessages] = useState([]); const [newComment, setNewComment] = useState(""); const [loading, setLoading] = useState(true); @@ -22,9 +24,11 @@ export default function Messages({ place, infos }) { if (newComment.replace(/\s/g, "").length) { ev.preventDefault(); axios - .post(`${process.env.REACT_APP_BASE_URL_BACK}/${encodeURIComponent(place)}/comments`, { - content: newComment, - }) + .post( + `${process.env.REACT_APP_BASE_URL_BACK}/${encodeURIComponent(place)}/comments`, + { content: newComment }, + { withCredentials: true }, + ) .then((res) => { if (messages.length) { let update = messages.map((_, index) => (index ? messages[index - 1] : res.data)); @@ -148,6 +152,9 @@ export default function Messages({ place, infos }) { let [year, month, day] = date.split("-"); return ( <div key={index} className="comment"> + <div className={`comment-title${infos ? "-infos" : ""}`}> + {infos ? message.title : message.username} + </div> <div className="comment-content">{message.content}</div> <div className="comment-date"> {`À ${hour.substring(0, 5)} le ${day}/${month}/${year}`} @@ -157,7 +164,7 @@ export default function Messages({ place, infos }) { }) )} </div> - {!infos && ( + {!infos && user && ( <div className="comment-input-container"> <textarea className="comments-input" diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index 7c92cced27dee9e53c35963cd255b4b0de1e0cfa..c7eeadea94d9f9dd54a4f600bd7a0bdf99218153 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -1,24 +1,66 @@ -import React from "react"; +import React, { useContext, useEffect } from "react"; import { Link } from "react-router-dom"; +import { BiLogOutCircle, BiLogInCircle } from "react-icons/bi"; import "../styles/Header.css"; +import { User } from "../index"; +import axios from "axios"; export default function Header({ selection, setSelection }) { + const [user, setUser] = useContext(User); + const connected = new URLSearchParams(location.search).get("connected"); + let width = window.innerWidth > 0 ? window.innerWidth : screen.width; width = width > 600; + useEffect(() => { + if (connected) { + axios + .get(`${process.env.REACT_APP_BASE_URL_BACK}/auth`, { withCredentials: true }) + .then((res) => { + setUser(res.data); + localStorage.setItem("user", res.data); + }) + .catch((e) => console.log(e)); + } + }, [connected]); + return ( - <div id="header-container"> + <div id="header-container" style={!selection ? { flexDirection: "row" } : {}}> <div id="header-restaurant-status"> - {!selection - ? "Accueil" - : `${selection.name} : actuellement ${selection.status ? "ouvert" : "fermé"}`} + {width && + (!selection + ? "Accueil" + : `${selection.name} : actuellement ${selection.status ? "ouvert" : "fermé"}`)} </div> <Link id="header-home-link" to="/" onClick={() => setSelection(null)}> <h2>{width || !selection ? "Eatfast" : selection.name}</h2> </Link> <div id="header-timetable"> - {selection && (width ? `Horaires : ${selection.timetable}` : selection.timetable)} + {selection ? ( + width ? ( + `Horaires : ${selection.timetable}` + ) : ( + selection.timetable + ) + ) : user ? ( + <BiLogOutCircle + id="header-button" + title="Déconnexion" + onClick={() => { + localStorage.removeItem("user"); + window.location.assign(`${process.env.REACT_APP_BASE_URL_BACK}/auth/logout`); + }} + /> + ) : ( + <BiLogInCircle + id="header-button" + title="Connexion" + onClick={() => { + window.location.assign(`${process.env.REACT_APP_BASE_URL_BACK}/auth/login`); + }} + /> + )} </div> </div> ); diff --git a/frontend/src/global.css b/frontend/src/global.css deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/frontend/src/index.js b/frontend/src/index.js index 6f911dfcf8e5526707544dfd9599fd1311cb984d..fae03242592ae3c43d94c0c78dd90c046f09c20d 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { createContext, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import axios from "axios"; @@ -9,10 +9,13 @@ import { HomePage, RestaurantPage, NotFoundPage, DetailsPage } from "./views"; import "bootstrap/dist/css/bootstrap.min.css"; import "./styles/index.css"; +export const User = createContext(null); + export default function App() { const [restaurantsList, setRestaurantsList] = useState([]); const [selection, setSelection] = useState(null); const [loading, setLoading] = useState(true); + const [user, setUser] = useState(localStorage.getItem("user")); useEffect(() => { axios @@ -40,26 +43,28 @@ export default function App() { return ( <div className="app"> - <Router> - <Header {...{ selection, setSelection }} /> - <div className="page"> - <Routes> - <Route - exact - path="/" - element={<HomePage {...{ restaurantsList, setSelection, loading }} />} - /> - <Route - path="/:restaurant" - element={<RestaurantPage {...{ selection, setSelection }} />} - > - <Route path="details" element={<DetailsPage selection={selection} />} /> - </Route> - <Route path="*" element={<NotFoundPage />} /> - </Routes> - </div> - </Router> - <Footer /> + <User.Provider value={[user, setUser]}> + <Router> + <Header {...{ selection, setSelection }} /> + <div className="page"> + <Routes> + <Route + exact + path="/" + element={<HomePage {...{ restaurantsList, setSelection, loading }} />} + /> + <Route + path="/:restaurant" + element={<RestaurantPage {...{ selection, setSelection }} />} + > + <Route path="details" element={<DetailsPage selection={selection} />} /> + </Route> + <Route path="*" element={<NotFoundPage />} /> + </Routes> + </div> + </Router> + <Footer /> + </User.Provider> </div> ); } diff --git a/frontend/src/styles/Comments.css b/frontend/src/styles/Comments.css index 0a2e94cf2c2c8172f341c58f8f5da99d779dfcf1..ffb1833dd575047db57d77acf6363966cd581a2b 100644 --- a/frontend/src/styles/Comments.css +++ b/frontend/src/styles/Comments.css @@ -111,6 +111,15 @@ margin-right: 0.5rem; } +.comment-title { + font-size: 0.8rem; + text-align: right; +} + +.comment-title-infos { + font-size : 1.2rem; +} + @media only screen and (max-width: 600px) { .comments-side-bar { width: 0px; diff --git a/frontend/src/styles/Header.css b/frontend/src/styles/Header.css index ddfdc39cb8fe67100c03df190fc6992aa98701f1..f37a45cee5114a5d4048c0f1cc9b62fb98c79aea 100644 --- a/frontend/src/styles/Header.css +++ b/frontend/src/styles/Header.css @@ -1,7 +1,7 @@ #header-container { display: flex; justify-content: space-between; - align-items: baseline; + align-items: center; padding-left: 1rem; padding-right: 1rem; background-color: rgb(33, 37, 41); @@ -24,15 +24,20 @@ font-weight: lighter; } +#header-button { + height: 2rem; + width: 2rem; +} + +#header-button:hover { + cursor: pointer; +} + @media only screen and (max-width: 600px) { #header-home-link > h2 { font-size: 2rem; } - #header-restaurant-status { - display: none; - } - #header-container { flex-direction: column; align-items: center;