This commit is contained in:
Garret Patti
2026-06-29 10:41:29 -04:00
parent d48c1e973e
commit 2b0b19eb91
23 changed files with 969 additions and 44 deletions

View File

@@ -0,0 +1,29 @@
"""add users table
Revision ID: 0002
Revises: 0001
Create Date: 2026-06-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(), nullable=False, unique=True),
sa.Column("password_hash", sa.String(), nullable=False),
sa.Column("is_admin", sa.Boolean(), default=False),
sa.Column("created_at", sa.DateTime(), default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("users")

81
backend/app/auth.py Normal file
View File

@@ -0,0 +1,81 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, Query
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
import bcrypt
from jose import JWTError, jwt
from app.config import settings
from app.database import get_db
from app.models import User
security = HTTPBearer()
_optional_security = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.access_token_expire_days)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
except JWTError:
raise HTTPException(401, "Invalid or expired token")
async def _resolve_user(token_str: str, db: AsyncSession) -> User:
payload = decode_access_token(token_str)
user_id_str: str = payload.get("sub")
if user_id_str is None:
raise HTTPException(401, "Invalid token payload")
try:
user_id = int(user_id_str)
except (ValueError, TypeError):
raise HTTPException(401, "Invalid token payload")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise HTTPException(401, "User not found")
return user
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
return await _resolve_user(credentials.credentials, db)
async def get_current_user_from_query_or_header(
token: str | None = Query(default=None),
credentials: HTTPAuthorizationCredentials | None = Depends(_optional_security),
db: AsyncSession = Depends(get_db),
) -> User:
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
else:
raise HTTPException(401, "Not authenticated")
return await _resolve_user(token_str, db)
async def get_current_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_admin:
raise HTTPException(403, "Admin privileges required")
return current_user

View File

@@ -1,15 +1,34 @@
import secrets
import logging
from pydantic_settings import BaseSettings
from pathlib import Path
log = logging.getLogger(__name__)
class Settings(BaseSettings):
database_url: str = "sqlite+aiosqlite:////data/medialore.db"
media_root: str = "/media"
thumbnail_dir: str = "/data/thumbnails"
secret_key: str = ""
admin_username: str = "admin"
admin_password: str = ""
access_token_expire_days: int = 30
model_config = {"env_file": ".env"}
settings = Settings()
if not settings.secret_key:
settings.secret_key = secrets.token_urlsafe(32)
log.warning(
"SECRET_KEY not set — generated random key: %s. "
"All sessions will be invalidated on restart. "
"Set SECRET_KEY in .env for persistent sessions.",
settings.secret_key,
)
THUMBNAIL_DIR = Path(settings.thumbnail_dir)
THUMBNAIL_DIR.mkdir(parents=True, exist_ok=True)

View File

@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import select
# uvicorn's dictConfig only configures uvicorn.* loggers; the root logger
# ends up with no handler, so app.* records are silently discarded.
@@ -17,18 +18,45 @@ _app_logger.propagate = False
log = logging.getLogger(__name__)
from app.database import engine, Base
from app.routers import libraries, media, tags, search
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import libraries, media, tags, search, auth
from app.services import watcher as watcher_service
from app.auth import hash_password
import app.models # noqa: F401 — registers models with Base.metadata
async def _ensure_admin_user():
from app.models import User
if not settings.admin_password:
log.warning("ADMIN_PASSWORD not set — no admin user will be created.")
return
async with SessionLocal() as db:
result = await db.execute(select(User).where(User.username == settings.admin_username))
if result.scalars().first():
log.info("Admin user '%s' already exists.", settings.admin_username)
return
user = User(
username=settings.admin_username,
password_hash=hash_password(settings.admin_password),
is_admin=True,
)
db.add(user)
await db.commit()
log.info("Created admin user '%s'.", settings.admin_username)
@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("Creating database tables...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _ensure_admin_user()
log.info("Starting library watchers...")
await watcher_service.start_all()
@@ -54,6 +82,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api")
app.include_router(libraries.router, prefix="/api")
app.include_router(media.router, prefix="/api")
app.include_router(tags.router, prefix="/api")

View File

@@ -54,3 +54,13 @@ class Tag(Base):
category: Mapped[str] = mapped_column(String, nullable=False)
items: Mapped[list["MediaItem"]] = relationship("MediaItem", secondary=media_item_tags, back_populates="tags")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String, nullable=False, unique=True)
password_hash: Mapped[str] = mapped_column(String, nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import User
from app.schemas import LoginRequest, TokenResponse, UserOut, UserCreate
from app.auth import (
verify_password,
hash_password,
create_access_token,
get_current_user,
get_current_admin_user,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == body.username))
user = result.scalars().first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Invalid username or password")
token = create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.get("/users", response_model=list[UserOut])
async def list_users(
db: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin_user),
):
result = await db.execute(select(User).order_by(User.username))
return result.scalars().all()
@router.post("/users", response_model=UserOut, status_code=201)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin_user),
):
existing = await db.execute(select(User).where(User.username == body.username))
if existing.scalars().first():
raise HTTPException(409, "Username already exists")
user = User(
username=body.username,
password_hash=hash_password(body.password),
is_admin=False,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=204)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
if user_id == current_user.id:
raise HTTPException(400, "Cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if not user:
raise HTTPException(404, "User not found")
await db.delete(user)
await db.commit()

View File

@@ -5,15 +5,19 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import Library, MediaItem
from app.models import Library, MediaItem, User
from app.schemas import LibraryCreate, LibraryOut, MediaItemOut, BrowseResult, BrowseEntry
from app.services import scanner, watcher as watcher_service
from app.auth import get_current_user
router = APIRouter(prefix="/libraries", tags=["libraries"])
@router.get("", response_model=list[LibraryOut])
async def list_libraries(db: AsyncSession = Depends(get_db)):
async def list_libraries(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library))
return result.scalars().all()
@@ -23,6 +27,7 @@ async def create_library(
body: LibraryCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
path = Path(body.path)
if not path.is_dir():
@@ -44,7 +49,10 @@ async def create_library(
@router.get("/{library_id}/scan-status")
async def get_scan_status(library_id: int):
async def get_scan_status(
library_id: int,
_user: User = Depends(get_current_user),
):
return {"scanning": scanner.is_scanning(library_id)}
@@ -53,6 +61,7 @@ async def rescan_library(
library_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
@@ -69,6 +78,7 @@ async def doom_scroll(
library_id: int,
path: str = "",
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
if not result.scalars().first():
@@ -87,7 +97,11 @@ async def doom_scroll(
@router.delete("/{library_id}", status_code=204)
async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
async def delete_library(
library_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
if not lib:
@@ -98,7 +112,12 @@ async def delete_library(library_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{library_id}/browse", response_model=BrowseResult)
async def browse_library(library_id: int, path: str = "", db: AsyncSession = Depends(get_db)):
async def browse_library(
library_id: int,
path: str = "",
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Library).where(Library.id == library_id))
lib = result.scalars().first()
if not lib:

View File

@@ -5,9 +5,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Library, MediaItem, Tag
from app.models import Library, MediaItem, Tag, User
from app.schemas import MediaItemOut, TagIdList
from app.services.thumbnails import get_or_create_thumbnail
from app.auth import get_current_user, get_current_user_from_query_or_header
router = APIRouter(prefix="/media", tags=["media"])
@@ -33,7 +34,11 @@ def _resolve_safe(lib: Library, item: MediaItem) -> Path:
@router.get("/{media_id}", response_model=MediaItemOut)
async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
async def get_media_item(
media_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(
select(MediaItem).where(MediaItem.id == media_id)
)
@@ -46,7 +51,11 @@ async def get_media_item(media_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{media_id}/file")
async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
async def serve_file(
media_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user_from_query_or_header),
):
item, lib = await _get_item_and_lib(media_id, db)
if item.missing:
raise HTTPException(404, "File is missing from disk")
@@ -57,7 +66,11 @@ async def serve_file(media_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/{media_id}/thumbnail")
async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
async def serve_thumbnail(
media_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user_from_query_or_header),
):
item, lib = await _get_item_and_lib(media_id, db)
abs_path = _resolve_safe(lib, item)
thumb = get_or_create_thumbnail(media_id, str(abs_path), item.media_type)
@@ -67,7 +80,12 @@ async def serve_thumbnail(media_id: int, db: AsyncSession = Depends(get_db)):
@router.put("/{media_id}/tags", response_model=MediaItemOut)
async def set_tags(media_id: int, body: TagIdList, db: AsyncSession = Depends(get_db)):
async def set_tags(
media_id: int,
body: TagIdList,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(MediaItem).where(MediaItem.id == media_id))
item = result.scalars().first()
if not item:

View File

@@ -4,8 +4,9 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models import MediaItem, media_item_tags
from app.models import MediaItem, media_item_tags, User
from app.schemas import MediaItemOut
from app.auth import get_current_user
router = APIRouter(prefix="/search", tags=["search"])
@@ -16,6 +17,7 @@ async def search(
tags: str = Query(default=""),
library_id: int | None = Query(default=None),
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
stmt = (
select(MediaItem)

View File

@@ -3,14 +3,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models import Tag
from app.models import Tag, User
from app.schemas import TagCreate, TagOut, TagsByCategory
from app.auth import get_current_user
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("", response_model=list[TagsByCategory])
async def list_tags(db: AsyncSession = Depends(get_db)):
async def list_tags(
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Tag).order_by(Tag.category, Tag.name))
tags = result.scalars().all()
@@ -22,7 +26,11 @@ async def list_tags(db: AsyncSession = Depends(get_db)):
@router.post("", response_model=TagOut, status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
async def create_tag(
body: TagCreate,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
existing = await db.execute(
select(Tag).where(Tag.name == body.name, Tag.category == body.category)
)
@@ -36,7 +44,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: int, db: AsyncSession = Depends(get_db)):
async def delete_tag(
tag_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(get_current_user),
):
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalars().first()
if not tag:

View File

@@ -86,3 +86,28 @@ class SearchResult(BaseModel):
class TagIdList(BaseModel):
tag_ids: list[int]
# --- Auth ---
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
id: int
username: str
is_admin: bool
created_at: datetime
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
username: str
password: str

View File

@@ -12,6 +12,8 @@ dependencies = [
"watchdog>=4.0",
"Pillow>=10.0",
"python-multipart>=0.0.9",
"python-jose[cryptography]>=3.3",
"bcrypt>=4.0",
]
[build-system]