update branch

Update fork
This commit is contained in:
Leandro Zazzi
2025-03-17 13:33:43 +01:00
committed by GitHub
45 changed files with 1553 additions and 633 deletions

View File

@@ -1,3 +1,4 @@
ABR_APP__CONFIG_DIR=config # Path to the config directory. Default: /config
ABR_APP__DEBUG=true # Default: false
ABR_APP__OPENAPI_ENABLED=true # Default: false
ABR_APP__LOG_LEVEL=DEBUG

View File

@@ -70,13 +70,18 @@ jobs:
MINOR=$(echo $VERSION | cut -d. -f2)
PATCH=$(echo $VERSION | cut -d. -f3)
echo "::set-output name=tags::${{ secrets.DOCKER_HUB_USERNAME }}/audiobookrequest:$VERSION,${{ secrets.DOCKER_HUB_USERNAME }}/audiobookrequest:$MAJOR.$MINOR,${{ secrets.DOCKER_HUB_USERNAME }}/audiobookrequest:$MAJOR,${{ secrets.DOCKER_HUB_USERNAME }}/audiobookrequest:latest"
echo "::set-output name=version::$VERSION"
else
echo "::set-output name=tags::${{ secrets.DOCKER_HUB_USERNAME }}/audiobookrequest:nightly"
github_sha_hash=${{ github.sha }}
echo "::set-output name=version::nightly:${github_sha_hash:0:7}"
fi
- name: Build and push
uses: docker/build-push-action@v5
with:
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.vars.outputs.tags }}
build-args: |
VERSION=${{ steps.vars.outputs.version }}

View File

@@ -1,19 +1,24 @@
# Install daisyui
FROM node:23-alpine3.20
WORKDIR /app
# Install daisyui
COPY package.json package.json
COPY package-lock.json package-lock.json
RUN npm install
# Setup python
FROM python:3.11-alpine
FROM python:3.11-alpine AS linux-amd64
WORKDIR /app
RUN apk add --no-cache curl gcompat build-base
RUN curl https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.6/tailwindcss-linux-x64-musl -L -o /bin/tailwindcss
FROM python:3.11-alpine AS linux-arm64
WORKDIR /app
RUN apk add --no-cache curl gcompat build-base
RUN curl https://github.com/tailwindlabs/tailwindcss/releases/download/v4.0.6/tailwindcss-linux-arm64-musl -L -o /bin/tailwindcss
FROM ${TARGETOS}-${TARGETARCH}${TARGETVARIANT}
RUN chmod +x /bin/tailwindcss
COPY requirements.txt requirements.txt
@@ -32,6 +37,8 @@ COPY app/ app/
RUN /bin/tailwindcss -i styles/globals.css -o static/globals.css -m
ENV ABR_APP__PORT=8000
ARG VERSION
ENV ABR_APP__VERSION=$VERSION
CMD alembic upgrade heads && fastapi run --port $ABR_APP__PORT

View File

@@ -1,3 +1,7 @@
![GitHub Release](https://img.shields.io/github/v/release/markbeep/AudioBookRequest)
[![Discord](https://dcbadge.limes.pink/api/server/https://discord.gg/SsFRXWMg7s)](https://discord.gg/SsFRXWMg7s)
![Header](/media/AudioBookRequestIcon.png)
Your tool for handling audiobook requests on a Plex/Audiobookshelf/Jellyfin instance.
@@ -13,12 +17,15 @@ If you've heard of Overseer, Ombi, or Jellyseer; this is in the similar vein, <i
- [Usage](#usage)
- [Auto download](#auto-download)
- [Notifications](#notifications)
- [OpenID Connect](#openid-connect)
- [Getting locked out](#getting-locked-out)
- [Alternative Deployments](#alternative-deployments)
- [Environment Variables](#environment-variables)
- [Contributing](#contributing)
- [Local Development](#local-development)
- [Initialize Database](#initialize-database)
- [Running](#running)
- [Docker Compose](#docker-compose)
# Getting Started
@@ -63,6 +70,25 @@ Notifications depend on [Apprise](https://github.com/caronc/apprise).
4. On AudioBookRequest, head to `Settings>Notifications` and add the URL.
5. Configure the remaining settings. **The event variables are case sensitive**.
### OpenID Connect
OIDC allows you to use an external authentication service (Authentik, Keycloak, etc.) for user and group authentication. It can be configured in `Settings>Security`. The following six settings are required to successfully set up oidc. Ensure you use the correct values. Incorrect values or changing values on your authentication server in the future can cause lead to locking you out of the service. In those cases head to [`Getting "locked" out`](#getting-locked-out).
- `well-known` configuration endpoint: This is located at `/realms/{realm-name}/.well-known/openid-configuration` for keycloak or `/application/o/{issuer}/.well-known/openid-configuration` for authentik.
- username claim: The claim that should be used for usernames. The username has to be unique. **NOTE:** Any user logging in with the username of the root admin account will be root admin, no matter what group they're assigned.
- group claim: This is the claim that contains the group of each user. It should either be a string or a list of strings with one of the following case-insensitive values: `untrusted`, `trusted`, or `admin`. Any user without any groups is assigned the `untrusted` role.
- scope: The scopes required to get all the necessary information. The scope `openid` is almost **always** required. You need to add all required scopes to that the username and group claim is available.
- client id
- client secret
In your auth server settings, make sure you allow for redirecting to `/auth/oidc`. The oidc-login flow will redirect you there after you log in. Additionally, the access token expiry time from the authentication server will be used if provided. This might be fairly low by default.
Applying settings does not directly invalidate your current session. To test OIDC-settings, press the "log out" button to invalidate your current session.
#### Getting locked out
In the case of an OIDC misconfiguration, i.e. changing a setting like your client secret on your auth server, can cause you to be locked out. In these cases, you can head to `/login?backup=1`, where you can log in using your root admin credentials allowing you to correctly configure any settings.
## Alternative Deployments
Docker image is located on [dockerhub](https://hub.docker.com/r/markbeep/audiobookrequest).
@@ -78,9 +104,7 @@ services:
web:
image: markbeep/audiobookrequest:1
ports:
- "8000:8765"
environment:
ABR_APP__PORT: 8765
- "8000:8000"
volumes:
- ./config:/config
```
@@ -111,12 +135,9 @@ spec:
volumeMounts:
- mountPath: /config
name: abr-config
env:
- name: ABR_APP__PORT
value: "8765"
ports:
- name: http-request
containerPort: 8765
containerPort: 8000
volumes:
- name: abr-config
hostPath:
@@ -131,6 +152,7 @@ spec:
| `ABR_APP__DEBUG` | If to enable debug mode. Not recommended for production. | false |
| `ABR_APP__OPENAPI_ENABLED` | If set to `true`, enables an OpenAPI specs page on `/docs`. | false |
| `ABR_APP__CONFIG_DIR` | The directory path where persistant data and configuration is stored. If ran using Docker or Kubernetes, this is the location a volume should be mounted to. | /config |
| `ABR_APP__LOG_LEVEL` | One of `DEBUG`, `INFO`, `WARN`, `ERROR`. | INFO |
| `ABR_DB__SQLITE_PATH` | If relative, path and name of the sqlite database in relation to `ABR_APP__CONFIG_DIR`. If absolute (path starts with `/`), the config dir is ignored and only the absolute path is used. | db.sqlite |
---
@@ -194,3 +216,11 @@ browser-sync http://localhost:8000 --files templates/** --files app/**
```
**NOTE**: Website has to be visited at http://localhost:3000 instead.
## Docker Compose
The docker compose can also be used to run the app locally:
```bash
docker compose up --build
```

View File

@@ -0,0 +1,42 @@
"""manual requests downloaded flag
Revision ID: 873737d287d3
Revises: 76d7ccb8a116
Create Date: 2025-03-16 09:39:19.684439
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "873737d287d3"
down_revision: Union[str, None] = "76d7ccb8a116"
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! ###
with op.batch_alter_table("manualbookrequest", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"downloaded",
sa.Boolean(),
nullable=False,
server_default="false",
)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("manualbookrequest", schema=None) as batch_op:
batch_op.drop_column("downloaded")
# ### end Alembic commands ###

View File

@@ -0,0 +1,179 @@
from math import inf
import time
from typing import Annotated, Optional
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBasic, OAuth2PasswordBearer, OpenIdConnect
from sqlmodel import Session, select
from app.internal.auth.config import LoginTypeEnum, auth_config
from app.internal.models import GroupEnum, User
from app.util.db import get_session
class DetailedUser(User):
login_type: LoginTypeEnum
def can_logout(self):
return self.login_type in [LoginTypeEnum.forms, LoginTypeEnum.oidc]
def raise_for_invalid_password(
session: Session,
password: str,
confirm_password: Optional[str] = None,
ignore_confirm: bool = False,
):
if not ignore_confirm and password != confirm_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Passwords must be equal",
)
min_password_length = auth_config.get_min_password_length(session)
if not len(password) >= min_password_length:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Password must be at least {min_password_length} characters long",
)
def is_correct_password(user: User, password: str) -> bool:
try:
return ph.verify(user.password, password)
except VerifyMismatchError:
return False
def authenticate_user(session: Session, username: str, password: str) -> Optional[User]:
user = session.get(User, username)
if not user:
return None
try:
ph.verify(user.password, password)
except VerifyMismatchError:
return None
if ph.check_needs_rehash(user.password):
user.password = ph.hash(password)
session.add(user)
session.commit()
return user
def create_user(
username: str,
password: str,
group: GroupEnum = GroupEnum.untrusted,
root: bool = False,
) -> User:
password_hash = ph.hash(password)
return User(username=username, password=password_hash, group=group, root=root)
class RequiresLoginException(Exception):
def __init__(self, detail: Optional[str] = None, **kwargs: object):
super().__init__(**kwargs)
self.detail = detail
class ABRAuth:
def __init__(self):
self.oidc_scheme: Optional[OpenIdConnect] = None
self.none_user: Optional[User] = None
def get_authenticated_user(self, lowest_allowed_group: GroupEnum):
async def get_user(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> DetailedUser:
login_type = auth_config.get_login_type(session)
if login_type == LoginTypeEnum.forms:
user = await self._get_session_auth(request, session)
elif login_type == LoginTypeEnum.none:
user = await self._get_none_auth(session)
elif login_type == LoginTypeEnum.oidc:
user = await self._get_oidc_auth(request, session)
else:
user = await self._get_basic_auth(request, session)
if not user.is_above(lowest_allowed_group):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden"
)
user = DetailedUser.model_validate(user, update={"login_type": login_type})
return user
return get_user
async def _get_basic_auth(
self,
request: Request,
session: Session,
) -> User:
invalid_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
credentials = await security(request)
if not credentials:
raise invalid_exception
user = authenticate_user(session, credentials.username, credentials.password)
if not user:
raise invalid_exception
return user
async def _get_session_auth(
self,
request: Request,
session: Session,
) -> User:
# It's enough to get the username from the signed session cookie
username = request.session.get("sub")
if not username:
raise RequiresLoginException()
user = session.get(User, username)
if not user:
raise RequiresLoginException("User does not exist")
return user
async def _get_oidc_auth(
self,
request: Request,
session: Session,
) -> User:
if request.session.get("exp", inf) < time.time():
raise RequiresLoginException()
return await self._get_session_auth(request, session)
async def _get_none_auth(self, session: Session) -> User:
"""Treats every request as being root by returning the first admin user"""
if self.none_user:
return self.none_user
self.none_user = session.exec(
select(User).where(User.group == GroupEnum.admin).limit(1)
).one()
return self.none_user
security = HTTPBasic()
ph = PasswordHasher()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False)
abr_authentication = ABRAuth()
def get_authenticated_user(lowest_allowed_group: GroupEnum = GroupEnum.untrusted):
return abr_authentication.get_authenticated_user(lowest_allowed_group)

View File

@@ -0,0 +1,77 @@
import base64
import secrets
from enum import Enum
from typing import Literal
from sqlmodel import Session
from app.internal.auth.session_middleware import middleware_linker
from app.util.cache import StringConfigCache
from app.util.time import Minute, Second
class LoginTypeEnum(str, Enum):
basic = "basic"
forms = "forms"
oidc = "oidc"
none = "none"
def is_basic(self):
return self == LoginTypeEnum.basic
def is_forms(self):
return self == LoginTypeEnum.forms
def is_none(self):
return self == LoginTypeEnum.none
def is_oidc(self):
return self == LoginTypeEnum.oidc
AuthConfigKey = Literal[
"login_type",
"access_token_expiry_minutes",
"auth_secret",
"min_password_length",
]
class AuthConfig(StringConfigCache[AuthConfigKey]):
def get_login_type(self, session: Session) -> LoginTypeEnum:
login_type = self.get(session, "login_type")
if login_type:
return LoginTypeEnum(login_type)
return LoginTypeEnum.basic
def set_login_type(self, session: Session, login_Type: LoginTypeEnum):
self.set(session, "login_type", login_Type.value)
def reset_auth_secret(self, session: Session):
auth_secret = base64.encodebytes(secrets.token_bytes(64)).decode("utf-8")
middleware_linker.update_secret(auth_secret)
self.set(session, "auth_secret", auth_secret)
def get_auth_secret(self, session: Session) -> str:
auth_secret = self.get(session, "auth_secret")
if auth_secret:
return auth_secret
auth_secret = base64.encodebytes(secrets.token_bytes(64)).decode("utf-8")
self.set(session, "auth_secret", auth_secret)
return auth_secret
def get_access_token_expiry_minutes(self, session: Session) -> Minute:
return Minute(self.get_int(session, "access_token_expiry_minutes", 60 * 24 * 7))
def set_access_token_expiry_minutes(self, session: Session, expiry: Minute):
middleware_linker.update_max_age(Second(expiry * 60))
self.set_int(session, "access_token_expiry_minutes", expiry)
def get_min_password_length(self, session: Session) -> int:
return self.get_int(session, "min_password_length", 1)
def set_min_password_length(self, session: Session, min_password_length: int):
self.set_int(session, "min_password_length", min_password_length)
auth_config = AuthConfig()

View File

@@ -0,0 +1,90 @@
from typing import Literal, Optional
from aiohttp import ClientSession
from sqlmodel import Session
from app.util.cache import StringConfigCache
oidcConfigKey = Literal[
"oidc_client_id",
"oidc_client_secret",
"oidc_scope",
"oidc_username_claim",
"oidc_group_claim",
"oidc_endpoint",
"oidc_token_endpoint",
"oidc_userinfo_endpoint",
"oidc_authorize_endpoint",
"oidc_redirect_https",
"oidc_logout_url",
]
class InvalidOIDCConfiguration(Exception):
def __init__(self, detail: Optional[str] = None, **kwargs: object):
super().__init__(**kwargs)
self.detail = detail
class oidcConfig(StringConfigCache[oidcConfigKey]):
async def set_endpoint(
self,
session: Session,
client_session: ClientSession,
endpoint: str,
):
self.set(session, "oidc_endpoint", endpoint)
async with client_session.get(endpoint) as response:
if response.status == 200:
data = await response.json()
self.set(
session, "oidc_authorize_endpoint", data["authorization_endpoint"]
)
self.set(session, "oidc_token_endpoint", data["token_endpoint"])
self.set(session, "oidc_userinfo_endpoint", data["userinfo_endpoint"])
if "end_session_endpoint" in data and not self.get(
session, "oidc_logout_url"
):
self.set(session, "oidc_logout_url", data["end_session_endpoint"])
def get_redirect_https(self, session: Session) -> bool:
if self.get(session, "oidc_redirect_https"):
return True
return False
async def validate(
self, session: Session, client_session: ClientSession
) -> Optional[str]:
"""
Returns None if valid, the error message otherwise
"""
endpoint = self.get(session, "oidc_endpoint")
if not endpoint:
return "Missing OIDC endpoint"
async with client_session.get(endpoint) as response:
if not response.ok:
return "Failed to fetch OIDC configuration"
data = await response.json()
config_scope = self.get(session, "oidc_scope", "").split(" ")
provider_scope = data.get("scopes_supported")
if not provider_scope or not all(
scope in provider_scope for scope in config_scope
):
return "Scopes are not all supported by the provider"
provider_claims = data.get("claims_supported")
if not provider_claims:
return "Provider does not support or list claims"
username_claim = self.get(session, "oidc_username_claim")
if not username_claim or username_claim not in provider_claims:
return "Username claim is not supported by the provider"
group_claim = self.get(session, "oidc_group_claim")
if group_claim and group_claim not in provider_claims:
return "Group claim is not supported by the provider"
oidc_config = oidcConfig()

View File

@@ -0,0 +1,73 @@
from starlette.middleware.sessions import SessionMiddleware
from starlette.types import ASGIApp, Receive, Scope, Send
from app.util.time import Second
class DynamicSessionMiddleware:
"""
A wrapper around the Starlette SessionMiddleware with the ability to
change options during run-time
https://www.starlette.io/middleware/#sessionmiddleware
"""
def __init__(
self,
app: ASGIApp,
secret_key: str,
linker: "DynamicMiddlewareLinker",
max_age: Second = Second(60 * 60 * 24 * 14),
):
self.app = app
self.secret_key = secret_key
self.expiry = max_age
self.session_middleware = SessionMiddleware(
app,
secret_key,
same_site="strict",
max_age=max_age,
)
linker.add_middleware(self)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
return await self.session_middleware(scope, receive, send)
def update_secret(self, secret_key: str):
self.session_middleware = SessionMiddleware(
self.app,
secret_key,
same_site="strict",
max_age=self.expiry,
)
def update_max_age(self, max_age: Second):
self.session_middleware = SessionMiddleware(
self.app,
self.secret_key,
same_site="strict",
max_age=max_age,
)
class DynamicMiddlewareLinker:
"""
Linker is passed in as an argument to the DynamicSessionMiddleware so
wherever FastAPI initializes the middleware, we can update
the options to take effect immediately instead of having to restart the server
"""
middlewares: list[DynamicSessionMiddleware] = []
def add_middleware(self, middleware: DynamicSessionMiddleware):
self.middlewares.append(middleware)
def update_secret(self, secret_key: str):
for middleware in self.middlewares:
middleware.update_secret(secret_key)
def update_max_age(self, expiry: Second):
for middleware in self.middlewares:
middleware.update_max_age(expiry)
middleware_linker = DynamicMiddlewareLinker()

View File

@@ -13,6 +13,8 @@ class ApplicationSettings(BaseModel):
openapi_enabled: bool = False
config_dir: str = "/config"
port: int = 8000
version: str = "local"
log_level: str = "INFO"
class Settings(BaseSettings):
@@ -20,7 +22,7 @@ class Settings(BaseSettings):
env_prefix="ABR_",
env_nested_delimiter="__",
nested_model_default_partial_update=True,
env_file=".env.local",
env_file=(".env.local", ".env"),
)
db: DBSettings = DBSettings()

View File

@@ -25,6 +25,12 @@ class User(BaseModel, table=True):
sa_column_kwargs={"server_default": "untrusted"},
)
root: bool = False
# TODO: Add last_login
# last_login: datetime = Field(
# default_factory=datetime.now, sa_column_kwargs={"server_default": "now()"}
# )
"""
untrusted: Requests need to be manually reviewed
trusted: Requests are automatically downloaded if possible
@@ -72,9 +78,13 @@ class BookSearchResult(BaseBook):
class BookWishlistResult(BaseBook):
amount_requested: int = 0
requested_by: list[str] = []
download_error: Optional[str] = None
@property
def amount_requested(self):
return len(self.requested_by)
class BookRequest(BaseBook, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
@@ -117,6 +127,7 @@ class ManualBookRequest(BaseModel, table=True):
nullable=False,
),
)
downloaded: bool = False
class Config: # pyright: ignore[reportIncompatibleVariableOverride]
arbitrary_types_allowed = True
@@ -131,7 +142,7 @@ class BaseSource(BaseModel):
narrators: list[str] = Field(default_factory=list, sa_column=Column(JSON))
size: int # in bytes
publish_date: datetime
info_url: str
info_url: Optional[str]
indexer_flags: list[str]
download_url: Optional[str] = None
magnet_url: Optional[str] = None
@@ -169,14 +180,6 @@ class Config(BaseModel, table=True):
value: str
# TODO: add logs
class Log(BaseModel):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_username: str
message: str
timestamp: datetime = Field(default_factory=datetime.now)
class EventEnum(str, Enum):
on_new_request = "onNewRequest"
on_successful_download = "onSuccessfulDownload"

View File

@@ -1,3 +1,4 @@
import logging
from typing import Optional
from aiohttp import ClientSession
@@ -6,6 +7,8 @@ from sqlmodel import Session, select
from app.internal.models import BookRequest, EventEnum, ManualBookRequest, Notification
from app.util.db import open_session
logger = logging.getLogger(__name__)
def replace_variables(
title_template: str,
@@ -137,5 +140,5 @@ async def send_manual_notification(
response.raise_for_status()
return await response.json()
except Exception as e:
print("Failed to send notification", e)
logger.error("Failed to send notification", e)
return None

View File

@@ -1,8 +1,9 @@
import json
import logging
from datetime import datetime
import posixpath
from typing import Any, Literal, Optional
from urllib.parse import urlencode, urljoin
from urllib.parse import urlencode
from aiohttp import ClientResponse, ClientSession
from sqlmodel import Session
@@ -96,7 +97,7 @@ async def start_download(
api_key = prowlarr_config.get_api_key(session)
assert base_url is not None and api_key is not None
url = urljoin(base_url, "/api/v1/search")
url = posixpath.join(base_url, "api/v1/search")
logger.debug("Starting download for %s", guid)
async with client_session.post(
url,
@@ -104,7 +105,6 @@ async def start_download(
headers={"X-Api-Key": api_key},
) as response:
if not response.ok:
print(response)
logger.error("Failed to start download for %s: %s", guid, response)
await send_all_notifications(
EventEnum.on_failed_download,
@@ -157,7 +157,7 @@ async def query_prowlarr(
if indexer_ids is not None:
params["indexerIds"] = indexer_ids
url = urljoin(base_url, f"/api/v1/search?{urlencode(params, doseq=True)}")
url = posixpath.join(base_url, f"api/v1/search?{urlencode(params, doseq=True)}")
logger.info("Querying prowlarr: %s", url)
@@ -169,44 +169,53 @@ async def query_prowlarr(
sources: list[ProwlarrSource] = []
for result in search_results:
if result["protocol"] not in ["torrent", "usenet"]:
print("Skipping source with unknown protocol", result["protocol"])
continue
if result["protocol"] == "torrent":
sources.append(
TorrentSource(
protocol="torrent",
guid=result["guid"],
indexer_id=result["indexerId"],
indexer=result["indexer"],
title=result["title"],
seeders=result.get("seeders", 0),
leechers=result.get("leechers", 0),
size=result.get("size", 0),
info_url=result["infoUrl"],
indexer_flags=[x.lower() for x in result.get("indexerFlags", [])],
download_url=result.get("downloadUrl"),
magnet_url=result.get("magnetUrl"),
publish_date=datetime.fromisoformat(result["publishDate"]),
try:
if result["protocol"] not in ["torrent", "usenet"]:
logger.info(
"Skipping source with unknown protocol %s", result["protocol"]
)
)
else:
sources.append(
UsenetSource(
protocol="usenet",
guid=result["guid"],
indexer_id=result["indexerId"],
indexer=result["indexer"],
title=result["title"],
grabs=result.get("grabs"),
size=result.get("size", 0),
info_url=result["infoUrl"],
indexer_flags=[x.lower() for x in result.get("indexerFlags", [])],
download_url=result.get("downloadUrl"),
magnet_url=result.get("magnetUrl"),
publish_date=datetime.fromisoformat(result["publishDate"]),
continue
if result["protocol"] == "torrent":
sources.append(
TorrentSource(
protocol="torrent",
guid=result["guid"],
indexer_id=result["indexerId"],
indexer=result["indexer"],
title=result["title"],
seeders=result.get("seeders", 0),
leechers=result.get("leechers", 0),
size=result.get("size", 0),
info_url=result.get("infoUrl"),
indexer_flags=[
x.lower() for x in result.get("indexerFlags", [])
],
download_url=result.get("downloadUrl"),
magnet_url=result.get("magnetUrl"),
publish_date=datetime.fromisoformat(result["publishDate"]),
)
)
)
else:
sources.append(
UsenetSource(
protocol="usenet",
guid=result["guid"],
indexer_id=result["indexerId"],
indexer=result["indexer"],
title=result["title"],
grabs=result.get("grabs"),
size=result.get("size", 0),
info_url=result.get("infoUrl"),
indexer_flags=[
x.lower() for x in result.get("indexerFlags", [])
],
download_url=result.get("downloadUrl"),
magnet_url=result.get("magnetUrl"),
publish_date=datetime.fromisoformat(result["publishDate"]),
)
)
except KeyError as e:
logger.error("Failed to parse source: %s. KeyError: %s", result, e)
prowlarr_source_cache.set(sources, query)

View File

@@ -1,28 +1,56 @@
import logging
from pathlib import Path
from typing import Any
from urllib.parse import quote_plus
from urllib.parse import quote_plus, urlencode
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware import Middleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse
from sqlalchemy import func
from sqlmodel import select
from app.internal.auth.authentication import RequiresLoginException, auth_config
from app.internal.auth.oidc_config import InvalidOIDCConfiguration
from app.internal.auth.session_middleware import (
DynamicSessionMiddleware,
middleware_linker,
)
from app.internal.env_settings import Settings
from app.internal.models import User
from app.routers import root, search, settings, wishlist
from app.util.auth import RequiresLoginException
from app.routers import auth, root, search, settings, wishlist
from app.util.db import open_session
from app.util.templates import templates
from app.util.toast import ToastException
logger = logging.getLogger(__name__)
logging.getLogger("uvicorn").handlers.clear()
file_handler = logging.FileHandler(Settings().app.config_dir / Path("abr.log"))
stream_handler = logging.StreamHandler()
logging.basicConfig(
level=Settings().app.log_level,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[file_handler, stream_handler],
)
with open_session() as session:
auth_secret = auth_config.get_auth_secret(session)
app = FastAPI(
title="AudioBookRequest",
debug=Settings().app.debug,
openapi_url="/openapi.json" if Settings().app.openapi_enabled else None,
middleware=[
Middleware(DynamicSessionMiddleware, auth_secret, middleware_linker),
Middleware(GZipMiddleware),
],
)
app.include_router(auth.router)
app.include_router(root.router)
app.include_router(search.router)
app.include_router(wishlist.router)
app.include_router(settings.router)
app.include_router(wishlist.router)
user_exists = False
@@ -30,9 +58,13 @@ user_exists = False
@app.exception_handler(RequiresLoginException)
async def redirect_to_login(request: Request, exc: RequiresLoginException):
if request.method == "GET":
params: dict[str, str] = {}
if exc.detail:
return RedirectResponse(f"/login?error={quote_plus(exc.detail)}")
return RedirectResponse("/login")
params["error"] = exc.detail
path = request.url.path
if path != "/" and not path.startswith("/login"):
params["redirect_uri"] = path
return RedirectResponse("/login?" + urlencode(params))
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -41,6 +73,32 @@ async def redirect_to_login(request: Request, exc: RequiresLoginException):
)
@app.exception_handler(InvalidOIDCConfiguration)
async def redirect_to_invalid_oidc(request: Request, exc: InvalidOIDCConfiguration):
path = "/auth/invalid-oidc"
if exc.detail:
path += f"?error={quote_plus(exc.detail)}"
return RedirectResponse(path)
@app.exception_handler(ToastException)
async def raise_toast(request: Request, exc: ToastException):
context: dict[str, Request | str] = {"request": request}
if exc.type == "error":
context["toast_error"] = exc.message
elif exc.type == "success":
context["toast_success"] = exc.message
elif exc.type == "info":
context["toast_info"] = exc.message
return templates.TemplateResponse(
"base.html",
context,
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
@app.middleware("http")
async def redirect_to_init(request: Request, call_next: Any):
"""

279
app/routers/auth.py Normal file
View File

@@ -0,0 +1,279 @@
import base64
import logging
import secrets
import time
from typing import Annotated, Optional
from urllib.parse import urlencode, urljoin
import jwt
from aiohttp import ClientSession
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from app.internal.auth.authentication import (
DetailedUser,
RequiresLoginException,
authenticate_user,
create_user,
get_authenticated_user,
)
from app.internal.auth.config import LoginTypeEnum, auth_config
from app.internal.auth.oidc_config import InvalidOIDCConfiguration, oidc_config
from app.internal.models import GroupEnum, User
from app.util.connection import get_connection
from app.util.db import get_session
from app.util.templates import templates
from app.util.toast import ToastException
router = APIRouter(prefix="/auth")
logger = logging.getLogger(__name__)
@router.get("/login")
async def login(
request: Request,
session: Annotated[Session, Depends(get_session)],
redirect_uri: str = "/",
backup: bool = False,
):
login_type = auth_config.get(session, "login_type")
if login_type in [LoginTypeEnum.basic, LoginTypeEnum.none]:
return RedirectResponse(redirect_uri)
if login_type != LoginTypeEnum.oidc and backup:
backup = False
try:
await get_authenticated_user()(request, session)
# already logged in
return RedirectResponse(redirect_uri)
except (HTTPException, RequiresLoginException):
pass
if login_type != LoginTypeEnum.oidc or backup:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"hide_navbar": True,
"redirect_uri": redirect_uri,
"backup": backup,
},
)
authorize_endpoint = oidc_config.get(session, "oidc_authorize_endpoint")
client_id = oidc_config.get(session, "oidc_client_id")
scope = oidc_config.get(session, "oidc_scope") or "openid"
if not authorize_endpoint:
raise InvalidOIDCConfiguration("Missing OIDC endpoint")
if not client_id:
raise InvalidOIDCConfiguration("Missing OIDC client ID")
auth_redirect_uri = urljoin(str(request.url), "/auth/oidc")
if oidc_config.get_redirect_https(session):
auth_redirect_uri = auth_redirect_uri.replace("http:", "https:")
logger.info(f"Redirecting to OIDC login: {authorize_endpoint}")
logger.info(f"Redirect URI: {auth_redirect_uri}")
state = jwt.encode( # pyright: ignore[reportUnknownMemberType]
{"redirect_uri": redirect_uri},
auth_config.get_auth_secret(session),
algorithm="HS256",
)
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": auth_redirect_uri,
"scope": scope,
"state": state,
}
return RedirectResponse(f"{authorize_endpoint}?" + urlencode(params))
@router.post("/logout")
async def logout(
request: Request,
user: Annotated[DetailedUser, Depends(get_authenticated_user())],
session: Annotated[Session, Depends(get_session)],
):
request.session["sub"] = ""
login_type = auth_config.get_login_type(session)
if login_type == LoginTypeEnum.oidc:
logout_url = oidc_config.get(session, "oidc_logout_url")
if logout_url:
return Response(
status_code=status.HTTP_204_NO_CONTENT,
headers={"HX-Redirect": logout_url},
)
return Response(
status_code=status.HTTP_204_NO_CONTENT, headers={"HX-Redirect": "/login"}
)
@router.post("/token")
def login_access_token(
request: Request,
session: Annotated[Session, Depends(get_session)],
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
redirect_uri: str = Form("/"),
):
user = authenticate_user(session, form_data.username, form_data.password)
if not user:
raise ToastException("Invalid login", "error")
# only admins can use the backup forms login
login_type = auth_config.get_login_type(session)
if login_type == LoginTypeEnum.oidc and not user.root:
raise ToastException("Not root admin", "error")
request.session["sub"] = form_data.username
return Response(
status_code=status.HTTP_200_OK, headers={"HX-Redirect": redirect_uri}
)
@router.get("/oidc")
async def login_oidc(
request: Request,
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
code: str,
state: Optional[str] = None,
):
token_endpoint = oidc_config.get(session, "oidc_token_endpoint")
userinfo_endpoint = oidc_config.get(session, "oidc_userinfo_endpoint")
client_id = oidc_config.get(session, "oidc_client_id")
client_secret = oidc_config.get(session, "oidc_client_secret")
username_claim = oidc_config.get(session, "oidc_username_claim")
group_claim = oidc_config.get(session, "oidc_group_claim")
if not token_endpoint:
raise InvalidOIDCConfiguration("Missing OIDC endpoint")
if not userinfo_endpoint:
raise InvalidOIDCConfiguration("Missing OIDC userinfo endpoint")
if not client_id:
raise InvalidOIDCConfiguration("Missing OIDC client ID")
if not client_secret:
raise InvalidOIDCConfiguration("Missing OIDC client secret")
if not username_claim:
raise InvalidOIDCConfiguration("Missing OIDC username claim")
auth_redirect_uri = urljoin(str(request.url), "/auth/oidc")
if oidc_config.get_redirect_https(session):
auth_redirect_uri = auth_redirect_uri.replace("http:", "https:")
data = {
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": auth_redirect_uri,
}
async with client_session.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
) as response:
body = await response.json()
access_token: Optional[str] = body.get("access_token")
if not access_token:
return Response(status_code=status.HTTP_401_UNAUTHORIZED)
async with client_session.get(
userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
) as response:
userinfo = await response.json()
username = userinfo.get(username_claim)
if not username:
raise InvalidOIDCConfiguration("Missing username claim")
if group_claim:
groups: list[str] | str = userinfo.get(group_claim, [])
if isinstance(groups, str):
groups = groups.split(" ")
else:
groups = []
user = session.exec(select(User).where(User.username == username)).first()
if not user:
user = create_user(
username=username,
# assign a random password to users created via OIDC
password=base64.encodebytes(secrets.token_bytes(64)).decode("utf-8"),
)
# Don't overwrite the group if the user is root admin
if not user.root:
for group in groups:
if group.lower() == "admin":
user.group = GroupEnum.admin
break
elif group.lower() == "trusted":
user.group = GroupEnum.trusted
break
elif group.lower() == "untrusted":
user.group = GroupEnum.untrusted
break
session.add(user)
session.commit()
expires_in: int = body.get(
"expires_in",
auth_config.get_access_token_expiry_minutes(session) * 60,
)
expires = int(time.time() + expires_in)
request.session["sub"] = username
request.session["exp"] = expires
if state:
decoded = jwt.decode( # pyright: ignore[reportUnknownMemberType]
state,
auth_config.get_auth_secret(session),
algorithms=["HS256"],
)
redirect_uri = decoded.get("redirect_uri", "/")
else:
redirect_uri = "/"
# We can't redirect server side, because that results in an infinite loop.
# The session token is never correctly set causing any other endpoint to
# redirect to the login page which in turn starts the OIDC flow again.
# The redirect page allows for the cookie to properly be set on the browser
# and then redirects client-side.
return templates.TemplateResponse(
"redirect.html",
{
"request": request,
"hide_navbar": True,
"redirect_uri": redirect_uri,
},
)
@router.get("/invalid-oidc")
def invalid_oidc(
request: Request,
session: Annotated[Session, Depends(get_session)],
error: Optional[str] = None,
):
if auth_config.get_login_type(session) != LoginTypeEnum.oidc:
return Response(status_code=status.HTTP_404_NOT_FOUND)
return templates.TemplateResponse(
"invalid_oidc.html",
{
"request": request,
"error": error,
"hide_navbar": True,
},
status_code=status.HTTP_200_OK,
)

View File

@@ -1,23 +1,18 @@
from datetime import timedelta
from typing import Annotated, Optional
from typing import Annotated
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session
from app.internal.models import GroupEnum
from app.util.auth import (
from app.internal.auth.config import LoginTypeEnum, auth_config
from app.internal.auth.authentication import (
DetailedUser,
LoginTypeEnum,
RequiresLoginException,
auth_config,
authenticate_user,
create_access_token,
create_user,
get_authenticated_user,
raise_for_invalid_password,
)
from app.internal.models import GroupEnum
from app.util.db import get_session
from app.util.templates import templates
@@ -120,65 +115,5 @@ def create_init(
@router.get("/login")
async def login(
request: Request,
session: Annotated[Session, Depends(get_session)],
error: Optional[str] = None,
):
login_type = auth_config.get(session, "login_type")
if login_type != LoginTypeEnum.forms:
return RedirectResponse("/")
try:
await get_authenticated_user()(request, session)
# already logged in
return RedirectResponse("/")
except (HTTPException, RequiresLoginException):
pass
return templates.TemplateResponse(
"login.html",
{"request": request, "hide_navbar": True, "error": error},
)
@router.post("/auth/logout")
def logout(user: Annotated[DetailedUser, Depends(get_authenticated_user())]):
return Response(
status_code=status.HTTP_204_NO_CONTENT,
headers={
"Set-Cookie": "audio_sess=; Path=/; SameSite=Strict; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
"HX-Redirect": "/login",
},
)
@router.post("/auth/token")
def login_access_token(
request: Request,
session: Annotated[Session, Depends(get_session)],
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
):
user = authenticate_user(session, form_data.username, form_data.password)
if not user:
return templates.TemplateResponse(
"login.html",
{"request": request, "hide_navbar": True, "error": "Invalid login"},
block_name="error_toast",
)
access_token_expires_minues = auth_config.get_access_token_expiry_minutes(session)
access_token_exires = timedelta(minutes=access_token_expires_minues)
access_token = create_access_token(
auth_config.get_auth_secret(session),
{"sub": form_data.username},
access_token_exires,
)
return Response(
status_code=status.HTTP_200_OK,
headers={
"HX-Redirect": "/",
"Set-Cookie": f"audio_sess={access_token}; Path=/; SameSite=Strict; HttpOnly; ",
},
)
def redirect_login(request: Request):
return RedirectResponse("/auth/login?" + urlencode(request.query_params))

View File

@@ -36,7 +36,7 @@ from app.internal.prowlarr.prowlarr import prowlarr_config
from app.internal.query import query_sources
from app.internal.ranking.quality import quality_config
from app.routers.wishlist import get_wishlist_books
from app.util.auth import DetailedUser, get_authenticated_user
from app.internal.auth.authentication import DetailedUser, get_authenticated_user
from app.util.connection import get_connection
from app.util.db import get_session, open_session
from app.util.templates import template_response
@@ -170,12 +170,6 @@ async def add_request(
except sa.exc.IntegrityError:
pass # ignore if already exists
if quality_config.get_auto_download(session) and user.is_above(GroupEnum.trusted):
# start querying and downloading if auto download is enabled
background_task.add_task(
background_start_query, asin=asin, requester_username=user.username
)
background_task.add_task(
send_all_notifications,
event_type=EventEnum.on_new_request,
@@ -183,6 +177,12 @@ async def add_request(
book_asin=asin,
)
if quality_config.get_auto_download(session) and user.is_above(GroupEnum.trusted):
# start querying and downloading if auto download is enabled
background_task.add_task(
background_start_query, asin=asin, requester_username=user.username
)
if audible_regions.get(region) is None:
raise HTTPException(status_code=400, detail="Invalid region")
if query:

View File

@@ -6,25 +6,28 @@ from aiohttp import ClientResponseError, ClientSession
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
from sqlmodel import Session, select
from app.internal.models import EventEnum, GroupEnum, Notification, User
from app.internal.prowlarr.indexer_categories import indexer_categories
from app.internal.notifications import send_notification
from app.internal.prowlarr.prowlarr import flush_prowlarr_cache, prowlarr_config
from app.internal.indexers.mam import mam_config
from app.internal.ranking.quality import IndexerFlag, QualityRange, quality_config
from app.util.auth import (
from app.internal.auth.authentication import (
DetailedUser,
LoginTypeEnum,
auth_config,
create_user,
get_authenticated_user,
is_correct_password,
raise_for_invalid_password,
)
from app.internal.auth.config import LoginTypeEnum, auth_config
from app.internal.auth.oidc_config import oidc_config
from app.internal.env_settings import Settings
from app.internal.models import EventEnum, GroupEnum, Notification, User
from app.internal.notifications import send_notification
from app.internal.prowlarr.indexer_categories import indexer_categories
from app.internal.prowlarr.prowlarr import flush_prowlarr_cache, prowlarr_config
from app.internal.indexers.mam import mam_config
from app.internal.ranking.quality import IndexerFlag, QualityRange, quality_config
from app.util.connection import get_connection
from app.util.db import get_session
from app.util.templates import template_response
from app.util.time import Minute
from app.util.toast import ToastException
router = APIRouter(prefix="/settings")
@@ -35,7 +38,10 @@ def read_account(
user: Annotated[DetailedUser, Depends(get_authenticated_user())],
):
return template_response(
"settings_page/account.html", request, user, {"page": "account"}
"settings_page/account.html",
request,
user,
{"page": "account", "version": Settings().app.version},
)
@@ -49,25 +55,11 @@ def change_password(
user: Annotated[DetailedUser, Depends(get_authenticated_user())],
):
if not is_correct_password(user, old_password):
return template_response(
"settings_page/account.html",
request,
user,
{"page": "account", "error": "Old password is incorrect"},
block_name="error",
headers={"HX-Retarget": "#error"},
)
raise ToastException("Old password is incorrect", "error")
try:
raise_for_invalid_password(session, password, confirm_password)
except HTTPException as e:
return template_response(
"settings_page/account.html",
request,
user,
{"page": "account", "error": e.detail},
block_name="error",
headers={"HX-Retarget": "#error"},
)
raise ToastException(e.detail, "error")
new_user = create_user(user.username, password, user.group)
old_user = session.exec(select(User).where(User.username == user.username)).one()
@@ -93,11 +85,17 @@ def read_users(
session: Annotated[Session, Depends(get_session)],
):
users = session.exec(select(User)).all()
is_oidc = auth_config.get_login_type(session) == LoginTypeEnum.oidc
return template_response(
"settings_page/users.html",
request,
admin_user,
{"page": "users", "users": users},
{
"page": "users",
"users": users,
"is_oidc": is_oidc,
"version": Settings().app.version,
},
)
@@ -113,49 +111,21 @@ def create_new_user(
],
):
if username.strip() == "":
return template_response(
"settings_page/users.html",
request,
admin_user,
{"error": "Invalid username"},
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
raise ToastException("Invalid username", "error")
try:
raise_for_invalid_password(session, password, ignore_confirm=True)
except HTTPException as e:
return template_response(
"settings_page/users.html",
request,
admin_user,
{"error": e.detail},
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
raise ToastException(e.detail, "error")
if group not in GroupEnum.__members__:
return template_response(
"settings_page/users.html",
request,
admin_user,
{"error": "Invalid group selected"},
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
raise ToastException("Invalid group selected", "error")
group = GroupEnum[group]
user = session.exec(select(User).where(User.username == username)).first()
if user:
return template_response(
"settings_page/users.html",
request,
admin_user,
{"error": "Username already exists"},
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
raise ToastException("Username already exists", "error")
user = create_user(username, password, group)
session.add(user)
@@ -182,26 +152,11 @@ def delete_user(
],
):
if username == admin_user.username:
users = session.exec(select(User)).all()
return template_response(
"settings_page/users.html",
request,
admin_user,
{"error": "Cannot delete own user"},
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
raise ToastException("Cannot delete own user", "error")
user = session.exec(select(User).where(User.username == username)).one_or_none()
if user and user.root:
return template_response(
"settings_page/users.html",
request,
admin_user,
{"error": "Cannot delete root user"},
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
raise ToastException("Cannot delete root user", "error")
if user:
session.delete(user)
@@ -230,14 +185,7 @@ def update_user(
):
user = session.exec(select(User).where(User.username == username)).one_or_none()
if user and user.root:
return template_response(
"settings_page/users.html",
request,
admin_user,
{"error": "Cannot change root user"},
block_name="toast_block",
headers={"HX-Retarget": "#toast-block"},
)
raise ToastException("Cannot change root user's group", "error")
if user:
user.group = group
@@ -280,6 +228,7 @@ def read_prowlarr(
"indexer_categories": indexer_categories,
"selected_categories": selected,
"prowlarr_misconfigured": True if prowlarr_misconfigured else False,
"version": Settings().app.version,
"mam_active": mam_is_active,
"mam_id": mam_id,
},
@@ -405,6 +354,7 @@ def read_download(
"name_ratio": name_ratio,
"title_ratio": title_ratio,
"indexer_flags": flags,
"version": Settings().app.version,
},
)
@@ -514,7 +464,6 @@ def remove_indexer_flag(
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
],
):
# TODO: very bad concurrency here
flags = quality_config.get_indexer_flags(session)
flags = [f for f in flags if f.flag != flag]
quality_config.set_indexer_flags(session, flags)
@@ -545,6 +494,7 @@ def read_notifications(
"page": "notifications",
"notifications": notifications,
"event_types": event_types,
"version": Settings().app.version,
},
)
@@ -649,7 +599,6 @@ async def execute_notification(
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
):
notification = session.exec(
select(Notification).where(Notification.id == notification_id)
@@ -682,6 +631,15 @@ def read_security(
"login_type": auth_config.get_login_type(session),
"access_token_expiry": auth_config.get_access_token_expiry_minutes(session),
"min_password_length": auth_config.get_min_password_length(session),
"oidc_endpoint": oidc_config.get(session, "oidc_endpoint", ""),
"oidc_client_secret": oidc_config.get(session, "oidc_client_secret", ""),
"oidc_client_id": oidc_config.get(session, "oidc_client_id", ""),
"oidc_scope": oidc_config.get(session, "oidc_scope", ""),
"oidc_username_claim": oidc_config.get(session, "oidc_username_claim", ""),
"oidc_group_claim": oidc_config.get(session, "oidc_group_claim", ""),
"oidc_redirect_https": oidc_config.get_redirect_https(session),
"oidc_logout_url": oidc_config.get(session, "oidc_logout_url", ""),
"version": Settings().app.version,
},
)
@@ -698,40 +656,72 @@ def reset_auth_secret(
@router.post("/security")
def update_security(
async def update_security(
login_type: Annotated[LoginTypeEnum, Form()],
access_token_expiry: Annotated[int, Form()],
min_password_length: Annotated[int, Form()],
request: Request,
admin_user: Annotated[
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
],
session: Annotated[Session, Depends(get_session)],
client_session: Annotated[ClientSession, Depends(get_connection)],
access_token_expiry: Optional[int] = Form(None),
min_password_length: Optional[int] = Form(None),
oidc_endpoint: Optional[str] = Form(None),
oidc_client_id: Optional[str] = Form(None),
oidc_client_secret: Optional[str] = Form(None),
oidc_scope: Optional[str] = Form(None),
oidc_username_claim: Optional[str] = Form(None),
oidc_group_claim: Optional[str] = Form(None),
oidc_redirect_https: Optional[bool] = Form(False),
oidc_logout_url: Optional[str] = Form(None),
):
if access_token_expiry < 1:
return template_response(
"settings_page/security.html",
request,
admin_user,
{"error": "Access token expiry can't be 0 or negative"},
block_name="error_toast",
headers={"HX-Retarget": "#message"},
)
if (
login_type in [LoginTypeEnum.basic, LoginTypeEnum.forms]
and min_password_length is not None
):
if min_password_length < 1:
raise ToastException(
"Minimum password length can't be 0 or negative", "error"
)
else:
auth_config.set_min_password_length(session, min_password_length)
if min_password_length < 1:
return template_response(
"settings_page/security.html",
request,
admin_user,
{"error": "Minimum password length can't be 0 or negative"},
block_name="error_toast",
headers={"HX-Retarget": "#message"},
)
if access_token_expiry is not None:
if access_token_expiry < 1:
raise ToastException("Access token expiry can't be 0 or negative", "error")
else:
auth_config.set_access_token_expiry_minutes(
session, Minute(access_token_expiry)
)
if login_type == LoginTypeEnum.oidc:
if oidc_endpoint:
await oidc_config.set_endpoint(session, client_session, oidc_endpoint)
if oidc_client_id:
oidc_config.set(session, "oidc_client_id", oidc_client_id)
if oidc_client_secret:
oidc_config.set(session, "oidc_client_secret", oidc_client_secret)
if oidc_scope:
oidc_config.set(session, "oidc_scope", oidc_scope)
if oidc_username_claim:
oidc_config.set(session, "oidc_username_claim", oidc_username_claim)
if oidc_redirect_https is not None:
oidc_config.set(
session,
"oidc_redirect_https",
"true" if oidc_redirect_https else "",
)
if oidc_logout_url:
oidc_config.set(session, "oidc_logout_url", oidc_logout_url)
if oidc_group_claim is not None:
oidc_config.set(session, "oidc_group_claim", oidc_group_claim)
error_message = await oidc_config.validate(session, client_session)
if error_message:
raise ToastException(error_message, "error")
old = auth_config.get_login_type(session)
auth_config.set_login_type(session, login_type)
auth_config.set_access_token_expiry_minutes(session, access_token_expiry)
auth_config.set_min_password_length(session, min_password_length)
return template_response(
"settings_page/security.html",
request,
@@ -740,6 +730,14 @@ def update_security(
"page": "security",
"login_type": auth_config.get_login_type(session),
"access_token_expiry": auth_config.get_access_token_expiry_minutes(session),
"oidc_client_id": oidc_config.get(session, "oidc_client_id", ""),
"oidc_scope": oidc_config.get(session, "oidc_scope", ""),
"oidc_username_claim": oidc_config.get(session, "oidc_username_claim", ""),
"oidc_group_claim": oidc_config.get(session, "oidc_group_claim", ""),
"oidc_client_secret": oidc_config.get(session, "oidc_client_secret", ""),
"oidc_endpoint": oidc_config.get(session, "oidc_endpoint", ""),
"oidc_redirect_https": oidc_config.get_redirect_https(session),
"oidc_logout_url": oidc_config.get(session, "oidc_logout_url", ""),
"success": "Settings updated",
},
block_name="form",

View File

@@ -1,3 +1,4 @@
from collections import defaultdict
import uuid
from typing import Annotated, Literal, Optional
@@ -12,8 +13,7 @@ from fastapi import (
Response,
)
from fastapi.responses import RedirectResponse
from sqlalchemy import func
from sqlmodel import Session, col, select
from sqlmodel import Session, asc, col, select
from app.internal.models import (
BookRequest,
@@ -28,8 +28,7 @@ from app.internal.prowlarr.prowlarr import (
)
from app.internal.indexers.mam import mam_config
from app.internal.query import query_sources
from app.internal.ranking.quality import quality_config
from app.util.auth import DetailedUser, get_authenticated_user
from app.internal.auth.authentication import DetailedUser, get_authenticated_user
from app.util.connection import get_connection
from app.util.db import get_session, open_session
from app.util.templates import template_response
@@ -42,23 +41,32 @@ def get_wishlist_books(
username: Optional[str] = None,
response_type: Literal["all", "downloaded", "not_downloaded"] = "all",
) -> list[BookWishlistResult]:
query = select(
BookRequest, func.count(col(BookRequest.user_username)).label("count")
)
"""
Gets the books that have been requested. If a username is given only the books requested by that
user are returned. If no username is given, all book requests are returned.
"""
if username:
query = query.where(BookRequest.user_username == username)
query = select(BookRequest).where(BookRequest.user_username == username)
else:
query = query.where(col(BookRequest.user_username).is_not(None))
query = select(BookRequest).where(col(BookRequest.user_username).is_not(None))
book_requests = session.exec(
query.select_from(BookRequest).group_by(BookRequest.asin)
).all()
book_requests = session.exec(query).all()
# group by asin and aggregate all usernames
usernames: dict[str, list[str]] = defaultdict(list)
distinct_books: dict[str, BookRequest] = {}
for book in book_requests:
if book.asin not in distinct_books:
distinct_books[book.asin] = book
if book.user_username:
usernames[book.asin].append(book.user_username)
# add information of what users requested the book
books: list[BookWishlistResult] = []
downloaded: list[BookWishlistResult] = []
for book, count in book_requests:
for asin, book in distinct_books.items():
b = BookWishlistResult.model_validate(book)
b.amount_requested = count
b.requested_by = usernames[asin]
if b.downloaded:
downloaded.append(b)
else:
@@ -103,23 +111,73 @@ async def downloaded(
)
@router.patch("/downloaded/{asin}")
async def update_downloaded(
request: Request,
asin: str,
admin_user: Annotated[
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
],
session: Annotated[Session, Depends(get_session)],
):
books = session.exec(select(BookRequest).where(BookRequest.asin == asin)).all()
for book in books:
book.downloaded = True
session.add(book)
session.commit()
username = None if admin_user.is_admin() else admin_user.username
books = get_wishlist_books(session, username, "not_downloaded")
return template_response(
"wishlist_page/wishlist.html",
request,
admin_user,
{"books": books, "page": "wishlist"},
block_name="book_wishlist",
)
@router.get("/manual")
async def manual(
request: Request,
user: Annotated[DetailedUser, Depends(get_authenticated_user())],
session: Annotated[Session, Depends(get_session)],
):
books = session.exec(select(ManualBookRequest)).all()
auto_download = quality_config.get_auto_download(session)
books = session.exec(
select(ManualBookRequest).order_by(asc(ManualBookRequest.downloaded))
).all()
return template_response(
"wishlist_page/manual.html",
request,
user,
{
"books": books,
"page": "manual",
"auto_download": auto_download,
},
{"books": books, "page": "manual"},
)
@router.patch("/manual/{id}")
async def downloaded_manual(
request: Request,
id: uuid.UUID,
admin_user: Annotated[
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
],
session: Annotated[Session, Depends(get_session)],
):
book = session.get(ManualBookRequest, id)
if book:
book.downloaded = True
session.add(book)
session.commit()
books = session.exec(
select(ManualBookRequest).order_by(asc(ManualBookRequest.downloaded))
).all()
return template_response(
"wishlist_page/manual.html",
request,
admin_user,
{"books": books, "page": "manual"},
block_name="book_wishlist",
)
@@ -138,12 +196,11 @@ async def delete_manual(
session.commit()
books = session.exec(select(ManualBookRequest)).all()
auto_download = quality_config.get_auto_download(session)
return template_response(
"wishlist_page/manual.html",
request,
admin_user,
{"books": books, "page": "manual", "auto_download": auto_download},
{"books": books, "page": "manual"},
block_name="book_wishlist",
)

View File

@@ -1,242 +0,0 @@
import base64
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Annotated, Literal, Optional
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBasic, OAuth2PasswordBearer
from sqlmodel import Session
from app.internal.models import GroupEnum, User
from app.util.cache import StringConfigCache
from app.util.db import get_session
JWT_ALGORITHM = "HS256"
class LoginTypeEnum(str, Enum):
basic = "basic"
forms = "forms"
none = "none"
def is_basic(self):
return self == LoginTypeEnum.basic
def is_forms(self):
return self == LoginTypeEnum.forms
def is_none(self):
return self == LoginTypeEnum.none
AuthConfigKey = Literal[
"login_type",
"access_token_expiry_minutes",
"auth_secret",
"min_password_length",
]
class AuthConfig(StringConfigCache[AuthConfigKey]):
def get_login_type(self, session: Session) -> LoginTypeEnum:
login_type = self.get(session, "login_type")
if login_type:
return LoginTypeEnum(login_type)
return LoginTypeEnum.basic
def set_login_type(self, session: Session, login_Type: LoginTypeEnum):
self.set(session, "login_type", login_Type.value)
def reset_auth_secret(self, session: Session):
auth_secret = base64.encodebytes(secrets.token_bytes(64)).decode("utf-8")
self.set(session, "auth_secret", auth_secret)
def get_auth_secret(self, session: Session) -> str:
auth_secret = self.get(session, "auth_secret")
if auth_secret:
return auth_secret
auth_secret = base64.encodebytes(secrets.token_bytes(64)).decode("utf-8")
self.set(session, "auth_secret", auth_secret)
return auth_secret
def get_access_token_expiry_minutes(self, session: Session):
return self.get_int(session, "access_token_expiry_minutes", 60 * 24 * 7)
def set_access_token_expiry_minutes(self, session: Session, expiry: int):
self.set_int(session, "access_token_expiry_minutes", expiry)
def get_min_password_length(self, session: Session) -> int:
return self.get_int(session, "min_password_length", 1)
def set_min_password_length(self, session: Session, min_password_length: int):
self.set_int(session, "min_password_length", min_password_length)
class DetailedUser(User):
login_type: LoginTypeEnum
def can_logout(self):
return self.login_type == LoginTypeEnum.forms
security = HTTPBasic()
ph = PasswordHasher()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False)
auth_config = AuthConfig()
def raise_for_invalid_password(
session: Session,
password: str,
confirm_password: Optional[str] = None,
ignore_confirm: bool = False,
):
if not ignore_confirm and password != confirm_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Passwords must be equal",
)
min_password_length = auth_config.get_min_password_length(session)
if not len(password) >= min_password_length:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Password must be at least {min_password_length} characters long",
)
def is_correct_password(user: User, password: str) -> bool:
try:
return ph.verify(user.password, password)
except VerifyMismatchError:
return False
def authenticate_user(session: Session, username: str, password: str) -> Optional[User]:
user = session.get(User, username)
if not user:
return None
try:
ph.verify(user.password, password)
except VerifyMismatchError:
return None
if ph.check_needs_rehash(user.password):
user.password = ph.hash(password)
session.add(user)
session.commit()
return user
def create_access_token(
auth_secret: str, data: dict[str, str | datetime], expires_delta: timedelta
):
to_encode = data.copy()
expires = datetime.now(timezone.utc) + expires_delta
to_encode.update({"exp": expires})
encoded_jwt = jwt.encode(to_encode, auth_secret, algorithm=JWT_ALGORITHM) # pyright: ignore[reportUnknownMemberType]
return encoded_jwt
def create_user(
username: str,
password: str,
group: GroupEnum = GroupEnum.untrusted,
root: bool = False,
) -> User:
password_hash = ph.hash(password)
return User(username=username, password=password_hash, group=group, root=root)
def get_authenticated_user(lowest_allowed_group: GroupEnum = GroupEnum.untrusted):
async def get_user(
request: Request,
session: Annotated[Session, Depends(get_session)],
) -> DetailedUser:
login_type = auth_config.get_login_type(session)
if login_type == LoginTypeEnum.forms:
user = await _get_forms_auth(request, session)
elif login_type == LoginTypeEnum.none:
user = await _get_none_auth()
else:
user = await _get_basic_auth(request, session)
if not user.is_above(lowest_allowed_group):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden"
)
user = DetailedUser.model_validate(user, update={"login_type": login_type})
return user
return get_user
async def _get_basic_auth(
request: Request,
session: Session,
) -> User:
invalid_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
credentials = await security(request)
if not credentials:
raise invalid_exception
user = authenticate_user(session, credentials.username, credentials.password)
if not user:
raise invalid_exception
return user
class RequiresLoginException(Exception):
def __init__(self, detail: Optional[str] = None, **kwargs: object):
super().__init__(**kwargs)
self.detail = detail
async def _get_forms_auth(
request: Request,
session: Session,
) -> User:
# Authentication is either through Authorization header or cookie
token = await oauth2_scheme(request)
if not token:
token = request.cookies.get("audio_sess")
if not token:
raise RequiresLoginException()
try:
payload = jwt.decode( # pyright: ignore[reportUnknownMemberType]
token, auth_config.get_auth_secret(session), algorithms=[JWT_ALGORITHM]
)
except jwt.InvalidTokenError:
raise RequiresLoginException("Token is expired/invalid")
username = payload.get("sub")
if username is None:
raise RequiresLoginException("Token is invalid")
user = session.get(User, username)
if not user:
raise RequiresLoginException("User does not exist")
return user
async def _get_none_auth() -> User:
"""Treats every request as being root / turns off authentication"""
return User(username="no-login", password="", group=GroupEnum.admin, root=True)

View File

@@ -35,10 +35,23 @@ L = TypeVar("L", bound=str)
class StringConfigCache(Generic[L], ABC):
_cache: dict[L, str] = {}
@overload
def get(self, session: Session, key: L) -> Optional[str]:
pass
@overload
def get(self, session: Session, key: L, default: str) -> str:
pass
def get(
self, session: Session, key: L, default: Optional[str] = None
) -> Optional[str]:
if key in self._cache:
return self._cache[key]
return session.exec(select(Config.value).where(Config.key == key)).one_or_none()
return (
session.exec(select(Config.value).where(Config.key == key)).one_or_none()
or default
)
def set(self, session: Session, key: L, value: str):
old = session.exec(select(Config).where(Config.key == key)).one_or_none()
@@ -59,7 +72,7 @@ class StringConfigCache(Generic[L], ABC):
del self._cache[key]
@overload
def get_int(self, session: Session, key: L, default: None = None) -> Optional[int]:
def get_int(self, session: Session, key: L) -> Optional[int]:
pass
@overload

View File

@@ -5,7 +5,7 @@ from fastapi import Request, Response
from jinja2_fragments.fastapi import Jinja2Blocks
from starlette.background import BackgroundTask
from app.util.auth import DetailedUser
from app.internal.auth.authentication import DetailedUser
templates = Jinja2Blocks(directory="templates")
templates.env.filters["quote_plus"] = lambda u: quote_plus(u) # pyright: ignore[reportUnknownLambdaType,reportUnknownMemberType,reportUnknownArgumentType]

4
app/util/time.py Normal file
View File

@@ -0,0 +1,4 @@
from typing import NewType
Second = NewType("Second", int)
Minute = NewType("Minute", int)

11
app/util/toast.py Normal file
View File

@@ -0,0 +1,11 @@
from typing import Literal
class ToastException(Exception):
"""Shows a toast on the frontend if raised on an HTMX endpoint"""
def __init__(
self, message: str, type: Literal["error", "success", "info"] = "error"
):
self.message = message
self.type = type

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
web:
build:
context: .
args:
- VERSION=local
volumes:
- ./config:/config
ports:
- "8000:8000"

View File

@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
[tool.pyright]
include = ["**/*.py"]
exclude = ["**/__pycache__", "**/.venv"]
exclude = ["**/__pycache__", "**/.venv", "**/.direnv"]
ignore = []
typeCheckingMode = "strict"

View File

@@ -25,6 +25,7 @@ httpcore==1.0.7
httptools==0.6.4
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
jinja2_fragments==1.8.0
Mako==1.3.9

View File

@@ -1,6 +1,9 @@
@import "tailwindcss";
@plugin "daisyui";
/* Enables dark mode specific styling with "dark:" */
@custom-variant dark (&:where(.dark, .dark *));
@plugin "daisyui/theme" {
name: "nord";
default: true;

View File

@@ -27,12 +27,14 @@
"light-dark-toggle",
)) {
elem.classList.add("DARKCLASS");
document.documentElement.classList.add("dark");
}
} else {
for (const elem of document.getElementsByClassName(
"light-dark-toggle",
)) {
elem.classList.remove("DARKCLASS");
document.documentElement.classList.remove("dark");
}
}
};
@@ -49,9 +51,27 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
{% include 'scripts/toast.html' %}
</head>
<body class="w-screen min-h-screen overflow-x-hidden" hx-ext="preload">
{% if not hide_navbar %}
{% block toast_block %}
<div class="hidden" id="toast-block">
{% if toast_error %}
<script>
toast("{{toast_error|safe}}", "error");
</script>
{% endif %} {% if toast_success %}
<script>
toast("{{toast_success|safe}}", "success");
</script>
{% endif %} {% if toast_info %}
<script>
toast("{{toast_info|safe}}", "info");
</script>
{% endif %}
</div>
{% endblock %} {% if not hide_navbar %}
<header class="shadow-lg">
<nav class="navbar">
<div class="flex-1">

View File

@@ -10,8 +10,8 @@
hx-target="#message"
>
<p class="opacity-60">
No user was found in the database. Please create an admin user to get set
up.
No user was found in the database. Please create a root admin user to get set
up. OpenID Connect can be configured later.
</p>
<label for="login-type">Login Type</label>

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %} {% block head %}
<title>Invalid OIDC Settings</title>{% endblock %} {% block body %}
<div class="h-screen w-full flex items-center justify-center">
<div class="card flex flex-col gap-4 max-w-[20rem]">
<h1 class="text-2xl font-bold text-center">Invalid OIDC Settings</h1>
<p>The OIDC configuration is invalid!</p>
<p>Error: <span class="font-mono text-error">{{ error }}</span></p>
<p>
Click the button below to log in with a root admin account as a backup:
</p>
<a class="btn" href="/login?backup=1">Backup Login</a>
</div>
</div>
{% endblock %}

View File

@@ -1,25 +1,26 @@
<!-- Admin initialization page -->
{% extends "base.html" %} {% block head %}
{% extends "base.html" %} {% block head %} {% if backup %}
<title>Backup Login</title>
{% else %}
<title>Login</title>
{% include 'scripts/toast.html' %} {% endblock %} {% block body %}
{% endif %} {% endblock %} {% block body %}
<div class="h-screen w-full flex items-center justify-center">
{% block error_toast %}
<div id="message" class="hidden">
{% if error %}
<script>
toast("{{error|safe}}", "error");
</script>
{% endif %}
</div>
{% endblock %}
<form
class="flex flex-col gap-2 max-w-[30rem]"
hx-post="/auth/token"
id="form"
hx-target="#message"
hx-target="this"
>
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}" />
{% if backup %}
<h1 class="text-3xl">Backup Login</h1>
<p class="text-error">
This instance has OIDC enabled. This login can only be used by the root
admin.
</p>
{% else %}
<h1 class="text-3xl">Login</h1>
{% endif %}
<label for="username">Username</label>
<input
id="username"

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %} {% block head %}
<title>Manual Entry</title>
{% include 'scripts/toast.html' %} {% endblock %} {% block body %}
<!-- prettier-ignore -->
{% endblock %} {% block body %}
<div
class="w-screen flex flex-col items-center justify-center p-8 overflow-x-hidden gap-4"
>

13
templates/redirect.html Normal file
View File

@@ -0,0 +1,13 @@
{% extends "base.html" %} {% block head %} <title>Redirecting...</title>{%
endblock %} {% block body %}
<div class="h-screen w-full flex items-center justify-center">
<div class="card flex flex-col gap-4 max-w-[20rem]">
<h1 class="text-2xl font-bold text-center">Redirecting...</h1>
<script>
window.location.href = "{{ redirect_uri }}";
</script>
<a class="btn" href="{{ redirect_uri }}">Redirect now</a>
</div>
</div>
{% endblock %}

View File

@@ -49,7 +49,7 @@
/>
{% block search_suggestions %}
<datalist id="search-suggestions">
{% for suggestion in suggestions %}
{% for suggestion in (suggestions or [])[:3] %}
<option value="{{ suggestion }}"></option>
{% endfor %}
</datalist>

View File

@@ -1,6 +1,5 @@
{% extends "settings_page/base.html" %} {% block head %}
<title>Settings - Account</title>
{% include 'scripts/toast.html' %} {% endblock %} {% block content %}
<title>Settings - Account</title> {% endblock %} {% block content %}
<form
id="change-password-form"
class="flex flex-col gap-2"
@@ -11,15 +10,7 @@
<script>
toast("{{success|safe}}", "success");
</script>
{% endif %} {% block error %}
<div id="error" class="hidden">
{% if error %}
<script>
toast("{{error|safe}}", "error");
</script>
{% endif %}
</div>
{% endblock %}
{% endif %}
<h2 class="text-lg">Change Password</h2>

View File

@@ -1,6 +1,12 @@
{% extends "base.html" %} {% block head %}
<title>Settings</title>
{% endblock %} {% block body %}
{% endblock %} {% block body %} {% if version %}
<p
class="fixed bottom-1 right-1 font-mono text-xs font-semibold text-neutral/60 dark:text-neutral-content/60"
>
version: {{ version }}
</p>
{% endif %}
<div class="flex items-start justify-center p-2 md:p-4 relative">
<main
class="flex flex-col w-[90%] md:w-3/4 max-w-[40rem] gap-4 gap-y-8 pb-[10rem]"

View File

@@ -1,6 +1,5 @@
{% extends "settings_page/base.html" %} {% block head %}
<title>Settings - Download</title>
{% include 'scripts/toast.html' %}
<link href="/nouislider.css" rel="stylesheet" />
<script src="/nouislider.js"></script>
<script>

View File

@@ -3,7 +3,7 @@
{% endblock %} {% block content %}
<div class="flex flex-col">
<h2 class="text-lg">Notications</h2>
<h2 class="text-lg">Notifications</h2>
<form
id="add-notification-form"

View File

@@ -4,7 +4,7 @@
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
{% include 'scripts/toast.html' %} {% endblock %} {% block content %}
{% endblock %} {% block content %}
<div class="flex flex-col gap-2">
<h2 class="text-lg">Prowlarr</h2>

View File

@@ -1,21 +1,18 @@
{% extends "settings_page/base.html" %} {% block head %}
<title>Settings - Security</title>
{% include 'scripts/toast.html' %} {% endblock %} {% block content %}
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
{% endblock %} {% block content %}
<div class="flex flex-col">
{% block error_toast %}
<div id="message" class="hidden">
{% if error %}
<script>
toast("{{error|safe}}", "error");
</script>
{% endif %}
</div>
{% endblock %} {% block form %}
{% block form %}
<form
class="flex flex-col gap-2"
hx-post="/settings/security"
hx-disabled-elt="#save-button"
hx-target="this"
x-data="{ loginType: '{{ login_type.value }}' }"
>
{% if success %}
<script>
@@ -26,36 +23,216 @@
<h2 class="text-lg">Login/Security</h2>
<label for="login-type">Login Type</label>
<select id="login-type" name="login_type" class="select w-full">
<select
id="login-type"
name="login_type"
class="select w-full"
x-model="loginType"
>
<option value="basic" {% if login_type.is_basic() %}selected{% endif %}>
Basic Auth (Dialog)
</option>
<option value="forms" {% if login_type.is_forms() %}selected{% endif %}>
Forms Login
</option>
<option value="oidc" {% if login_type.is_oidc() %}selected{% endif %}>
OpenID Connect
</option>
<option value="none" {% if login_type.is_none() %}selected{% endif %}>
None (Insecure)
</option>
</select>
<label for="expiry-input">Access Token Expiry (minutes)</label>
<input
id="expiry-input"
type="number"
name="access_token_expiry"
class="input w-full"
value="{{ access_token_expiry }}"
/>
<template x-if="loginType === 'forms'">
<div class="contents">
<label for="expiry-input">Access Token Expiry (minutes)</label>
<input
id="expiry-input"
type="number"
name="access_token_expiry"
class="input w-full"
value="{{ access_token_expiry }}"
/>
</div>
</template>
<label for="pw-len-input">Minimum Password Length</label>
<input
id="pw-len-input"
type="number"
name="min_password_length"
class="input w-full"
placeholder="1"
value="{{ min_password_length }}"
/>
<template x-if="loginType === 'forms' || loginType === 'basic'">
<div class="contents">
<label for="pw-len-input">Minimum Password Length</label>
<input
id="pw-len-input"
type="number"
name="min_password_length"
class="input w-full"
placeholder="1"
value="{{ min_password_length }}"
/>
</div>
</template>
<template x-if="loginType === 'oidc'">
<div class="contents">
<label for="oidc-client-id"
>OIDC Client ID <span class="text-error">*</span></label
>
<input
id="oidc-client-id"
required
type="text"
autocomplete="off"
name="oidc_client_id"
class="input w-full"
value="{{ oidc_client_id}}"
/>
<label for="oidc-client-secret"
>OIDC Client Secret <span class="text-error">*</span></label
>
<input
id="oidc-client-secret"
required
type="text"
autocomplete="off"
name="oidc_client_secret"
class="input w-full"
value="{{ oidc_client_secret }}"
/>
<div>
<label for="oidc-endpoint"
>OIDC Configuration Endpoint
<span class="text-error">*</span></label
>
<p class="opacity-60 text-xs">
The
<span class="font-mono">.well-known/openid-configuration</span>
endpoint containing all the OIDC information. You should be able to
visit the page and view it yourself.
</p>
</div>
<input
id="oidc-endpoint"
required
type="text"
placeholder="https://example.com/.well-known/openid-configuration"
name="oidc_endpoint"
class="input w-full"
value="{{ oidc_endpoint }}"
/>
<div>
<label for="oidc-scope"
>OIDC Scopes <span class="text-error">*</span></label
>
<p class="opacity-60 text-xs">
The scopes that will be requested from the OIDC provider. "openid"
is almost always required. Add the scopes required to fetch the
username and group claims.
</p>
</div>
<input
id="oidc-scope"
required
type="text"
placeholder="openid profile"
autocomplete="off"
name="oidc_scope"
class="input w-full"
value="{{ oidc_scope }}"
/>
<div>
<label for="oidc-username-claim"
>OIDC Username Claim <span class="text-error">*</span></label
>
<p class="opacity-60 text-xs">
The claim that will be used for the username. Make sure the
respective scope is passed along above. For example some services
expect the "email" claim to be able to use the email. "sub" is
always avaiable. You can head to the OIDC endpoint to see what
claims are avaiable.
</p>
</div>
<input
id="oidc-username-claim"
required
type="text"
autocomplete="off"
placeholder="sub"
name="oidc_username_claim"
class="input w-full"
value="{{ oidc_username_claim }}"
/>
<div>
<label for="oidc-username-claim">OIDC Group Claim</label>
<p class="opacity-60 text-xs">
The claim that contains the group(s) the user is in. For example, if
a user is in the group "trusted" they will be assigned the Trusted
role here. The group claim can be a list of groups or a single one
and is case-insensitive.
</p>
</div>
<input
id="oidc-group-claim"
type="text"
autocomplete="off"
placeholder="group"
name="oidc_group_claim"
class="input w-full"
value="{{ oidc_group_claim }}"
/>
<div>
<label for="oidc-logout-url"
>Use http or https for the redirect URL</label
>
<p class="opacity-60 text-xs">
After you login on your authentication server, you will be
redirected to <span class="font-mono">/auth/oidc</span>. Determine
if you should be redirected to http or https. This should match up
with what you configured as the redirect URL in your OIDC provider.
</p>
</div>
<!-- prettier-ignore -->
<select class="select" name="oidc_redirect_https">
<option value="True" {% if oidc_redirect_https %}selected{% endif %}>https</option>
<option value="False" {% if not oidc_redirect_https %}selected{% endif %}>http</option>
</select>
<div>
<label for="oidc-logout-url">OIDC Logout URL</label>
<p class="opacity-60 text-xs">
The link you'll be redirected to upon logging out. If your OIDC
provider has the
<span class="font-mono">end_session_endpoint</span> defined, it'll
use that as the logout url.
</p>
</div>
<input
id="oidc-logout-url"
type="text"
autocomplete="off"
name="oidc_logout_url"
class="input w-full"
value="{{ oidc_logout_url }}"
/>
<p class="text-error text-xs">
Make sure all the settings are correct. In the case of a
miconfiguration, you can log in at
<a
href="/login?backup=1"
class="font-mono link whitespace-nowrap inline-block"
>/login?backup=1</a
>
to fix the settings.
<br />
<span class="font-semibold">Note:</span> To test your OpenID Connect
settings you have to log out to invalidate your current session first.
</p>
</div>
</template>
<button
id="save-button"

View File

@@ -1,7 +1,13 @@
{% extends "settings_page/base.html" %} {% block head %}
<title>Settings - Users</title>
{% include 'scripts/toast.html' %}
{% endblock %} {% block content %}
{% if is_oidc %}
<p class="text-error font-semibold">
OpenID Connect is enabled. Users will be automatically created when they log in.
If a user has a group assigned on the authentication server, it will override their group here.
</p>
{% endif %}
<form
id="create-user-form"
class="flex flex-col gap-2"
@@ -44,22 +50,18 @@
{% block user_block %}
<div id="user-list" class="pt-4 border-t border-base-200">
<h2 class="text-lg">Users</h2>
{% block toast_block %}
<div class="hidden" id="toast-block">
{% if error %}
<script>
toast("{{error|safe}}", "error");
</script>
{% endif %}
{% if success %}
<script>
toast("{{success|safe}}", "success");
</script>
{% endif %}
{% if success %}
<script>
toast("{{success|safe}}", "success");
</script>
{% endif %}
<div class="flex flex-col opacity-60 text-sm">
<span class="font-bold">Untrusted:</span> <span>Can search and request files.</span>
<span class="font-bold">Trusted:</span> <span>Same as untrused, but if auto-download is enabled the user can start downloads.</span>
<span class="font-bold">Admin:</span> <span>Can manage users and settings.</span>
</div>
{% endblock %}
<div class="max-h-[30rem] overflow-x-auto">
<table class="table table-pin-rows">
<thead>
@@ -94,7 +96,7 @@
{% if u.root %}<option value="admin" selected>Root Admin</option>{% endif %}
</select>
</td>
<td {% if u.root %}title="Root user" {% endif %}>
<td {% if u.root %}title="Can't delete the root admin"{% elif u.is_self(user.username) %}title="Can't delete yourself"{% endif %}>
<!--prettier-ignore -->
<button
class="btn btn-square btn-ghost"

View File

@@ -3,7 +3,8 @@
{% endblock %} {% block content %}
<div class="overflow-x-auto h-[75vh] border-b pb-2 border-b-base-200">
<table class="table table-pin-rows min-w-[60rem]">
{% block book_wishlist %}
<table class="table table-pin-rows min-w-[60rem]" id="book-table-body">
<thead>
<tr>
<th></th>
@@ -17,36 +18,37 @@
</tr>
</thead>
{% block book_wishlist %}
<tbody id="book-table-body">
{% if not books %}
<div role="alert" class="alert my-2">
<span class="stroke-info h-6 w-6 shrink-0"
>{% include 'icons/info-circle.html' %}</span
>
<span>
No manual book requests on your wishlist. Add some books by heading to
the
<a preload class="link" href="/search/manual">search</a> tab.
</span>
</div>
{% endif %} {% for book in books %}
<tr class="text-xs lg:text-sm" id="{{ book.asin }}">
<th>{{ loop.index }}</th>
{% if not books %}
<div role="alert" class="alert my-2">
<span class="stroke-info h-6 w-6 shrink-0"
>{% include 'icons/info-circle.html' %}</span
>
<span>
No manual book requests on your wishlist. Add some books by heading to
the
<a preload class="link" href="/search/manual">search</a> tab.
</span>
</div>
<td class="flex flex-col">
{% endif %}
<tbody>
{% for book in books %}
<tr
class="text-xs lg:text-sm {% if book.downloaded %}bg-success/30{% endif %}"
id="{{ book.asin }}"
>
<th>{{ loop.index }}</th>
<td class="{% if book.subtitle %}flex{% endif %} flex-col">
<span>{{ book.title }}</span>
{% if book.subtitle %}
<span class="font-semibold line-clamp-4">{{ book.subtitle }}</span>
{% endif %}
</td>
<td>{{ book.authors|join(', ') }}</td>
<td>{{ book.narrators|join(', ') }}</td>
<td>{{ book.publish_date }}</td>
<td>{{ book.additional_info }}</td>
<td>{{ book.user_username }}</td>
<td>
<!--prettier-ignore -->
<button
@@ -60,12 +62,34 @@
>
{% include 'icons/ban.html' %}
</button>
{% if book.downloaded %}
<button
class="btn btn-square btn-ghost bg-success text-neutral/20"
disabled
title="Set as downloaded"
>
{% include 'icons/checkmark.html' %}
</button>
{% else %}
<!--prettier-ignore -->
<button
class="btn btn-square"
title="Set as downloaded"
{% if not user.is_admin() %}disabled{% endif %}
hx-patch="/wishlist/manual/{{ book.id }}"
hx-swap="outerHTML"
hx-target="#book-table-body"
hx-disabled-elt="this"
>
{% include 'icons/checkmark.html' %}
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
{% endblock %}
</table>
{% endblock %}
</div>
{% endblock %}

View File

@@ -43,12 +43,14 @@
<tbody>
{% for source in sources %}
<tr
class="text-xs lg:text-sm {% if loop.index==1 %}bg-success{% endif %}"
class="text-xs lg:text-sm {% if loop.index==1 %}bg-success dark:text-gray-700{% endif %}"
>
<th>{{ loop.index }}</th>
<td>
<a href="{{ source.info_url }}" class="link">{{ source.title }}</a>
{% if source.info_url %}<a href="{{ source.info_url }}" class="link"
>{{ source.title }}</a
>{% else %}{{ source.title }}{% endif %}
</td>
{% if mam_active %}
<td>{{ source.authors|join(', ') }}</td>

View File

@@ -32,6 +32,7 @@
</span>
</div>
{% endif %}
<tbody>
{% for book in books %}
<tr class="text-xs lg:text-sm" id="{{ book.asin }}">
@@ -78,7 +79,9 @@
</td>
<td class="lg:hidden">{{ book.release_date.strftime("%Y") }}</td>
<td>{{ book.runtime_length_hrs }}</td>
<td>{{ book.amount_requested }}</td>
<td title="{{ book.requested_by|join('\n') }}">
{{ book.amount_requested }}
</td>
<td class="grid grid-cols-2 min-w-[8rem] gap-1">
<!--prettier-ignore -->
@@ -129,9 +132,26 @@
>
{% include 'icons/ban.html' %}
</button>
<button class="btn btn-square" disabled>
{% if book.downloaded %}
<button
class="btn btn-square btn-ghost bg-success text-neutral/20"
disabled
title="Set as downloaded"
>
{% include 'icons/checkmark.html' %}
</button>
{% else %}
<button
class="btn btn-square"
title="Set as downloaded"
hx-patch="/wishlist/downloaded/{{ book.asin }}"
hx-swap="outerHTML"
hx-target="#book-table-body"
hx-disabled-elt="this"
>
{% include 'icons/checkmark.html' %}
</button>
{% endif %}
</td>
</tr>
{% endfor %}