Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Exclude everything from the Docker context by default.
# The backend image is built from the repository root because uv keeps the
# workspace lockfile at the root level.
*

# Workspace dependency files
!pyproject.toml
!uv.lock

# Backend source and config
!backend/
!backend/pyproject.toml
!backend/alembic.ini
!backend/alembic/
!backend/alembic/**
!backend/app/
!backend/app/**
!backend/entrypoint.sh

# Do not ship test code in the image
backend/tests/
2 changes: 1 addition & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ jobs:
- name: backend
image: librislog-api
dockerfile: ./backend/Dockerfile
context: ./backend
context: .

arch: [amd64, arm64]

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ jobs:
- name: Build backend image
uses: docker/build-push-action@v7
with:
context: ./backend
context: .
file: ./backend/Dockerfile
tags: librislog-e2e-backend
load: true
build-args: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<a href="https://docs.librislog.app/"><img src="https://github.com/codebude/librislog/actions/workflows/docs.yml/badge.svg" alt="Docs Build"></a>
<img src="https://img.shields.io/badge/python-3.14-%233776AB?logo=python" alt="Python">
<img src="https://img.shields.io/badge/svelte-5-%23FF3E00?logo=svelte" alt="Svelte">
<img src="https://img.shields.io/badge/FastAPI-0.136-%23009688?logo=fastapi" alt="FastAPI">
<img src="https://img.shields.io/badge/FastAPI-0.141-%23009688?logo=fastapi" alt="FastAPI">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p>

Expand Down
28 changes: 17 additions & 11 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,32 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

# Copy dependency files first for layer caching
# Copy workspace dependency files first for layer caching.
# The backend is a uv workspace member, so the lockfile lives at the repo root.
COPY pyproject.toml uv.lock ./
COPY backend/pyproject.toml ./backend/

# Install production dependencies only (no dev extras)
RUN uv sync --no-dev --frozen
# Sync only the backend workspace member's production dependencies.
# The workspace root keeps the lockfile, so we build from the repo root context.
RUN uv sync --project backend --no-dev --frozen

# Copy application source
COPY alembic.ini ./
COPY alembic/ ./alembic/
COPY app/ ./app/
COPY backend/alembic.ini ./backend/
COPY backend/alembic/ ./backend/alembic/
COPY backend/app/ ./backend/app/

# Make the backend source importable from the workspace root
ENV PYTHONPATH=/app/backend

# Inject version from build args (overwrites fallback in _build_info.py)
ARG APP_VERSION=v0.0.0-dev
ARG GIT_SHA=unknown
RUN echo "__version__ = \"$APP_VERSION\"" > app/_build_info.py && \
echo "__git_sha__ = \"$GIT_SHA\"" >> app/_build_info.py
RUN echo "__version__ = \"$APP_VERSION\"" > ./backend/app/_build_info.py && \
echo "__git_sha__ = \"$GIT_SHA\"" >> ./backend/app/_build_info.py

# Entrypoint: run migrations then start server
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
COPY backend/entrypoint.sh ./backend/
RUN chmod +x ./backend/entrypoint.sh

EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]
ENTRYPOINT ["./backend/entrypoint.sh"]
6 changes: 3 additions & 3 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from fastapi.security import APIKeyHeader
from passlib.exc import UnknownHashError
from passlib.context import CryptContext
from sqlmodel import Session, select
from sqlmodel import Session, col, select

from itsdangerous import URLSafeTimedSerializer

Expand All @@ -27,7 +27,7 @@ class _BcryptAbout:
__version__: str = getattr(bcrypt, "__version__", "")


bcrypt.__about__ = _BcryptAbout() # type: ignore[attr-defined]
bcrypt.__about__ = _BcryptAbout() # ty: ignore[unresolved-attribute]

bcrypt_context: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto")
fallback_context: CryptContext = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
Expand Down Expand Up @@ -190,7 +190,7 @@ def require_user_by_api_key(

key_hash = hash_api_key(x_api_key)
key = session.exec(
select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.revoked_at.is_(None))
select(ApiKey).where(ApiKey.key_hash == key_hash, col(ApiKey.revoked_at).is_(None))
).first()
if not key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
Expand Down
2 changes: 2 additions & 0 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def setup(
session.add(user)
session.commit()
session.refresh(user)
assert user.id is not None

session.add(UserSettings(user_id=user.id, language="en"))
session.commit()
Expand All @@ -81,6 +82,7 @@ def login(
user = session.exec(select(User).where(User.email == credentials.email)).first()
if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password")
assert user.id is not None

start_browser_session(http_request, user.id, user.credentials_version)
return {"user": UserRead.model_validate(user)}
Expand Down
34 changes: 20 additions & 14 deletions backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,20 +179,20 @@ def list_books(
)
base_statement = base_statement.where(
or_(
Book.title.ilike(pattern),
Book.subtitle.ilike(pattern),
Book.author.ilike(pattern),
Book.blurb.ilike(pattern),
Book.id.in_(matching_tag_book_ids),
col(Book.title).ilike(pattern),
col(Book.subtitle).ilike(pattern),
col(Book.author).ilike(pattern),
col(Book.blurb).ilike(pattern),
col(Book.id).in_(matching_tag_book_ids),
)
)

if has_cover is not None:
if has_cover:
base_statement = base_statement.where(Book.cover_url.is_not(None), Book.cover_url != "")
base_statement = base_statement.where(col(Book.cover_url).is_not(None), Book.cover_url != "")
else:
base_statement = base_statement.where(
sa.or_(Book.cover_url.is_(None), Book.cover_url == "")
sa.or_(col(Book.cover_url).is_(None), col(Book.cover_url) == "")
)

total = session.exec(
Expand All @@ -218,7 +218,7 @@ def list_books(
sort_col = Book.date_added
sort_order = order

sort_expression = sort_col.desc() if sort_order == "desc" else sort_col.asc()
sort_expression = col(sort_col).desc() if sort_order == "desc" else col(sort_col).asc()
if sort_col in (Book.date_started, Book.date_finished):
sort_expression = sort_expression.nullslast()

Expand Down Expand Up @@ -306,13 +306,13 @@ def get_tag_cloud(
session: Session = Depends(get_session),
) -> List[TagCloudEntry]:
"""Return tags sorted by usage count (descending) for the authenticated user."""
count_label = func.count(BookTag.book_id).label("cnt")
count_label = func.count(col(BookTag.book_id)).label("cnt")
rows = session.exec(
select(Tag.name, count_label)
.join(BookTag, BookTag.tag_id == Tag.id)
.join(BookTag, col(BookTag.tag_id) == col(Tag.id))
.where(Tag.user_id == current_user.id)
.group_by(Tag.id)
.order_by(count_label.desc(), Tag.name.asc())
.group_by(col(Tag.id))
.order_by(count_label.desc(), col(Tag.name).asc())
.limit(limit)
).all()
return [TagCloudEntry(tag=name, count=count) for name, count in rows]
Expand Down Expand Up @@ -352,6 +352,7 @@ def suggest_authors(
session: Session = Depends(get_session),
) -> SuggestionList:
"""Autocomplete author names from the user's existing books."""
assert current_user.id is not None
suggestions = _suggest_field(session, current_user.id, "author", q, limit)
return SuggestionList(suggestions=suggestions)

Expand All @@ -364,6 +365,7 @@ def suggest_publishers(
session: Session = Depends(get_session),
) -> SuggestionList:
"""Autocomplete publisher names from the user's existing books."""
assert current_user.id is not None
suggestions = _suggest_field(session, current_user.id, "publisher", q, limit)
return SuggestionList(suggestions=suggestions)

Expand All @@ -383,7 +385,7 @@ def suggest_tags(
select(Tag.name)
.where(
Tag.user_id == current_user.id,
Tag.name.ilike(pattern),
col(Tag.name).ilike(pattern),
)
.distinct()
.order_by(Tag.name)
Expand All @@ -400,11 +402,12 @@ async def create_book(
) -> BookRead:
"""Create a new book, downloading the cover if an external URL is provided."""
logger.debug("create_book — title=%r", book_in.title)
assert current_user.id is not None

cover_url = book_in.cover_url
if is_external_cover_url(cover_url):
filename = await import_cover_from_url(
cover_url,
cover_url or "",
settings.covers_dir,
current_user.id,
settings.cover_import_timeout_seconds,
Expand Down Expand Up @@ -464,6 +467,7 @@ async def update_book(
) -> BookRead:
"""Partially update a book, handling cover download and tag sync."""
logger.debug("update_book — id=%s fields=%s", book_id, list(book_in.model_dump(exclude_unset=True)))
assert current_user.id is not None
book = session.get(Book, book_id)
if not book or book.user_id != current_user.id:
logger.debug("update_book — id=%s not found", book_id)
Expand Down Expand Up @@ -516,6 +520,7 @@ async def update_book(
session.rollback()
_raise_integrity_conflict(exc)
if tags_provided:
assert book.id is not None
sync_book_tags(session, current_user.id, book.id, tags_raw)
cleanup_orphan_tags(session, current_user.id)
try:
Expand Down Expand Up @@ -658,6 +663,7 @@ def delete_book(
) -> None:
"""Delete a book, its tags, progress entries, and orphaned cover files."""
logger.debug("delete_book — id=%s", book_id)
assert current_user.id is not None
book = session.get(Book, book_id)
if not book or book.user_id != current_user.id:
logger.debug("delete_book — id=%s not found", book_id)
Expand Down
6 changes: 3 additions & 3 deletions backend/app/routers/cover_candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
import logging
from typing import Optional
from typing import Any, Optional

import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
Expand All @@ -24,7 +24,7 @@
_THALIA_FETCHER_CLASS: object = None


def _get_thalia_fetcher_class() -> object:
def _get_thalia_fetcher_class() -> Any:
"""Lazily import and configure the Scrapling Fetcher for Thalia.de."""
global _THALIA_FETCHER_CLASS
if _THALIA_FETCHER_CLASS is None:
Expand All @@ -41,7 +41,7 @@ def _get_thalia_fetcher_class() -> object:
return _THALIA_FETCHER_CLASS


def _extract_css_adaptive(page: object, selector: str, attr: str | None = None) -> str | None:
def _extract_css_adaptive(page: Any, selector: str, attr: str | None = None) -> str | None:
"""Extract a CSS value with adaptive fallback.

First tries exact selector with ``auto_save`` (to refresh stored fingerprint).
Expand Down
7 changes: 5 additions & 2 deletions backend/app/routers/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from sqlmodel import Session, col, select

from app.auth import require_user
from app.config import settings
Expand Down Expand Up @@ -90,6 +90,7 @@ async def parse_import_file(
current_user: User = Depends(require_user),
) -> DataImportParseResponse:
"""Parse an uploaded CSV or JSON import file and return field info and samples."""
assert current_user.id is not None
allowed_content_types = {
"text/csv",
"application/csv",
Expand All @@ -112,6 +113,7 @@ def suggest_import_mapping(
current_user: User = Depends(require_user),
) -> DataImportSuggestResponse:
"""Suggest a field-name mapping based on the parsed import file."""
assert current_user.id is not None
try:
parsed = load_parsed_upload(body.file_id, current_user.id)
except FileNotFoundError as exc:
Expand All @@ -130,6 +132,7 @@ def save_import_mapping(
session: Session = Depends(get_session),
) -> DataImportMappingRead:
"""Create or update a saved column-mapping configuration."""
assert current_user.id is not None
now = utcnow()
schema_fingerprint = compute_schema_fingerprint(body.source_fields)

Expand Down Expand Up @@ -191,7 +194,7 @@ def list_import_mappings(
session.exec(
select(ImportMapping)
.where(ImportMapping.user_id == current_user.id)
.order_by(ImportMapping.updated_at.desc())
.order_by(col(ImportMapping.updated_at).desc())
).all()
)
user_mappings = [
Expand Down
8 changes: 4 additions & 4 deletions backend/app/routers/embed.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, Response
from sqlmodel import Session, select
from sqlmodel import Session, col, select

from app.auth import EMBED_TOKEN_SCOPE_STATS_READ, hash_embed_token
from app.database import get_session
Expand Down Expand Up @@ -68,7 +68,7 @@ def _verify_embed_token(
db_token = session.exec(
select(EmbedToken).where(
EmbedToken.token_hash == token_hash_val,
EmbedToken.revoked_at.is_(None),
col(EmbedToken.revoked_at).is_(None),
)
).first()

Expand Down Expand Up @@ -270,14 +270,14 @@ def get_embed_stats(
invalid = keys - VALID_STAT_KEYS
if invalid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid stat keys: {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_STAT_KEYS))}",
)
show_set = keys if keys else None

if layout not in LAYOUT_MODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid layout '{layout}'. Valid: {', '.join(sorted(LAYOUT_MODES))}",
)

Expand Down
4 changes: 3 additions & 1 deletion backend/app/routers/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _result(*, healthy: bool, detail: str | None = None) -> dict:
db_ok = True
db_detail = None
try:
db_session.execute(text("SELECT 1"))
db_session.connection().execute(text("SELECT 1"))
except Exception as exc:
db_ok = False
db_detail = str(exc)
Expand All @@ -53,6 +53,8 @@ def _result(*, healthy: bool, detail: str | None = None) -> dict:
schema_detail = None
try:
inspector = inspect(db_session.bind)
if inspector is None:
raise RuntimeError("Engine binding returned no inspector")
existing = set(inspector.get_table_names())
required = {"user", "book"}
missing = required - existing
Expand Down
Loading