From 9051e98d63e911bd600419d267126ecf6c7ed4ce Mon Sep 17 00:00:00 2001 From: Markbeep Date: Sun, 16 Feb 2025 23:07:06 +0100 Subject: [PATCH] added settings page --- .../d6a02deef57b_user_group_enum_config.py | 35 +++ app/main.py | 3 +- app/models.py | 19 +- app/routers/root.py | 28 +- app/routers/settings.py | 170 ++++++++++++ app/routers/wishlist.py | 38 +-- app/util/auth.py | 26 +- app/util/prowlarr.py | 60 ++++- templates/base.html | 11 +- templates/init.html | 26 +- templates/search.html | 14 +- templates/settings.html | 251 ++++++++++++++++++ templates/sources.html | 50 ++-- 13 files changed, 617 insertions(+), 114 deletions(-) create mode 100644 alembic/versions/d6a02deef57b_user_group_enum_config.py create mode 100644 app/routers/settings.py create mode 100644 templates/settings.html diff --git a/alembic/versions/d6a02deef57b_user_group_enum_config.py b/alembic/versions/d6a02deef57b_user_group_enum_config.py new file mode 100644 index 0000000..c8719e1 --- /dev/null +++ b/alembic/versions/d6a02deef57b_user_group_enum_config.py @@ -0,0 +1,35 @@ +"""user group enum & config + +Revision ID: d6a02deef57b +Revises: 6477fe89a011 +Create Date: 2025-02-16 22:23:25.519453 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'd6a02deef57b' +down_revision: Union[str, None] = '6477fe89a011' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('config', + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('value', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('key') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('config') + # ### end Alembic commands ### diff --git a/app/main.py b/app/main.py index 94f2e77..1b5fd8b 100644 --- a/app/main.py +++ b/app/main.py @@ -5,13 +5,14 @@ from sqlalchemy import func from sqlmodel import select from app.db import get_session from app.models import User -from app.routers import root, search, wishlist +from app.routers import root, search, settings, wishlist app = FastAPI() app.include_router(root.router) app.include_router(search.router) app.include_router(wishlist.router) +app.include_router(settings.router) user_exists = False diff --git a/app/models.py b/app/models.py index fd9407f..a8a10dc 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ # pyright: reportUnknownVariableType=false from datetime import datetime +from enum import Enum from typing import Optional from sqlmodel import Field, SQLModel @@ -8,11 +9,18 @@ class BaseModel(SQLModel): pass +class GroupEnum(str, Enum): + untrusted = "untrusted" + trusted = "trusted" + admin = "admin" + + class User(BaseModel, table=True): username: str = Field(primary_key=True) password: str - group: str = Field( - default="untrusted", sa_column_kwargs={"server_default": "untrusted"} + group: GroupEnum = Field( + default=GroupEnum.untrusted, + sa_column_kwargs={"server_default": "untrusted"}, ) """ untrusted: Requests need to be manually reviewed @@ -21,7 +29,7 @@ class User(BaseModel, table=True): """ def is_admin(self): - return self.group == "admin" + return self.group == GroupEnum.admin class BookRequest(BaseModel, table=True): @@ -54,3 +62,8 @@ class Indexer(BaseModel, table=True): name: str enabled: bool privacy: str + + +class Config(BaseModel, table=True): + key: str = Field(primary_key=True) + value: str diff --git a/app/routers/root.py b/app/routers/root.py index 224521a..0f49af0 100644 --- a/app/routers/root.py +++ b/app/routers/root.py @@ -1,14 +1,17 @@ -import re from typing import Annotated -from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response from fastapi.responses import FileResponse from jinja2_fragments.fastapi import Jinja2Blocks from sqlmodel import Session from app.db import get_session -from app.models import User -from app.util.auth import create_user, get_authenticated_user +from app.models import User, GroupEnum +from app.util.auth import ( + create_user, + get_authenticated_user, + raise_for_invalid_password, +) router = APIRouter() @@ -35,12 +38,7 @@ def read_init(request: Request): return templates.TemplateResponse("init.html", {"request": request}) -validate_password_regex = re.compile( - r"^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$" -) - - -@router.post("/init", status_code=201) +@router.post("/init") def create_init( username: Annotated[str, Form()], password: Annotated[str, Form()], @@ -50,12 +48,10 @@ def create_init( if password != confirm_password: raise HTTPException(status_code=400, detail="Passwords do not match") - if not validate_password_regex.match(password): - raise HTTPException( - status_code=400, - detail="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number", - ) + raise_for_invalid_password(password) - user = create_user(username, password, "admin") + user = create_user(username, password, GroupEnum.admin) session.add(user) session.commit() + + return Response(status_code=201, headers={"HX-Redirect": "/"}) diff --git a/app/routers/settings.py b/app/routers/settings.py new file mode 100644 index 0000000..874c5a3 --- /dev/null +++ b/app/routers/settings.py @@ -0,0 +1,170 @@ +from typing import Annotated, Any, Optional +from urllib.parse import quote_plus +from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response +from jinja2_fragments.fastapi import Jinja2Blocks +from sqlmodel import Session, select + +from app.db import get_session + +from app.models import Config, User, GroupEnum +from app.util.auth import ( + create_user, + get_authenticated_user, + raise_for_invalid_password, +) + +router = APIRouter(prefix="/settings") + +templates = Jinja2Blocks(directory="templates") +templates.env.filters["quote_plus"] = lambda u: quote_plus(u) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportUnknownArgumentType] + + +@router.get("/") +def read_settings( + request: Request, + user: Annotated[User, Depends(get_authenticated_user())], + session: Annotated[Session, Depends(get_session)], + prowlarr_misconfigured: Optional[Any] = None, +): + if user.is_admin(): + users = session.exec(select(User)).all() + else: + users = [] + + prowlarr_base_url = session.exec( + select(Config.value).where(Config.key == "prowlarr_base_url") + ).one_or_none() + + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "user": user, + "users": users, + "prowlarr_base_url": prowlarr_base_url or "", + "prowlarr_misconfigured": True if prowlarr_misconfigured else False, + }, + ) + + +@router.post("/user") +def create_new_user( + request: Request, + username: Annotated[str, Form()], + password: Annotated[str, Form()], + confirm_password: Annotated[str, Form()], + group: Annotated[str, Form()], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))], +): + if password != confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + if group not in GroupEnum.__members__: + raise HTTPException(status_code=400, detail="Invalid group") + group = GroupEnum[group] + + raise_for_invalid_password(password) + + user = session.exec(select(User).where(User.username == username)).first() + if user: + raise HTTPException(status_code=400, detail="Username already exists") + + user = create_user(username, password, group) + session.add(user) + session.commit() + + users = session.exec(select(User)).all() + + return templates.TemplateResponse( + "settings.html", + {"request": request, "user": user, "users": users}, + block_name="user_block", + ) + + +@router.delete("/user") +def delete_user( + request: Request, + username: str, + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))], +): + if username == admin_user.username: + raise HTTPException(status_code=400, detail="Cannot delete own user") + + user = session.exec(select(User).where(User.username == username)).one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + session.delete(user) + session.commit() + + users = session.exec(select(User)).all() + + return templates.TemplateResponse( + "settings.html", + {"request": request, "user": user, "users": users}, + block_name="user_block", + ) + + +@router.post("/password") +def change_password( + password: Annotated[str, Form()], + confirm_password: Annotated[str, Form()], + session: Annotated[Session, Depends(get_session)], + user: Annotated[User, Depends(get_authenticated_user())], +): + if password != confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + + raise_for_invalid_password(password) + + new_user = create_user(user.username, password, user.group) + + user.password = new_user.password + session.add(user) + session.commit() + + return Response(status_code=204, headers={"HX-Refresh": "true"}) + + +@router.put("/prowlarr/api-key") +def update_prowlarr_api_key( + api_key: Annotated[str, Form()], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))], +): + config = session.exec( + select(Config).where(Config.key == "prowlarr_api_key") + ).one_or_none() + if config: + config.value = api_key + else: + config = Config(key="prowlarr_api_key", value=api_key) + session.add(config) + session.commit() + + return Response(status_code=204, headers={"HX-Refresh": "true"}) + + +@router.put("/prowlarr/base-url") +def update_prowlarr_base_url( + base_url: Annotated[str, Form()], + session: Annotated[Session, Depends(get_session)], + admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))], +): + config = session.exec( + select(Config).where(Config.key == "prowlarr_base_url") + ).one_or_none() + + base_url = base_url.strip("/") + + if config: + config.value = base_url + else: + config = Config(key="prowlarr_base_url", value=base_url) + session.add(config) + session.commit() + + return Response(status_code=204, headers={"HX-Refresh": "true"}) diff --git a/app/routers/wishlist.py b/app/routers/wishlist.py index 6fcc540..23c647c 100644 --- a/app/routers/wishlist.py +++ b/app/routers/wishlist.py @@ -3,16 +3,23 @@ from typing import Annotated from aiohttp import ClientSession from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse from jinja2_fragments.fastapi import Jinja2Blocks from sqlalchemy import func from sqlmodel import Session, col, select from app.db import get_session -from app.models import BookRequest, Indexer, User +from app.models import BookRequest, GroupEnum, Indexer, User from app.util.auth import get_authenticated_user from app.util.book_search import get_audnexus_book from app.util.connection import get_connection -from app.util.prowlarr import get_indexers, query_prowlarr, start_download +from app.util.prowlarr import ( + ProwlarrConfig, + get_indexers, + get_prowlarr_config, + query_prowlarr, + start_download, +) router = APIRouter(prefix="/wishlist") @@ -50,23 +57,21 @@ async def wishlist( ) -@router.post("") -async def refresh_request( - user: Annotated[User, Depends(get_authenticated_user("trusted"))], - session: Annotated[Session, Depends(get_session)], - client_session: Annotated[ClientSession, Depends(get_connection)], -): - return {"message": "Refreshed"} - - @router.get("/sources/{asin}") async def list_sources( request: Request, asin: str, - admin_user: Annotated[User, Depends(get_authenticated_user("admin"))], + admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))], session: Annotated[Session, Depends(get_session)], client_session: Annotated[ClientSession, Depends(get_connection)], ): + try: + prowlarr_config = get_prowlarr_config(session) + except HTTPException: + return RedirectResponse( + "/settings?prowlarr_misconfigured=1#prowlarr-base-url", status_code=302 + ) + book = session.exec(select(BookRequest).where(BookRequest.asin == asin)).first() if not book: raise HTTPException(status_code=404, detail="Book not found") @@ -76,13 +81,13 @@ async def list_sources( raise HTTPException(status_code=500, detail="Book asin error") query = book.title + " " + " ".join(book.authors) - sources = await query_prowlarr(query) + sources = await query_prowlarr(prowlarr_config, query) if len(sources) > 0: indexers = session.exec(select(Indexer)).all() indexers = {indexer.id: indexer for indexer in indexers} if len(indexers) == 0: - indexers = await get_indexers(client_session) + indexers = await get_indexers(prowlarr_config, client_session) for indexer in indexers.values(): session.add(indexer) session.commit() @@ -111,11 +116,12 @@ async def download_book( asin: str, guid: str, indexer_id: int, - admin_user: Annotated[User, Depends(get_authenticated_user("admin"))], + admin_user: Annotated[User, Depends(get_authenticated_user(GroupEnum.admin))], session: Annotated[Session, Depends(get_session)], client_session: Annotated[ClientSession, Depends(get_connection)], + prowlarr_config: Annotated[ProwlarrConfig, Depends(get_prowlarr_config)], ): - resp = await start_download(client_session, guid, indexer_id) + resp = await start_download(prowlarr_config, client_session, guid, indexer_id) if not resp.ok: raise HTTPException(status_code=500, detail="Failed to start download") diff --git a/app/util/auth.py b/app/util/auth.py index 35a9db7..9ba9746 100644 --- a/app/util/auth.py +++ b/app/util/auth.py @@ -1,4 +1,5 @@ -from typing import Annotated, Literal +import re +from typing import Annotated from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError @@ -7,23 +8,36 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials from sqlmodel import Session, select from app.db import get_session -from app.models import User +from app.models import User, GroupEnum security = HTTPBasic() ph = PasswordHasher() +validate_password_regex = re.compile( + r"^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$" +) + + +def raise_for_invalid_password(password: str): + if not validate_password_regex.match(password): + raise HTTPException( + status_code=400, + detail="Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number", + ) + + def create_user( username: str, password: str, - group: Literal["admin", "trusted", "untrusted"] = "untrusted", + group: GroupEnum = GroupEnum.untrusted, ) -> User: password_hash = ph.hash(password) return User(username=username, password=password_hash, group=group) def get_authenticated_user( - lowest_allowed_group: Literal["admin", "trusted", "untrusted"] = "untrusted", + lowest_allowed_group: GroupEnum = GroupEnum.untrusted, ): def get_user( session: Annotated[Session, Depends(get_session)], @@ -55,10 +69,10 @@ def get_authenticated_user( session.commit() if lowest_allowed_group == "admin": - if user.group != "admin": + if user.group != GroupEnum.admin: raise HTTPException(status_code=403, detail="Forbidden") elif lowest_allowed_group == "trusted": - if user.group not in ["admin", "trusted"]: + if user.group not in [GroupEnum.admin, GroupEnum.trusted]: raise HTTPException(status_code=403, detail="Forbidden") return user diff --git a/app/util/prowlarr.py b/app/util/prowlarr.py index 0ecb70b..551216a 100644 --- a/app/util/prowlarr.py +++ b/app/util/prowlarr.py @@ -1,30 +1,57 @@ import logging -import os from datetime import datetime -from typing import Any, Optional +from typing import Annotated, Any, Optional from urllib.parse import urlencode, urljoin from aiohttp import ClientResponse, ClientSession from async_lru import alru_cache +from fastapi import Depends, HTTPException +import pydantic +from sqlmodel import Session, select -from app.models import Indexer, ProwlarrSource +from app.db import get_session +from app.models import Config, Indexer, ProwlarrSource logger = logging.getLogger(__name__) -prowlarr_base_url = os.getenv("PROWLARR_BASE_URL", "") -prowlarr_api_key = os.getenv("PROWLARR_API_KEY", "") + +class ProwlarrConfig(pydantic.BaseModel): + base_url: str + api_key: str + + def __hash__(self) -> int: + return hash((self.base_url, self.api_key)) + + +def get_prowlarr_config( + session: Annotated[Session, Depends(get_session)], +) -> ProwlarrConfig: + api_key = session.exec( + select(Config.value).where(Config.key == "prowlarr_api_key") + ).one_or_none() + base_url = session.exec( + select(Config.value).where(Config.key == "prowlarr_base_url") + ).one_or_none() + + if not api_key or not base_url: + raise HTTPException(500, "Prowlarr configuration missing") + + return ProwlarrConfig(base_url=base_url, api_key=api_key) async def start_download( - session: ClientSession, guid: str, indexer_id: int + config: ProwlarrConfig, + session: ClientSession, + guid: str, + indexer_id: int, ) -> ClientResponse: - url = prowlarr_base_url + "/api/v1/search" + url = config.base_url + "/api/v1/search" logger.debug("Starting download for %s", guid) async with session.post( url, json={"guid": guid, "indexerId": indexer_id}, - headers={"X-Api-Key": prowlarr_api_key}, + headers={"X-Api-Key": config.api_key}, ) as response: if not response.ok: print(response) @@ -34,12 +61,15 @@ async def start_download( return response -async def get_indexers(session: ClientSession) -> dict[int, Indexer]: - url = prowlarr_base_url + "/api/v1/indexer" +async def get_indexers( + config: ProwlarrConfig, + session: ClientSession, +) -> dict[int, Indexer]: + url = config.base_url + "/api/v1/indexer" async with session.get( url, - headers={"X-Api-Key": prowlarr_api_key}, + headers={"X-Api-Key": config.api_key}, ) as response: indexers = await response.json() @@ -56,7 +86,9 @@ async def get_indexers(session: ClientSession) -> dict[int, Indexer]: @alru_cache(ttl=300) async def query_prowlarr( - query: Optional[str], indexer_ids: Optional[list[int]] = None + config: ProwlarrConfig, + query: Optional[str], + indexer_ids: Optional[list[int]] = None, ) -> list[ProwlarrSource]: if not query: return [] @@ -70,14 +102,14 @@ async def query_prowlarr( if indexer_ids is not None: params["indexerIds"] = indexer_ids - url = urljoin(prowlarr_base_url, f"/api/v1/search?{urlencode(params, doseq=True)}") + url = urljoin(config.base_url, f"/api/v1/search?{urlencode(params, doseq=True)}") logger.info("Querying prowlarr: %s", url) async with ClientSession() as session: async with session.get( url, - headers={"X-Api-Key": prowlarr_api_key}, + headers={"X-Api-Key": config.api_key}, ) as response: search_results = await response.json() diff --git a/templates/base.html b/templates/base.html index 82bf771..31ff9a6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,6 +6,11 @@ AudioBookRequest {% endblock %} +
@@ -78,7 +83,11 @@
- + {% endblock %} {% block body %}
-
- - +

No user was found in the database. Please create an admin user to get set up. diff --git a/templates/search.html b/templates/search.html index 1d74594..eebfd63 100644 --- a/templates/search.html +++ b/templates/search.html @@ -26,14 +26,14 @@ const onPageChange = (page) => { {% endblock %} {% block body %}

- + - {% for region in regions %} {% endfor %} - @@ -120,9 +120,9 @@ const onPageChange = (page) => {
-
{{ book.title }}
- {% if book.subtitle %}
{{ book.subtitle }}
{% endif %} -
+
{{ book.title }}
+ {% if book.subtitle %}
{{ book.subtitle }}
{% endif %} +
{{ book.authors | join(", ") }}
diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..68a119d --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,251 @@ +{% extends "base.html" %} {% block head %} +Settings + +{% endblock %} {% block body %} +
+
+

Settings

+ + +

Change Password

+ + + + + + + + + + + {% if user.is_admin() %} + +
+

Create user

+ + + + + + + + + + + + + + + +
+ + {% block user_block %} +
+

Users

+
+ + + + + + + + + + + {% for u in users %} + + + + + + + {% endfor %} + +
UsernameGroupDelete
{{ loop.index }}{{ u.username }}{{ u.group.value.capitalize() }} + + + + + +
+
+
+ {% endblock %} + + +
+

Prowlarr

+ + {% if prowlarr_misconfigured %} +

+ Prowlarr is misconfigured. Please configure it. +

+ {% endif %} + + +
+ + +
+ + +
+ + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/sources.html b/templates/sources.html index 883f36e..a313c44 100644 --- a/templates/sources.html +++ b/templates/sources.html @@ -15,32 +15,30 @@ {% endblock %} {% block body %} -
+

Sources for {{ book.title }}

- - {% if not sources %} - - {% endif %} {% for source in sources %} -
+ {% if not sources %} + + {% endif %} +
@@ -53,6 +51,8 @@ + + {% for source in sources %}
{{ loop.index }}