diff --git a/app/main.py b/app/main.py index e1b5cae..9c0bf0d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,5 @@ -from fastapi import FastAPI, Request, Depends -from app.routers import admin, user, songs, session +from fastapi import FastAPI, Request, Depends, Cookie, Security +from app.routers import admin, songs, session from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates @@ -11,6 +11,8 @@ from app.schemas import Song import json import os import asyncio +from jose import JWTError, jwt +from app.security import get_current_user from starlette.middleware import Middleware @@ -23,7 +25,7 @@ if os.path.isfile("first_run") and (os.environ.get("RELOAD_ON_FIRST_RUN").lower( asyncio.run(admin.create_upload_file(include_non_singable=True, db=db)) os.remove("first_run") -#Base.metadata.create_all(engine) +# Base.metadata.create_all(engine) middleware = [ Middleware( @@ -38,7 +40,6 @@ middleware = [ app = FastAPI(middleware=middleware) app.include_router(admin.router) -app.include_router(user.router) app.include_router(songs.router) app.include_router(session.router) @@ -47,22 +48,29 @@ app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") +#@app.get("/") +#async def root(request: Request) -> HTMLResponse: +# return templates.TemplateResponse( +# request=request, name="landing.html" +# ) + + @app.get("/") -async def root(request: Request) -> HTMLResponse: - return templates.TemplateResponse( - request=request, name="landing.html" - ) +async def vote(request: Request, session_id: str | None = None , unordered: bool = False, user=Security(get_current_user, scopes=[]), + db: Session = Depends(get_db)) -> HTMLResponse: + if not session_id: + session_id = user["sub"] + + #print(user) -@app.get("/vote") -async def vote(request: Request, session_id: str, unordered: bool = False, db: Session = Depends(get_db)) -> HTMLResponse: veto_mode = get_setting(db, "veto_mode") songs = [Song(**s.__dict__, vote=v, vote_comment=c) for s, v, c in get_songs_and_vote_for_session(db, session_id)] if unordered: - songs_by_category = {"Alle Lieder" : songs} + songs_by_category = {"Alle Lieder": songs} all_categories = {"Alle Lieder"} for song in songs: all_categories.update(song.categories.keys()) diff --git a/app/routers/admin.py b/app/routers/admin.py index 362c8df..f85c77b 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Security, Depends from sqlalchemy.orm import Session from app.database import get_db, engine, Base -from app.routers.user import get_current_user +from app.security import get_current_user from app.crud import create_song, get_setting, set_setting router = APIRouter( diff --git a/app/routers/user.py b/app/routers/user.py deleted file mode 100644 index 5824a6d..0000000 --- a/app/routers/user.py +++ /dev/null @@ -1,169 +0,0 @@ -from datetime import datetime, timedelta, timezone -from typing import Annotated - -from fastapi import Depends, APIRouter, HTTPException, Security, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, SecurityScopes -from jose import JWTError, jwt -from passlib.context import CryptContext -from pydantic import BaseModel, ValidationError -import os - -#from app.secrets import SECRET_KEY, fake_users_db -# to get a string like this run: -# openssl rand -hex 32 - -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 31 -SECRET_KEY = os.environ['SECRET_KEY'] - -fake_users_db = { - "admin": { - "username": "admin", - "email": "admin@example.com", - "hashed_password": os.environ["ADMIN_PWD"], - "disabled": False, - "scopes" : ["admin", "public"] - } -} - - - -class Token(BaseModel): - access_token: str - token_type: str - - -class TokenData(BaseModel): - username: str | None = None - scopes: list[str] = [] - - -class User(BaseModel): - username: str - email: str | None = None - #full_name: str | None = None - disabled: bool | None = None - - -class UserInDB(User): - hashed_password: str - scopes: list[str] = [] - - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -oauth2_scheme = OAuth2PasswordBearer( - tokenUrl="user/token", - scopes={ - "admin": "Perform admin actions.", - "public": "Perform public actions." - } -) - -router = APIRouter( - prefix="/user" -) - - -def verify_password(plain_password, hashed_password): - print(get_password_hash(plain_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( - security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)] -): - if security_scopes.scopes: - authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' - else: - authenticate_value = "Bearer" - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": authenticate_value}, - ) - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username: str = payload.get("sub") # type: ignore - if username is None: - raise credentials_exception - token_scopes = payload.get("scopes", []) - token_data = TokenData(scopes=token_scopes, username=username) - except (JWTError, ValidationError): - raise credentials_exception - user = get_user(fake_users_db, username=token_data.username or "") - if user is None: - raise credentials_exception - for scope in security_scopes.scopes: - if scope not in token_data.scopes: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not enough permissions", - headers={"WWW-Authenticate": authenticate_value}, - ) - return user - - -async def get_current_active_user( - current_user: Annotated[User, Security(get_current_user, scopes=["me"])], -): - if current_user.disabled: - raise HTTPException(status_code=400, detail="Inactive user") - return current_user - - -@router.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, "scopes": user.scopes}, expires_delta=access_token_expires - ) - return Token(access_token=access_token, token_type="bearer") - -# @router.get("/public_token") -# async def get_public_access_token(secret_identity : str) -> Token: -# access_token_expires = timedelta(minutes=60*24*365) -# access_token = create_access_token( -# data={"sub": "public", "secret_identity" : secret_identity, "scopes": ["public"]}, expires_delta=access_token_expires -# ) -# return Token(access_token=access_token, token_type="bearer") \ No newline at end of file diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..6211a2e --- /dev/null +++ b/app/security.py @@ -0,0 +1,36 @@ +from typing import Annotated + +from fastapi import HTTPException, Cookie, status, Request +from fastapi.security import SecurityScopes +from jose import JWTError, jwt +from pydantic import ValidationError +import os + +#from app.secrets import SECRET_KEY, fake_users_db +# to get a string like this run: +# openssl rand -hex 32 + +scopes_db = { + os.environ['ADMIN_EMAIL'] : ["admin"] +} + +credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not enough permissions" + ) + +async def get_current_user( + security_scopes: SecurityScopes, request: Request + ): + try: + username: str = request.headers.get("x-auth-request-user") # type: ignore + if username is None: + raise credentials_exception + email: str = request.headers.get("x-auth-request-email") # type: ignore + except (JWTError, ValidationError): + raise credentials_exception + scopes = scopes_db.get(email, []) + for scope in security_scopes.scopes: + if scope not in scopes: + raise credentials_exception + return {"sub" : username, "email" : email, "internal_scopes" : scopes} \ No newline at end of file diff --git a/static/FiraSans-Italic.woff2 b/static/FiraSans-Italic.woff2 new file mode 100644 index 0000000..d7276c6 Binary files /dev/null and b/static/FiraSans-Italic.woff2 differ diff --git a/static/FiraSans-Medium.woff2 b/static/FiraSans-Medium.woff2 new file mode 100644 index 0000000..0d1b17b Binary files /dev/null and b/static/FiraSans-Medium.woff2 differ diff --git a/static/FiraSans-Regular.woff2 b/static/FiraSans-Regular.woff2 new file mode 100644 index 0000000..60c2cb4 Binary files /dev/null and b/static/FiraSans-Regular.woff2 differ diff --git a/static/FiraSans-SemiBold.woff2 b/static/FiraSans-SemiBold.woff2 new file mode 100644 index 0000000..3f87175 Binary files /dev/null and b/static/FiraSans-SemiBold.woff2 differ diff --git a/static/site.css b/static/site.css index a3b4aa1..41c7d62 100644 --- a/static/site.css +++ b/static/site.css @@ -1,8 +1,55 @@ +:root { + --color-white-100: white; + --color-white-90: #e6e6e6; + --color-white-80: #cccccc; + --color-white-70: #b3b3b3; + --color-white-10: #191919; + --color-choriosity-red: #be0519; + --color-choriosity-red--medium: #990514; + --color-choriosity-red--darker: #66030d; + --color-choriosity-red--rgba: 190, 5, 25; + --color-choriosity-red--light: #faeaea; + --color-background: #fffffa; + --color-tap: var(--color-choriosity-red--darker); + --color-callout-bg: var(--color-background); + } + + + @font-face { + font-family: 'Fira Sans'; + src: url(FiraSans-Regular.woff2) format("woff2"); + font-weight: 400; + font-style: normal; + } + + @font-face { + font-family: 'Fira Sans'; + src: url(FiraSans-Italic.woff2) format("woff2"); + font-weight: 400; + font-style: italic; + } + + @font-face { + font-family: 'Fira Sans'; + src: url(FiraSans-Medium.woff2) format("woff2"); + font-weight: 500; + font-style: normal; + } + + @font-face { + font-family: 'Fira Sans'; + src: url(FiraSans-SemiBold.woff2) format("woff2"); + font-weight: 600; + font-style: normal; + } + + + * { box-sizing: border-box; margin: 0; padding: 0; - font-family: sans-serif; + font-family: Fira Sans, Helvetica, Arial, sans-serif; } .vote-buttons, @@ -31,6 +78,7 @@ padding: 0.3em; margin-bottom: 0.7em; display: inline-block; + font-size: 1.25rem; } .clear { @@ -38,13 +86,13 @@ } .song { - background-color: #f0f0f0; + background-color: var(--color-choriosity-red--light); padding: 0.4em; border-radius: 0.5em; width: 30em; - font-family: sans-serif; margin-bottom: 1rem; margin-left: 0.5em; + float: left; } @@ -225,21 +273,28 @@ } h1 { - font-family: sans-serif; padding: 0.1em; padding-left: 0.2em; margin-bottom: 1rem; - font-size: 1.5em; + font-size: 1.25rem; + background-color: var(--color-choriosity-red); + color: white; + /*border-bottom: 0.3rem solid var(--color-choriosity-red--light);*/ + clear: both; +} + +#songs h1 .color { + border: 0.2rem solid color-mix(in srgb, hsl(calc(var(--hue) * 360), 100%, 40%) 50%, white); + background-color: hsl(calc(var(--hue) * 360), 100%, 40%); + border-radius: 0.5em; + margin-right: 1em; } #songs h1 { - background-color: color-mix(in srgb, hsl(calc(var(--hue) * 360), 100%, 40%) 50%, transparent); - border-bottom: 0.3rem solid hsl(calc(var(--hue) * 360), 100%, 40%); -} - -body>h1 { - background-color: color-mix(in srgb, hsl(0, 0%, 40%) 50%, transparent); - border-bottom: 0.3rem solid hsl(0, 0%, 40%); + border-left: 0.3em solid white; + box-shadow: -1em 0px 0px 0px hsl(calc(var(--hue) * 360), 100%, 40%); + margin-left: 1em; + padding-left: 0.3em; } #yt-player, @@ -278,4 +333,19 @@ body>h1 { -ms-transform: translate(-50%, -50%); transform: translate(-50%, -50%); width: 1.5em; +} + +#title { + margin-top: 1em; + font-size: 2rem; + /*line-height: 2rem;*/ +} + +#title-logo { + margin-top: -0.7em; + margin-bottom: -0.8em; + vertical-align: middle; + height: 3em; + filter: drop-shadow(0 0 1px var(--color-choriosity-red--medium)); + margin-right: 1em; } \ No newline at end of file diff --git a/templates/voting.html b/templates/voting.html index 8b240b5..74d4acc 100644 --- a/templates/voting.html +++ b/templates/voting.html @@ -19,6 +19,7 @@ border-radius: 0.2em; padding: 0.1em; } + .not_singable { background-color: color-mix(in srgb, #e1412f 30%, #f0f0f0); } @@ -124,41 +125,49 @@ } function vote(song_id, vote) { - no_button = $("#song-" + song_id).find(".button-no") - yes_button = $("#song-" + song_id).find(".button-yes") - neutral_button = $("#song-" + song_id).find(".button-neutral") - - no_button.removeClass("selected") - yes_button.removeClass("selected") - neutral_button.removeClass("selected") - - switch (vote) { - case 0: - neutral_button.addClass("selected") - {% if veto_mode %} - $("#song-" + song_id).removeClass("not_singable") - {% endif %} - break; - case 1: - yes_button.addClass("selected") - {% if veto_mode %} - $("#song-" + song_id).removeClass("not_singable") - {% endif %} - break; - case -1: - no_button.addClass("selected") - {% if veto_mode %} - $("#song-" + song_id).addClass("not_singable") - {% endif %} - break; - default: - break; - } $.ajax({ url: "/songs/" + song_id + "/vote?" + $.param({ session_id: session_id, vote: vote }), - method: "POST" - }) + method: "POST", + success: function (data, textStatus) { + + no_button = $("#song-" + song_id).find(".button-no") + yes_button = $("#song-" + song_id).find(".button-yes") + neutral_button = $("#song-" + song_id).find(".button-neutral") + + no_button.removeClass("selected") + yes_button.removeClass("selected") + neutral_button.removeClass("selected") + + switch (vote) { + case 0: + neutral_button.addClass("selected") + {% if veto_mode %} + $("#song-" + song_id).removeClass("not_singable") + {% endif %} + break; + case 1: + yes_button.addClass("selected") + {% if veto_mode %} + $("#song-" + song_id).removeClass("not_singable") + {% endif %} + break; + case -1: + no_button.addClass("selected") + {% if veto_mode %} + $("#song-" + song_id).addClass("not_singable") + {% endif %} + break; + default: + break; + } + + } + }); + + + + } {% if veto_mode %} @@ -176,11 +185,12 @@
{% if veto_mode %}