Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d104305
Add the profile picture model, storage, and migration
codebude Oct 30, 2026
3258786
Add the authenticated profile picture API and user serialization
codebude Oct 30, 2026
37878f8
Add the public profile picture section and endpoint
codebude Oct 30, 2026
e62a227
Cache OIDC profile pictures during login and linking
codebude Oct 30, 2026
87898b4
Clean up profile picture files and include them in backups
codebude Oct 30, 2026
195b6c8
Add the profile picture editor and cropper
codebude Oct 30, 2026
bab4072
Render the profile picture section on public profiles
codebude Oct 30, 2026
a5b1a17
Add avatar tests and documentation
codebude Oct 30, 2026
b3f77bd
Allow the Vite dev server to accept any host
codebude Oct 30, 2026
8acdc4b
Add configurable OIDC callback base URL
codebude Oct 30, 2026
7368fcf
Add debug logging for failed OIDC callbacks
codebude Oct 30, 2026
26da10c
Allow OIDC pictures from the provider's private host
codebude Oct 30, 2026
ce3ce88
Refresh OIDC pictures from the provider's private host
codebude Oct 30, 2026
d3fa4b9
Make avatar uploads robust across browsers and small screens
codebude Oct 30, 2026
746c6da
Show clearer avatar upload format and size errors
codebude Oct 30, 2026
5b7414d
Polish the profile picture section and crop editor
codebude Oct 30, 2026
27e39e1
Document profile picture settings and update release notes
codebude Oct 30, 2026
549a7a7
Group the Mass Tag Editor release notes under one entry
codebude Oct 30, 2026
da07164
Group the profile picture release notes under one entry
codebude Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ GOOGLE_BOOKS_API_KEY= # optional — see https://codebude.github.io/libri
CORS_ORIGINS=["http://localhost", "http://localhost:5173"]
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR | CRITICAL
COVERS_DIR=./data/covers # Directory where downloaded cover images are stored locally.
AVATARS_DIR=./data/avatars # Directory where uploaded and cached OIDC profile pictures are stored.
AVATAR_MAX_UPLOAD_MB=10
AVATAR_MAX_DIMENSION=512
AVATAR_QUALITY=90
AVATAR_FETCH_TIMEOUT_SECONDS=10
AVATAR_FETCH_MAX_BYTES=20000000
# Generate a real secret: openssl rand -base64 32
API_KEY_ENCRYPTION_KEY=CHANGE_ME_TO_32PLUS_CHARS # <-- YOU MUST CHANGE THIS
AUTH_COOKIE_NAME=librislog_session
Expand Down Expand Up @@ -35,6 +41,10 @@ OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_WELL_KNOWN_URL=
OIDC_SCOPE="openid email profile"
# Public origin the browser uses for OIDC callbacks, without a path. Set this when
# the backend sees a different host, for example behind the Vite dev proxy, which
# rewrites Host to localhost. Leave empty to build the URI from the request.
# OIDC_CALLBACK_BASE_URL=http://dev.example.lan:5173

# Dashboard quote settings
DASHBOARD_QUOTE_ENABLED=true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""add user image table and avatar source

Revision ID: b4c5d6e7f8a9
Revises: a3b4c5d6e7f8
Create Date: 2026-10-30 00:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "b4c5d6e7f8a9"
down_revision: Union[str, Sequence[str], None] = "a3b4c5d6e7f8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("user") as batch_op:
batch_op.add_column(
sa.Column(
"avatar_source",
sa.Enum("animal", "oidc", "upload", name="avatarsource"),
nullable=False,
server_default="animal",
)
)
batch_op.create_index(op.f("ix_user_avatar_source"), ["avatar_source"], unique=False)

op.create_table(
"user_image",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("kind", sa.String(), nullable=False, server_default="avatar"),
sa.Column("variant", sa.String(), nullable=False),
sa.Column("filename", sa.String(), nullable=True),
sa.Column("external_url", sa.String(), nullable=True),
sa.Column("content_type", sa.String(), nullable=True),
sa.Column("byte_size", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id",
"kind",
"variant",
name="uq_user_image_user_kind_variant",
),
)
op.create_index(op.f("ix_user_image_user_id"), "user_image", ["user_id"], unique=False)
op.create_index(op.f("ix_user_image_kind"), "user_image", ["kind"], unique=False)
op.create_index(op.f("ix_user_image_variant"), "user_image", ["variant"], unique=False)


def downgrade() -> None:
op.drop_index(op.f("ix_user_image_variant"), table_name="user_image")
op.drop_index(op.f("ix_user_image_kind"), table_name="user_image")
op.drop_index(op.f("ix_user_image_user_id"), table_name="user_image")
op.drop_table("user_image")

with op.batch_alter_table("user") as batch_op:
batch_op.drop_index(op.f("ix_user_avatar_source"))
batch_op.drop_column("avatar_source")
9 changes: 9 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class Settings(BaseSettings):
cors_origins: List[str] = ["http://localhost", "http://localhost:5173", "http://localhost:4173"]
log_level: str = "INFO"
covers_dir: str = "./data/covers"
avatars_dir: str = "./data/avatars"
avatar_max_upload_mb: int = 10
avatar_max_dimension: int = 512
avatar_quality: int = 90
avatar_fetch_timeout_seconds: float = 10.0
avatar_fetch_max_bytes: int = 20_000_000
api_key_encryption_key: str
auth_cookie_name: str = "librislog_session"
auth_cookie_secure: bool = False
Expand All @@ -33,6 +39,9 @@ class Settings(BaseSettings):
oidc_client_secret: str = ""
oidc_well_known_url: str = ""
oidc_scope: str = "openid email profile"
# Public origin (scheme, host, optional port) under which the browser reaches
# the OIDC callback routes. Empty builds the redirect URI from the request.
oidc_callback_base_url: str = ""
dashboard_quote_enabled: bool = True
dashboard_quote_url: str = (
"https://motivational-spark-api.vercel.app/api/quotes/random"
Expand Down
13 changes: 13 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from app.config import settings
from app.logging_config import configure_logging
from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, sessions, share_links, statistics, tags, users
from app.services.avatar_storage import cleanup_orphan_avatars
from app.services.cover_storage import cleanup_orphan_covers
from app.services.data_import import cleanup_temp_files
from app.services.telemetry import send_telemetry_once
Expand All @@ -38,6 +39,7 @@ async def _periodic_maintenance(interval_hours: int = 1) -> None:
Tasks:
- Clean up stale temporary import files.
- Delete orphaned cover files no longer referenced by any book.
- Delete orphaned avatar files no longer referenced by any user image.

Args:
interval_hours: Hours between cleanup cycles. Defaults to 1.
Expand All @@ -57,6 +59,15 @@ async def _periodic_maintenance(interval_hours: int = 1) -> None:
if deleted:
logger.info("Orphaned cover cleanup: deleted %d file(s)", deleted)

deleted_avatars = await loop.run_in_executor(
None, cleanup_orphan_avatars, session
)
if deleted_avatars:
logger.info(
"Orphaned avatar cleanup: deleted %d file(s)",
deleted_avatars,
)

failures = 0
except Exception as exc:
failures += 1
Expand Down Expand Up @@ -121,6 +132,7 @@ async def lifespan(app: FastAPI):
"""Application lifespan: create required directories and start background tasks."""
_validate_import_sources()
Path(settings.covers_dir).mkdir(parents=True, exist_ok=True)
Path(settings.avatars_dir).mkdir(parents=True, exist_ok=True)
Path(settings.import_temp_dir).mkdir(parents=True, exist_ok=True)
cleanup_temp_files()

Expand All @@ -131,6 +143,7 @@ async def lifespan(app: FastAPI):
)

oidc.warn_about_loopback_public_app_url()
oidc.warn_about_invalid_oidc_callback_base_url()

maintenance_task = asyncio.create_task(_periodic_maintenance())
telemetry_task = (
Expand Down
47 changes: 47 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ class UserRole(str, Enum):
user = "user"


class AvatarSource(str, Enum):
"""Selected source for a user's profile picture."""

animal = "animal"
oidc = "oidc"
upload = "upload"


class Book(SQLModel, table=True):
"""A book in the user's library."""

Expand Down Expand Up @@ -188,6 +196,7 @@ class User(SQLModel, table=True):
lastname: str
email: str = Field(index=True, unique=True)
role: UserRole = Field(default=UserRole.user, index=True)
avatar_source: AvatarSource = Field(default=AvatarSource.animal, index=True)
hashed_password: str
credentials_version: int = Field(
default=0,
Expand Down Expand Up @@ -228,6 +237,44 @@ class UserSettings(SQLModel, table=True):
statistics_custom_to: Optional[date] = Field(default=None)


class UserImage(SQLModel, table=True):
"""A locally cached or uploaded image owned by a user."""

__tablename__: str = "user_image"
__table_args__ = (
sa.UniqueConstraint("user_id", "kind", "variant", name="uq_user_image_user_kind_variant"),
)

id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
sa.Integer,
sa.ForeignKey("user.id"),
index=True,
nullable=False,
),
)
kind: str = Field(default="avatar", index=True)
variant: str = Field(index=True)
filename: Optional[str] = None
external_url: Optional[str] = None
content_type: Optional[str] = None
byte_size: Optional[int] = None
created_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow, nullable=False),
)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(
UtcDateTime,
default=utcnow,
onupdate=utcnow,
nullable=False,
),
)


class ApiKey(SQLModel, table=True):
"""API key for programmatic access."""

Expand Down
10 changes: 10 additions & 0 deletions backend/app/oidc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""OIDC / SSO integration — provider registration and client creation."""

from typing import Optional
from urllib.parse import urlparse

from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
from authlib.oauth2.client import OAuth2Client
Expand All @@ -23,6 +24,15 @@ def oidc_is_enabled() -> bool:
)


def oidc_provider_host() -> str | None:
"""Return the OIDC provider hostname from the well-known URL."""
try:
hostname = urlparse(settings.oidc_well_known_url).hostname
except ValueError:
return None
return hostname.lower() if hostname else None


def get_oidc_client() -> Optional[StarletteOAuth2App]:
"""Return the OIDC client, registering it on first call.

Expand Down
2 changes: 2 additions & 0 deletions backend/app/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def backup_download(
data_dir=settings.data_dir,
covers_dir=settings.covers_dir,
import_temp_dir=settings.import_temp_dir,
avatars_dir=settings.avatars_dir,
)
except RuntimeError as exc:
if "already in progress" in str(exc):
Expand Down Expand Up @@ -97,6 +98,7 @@ def restore(
data_dir=settings.data_dir,
covers_dir=settings.covers_dir,
import_temp_dir=settings.import_temp_dir,
avatars_dir=settings.avatars_dir,
)
except RuntimeError as exc:
if "already in progress" in str(exc):
Expand Down
12 changes: 8 additions & 4 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
UserLogin,
UserRead,
)
from app.services.avatar import build_user_read
from app.time_utils import utcnow

router = APIRouter(prefix="/api/auth", tags=["auth"])
Expand Down Expand Up @@ -78,7 +79,7 @@ def setup(
session.commit()

start_browser_session(http_request, user.id, user.credentials_version)
return {"user": UserRead.model_validate(user)}
return {"user": build_user_read(session, user)}


@router.post("/login")
Expand All @@ -95,7 +96,7 @@ def login(
assert user.id is not None

start_browser_session(http_request, user.id, user.credentials_version)
return {"user": UserRead.model_validate(user)}
return {"user": build_user_read(session, user)}


@router.post("/logout")
Expand All @@ -106,9 +107,12 @@ def logout(http_request: Request) -> dict:


@router.get("/me", response_model=UserRead)
def me(current_user: User = Depends(require_user)) -> User:
def me(
current_user: User = Depends(require_user),
session: Session = Depends(get_session),
) -> UserRead:
"""Return the currently authenticated user."""
return current_user
return build_user_read(session, current_user)


@router.get("/csrf")
Expand Down
2 changes: 2 additions & 0 deletions backend/app/routers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,6 @@ def app_config() -> AppConfigRead:
password_login_enabled=settings.password_login_enabled,
# Report the effective state: a misconfigured proxy feature is inactive.
trusted_proxy_auth_enabled=proxy_auth.is_active(),
avatar_max_upload_mb=settings.avatar_max_upload_mb,
avatar_max_dimension=settings.avatar_max_dimension,
)
Loading
Loading