mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-07-31 00:56:16 -04:00
Hey! @maxdorninger, could you please take a look on the other one ;) ? Closes #145 ; seems like a must have thing - Add `auth.registration_enabled` flag (default `false` — sounds safer default in current setup) - Password registration: `/auth/register` route will return 403 if disabled - OIDC registration: `UserManager.oauth_callback` is overridden to reject user without match; so no auto-provision - `/auth/metadata` exposes the flag to the frontend - Frontend: signup link hidden on the login card; direct navigation to `/login/signup` redirects to `/login` via a `+page.ts` load guard <img width="463" height="461" alt="Screenshot 2026-05-16 at 20 52 11" src="https://github.com/user-attachments/assets/1def5142-e930-4aa6-8771-cbff54250c1f" /> <img width="450" height="391" alt="Screenshot 2026-05-16 at 20 52 22" src="https://github.com/user-attachments/assets/b4013964-cace-4aeb-a848-48ced86fcc5f" /> Also this means uses need to be created somehow -- so.. - Add POST `/users` - admin-protected endpoint to create a new users (created user can be used with OIDC) - Fronted: Add new user button and modal dialog <img width="800" height="379" alt="Screenshot 2026-05-17 at 11 32 06" src="https://github.com/user-attachments/assets/048e6c43-a1c1-42ce-a19c-fd9d916a47d9" /> <img width="537" height="439" alt="Screenshot 2026-05-17 at 11 32 12" src="https://github.com/user-attachments/assets/3f83c2c1-027b-4279-b6a1-bbc8da77efed" /> --- <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Add config toggle to enable/disable user registration; when disabled sign-up endpoints return 403 and OIDC won’t auto-create unknown users. Added admin API to create users. * **Frontend** * Login UI hides signup link when registration is disabled; signup page redirects to login. Admin users list gains “Add User” modal to create users. * **Documentation** * Authentication docs and config examples updated to document the new option. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/maxdorninger/MediaManager/pull/543?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
272 lines
8.5 KiB
Python
272 lines
8.5 KiB
Python
import asyncio
|
|
import logging
|
|
import os
|
|
from collections.abc import AsyncGenerator
|
|
from contextlib import asynccontextmanager
|
|
|
|
import uvicorn
|
|
from asgi_correlation_id import CorrelationIdMiddleware
|
|
from fastapi import (
|
|
APIRouter,
|
|
Depends,
|
|
FastAPI,
|
|
HTTPException,
|
|
Request,
|
|
Response,
|
|
status,
|
|
)
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from psycopg.errors import UniqueViolation
|
|
from sqlalchemy.exc import IntegrityError
|
|
from starlette.responses import FileResponse, RedirectResponse
|
|
from taskiq.receiver import Receiver
|
|
from taskiq_fastapi import populate_dependency_context
|
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
|
|
import media_manager.movies.router as movies_router
|
|
import media_manager.torrent.router as torrent_router
|
|
import media_manager.tv.router as tv_router
|
|
from media_manager.auth.router import (
|
|
auth_metadata_router,
|
|
get_openid_router,
|
|
)
|
|
from media_manager.auth.router import (
|
|
users_router as custom_users_router,
|
|
)
|
|
from media_manager.auth.schemas import UserCreate, UserRead, UserUpdate
|
|
from media_manager.auth.users import (
|
|
bearer_auth_backend,
|
|
cookie_auth_backend,
|
|
fastapi_users,
|
|
)
|
|
from media_manager.config import MediaManagerConfig
|
|
from media_manager.database import init_engine
|
|
from media_manager.exceptions import (
|
|
ConflictError,
|
|
InvalidConfigError,
|
|
MediaAlreadyExistsError,
|
|
NotFoundError,
|
|
conflict_error_handler,
|
|
invalid_config_error_exception_handler,
|
|
media_already_exists_exception_handler,
|
|
not_found_error_exception_handler,
|
|
sqlalchemy_integrity_error_handler,
|
|
)
|
|
from media_manager.filesystem_checks import run_filesystem_checks
|
|
from media_manager.logging import LOGGING_CONFIG, setup_logging
|
|
from media_manager.notification.router import router as notification_router
|
|
from media_manager.scheduler import (
|
|
broker,
|
|
build_scheduler_loop,
|
|
import_all_movie_torrents_task,
|
|
import_all_show_torrents_task,
|
|
update_all_movies_metadata_task,
|
|
update_all_non_ended_shows_metadata_task,
|
|
)
|
|
|
|
setup_logging()
|
|
|
|
config = MediaManagerConfig()
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
if config.misc.development:
|
|
log.warning("Development Mode activated!")
|
|
|
|
run_filesystem_checks(config, log)
|
|
|
|
BASE_PATH = os.getenv("BASE_PATH", "")
|
|
FRONTEND_FILES_DIR = os.getenv("FRONTEND_FILES_DIR")
|
|
DISABLE_FRONTEND_MOUNT = os.getenv("DISABLE_FRONTEND_MOUNT", "").lower() == "true"
|
|
FRONTEND_FOLLOW_SYMLINKS = os.getenv("FRONTEND_FOLLOW_SYMLINKS", "").lower() == "true"
|
|
|
|
log.info("Hello World!")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
|
init_engine(config.database)
|
|
broker_started = False
|
|
started_sources: list = []
|
|
finish_event: asyncio.Event | None = None
|
|
receiver_task: asyncio.Task | None = None
|
|
loop_task: asyncio.Task | None = None
|
|
try:
|
|
if not broker.is_worker_process:
|
|
await broker.startup()
|
|
broker_started = True
|
|
populate_dependency_context(broker, app)
|
|
scheduler_loop = build_scheduler_loop()
|
|
for source in scheduler_loop.scheduler.sources:
|
|
await source.startup()
|
|
started_sources.append(source)
|
|
finish_event = asyncio.Event()
|
|
receiver = Receiver(broker, run_startup=False, max_async_tasks=10)
|
|
receiver_task = asyncio.create_task(receiver.listen(finish_event))
|
|
loop_task = asyncio.create_task(scheduler_loop.run(skip_first_run=True))
|
|
try:
|
|
await asyncio.gather(
|
|
import_all_movie_torrents_task.kiq(),
|
|
import_all_show_torrents_task.kiq(),
|
|
update_all_movies_metadata_task.kiq(),
|
|
update_all_non_ended_shows_metadata_task.kiq(),
|
|
)
|
|
except Exception:
|
|
log.exception("Failed to submit initial background tasks during startup.")
|
|
raise
|
|
yield
|
|
finally:
|
|
if loop_task is not None:
|
|
loop_task.cancel()
|
|
try:
|
|
await loop_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
if finish_event is not None and receiver_task is not None:
|
|
finish_event.set()
|
|
await receiver_task
|
|
for source in started_sources:
|
|
await source.shutdown()
|
|
if broker_started:
|
|
await broker.shutdown()
|
|
|
|
|
|
app = FastAPI(root_path=BASE_PATH, lifespan=lifespan)
|
|
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
|
origins = config.misc.cors_urls
|
|
log.info(f"CORS URLs activated for following origins: {origins}")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=origins,
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "PUT", "POST", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
|
)
|
|
app.add_middleware(CorrelationIdMiddleware, header_name="X-Correlation-ID")
|
|
api_app = APIRouter(prefix="/api/v1")
|
|
|
|
|
|
@api_app.get("/health")
|
|
async def hello_world() -> dict:
|
|
return {"message": "Hello World!", "version": os.getenv("PUBLIC_VERSION")}
|
|
|
|
|
|
api_app.include_router(
|
|
fastapi_users.get_auth_router(bearer_auth_backend),
|
|
prefix="/auth/jwt",
|
|
tags=["auth"],
|
|
)
|
|
api_app.include_router(
|
|
fastapi_users.get_auth_router(cookie_auth_backend),
|
|
prefix="/auth/cookie",
|
|
tags=["auth"],
|
|
)
|
|
|
|
|
|
def reject_if_registration_disabled() -> None:
|
|
if not config.auth.registration_enabled:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User registration is disabled.",
|
|
)
|
|
|
|
|
|
api_app.include_router(
|
|
fastapi_users.get_register_router(UserRead, UserCreate),
|
|
prefix="/auth",
|
|
tags=["auth"],
|
|
dependencies=[Depends(reject_if_registration_disabled)],
|
|
)
|
|
api_app.include_router(
|
|
fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"]
|
|
)
|
|
api_app.include_router(
|
|
fastapi_users.get_verify_router(UserRead), prefix="/auth", tags=["auth"]
|
|
)
|
|
api_app.include_router(custom_users_router, tags=["users"])
|
|
api_app.include_router(
|
|
fastapi_users.get_users_router(UserRead, UserUpdate),
|
|
prefix="/users",
|
|
tags=["users"],
|
|
)
|
|
api_app.include_router(auth_metadata_router, tags=["openid"])
|
|
|
|
if get_openid_router():
|
|
api_app.include_router(get_openid_router(), tags=["openid"], prefix="/auth/oauth")
|
|
|
|
api_app.include_router(tv_router.router, prefix="/tv", tags=["tv"])
|
|
api_app.include_router(torrent_router.router, prefix="/torrent", tags=["torrent"])
|
|
api_app.include_router(movies_router.router, prefix="/movies", tags=["movie"])
|
|
api_app.include_router(
|
|
notification_router, prefix="/notification", tags=["notification"]
|
|
)
|
|
|
|
# serve static image files
|
|
app.mount(
|
|
"/api/v1/static/image",
|
|
StaticFiles(directory=config.misc.image_directory),
|
|
name="static-images",
|
|
)
|
|
app.include_router(api_app)
|
|
|
|
# handle static frontend files
|
|
if not DISABLE_FRONTEND_MOUNT:
|
|
app.mount(
|
|
"/web",
|
|
StaticFiles(
|
|
directory=FRONTEND_FILES_DIR,
|
|
html=True,
|
|
follow_symlink=FRONTEND_FOLLOW_SYMLINKS,
|
|
),
|
|
name="frontend",
|
|
)
|
|
log.debug(f"Mounted frontend at /web from {FRONTEND_FILES_DIR}")
|
|
else:
|
|
log.info("Frontend mounting disabled (DISABLE_FRONTEND_MOUNT is set)")
|
|
|
|
|
|
@app.get("/")
|
|
async def root() -> RedirectResponse:
|
|
return RedirectResponse(url="/web/")
|
|
|
|
|
|
@app.get("/dashboard")
|
|
async def dashboard() -> RedirectResponse:
|
|
return RedirectResponse(url="/web/")
|
|
|
|
|
|
@app.get("/login")
|
|
async def login() -> RedirectResponse:
|
|
return RedirectResponse(url="/web/")
|
|
|
|
|
|
# this will serve the custom 404 page for frontend routes, so SvelteKit can handle routing
|
|
@app.exception_handler(404)
|
|
async def not_found_handler(request: Request, _exc: Exception) -> Response:
|
|
if not DISABLE_FRONTEND_MOUNT and any(
|
|
base_path in ["/web", "/dashboard", "/login"] for base_path in request.url.path
|
|
):
|
|
return FileResponse(f"{FRONTEND_FILES_DIR}/404.html")
|
|
return Response(content="Not Found", status_code=404)
|
|
|
|
|
|
# Register exception handlers for custom exceptions
|
|
app.add_exception_handler(NotFoundError, not_found_error_exception_handler)
|
|
app.add_exception_handler(
|
|
MediaAlreadyExistsError, media_already_exists_exception_handler
|
|
)
|
|
app.add_exception_handler(InvalidConfigError, invalid_config_error_exception_handler)
|
|
app.add_exception_handler(IntegrityError, sqlalchemy_integrity_error_handler)
|
|
app.add_exception_handler(UniqueViolation, sqlalchemy_integrity_error_handler)
|
|
app.add_exception_handler(ConflictError, conflict_error_handler)
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run(
|
|
app,
|
|
host="127.0.0.1",
|
|
port=5049,
|
|
log_config=LOGGING_CONFIG,
|
|
proxy_headers=True,
|
|
forwarded_allow_ips="*",
|
|
)
|