mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2026-01-06 21:49:45 -06:00
added settings page
This commit is contained in:
35
alembic/versions/d6a02deef57b_user_group_enum_config.py
Normal file
35
alembic/versions/d6a02deef57b_user_group_enum_config.py
Normal file
@@ -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 ###
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "/"})
|
||||
|
||||
170
app/routers/settings.py
Normal file
170
app/routers/settings.py
Normal file
@@ -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"})
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
<title>AudioBookRequest</title>
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" href="/globals.css" />
|
||||
<script
|
||||
src="https://unpkg.com/htmx.org@2.0.4"
|
||||
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
</head>
|
||||
<body class="w-screen min-h-screen overflow-x-hidden">
|
||||
<header class="bg-accent text-primary-content shadow-lg">
|
||||
@@ -78,7 +83,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-none pr-4">
|
||||
<a class="btn btn-ghost btn-square group" title="Settings">
|
||||
<a
|
||||
href="/settings"
|
||||
class="btn btn-ghost btn-square group"
|
||||
title="Settings"
|
||||
>
|
||||
<svg
|
||||
class="group-hover:rotate-90 transition-all duration-500 ease-in-out"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -26,31 +26,7 @@
|
||||
</script>
|
||||
{% endblock %} {% block body %}
|
||||
<div class="h-screen w-full flex items-center justify-center">
|
||||
<form class="flex flex-col gap-2 max-w-[30rem]" method="post" id="form">
|
||||
<script>
|
||||
// Redirect to the home page after initialization
|
||||
const onSubmit = event => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.target);
|
||||
console.log(formData);
|
||||
fetch("/init", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
alert("An error occurred. Please try again.");
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error:", error);
|
||||
});
|
||||
};
|
||||
document.getElementById("form").addEventListener("submit", onSubmit);
|
||||
</script>
|
||||
|
||||
<form class="flex flex-col gap-2 max-w-[30rem]" hx-post="/init" id="form">
|
||||
<p>
|
||||
No user was found in the database. Please create an admin user to get set
|
||||
up.
|
||||
|
||||
@@ -26,14 +26,14 @@ const onPageChange = (page) => {
|
||||
{% endblock %} {% block body %}
|
||||
<div class="w-screen flex items-center justify-center p-8 overflow-x-hidden gap-4">
|
||||
<div class="flex flex-col gap-4 justify-start items-center">
|
||||
<form class="flex items-start gap-2 w-full" onsubmit="onSearch();">
|
||||
<form class="flex items-start w-full join" onsubmit="onSearch();">
|
||||
<input
|
||||
name="q"
|
||||
class="input input-bordered"
|
||||
class="input input-bordered join-item"
|
||||
placeholder="Book name..."
|
||||
value="{{ search_term }}"
|
||||
/>
|
||||
<select class="select" name="region">
|
||||
<select class="select join-item max-w-[5rem]" name="region">
|
||||
{% for region in regions %}
|
||||
<option
|
||||
value="{{ region }}"
|
||||
@@ -43,7 +43,7 @@ const onPageChange = (page) => {
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button id="search" class="btn btn-primary" type="submit">
|
||||
<button id="search" class="btn btn-primary join-item" type="submit">
|
||||
<span id="search-text">Search</span>
|
||||
<span id="search-spinner" class="loading" style="display: none"></span>
|
||||
</button>
|
||||
@@ -120,9 +120,9 @@ const onPageChange = (page) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-primary font-semibold pt-1" title="Title">{{ book.title }}</div>
|
||||
{% if book.subtitle %}<div class="text-neutral text-xs" title="Subtitle">{{ book.subtitle }}</div>{% endif %}
|
||||
<div class="text-neutral text-xs" title="Authors">
|
||||
<div class="text-sm text-primary font-bold pt-1" title="Title">{{ book.title }}</div>
|
||||
{% if book.subtitle %}<div class="text-neutral/60 font-semibold text-xs" title="Subtitle">{{ book.subtitle }}</div>{% endif %}
|
||||
<div class="text-xs text-neutral font-semibold" title="Authors">
|
||||
{{ book.authors | join(", ") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
251
templates/settings.html
Normal file
251
templates/settings.html
Normal file
@@ -0,0 +1,251 @@
|
||||
{% extends "base.html" %} {% block head %}
|
||||
<title>Settings</title>
|
||||
<script>
|
||||
const checkEqualPasswords = function (formId) {
|
||||
const form = document.getElementById(formId);
|
||||
const firstPw = form.elements["password"].value;
|
||||
const secondPw = form.elements["confirm_password"].value;
|
||||
const submit = form.elements["submit"];
|
||||
const nonEqualMessage = form.querySelectorAll(
|
||||
"[name='non-equal-message']",
|
||||
)[0];
|
||||
const invalidPwMessage = form.querySelectorAll(
|
||||
"[name='invalid-pw-message']",
|
||||
)[0];
|
||||
|
||||
if (firstPw.length === 0 && secondPw.length === 0) {
|
||||
invalidPwMessage.style.display = "none";
|
||||
nonEqualMessage.style.display = "none";
|
||||
submit.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstPw !== secondPw && firstPw.length > 0 && secondPw.length > 0) {
|
||||
nonEqualMessage.style.display = "block";
|
||||
submit.disabled = true;
|
||||
} else {
|
||||
nonEqualMessage.style.display = "none";
|
||||
if (firstPw.length > 0 && secondPw.length > 0) {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const rule = /^(?=[^A-Z]*[A-Z])(?=[^a-z]*[a-z])(?=\D*\d).{8,}$/;
|
||||
if (!rule.test(firstPw)) {
|
||||
invalidPwMessage.style.display = "block";
|
||||
submit.disabled = true;
|
||||
} else {
|
||||
invalidPwMessage.style.display = "none";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %} {% block body %}
|
||||
<div class="flex items-start justify-center p-2 md:p-4">
|
||||
<main class="flex flex-col w-[90%] md:w-3/4 max-w-[40rem] gap-4 gap-y-8 pb-[20rem]">
|
||||
<h1 class="text-3xl font-bold">Settings</h1>
|
||||
|
||||
<form
|
||||
id="change-password-form"
|
||||
hx-post="/settings/password"
|
||||
method="post"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<h2 class="text-lg">Change Password</h2>
|
||||
<label for="change-password-1">Password</label>
|
||||
<input
|
||||
id="change-password-1"
|
||||
name="password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
onkeyup="checkEqualPasswords('change-password-form');"
|
||||
required
|
||||
/>
|
||||
<label for="change-password-2">Confirm password</label>
|
||||
<input
|
||||
id="change-password-2"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
onkeyup="checkEqualPasswords('change-password-form');"
|
||||
required
|
||||
/>
|
||||
|
||||
<span name="non-equal-message" class="text-red-400 hidden"
|
||||
>Passwords do not match</span
|
||||
>
|
||||
<span name="invalid-pw-message" class="text-red-400 hidden"
|
||||
>Password must be at least 8 characters long and contain at least one
|
||||
uppercase letter, one lowercase letter, and one number</span
|
||||
>
|
||||
<button name="submit" class="btn btn-primary" type="submit" disabled>
|
||||
Change password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if user.is_admin() %}
|
||||
|
||||
<form
|
||||
id="create-user-form"
|
||||
class="flex flex-col gap-2 p-2 pt-4 border-base-200 border-t"
|
||||
hx-post="/settings/user"
|
||||
hx-target="#user-list"
|
||||
hx-on::after-request="if (event.detail.successful) this.reset()"
|
||||
>
|
||||
<h2 class="text-lg">Create user</h2>
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
|
||||
<label for="confirm-password-1">Confirm password</label>
|
||||
<input
|
||||
id="confirm-password-1"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
onkeyup="checkEqualPasswords('create-user-form');"
|
||||
required
|
||||
/>
|
||||
|
||||
<label for="confirm-password-2">Password</label>
|
||||
<input
|
||||
id="confirm-password-2"
|
||||
name="password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
onkeyup="checkEqualPasswords('create-user-form');"
|
||||
required
|
||||
/>
|
||||
|
||||
<label for="select-group">Group</label>
|
||||
<select id="select-group" name="group" class="select w-full" required>
|
||||
<option value="untrusted" selected>Untrusted</option>
|
||||
<option value="trusted">Trusted</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
|
||||
<span name="non-equal-message" class="text-red-400 hidden"
|
||||
>Passwords do not match</span
|
||||
>
|
||||
<span name="invalid-pw-message" class="text-red-400 hidden"
|
||||
>Password must be at least 8 characters long and contain at least one
|
||||
uppercase letter, one lowercase letter, and one number</span
|
||||
>
|
||||
<button id="submit" class="btn btn-primary" type="submit" disabled>
|
||||
Create user
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% block user_block %}
|
||||
<div class="pt-4 border-t border-base-200">
|
||||
<h2 class="text-lg">Users</h2>
|
||||
<div class="max-h-[30rem] overflow-x-auto">
|
||||
<table id="user-list" class="table table-pin-rows">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Username</th>
|
||||
<th>Group</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<th>{{ loop.index }}</th>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.group.value.capitalize() }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-square btn-ghost"
|
||||
onclick="delete_modal_{{ loop.index }}.showModal()"
|
||||
{% if u.username==user.username %}disabled{% endif %}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
width="24"
|
||||
height="24"
|
||||
stroke-width="2"
|
||||
style="--darkreader-inline-stroke: currentColor"
|
||||
data-darkreader-inline-stroke=""
|
||||
>
|
||||
<path d="M4 7h16"></path>
|
||||
<path
|
||||
d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"
|
||||
></path>
|
||||
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"></path>
|
||||
<path d="M10 12l4 4m0 -4l-4 4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<dialog id="delete_modal_{{ loop.index }}" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">
|
||||
Are you sure you want to delete a user?
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 py-4">
|
||||
<span class="font-semibold mr-2">Username</span
|
||||
><span class="font-mono">{{ u.username }}</span>
|
||||
<span class="font-semibold mr-2">Group</span
|
||||
><span class="font-mono"
|
||||
>{{ u.group.value.capitalize() }}</span
|
||||
>
|
||||
</div>
|
||||
<form method="dialog" class="flex justify-between">
|
||||
<button class="btn">Cancel</button>
|
||||
<button
|
||||
class="btn bg-primary"
|
||||
hx-delete="/settings/user?username={{ u.username|quote_plus }}"
|
||||
hx-target="#user-list"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
<div class="pt-4 border-t border-base-200 flex flex-col">
|
||||
<h2 class="text-lg">Prowlarr</h2>
|
||||
|
||||
{% if prowlarr_misconfigured %}
|
||||
<p class="text-red-400">
|
||||
Prowlarr is misconfigured. Please configure it.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="prowlarr-api-key">API Key</label>
|
||||
<form class="join w-full" hx-put="/settings/prowlarr/api-key">
|
||||
<input id="prowlarr-api-key" name="api_key" type="password" placeholder="●●●●●●●●●●●●●●●●●" class="input join-item w-full" />
|
||||
<button class="join-item btn">Save</button>
|
||||
</form>
|
||||
|
||||
<label for="prowlarr-base-url" class="pt-2">Base URL</label>
|
||||
<form class="join w-full" hx-put="/settings/prowlarr/base-url">
|
||||
<input id="prowlarr-base-url" name="base_url" type="url" value="{{ prowlarr_base_url }}" class="input join-item w-full" />
|
||||
<button class="join-item btn">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -15,32 +15,30 @@
|
||||
</script>
|
||||
{% endblock %} {% block body %}
|
||||
|
||||
<div class="w-screen p-2 md:p-4 lg:p-8 min-h-screen flex flex-col gap-2">
|
||||
<div class="w-screen p-2 md:p-4 lg:p-8 flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold">Sources for {{ book.title }}</h1>
|
||||
<tbody>
|
||||
{% if not sources %}
|
||||
<div role="alert" class="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
>No results found for "{{ book.title }}" by
|
||||
{{book.authors|join(",")}}. Might have to be looked up
|
||||
manually.</span
|
||||
>
|
||||
</div>
|
||||
{% endif %} {% for source in sources %}
|
||||
<div class="overflow-x-auto h-[90vh] rounded-md outline outline-primary">
|
||||
{% if not sources %}
|
||||
<div role="alert" class="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
>No results found for "{{ book.title }}" by {{book.authors|join(",")}}.
|
||||
Might have to be looked up manually.</span
|
||||
>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-pin-rows min-w-[60rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -53,6 +51,8 @@
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for source in sources %}
|
||||
<tr class="text-xs lg:text-sm">
|
||||
<th>{{ loop.index }}</th>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user