Skip to content
Merged
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ __marimo__/

/ideas.txt
/backend/data/
/backend/librislog.db
/data/
/data-e2e/
/backend/data/
Expand All @@ -235,4 +236,4 @@ node_modules/
/.playwright-mcp
/.sverklo
.plan/
/.opencode
/.opencodebackend/librislog.db
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""add raw token to public_profile_link

Revision ID: b2c3d4e5f6a7
Revises: c9a4b7d8e3f1
Create Date: 2026-09-10 12:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "b2c3d4e5f6a7"
down_revision: Union[str, Sequence[str], None] = "c9a4b7d8e3f1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("public_profile_link") as batch_op:
batch_op.add_column(sa.Column("token", sa.String(length=255), nullable=True))


def downgrade() -> None:
with op.batch_alter_table("public_profile_link") as batch_op:
batch_op.drop_column("token")
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""add public_profile_link table for shareable public profiles

Revision ID: c9a4b7d8e3f1
Revises: 7a8b9c0d1e2f
Create Date: 2026-09-09 23:40:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "c9a4b7d8e3f1"
down_revision: Union[str, Sequence[str], None] = "7a8b9c0d1e2f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Create the public_profile_link table."""
op.create_table(
"public_profile_link",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("token_prefix", sa.String(length=255), nullable=False),
sa.Column("token_hash", sa.String(length=255), nullable=False),
sa.Column("audience", sa.String(length=32), nullable=False),
sa.Column("visibility_config_json", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_public_profile_link_user_id", "public_profile_link", ["user_id"], unique=False)
op.create_index("ix_public_profile_link_token_prefix", "public_profile_link", ["token_prefix"], unique=False)
op.create_index("ix_public_profile_link_token_hash", "public_profile_link", ["token_hash"], unique=True)


def downgrade() -> None:
"""Drop the public_profile_link table."""
op.drop_index("ix_public_profile_link_token_hash", table_name="public_profile_link")
op.drop_index("ix_public_profile_link_token_prefix", table_name="public_profile_link")
op.drop_index("ix_public_profile_link_user_id", table_name="public_profile_link")
op.drop_table("public_profile_link")
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""add language to public_profile_link

Revision ID: d3e4f5a6b7c8
Revises: b2c3d4e5f6a7
Create Date: 2026-09-10 13:00:00.000000

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "d3e4f5a6b7c8"
down_revision: Union[str, Sequence[str], None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("public_profile_link") as batch_op:
batch_op.add_column(sa.Column("language", sa.String(length=10), nullable=True))


def downgrade() -> None:
with op.batch_alter_table("public_profile_link") as batch_op:
batch_op.drop_column("language")
24 changes: 24 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,30 @@ def get_embed_token_prefix(token: str) -> str:
return token[:12]


# --- Public profile share-link token utilities ---

PUBLIC_PROFILE_TOKEN_PREFIX = "lp_"


def generate_public_profile_token() -> str:
"""Generate a new random public profile token prefixed with 'lp_'."""
return f"{PUBLIC_PROFILE_TOKEN_PREFIX}{secrets.token_urlsafe(32)}"


def hash_public_profile_token(value: str) -> str:
"""Return a HMAC-SHA256 hex digest of a public profile token."""
return hmac.new(
settings.api_key_encryption_key.encode("utf-8"),
value.encode("utf-8"),
hashlib.sha256,
).hexdigest()


def get_public_profile_token_prefix(token: str) -> str:
"""Return the first 12 characters of the public profile token (visible prefix)."""
return token[:12]


# --- Password reset token utilities ---

_password_reset_serializer = URLSafeTimedSerializer(
Expand Down
4 changes: 3 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from app._build_info import __git_sha__, __version__
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, statistics, users
from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, share_links, statistics, users
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 Down Expand Up @@ -189,6 +189,8 @@ async def proxy_headers_middleware(request: Request, call_next) -> Response:
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(profile.router)
app.include_router(share_links.router)
app.include_router(public_profile.router)
app.include_router(oidc.router)
app.include_router(progress.router)
app.include_router(docs.router)
Expand Down
38 changes: 38 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,44 @@ class EmbedToken(SQLModel, table=True):
)


class PublicProfileAudience(str, Enum):
"""Who may access a public profile share link."""

public = "public" # everyone, including anonymous viewers
authenticated = "authenticated" # logged-in users only


class PublicProfileLink(SQLModel, table=True):
"""A shareable public profile link owned by a user."""

__tablename__: str = "public_profile_link"

id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
name: str = Field(max_length=255)
token_prefix: str = Field(index=True)
token: Optional[str] = Field(default=None, nullable=True)
token_hash: str = Field(index=True, unique=True)
audience: PublicProfileAudience = Field(default=PublicProfileAudience.public)
language: Optional[str] = Field(default=None, nullable=True)
visibility_config_json: str = Field(
default="{}",
sa_column=Column(sa.Text, default="{}"),
)
expires_at: Optional[datetime] = Field(
default=None,
sa_column=Column(UtcDateTime, default=None),
)
created_at: datetime = Field(
default_factory=utcnow,
sa_column=Column(UtcDateTime, default=utcnow),
)
revoked_at: Optional[datetime] = Field(
default=None,
sa_column=Column(UtcDateTime, default=None),
)


class ImportMapping(SQLModel, table=True):
"""A saved column-mapping configuration for data import."""

Expand Down
2 changes: 1 addition & 1 deletion backend/app/routers/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
UserSettingsRead,
UserSettingsUpdate,
)
from app.routers.statistics import MAX_CUSTOM_RANGE_DAYS
from app.services.statistics import MAX_CUSTOM_RANGE_DAYS
from app.time_utils import utcnow
from app.services.user_deletion import (
assert_not_last_admin,
Expand Down
143 changes: 143 additions & 0 deletions backend/app/routers/public_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Public (unauthenticated) profile data endpoint.

Validates a share-link token, checks expiry and audience rules, and returns a
whitelisted view of the owner's profile. Private account data (email, API
keys, settings, notes, blurbs, OIDC info) is never serialized here.
"""

import logging

from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, Security
from fastapi.security import APIKeyHeader
from sqlmodel import Session, col, select

from app.auth import hash_public_profile_token, require_user
from app.database import get_session
from app.models import PublicProfileAudience, PublicProfileLink, User
from app.schemas import (
PublicProfileBook,
PublicProfileResponse,
PublicProfileSectionKey,
PublicProfileUserInfo,
StatisticsRange,
)
from app.services.public_profile import (
BOOK_SECTIONS,
build_public_books,
filter_statistics,
load_owner_books,
parse_visibility_config,
)
from app.services.statistics import compute_statistics
from app.time_utils import utcnow

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/public-profiles", tags=["public-profile"])

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)


def get_optional_user(
request: Request,
x_api_key: str | None = Security(api_key_header),
x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"),
session: Session = Depends(get_session),
) -> User | None:
"""Resolve the viewer if a session or API key is present, else None.

This is intentionally non-fatal: public links marked ``public`` must be
viewable by anonymous visitors. Invalid credentials degrade to anonymous.
"""
if not x_api_key and request.session.get("user_id") is None:
return None
try:
return require_user(
request=request,
x_api_key=x_api_key,
x_csrf_token=x_csrf_token,
session=session,
)
except HTTPException:
return None


@router.get("/{token}", response_model=PublicProfileResponse)
def get_public_profile(
token: str,
response: Response,
viewer: User | None = Depends(get_optional_user),
session: Session = Depends(get_session),
) -> PublicProfileResponse:
"""Return the whitelisted public profile for a share-link token.

Invalid, expired, or revoked tokens all yield HTTP 404 so that link
existence cannot be probed. Tokens restricted to logged-in users yield
HTTP 401 for anonymous viewers.
"""
_with_security_headers(response)
link = session.exec(
select(PublicProfileLink).where(
PublicProfileLink.token_hash == hash_public_profile_token(token),
col(PublicProfileLink.revoked_at).is_(None),
)
).first()

if not link:
raise HTTPException(status_code=404, detail="Public profile not found")

now = utcnow()
if link.expires_at is not None and link.expires_at < now:
logger.debug("Public profile link expired: id=%s", link.id)
raise HTTPException(status_code=404, detail="Public profile not found")

if link.audience == PublicProfileAudience.authenticated and viewer is None:
raise HTTPException(status_code=401, detail="Login required to view this profile")

owner = session.get(User, link.user_id)
if owner is None:
raise HTTPException(status_code=404, detail="Public profile not found")
assert owner.id is not None

config = parse_visibility_config(link.visibility_config_json)
visible_sections = set(config.sections)

books: list[PublicProfileBook] = []
if visible_sections & set(BOOK_SECTIONS):
books = build_public_books(session, load_owner_books(session, owner.id))

statistics = None
if PublicProfileSectionKey.statistics in visible_sections:
full_stats = compute_statistics(
session, owner.id, range_value=StatisticsRange.alltime
)
statistics = filter_statistics(full_stats, config.statistics)

# The owner's name is only emitted when a section that renders it
# (username or user_info) is visible, so it cannot leak through the
# page title or share metadata otherwise.
show_name = bool(
visible_sections
& {PublicProfileSectionKey.username, PublicProfileSectionKey.user_info}
)

return PublicProfileResponse(
owner=PublicProfileUserInfo(
firstname=owner.firstname if show_name else None,
lastname=owner.lastname if show_name else None,
),
audience=link.audience,
language=link.language,
expires_at=link.expires_at,
visibility_config=config,
books=books,
statistics=statistics,
)


def _with_security_headers(response: Response) -> Response:
"""Apply baseline security headers to the unauthenticated profile response."""
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
return response
Loading