diff --git a/Dockerfile.viewer b/Dockerfile.viewer index c69ddf5e..4123ab4b 100644 --- a/Dockerfile.viewer +++ b/Dockerfile.viewer @@ -6,7 +6,12 @@ WORKDIR /app # Copy requirements first for better caching COPY requirements-viewer.txt . -# Install Python dependencies (no gcc needed - viewer has no native extensions) +# Install system libraries for Pillow (thumbnail generation) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libjpeg62-turbo libwebp7 libwebpmux3 zlib1g \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies RUN pip install --no-cache-dir -r requirements-viewer.txt # Copy only the necessary application code for the viewer diff --git a/SECURITY.md b/SECURITY.md index 01fd93c8..7e18f8fd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Supported | | ------- | ------------------ | +| 7.x.x | :white_check_mark: | | 6.x.x | :white_check_mark: | | 5.x.x | :x: | | < 5.0 | :x: | diff --git a/alembic/versions/20260310_010_add_tokens_settings_no_download.py b/alembic/versions/20260310_010_add_tokens_settings_no_download.py new file mode 100644 index 00000000..032bfd8d --- /dev/null +++ b/alembic/versions/20260310_010_add_tokens_settings_no_download.py @@ -0,0 +1,94 @@ +"""Add viewer tokens, app settings, session fields, and no_download columns (v7.2.0). + +Creates: +1. viewer_tokens table for share-token authentication +2. app_settings table for cross-container configuration +3. no_download column on viewer_accounts +4. no_download + source_token_id columns on viewer_sessions + +Revision ID: 010 +Revises: 009 +Create Date: 2026-03-10 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "010" +down_revision: str | None = "009" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + existing_tables = set(inspector.get_table_names()) + + # -- viewer_tokens table -- + if "viewer_tokens" not in existing_tables: + op.create_table( + "viewer_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("label", sa.String(255), nullable=True), + sa.Column("token_hash", sa.String(128), nullable=False, unique=True), + sa.Column("token_salt", sa.String(64), nullable=False), + sa.Column("created_by", sa.String(255), nullable=False), + sa.Column("allowed_chat_ids", sa.Text(), nullable=False), + sa.Column("is_revoked", sa.Integer(), server_default="0"), + sa.Column("no_download", sa.Integer(), server_default="0"), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + sa.Column("use_count", sa.Integer(), server_default="0"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_viewer_tokens_created_by", "viewer_tokens", ["created_by"]) + op.create_index("idx_viewer_tokens_is_revoked", "viewer_tokens", ["is_revoked"]) + else: + existing_indexes = {idx["name"] for idx in inspector.get_indexes("viewer_tokens")} + if "idx_viewer_tokens_created_by" not in existing_indexes: + op.create_index("idx_viewer_tokens_created_by", "viewer_tokens", ["created_by"]) + if "idx_viewer_tokens_is_revoked" not in existing_indexes: + op.create_index("idx_viewer_tokens_is_revoked", "viewer_tokens", ["is_revoked"]) + + # -- app_settings table -- + if "app_settings" not in existing_tables: + op.create_table( + "app_settings", + sa.Column("key", sa.String(255), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()), + sa.PrimaryKeyConstraint("key"), + ) + + # -- no_download column on viewer_accounts -- + existing_va_cols = {c["name"] for c in inspector.get_columns("viewer_accounts")} + if "no_download" not in existing_va_cols: + op.add_column("viewer_accounts", sa.Column("no_download", sa.Integer(), server_default="0", nullable=True)) + + # -- no_download and source_token_id columns on viewer_sessions -- + if "viewer_sessions" in existing_tables: + existing_vs_cols = {c["name"] for c in inspector.get_columns("viewer_sessions")} + if "no_download" not in existing_vs_cols: + op.add_column("viewer_sessions", sa.Column("no_download", sa.Integer(), server_default="0", nullable=True)) + if "source_token_id" not in existing_vs_cols: + op.add_column("viewer_sessions", sa.Column("source_token_id", sa.Integer(), nullable=True)) + existing_vs_indexes = {idx["name"] for idx in inspector.get_indexes("viewer_sessions")} + if "idx_viewer_sessions_source_token" not in existing_vs_indexes: + op.create_index("idx_viewer_sessions_source_token", "viewer_sessions", ["source_token_id"]) + + +def downgrade() -> None: + op.drop_index("idx_viewer_sessions_source_token", table_name="viewer_sessions") + op.drop_column("viewer_sessions", "source_token_id") + op.drop_column("viewer_sessions", "no_download") + op.drop_column("viewer_accounts", "no_download") + op.drop_table("app_settings") + op.drop_index("idx_viewer_tokens_is_revoked", table_name="viewer_tokens") + op.drop_index("idx_viewer_tokens_created_by", table_name="viewer_tokens") + op.drop_table("viewer_tokens") diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 004f2dfb..04b56187 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,41 @@ For upgrade instructions, see [Upgrading](#upgrading) at the bottom. ## [Unreleased] +## [7.2.0] - 2026-03-10 + +### Added + +- **Share tokens** — Admins can create link-shareable tokens scoped to specific chats. Recipients authenticate via token without needing an account. Tokens support expiry dates, revocation, and use tracking +- **Download restrictions** — `no_download` flag on both viewer accounts and share tokens. Restricted users can still view media inline but cannot explicitly download files or export chat history. Download buttons hidden in the UI for restricted users +- **On-demand thumbnails** — WebP thumbnail generation at whitelisted sizes (200px, 400px) with disk caching under `{media_root}/.thumbs/`. Includes Pillow decompression bomb protection and path traversal guards +- **App settings** — Key-value `app_settings` table for cross-container configuration, with admin CRUD endpoints +- **Audit log improvements** — Action-based filtering in admin panel (prefix match for suffixed events like `viewer_updated:username`), token auth events tracked (`token_auth_success`, `token_auth_failed`, `token_created`, etc.) +- **Admin chat picker metadata** — Chat picker now returns `username`, `first_name`, `last_name` for better display +- **Token management UI** — New "Share Tokens" tab in admin panel with create, revoke, and delete controls. Plaintext token shown once at creation with copy button +- **Token login UI** — Login page has a "Share Token" tab for token-based authentication + +### Security + +- **Token revocation enforced on active sessions** — Revoking, deleting, or changing scope/permissions of a share token immediately invalidates all sessions created from that token. Sessions track `source_token_id` for precise invalidation +- **Session persistence includes restrictions** — `no_download` and `source_token_id` are now persisted in `viewer_sessions` table, surviving container restarts. Previously `no_download` was lost after restart, silently granting download access +- **Export endpoint respects no_download** — The `GET /api/chats/{chat_id}/export` endpoint now returns 403 for restricted users + +### Fixed + +- **Create viewer passes all flags** — `is_active` and `no_download` from the admin form are now correctly passed through to `create_viewer_account()`. Previously both flags were silently ignored on creation +- **Token expiry timezone handling** — Frontend now converts local datetime to UTC ISO before sending to the backend, fixing early/late expiry for non-UTC admins +- **Audit filter matches suffixed actions** — Filter now uses prefix matching so "viewer_updated" catches "viewer_updated:username" +- **Migration stamping checks all artifacts** — Entrypoint now checks `viewer_tokens`, `app_settings`, AND `viewer_accounts.no_download` before stamping migration 010 as complete + +### Changed + +- **Migration 010** — Consolidated idempotent migration creates `viewer_tokens`, `app_settings` tables and adds `no_download` column to `viewer_accounts`. Also adds `no_download` and `source_token_id` columns to `viewer_sessions` +- **Entrypoint stamping** — Updated both PostgreSQL and SQLite stamping blocks to detect all migration 010 artifacts +- **Dockerfile.viewer** — Added Pillow system dependencies (libjpeg, libwebp) for thumbnail generation +- **Version declarations** — `pyproject.toml` and `src/__init__.py` both set to 7.2.0 +- **SECURITY.md** — Added 7.x.x as a supported version +- **pyproject.toml** — Added `viewer` optional dependency group for Pillow + ## [7.1.3] - 2026-03-05 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index e7cc4a97..0fbd230a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "telegram-archive" -version = "7.1.3" +version = "7.2.0" description = "Automated Telegram backup with Docker. Performs incremental backups of messages and media on a configurable schedule." readme = "README.md" requires-python = ">=3.14" @@ -43,6 +43,9 @@ dependencies = [ ] [project.optional-dependencies] +viewer = [ + "Pillow>=10.0.0", # v7.2.0: thumbnail generation +] dev = [ "pytest>=7.0", "pytest-asyncio>=0.21.0", diff --git a/requirements-viewer.txt b/requirements-viewer.txt index 51101ea7..4835ad54 100644 --- a/requirements-viewer.txt +++ b/requirements-viewer.txt @@ -21,6 +21,9 @@ greenlet>=3.1.0 pywebpush>=2.0.0 py-vapid>=1.9.0 +# Image processing for thumbnails (v7.2.0+) +Pillow>=10.0.0 + diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 84b32150..19fd6989 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -81,6 +81,30 @@ if has_tables and not has_alembic: CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) ); \"\"\") + # Check all artifacts from migration 010: viewer_tokens, app_settings, viewer_accounts.no_download + cur.execute(\"\"\" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'viewer_tokens' + ); + \"\"\") + has_010_tokens = cur.fetchone()[0] + cur.execute(\"\"\" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'app_settings' + ); + \"\"\") + has_010_settings = cur.fetchone()[0] + cur.execute(\"\"\" + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'viewer_accounts' AND column_name = 'no_download' + ); + \"\"\") + has_010_no_download = cur.fetchone()[0] + has_010_all = has_010_tokens and has_010_settings and has_010_no_download + # Check if viewer_sessions table exists (added in migration 009) cur.execute(\"\"\" SELECT EXISTS ( @@ -145,7 +169,9 @@ if has_tables and not has_alembic: has_push_subs = cur.fetchone()[0] # Determine which version to stamp based on existing schema - if has_009_table: + if has_010_all: + stamp_version = '010' + elif has_009_table: stamp_version = '009' elif has_008_column: stamp_version = '008' @@ -212,6 +238,16 @@ if has_tables and not has_alembic: ) ''') + # Check all artifacts from migration 010: viewer_tokens, app_settings, viewer_accounts.no_download + cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='viewer_tokens'\") + has_010_tokens = cur.fetchone() is not None + cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'\") + has_010_settings = cur.fetchone() is not None + cur.execute(\"PRAGMA table_info(viewer_accounts)\") + va_columns = {row[1] for row in cur.fetchall()} + has_010_no_download = 'no_download' in va_columns + has_010_all = has_010_tokens and has_010_settings and has_010_no_download + # Check if viewer_sessions table exists (added in migration 009) cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='viewer_sessions'\") has_009_table = cur.fetchone() is not None @@ -243,7 +279,9 @@ if has_tables and not has_alembic: has_push_subs = cur.fetchone() is not None # Determine which version to stamp based on existing schema - if has_009_table: + if has_010_all: + stamp_version = '010' + elif has_009_table: stamp_version = '009' elif has_008_column: stamp_version = '008' diff --git a/src/__init__.py b/src/__init__.py index 396578e7..7cad0eaf 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ Telegram Backup Automation - Main Package """ -__version__ = "7.0.3" +__version__ = "7.2.0" diff --git a/src/db/__init__.py b/src/db/__init__.py index be2dca5c..b9c88b36 100644 --- a/src/db/__init__.py +++ b/src/db/__init__.py @@ -34,6 +34,7 @@ from .base import DatabaseManager, close_database, get_db_manager, init_database from .migrate import migrate_sqlite_to_postgres, verify_migration from .models import ( + AppSettings, Base, Chat, ChatFolder, @@ -48,6 +49,7 @@ ViewerAccount, ViewerAuditLog, ViewerSession, + ViewerToken, ) __all__ = [ @@ -66,6 +68,8 @@ "ViewerAccount", "ViewerAuditLog", "ViewerSession", + "ViewerToken", + "AppSettings", # Database management "DatabaseManager", "init_database", diff --git a/src/db/adapter.py b/src/db/adapter.py index 44661b6c..0d5308b5 100644 --- a/src/db/adapter.py +++ b/src/db/adapter.py @@ -7,9 +7,11 @@ import asyncio import glob +import hashlib import json import logging import os +import secrets import shutil from datetime import datetime from functools import wraps @@ -21,6 +23,7 @@ from .base import DatabaseManager from .models import ( + AppSettings, Chat, ChatFolder, ChatFolderMember, @@ -34,6 +37,7 @@ ViewerAccount, ViewerAuditLog, ViewerSession, + ViewerToken, ) logger = logging.getLogger(__name__) @@ -1676,6 +1680,8 @@ async def create_viewer_account( salt: str, allowed_chat_ids: str | None = None, created_by: str | None = None, + is_active: int = 1, + no_download: int = 0, ) -> dict[str, Any]: """Create a new viewer account. Returns the created account dict.""" async with self.db_manager.async_session_factory() as session: @@ -1685,6 +1691,8 @@ async def create_viewer_account( salt=salt, allowed_chat_ids=allowed_chat_ids, created_by=created_by, + is_active=is_active, + no_download=no_download, ) session.add(account) await session.commit() @@ -1740,6 +1748,7 @@ def _viewer_account_to_dict(account: ViewerAccount) -> dict[str, Any]: "salt": account.salt, "allowed_chat_ids": account.allowed_chat_ids, "is_active": account.is_active, + "no_download": account.no_download, "created_by": account.created_by, "created_at": account.created_at.isoformat() if account.created_at else None, "updated_at": account.updated_at.isoformat() if account.updated_at else None, @@ -1774,12 +1783,14 @@ async def create_audit_log( await session.commit() async def get_audit_logs( - self, limit: int = 100, offset: int = 0, username: str | None = None + self, limit: int = 100, offset: int = 0, username: str | None = None, action: str | None = None ) -> list[dict[str, Any]]: async with self.db_manager.async_session_factory() as session: stmt = select(ViewerAuditLog).order_by(ViewerAuditLog.created_at.desc()) if username: stmt = stmt.where(ViewerAuditLog.username == username) + if action: + stmt = stmt.where(ViewerAuditLog.action.startswith(action)) stmt = stmt.limit(limit).offset(offset) result = await session.execute(stmt) return [ @@ -1810,31 +1821,29 @@ async def save_session( allowed_chat_ids: str | None, created_at: float, last_accessed: float, + no_download: int = 0, + source_token_id: int | None = None, ) -> None: """Save or update a session in the database.""" async with self.db_manager.async_session_factory() as session: + values = { + "token": token, + "username": username, + "role": role, + "allowed_chat_ids": allowed_chat_ids, + "no_download": no_download, + "source_token_id": source_token_id, + "created_at": created_at, + "last_accessed": last_accessed, + } if self._is_sqlite: - stmt = sqlite_insert(ViewerSession).values( - token=token, - username=username, - role=role, - allowed_chat_ids=allowed_chat_ids, - created_at=created_at, - last_accessed=last_accessed, - ) + stmt = sqlite_insert(ViewerSession).values(**values) stmt = stmt.on_conflict_do_update( index_elements=["token"], set_={"last_accessed": last_accessed}, ) else: - stmt = pg_insert(ViewerSession).values( - token=token, - username=username, - role=role, - allowed_chat_ids=allowed_chat_ids, - created_at=created_at, - last_accessed=last_accessed, - ) + stmt = pg_insert(ViewerSession).values(**values) stmt = stmt.on_conflict_do_update( index_elements=["token"], set_={"last_accessed": last_accessed}, @@ -1882,6 +1891,14 @@ async def cleanup_expired_sessions(self, max_age_seconds: float) -> int: await session.commit() return result.rowcount + @retry_on_locked() + async def delete_sessions_by_source_token_id(self, token_id: int) -> int: + """Delete all sessions created from a specific share token.""" + async with self.db_manager.async_session_factory() as session: + result = await session.execute(delete(ViewerSession).where(ViewerSession.source_token_id == token_id)) + await session.commit() + return result.rowcount + @staticmethod def _viewer_session_to_dict(row: ViewerSession) -> dict[str, Any]: return { @@ -1889,10 +1906,142 @@ def _viewer_session_to_dict(row: ViewerSession) -> dict[str, Any]: "username": row.username, "role": row.role, "allowed_chat_ids": row.allowed_chat_ids, + "no_download": row.no_download, + "source_token_id": row.source_token_id, "created_at": row.created_at, "last_accessed": row.last_accessed, } + # ======================================================================== + # Viewer Tokens (v7.2.0 - share tokens) + # ======================================================================== + + @retry_on_locked() + async def create_viewer_token( + self, + label: str | None, + token_hash: str, + token_salt: str, + created_by: str, + allowed_chat_ids: str, + no_download: int = 0, + expires_at: datetime | None = None, + ) -> dict[str, Any]: + """Create a new share token. Returns the created token dict.""" + async with self.db_manager.async_session_factory() as session: + token = ViewerToken( + label=label, + token_hash=token_hash, + token_salt=token_salt, + created_by=created_by, + allowed_chat_ids=allowed_chat_ids, + no_download=no_download, + expires_at=expires_at, + ) + session.add(token) + await session.commit() + await session.refresh(token) + return self._viewer_token_to_dict(token) + + async def get_all_viewer_tokens(self) -> list[dict[str, Any]]: + """Get all tokens (for admin panel).""" + async with self.db_manager.async_session_factory() as session: + result = await session.execute(select(ViewerToken).order_by(ViewerToken.created_at.desc())) + return [self._viewer_token_to_dict(t) for t in result.scalars().all()] + + async def verify_viewer_token(self, plaintext_token: str) -> dict[str, Any] | None: + """Verify a plaintext token against stored hashes. Returns token dict or None.""" + async with self.db_manager.async_session_factory() as session: + result = await session.execute(select(ViewerToken).where(ViewerToken.is_revoked == 0)) + for record in result.scalars().all(): + if record.expires_at and record.expires_at < datetime.utcnow(): + continue + computed = hashlib.pbkdf2_hmac( + "sha256", plaintext_token.encode(), bytes.fromhex(record.token_salt), 600_000 + ).hex() + if secrets.compare_digest(computed, record.token_hash): + record.last_used_at = datetime.utcnow() + record.use_count = (record.use_count or 0) + 1 + await session.commit() + return self._viewer_token_to_dict(record) + return None + + @retry_on_locked() + async def update_viewer_token(self, token_id: int, **kwargs) -> dict[str, Any] | None: + """Update token fields. Supports: label, allowed_chat_ids, is_revoked, no_download.""" + async with self.db_manager.async_session_factory() as session: + result = await session.execute(select(ViewerToken).where(ViewerToken.id == token_id)) + token = result.scalar_one_or_none() + if not token: + return None + allowed_fields = {"label", "allowed_chat_ids", "is_revoked", "no_download"} + for key, value in kwargs.items(): + if key in allowed_fields: + setattr(token, key, value) + await session.commit() + await session.refresh(token) + return self._viewer_token_to_dict(token) + + @retry_on_locked() + async def delete_viewer_token(self, token_id: int) -> bool: + async with self.db_manager.async_session_factory() as session: + result = await session.execute(delete(ViewerToken).where(ViewerToken.id == token_id)) + await session.commit() + return result.rowcount > 0 + + @staticmethod + def _viewer_token_to_dict(token: ViewerToken) -> dict[str, Any]: + return { + "id": token.id, + "label": token.label, + "token_hash": token.token_hash, + "token_salt": token.token_salt, + "created_by": token.created_by, + "allowed_chat_ids": token.allowed_chat_ids, + "is_revoked": token.is_revoked, + "no_download": token.no_download, + "expires_at": token.expires_at.isoformat() if token.expires_at else None, + "last_used_at": token.last_used_at.isoformat() if token.last_used_at else None, + "use_count": token.use_count, + "created_at": token.created_at.isoformat() if token.created_at else None, + } + + # ======================================================================== + # App Settings (v7.2.0 - key-value store) + # ======================================================================== + + @retry_on_locked() + async def set_setting(self, key: str, value: str) -> None: + """Set a key-value setting (upsert).""" + async with self.db_manager.async_session_factory() as session: + if self._is_sqlite: + stmt = sqlite_insert(AppSettings).values(key=key, value=value, updated_at=datetime.utcnow()) + stmt = stmt.on_conflict_do_update( + index_elements=["key"], + set_={"value": value, "updated_at": datetime.utcnow()}, + ) + else: + stmt = pg_insert(AppSettings).values(key=key, value=value, updated_at=datetime.utcnow()) + stmt = stmt.on_conflict_do_update( + index_elements=["key"], + set_={"value": value, "updated_at": datetime.utcnow()}, + ) + await session.execute(stmt) + await session.commit() + + async def get_setting(self, key: str) -> str | None: + """Get a setting value by key. Returns None if not found.""" + async with self.db_manager.async_session_factory() as session: + result = await session.execute(select(AppSettings).where(AppSettings.key == key)) + row = result.scalar_one_or_none() + return row.value if row else None + + async def get_all_settings(self) -> dict[str, str]: + """Get all settings as a dict.""" + async with self.db_manager.async_session_factory() as session: + result = await session.execute(select(AppSettings)) + return {row.key: row.value for row in result.scalars().all()} + async def close(self) -> None: """Close database connections.""" await self.db_manager.close() diff --git a/src/db/models.py b/src/db/models.py index cc944dc9..4fa5d92c 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -342,6 +342,7 @@ class ViewerAccount(Base): salt: Mapped[str] = mapped_column(String(64), nullable=False) allowed_chat_ids: Mapped[str | None] = mapped_column(Text) # JSON array of chat IDs, NULL = all is_active: Mapped[int] = mapped_column(Integer, default=1, server_default="1") + no_download: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # v7.2.0 created_by: Mapped[str | None] = mapped_column(String(255)) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( @@ -380,12 +381,58 @@ class ViewerSession(Base): token: Mapped[str] = mapped_column(String(64), primary_key=True) username: Mapped[str] = mapped_column(String(255), nullable=False) - role: Mapped[str] = mapped_column(String(20), nullable=False) # "master" or "viewer" + role: Mapped[str] = mapped_column(String(20), nullable=False) # "master", "viewer", or "token" allowed_chat_ids: Mapped[str | None] = mapped_column(Text) # JSON array or NULL = all chats + no_download: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # v7.2.0 + source_token_id: Mapped[int | None] = mapped_column(Integer) # v7.2.0: FK to viewer_tokens.id for revocation created_at: Mapped[float] = mapped_column(Float, nullable=False) last_accessed: Mapped[float] = mapped_column(Float, nullable=False) __table_args__ = ( Index("idx_viewer_sessions_username", "username"), Index("idx_viewer_sessions_created_at", "created_at"), + Index("idx_viewer_sessions_source_token", "source_token_id"), + ) + + +class ViewerToken(Base): + """Share tokens for scoped chat access without username/password. + + v7.2.0: Admins create tokens granting time-bound access to specific chats. + Tokens are hashed (PBKDF2-SHA256) - plaintext shown only at creation. + """ + + __tablename__ = "viewer_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + label: Mapped[str | None] = mapped_column(String(255)) + token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + token_salt: Mapped[str] = mapped_column(String(64), nullable=False) + created_by: Mapped[str] = mapped_column(String(255), nullable=False) + allowed_chat_ids: Mapped[str] = mapped_column(Text, nullable=False) # JSON array of chat IDs + is_revoked: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + no_download: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + expires_at: Mapped[datetime | None] = mapped_column(DateTime) # NULL = no expiry + last_used_at: Mapped[datetime | None] = mapped_column(DateTime) + use_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, server_default=func.now()) + + __table_args__ = ( + Index("idx_viewer_tokens_created_by", "created_by"), + Index("idx_viewer_tokens_is_revoked", "is_revoked"), + ) + + +class AppSettings(Base): + """Key-value settings shared between backup and viewer containers. + + v7.2.0: Used for backup schedule override, viewer tracking, and status. + """ + + __tablename__ = "app_settings" + + key: Mapped[str] = mapped_column(String(255), primary_key=True) + value: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now() ) diff --git a/src/web/main.py b/src/web/main.py index 39773e1f..34b4c834 100644 --- a/src/web/main.py +++ b/src/web/main.py @@ -327,6 +327,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: username=row["username"], role=row["role"], allowed_chat_ids=allowed, + no_download=bool(row.get("no_download", 0)), + source_token_id=row.get("source_token_id"), created_at=row["created_at"], last_accessed=row["last_accessed"], ) @@ -446,8 +448,9 @@ async def add_security_headers(request: Request, call_next): @dataclass class UserContext: username: str - role: str # "master" or "viewer" + role: str # "master", "viewer", or "token" allowed_chat_ids: set[int] | None = None # None = all chats + no_download: bool = False # v7.2.0: restrict file downloads @dataclass @@ -455,6 +458,8 @@ class SessionData: username: str role: str allowed_chat_ids: set[int] | None = None + no_download: bool = False + source_token_id: int | None = None # v7.2.0: tracks originating share token for revocation created_at: float = field(default_factory=time.time) last_accessed: float = field(default_factory=time.time) @@ -484,7 +489,13 @@ def _record_login_attempt(ip: str) -> None: _login_attempts.setdefault(ip, []).append(time.time()) -async def _create_session(username: str, role: str, allowed_chat_ids: set[int] | None = None) -> str: +async def _create_session( + username: str, + role: str, + allowed_chat_ids: set[int] | None = None, + no_download: bool = False, + source_token_id: int | None = None, +) -> str: """Create a new session, evicting oldest if user exceeds max sessions.""" user_sessions = [(k, v) for k, v in _sessions.items() if v.username == username] if len(user_sessions) >= _MAX_SESSIONS_PER_USER: @@ -503,6 +514,8 @@ async def _create_session(username: str, role: str, allowed_chat_ids: set[int] | username=username, role=role, allowed_chat_ids=allowed_chat_ids, + no_download=no_download, + source_token_id=source_token_id, created_at=now, last_accessed=now, ) @@ -518,6 +531,8 @@ async def _create_session(username: str, role: str, allowed_chat_ids: set[int] | allowed_chat_ids=chat_ids_json, created_at=now, last_accessed=now, + no_download=1 if no_download else 0, + source_token_id=source_token_id, ) except Exception as e: logger.warning(f"Failed to persist session to database: {e}") @@ -537,6 +552,18 @@ async def _invalidate_user_sessions(username: str) -> None: logger.warning(f"Failed to delete DB sessions for {username}: {e}") +async def _invalidate_token_sessions(token_id: int) -> None: + """Remove all sessions created from a specific share token (on revoke/delete/update).""" + to_remove = [k for k, v in _sessions.items() if v.source_token_id == token_id] + for k in to_remove: + _sessions.pop(k, None) + if db: + try: + await db.delete_sessions_by_source_token_id(token_id) + except Exception as e: + logger.warning(f"Failed to delete token sessions for token_id={token_id}: {e}") + + def _get_secure_cookies(request: Request) -> bool: secure_env = os.getenv("SECURE_COOKIES", "").strip().lower() if secure_env == "true": @@ -576,6 +603,8 @@ async def _resolve_session(auth_cookie: str) -> SessionData | None: username=row["username"], role=row["role"], allowed_chat_ids=allowed, + no_download=bool(row.get("no_download", 0)), + source_token_id=row.get("source_token_id"), created_at=row["created_at"], last_accessed=row["last_accessed"], ) @@ -604,6 +633,7 @@ async def require_auth(auth_cookie: str | None = Cookie(default=None, alias=AUTH username=session.username, role=session.role, allowed_chat_ids=session.allowed_chat_ids, + no_download=session.no_download, ) @@ -662,12 +692,44 @@ async def serve_service_worker(): _media_root = Path(config.media_path).resolve() if os.path.exists(config.media_path) else None +# Thumbnail endpoint MUST be defined before the catch-all /media/{path:path} route +@app.get("/media/thumb/{size}/{folder:path}/{filename}") +async def serve_thumbnail(size: int, folder: str, filename: str, user: UserContext = Depends(require_auth)): + """Serve on-demand generated thumbnails with auth and path traversal protection.""" + if not _media_root: + raise HTTPException(status_code=404, detail="Media directory not configured") + + # Chat-level access check + user_chat_ids = get_user_chat_ids(user) + if user_chat_ids is not None: + try: + media_chat_id = int(folder.split("/")[0]) + if media_chat_id not in user_chat_ids: + raise HTTPException(status_code=403, detail="Access denied") + except ValueError: + pass + + from .thumbnails import ensure_thumbnail + + thumb_path = await ensure_thumbnail(_media_root, size, folder, filename) + if not thumb_path: + raise HTTPException(status_code=404, detail="Thumbnail not available") + + return FileResponse(thumb_path, media_type="image/webp", headers={"Cache-Control": "public, max-age=86400"}) + + @app.get("/media/{path:path}") -async def serve_media(path: str, user: UserContext = Depends(require_auth)): - """Serve media files with authentication and path traversal protection.""" +async def serve_media(path: str, download: int = Query(0), user: UserContext = Depends(require_auth)): + """Serve media files with authentication, path traversal protection, and no_download enforcement.""" if not _media_root: raise HTTPException(status_code=404, detail="Media directory not configured") + # v7.2.0: Server-side download restriction + # Inline rendering (images, video, audio in browser) is always allowed. + # Explicit downloads (download=1 query param) are blocked for restricted users. + if user.no_download and download: + raise HTTPException(status_code=403, detail="Downloads disabled for this account") + # Reject path traversal and absolute paths before any filesystem operations if ".." in path.split("/") or path.startswith("/"): raise HTTPException(status_code=403, detail="Access denied") @@ -728,6 +790,7 @@ async def check_auth(auth_cookie: str | None = Cookie(default=None, alias=AUTH_C "auth_required": True, "role": session.role, "username": session.username, + "no_download": session.no_download, } @@ -776,7 +839,8 @@ async def login(request: Request): except json.JSONDecodeError, TypeError: allowed = None - token = await _create_session(username, "viewer", allowed) + viewer_no_download = bool(viewer.get("no_download", 0)) + token = await _create_session(username, "viewer", allowed, no_download=viewer_no_download) response = JSONResponse({"success": True, "role": "viewer", "username": username}) response.set_cookie( key=AUTH_COOKIE_NAME, @@ -870,6 +934,98 @@ async def logout( return response +# ============================================================================ +# Share Token Authentication (v7.2.0) +# ============================================================================ + + +@app.post("/auth/token") +async def auth_via_token(request: Request): + """Authenticate using a share token. Creates a session scoped to the token's allowed chats.""" + if not db: + raise HTTPException(status_code=500, detail="Database not available") + + direct_ip = request.client.host if request.client else "unknown" + _trusted = direct_ip.startswith(("172.", "10.", "192.168.", "127.")) or direct_ip in ("::1", "localhost") + if _trusted: + client_ip = ( + request.headers.get("x-forwarded-for", "").split(",")[0].strip() + or request.headers.get("x-real-ip", "") + or direct_ip + ) + else: + client_ip = direct_ip + + if not _check_rate_limit(client_ip): + raise HTTPException(status_code=429, detail="Too many attempts. Try again later.") + + try: + data = await request.json() + plaintext_token = data.get("token", "").strip() + except Exception: + raise HTTPException(status_code=400, detail="Invalid request") + + if not plaintext_token: + raise HTTPException(status_code=400, detail="Token required") + + _record_login_attempt(client_ip) + + token_record = await db.verify_viewer_token(plaintext_token) + if not token_record: + await db.create_audit_log( + username="(token)", + role="token", + action="token_auth_failed", + endpoint="/auth/token", + ip_address=client_ip, + ) + raise HTTPException(status_code=401, detail="Invalid or expired token") + + allowed = None + if token_record["allowed_chat_ids"]: + try: + allowed = set(json.loads(token_record["allowed_chat_ids"])) + except json.JSONDecodeError, TypeError: + allowed = None + + token_no_download = bool(token_record.get("no_download", 0)) + token_label = token_record.get("label") or f"token:{token_record['id']}" + session_token = await _create_session( + username=f"token:{token_label}", + role="token", + allowed_chat_ids=allowed, + no_download=token_no_download, + source_token_id=token_record["id"], + ) + + response = JSONResponse( + { + "success": True, + "role": "token", + "username": f"token:{token_label}", + "no_download": token_no_download, + } + ) + response.set_cookie( + key=AUTH_COOKIE_NAME, + value=session_token, + httponly=True, + secure=_get_secure_cookies(request), + samesite="lax", + max_age=AUTH_SESSION_SECONDS, + ) + + await db.create_audit_log( + username=f"token:{token_label}", + role="token", + action="token_auth_success", + endpoint="/auth/token", + ip_address=client_ip, + ) + + return response + + def _find_avatar_path(chat_id: int, chat_type: str) -> str | None: """Find avatar file path for a chat. @@ -1355,6 +1511,8 @@ async def get_message_by_date( @app.get("/api/chats/{chat_id}/export") async def export_chat(chat_id: int, user: UserContext = Depends(require_auth)): """Export chat history to JSON.""" + if user.no_download: + raise HTTPException(status_code=403, detail="Downloads disabled for this account") user_chat_ids = get_user_chat_ids(user) if user_chat_ids is not None and chat_id not in user_chat_ids: raise HTTPException(status_code=403, detail="Access denied") @@ -1411,6 +1569,7 @@ async def list_viewers(user: UserContext = Depends(require_master)): "username": v["username"], "allowed_chat_ids": json.loads(v["allowed_chat_ids"]) if v["allowed_chat_ids"] else None, "is_active": v["is_active"], + "no_download": v.get("no_download", 0), "created_by": v["created_by"], "created_at": v["created_at"], "updated_at": v["updated_at"], @@ -1430,6 +1589,8 @@ async def create_viewer(request: Request, user: UserContext = Depends(require_ma username = data.get("username", "").strip() password = data.get("password", "").strip() allowed_chat_ids = data.get("allowed_chat_ids") + is_active = 1 if data.get("is_active", 1) else 0 + viewer_no_download = 1 if data.get("no_download", 0) else 0 if not username or len(username) < 3: raise HTTPException(status_code=400, detail="Username must be at least 3 characters") @@ -1458,6 +1619,8 @@ async def create_viewer(request: Request, user: UserContext = Depends(require_ma salt=salt, allowed_chat_ids=chat_ids_json, created_by=user.username, + is_active=is_active, + no_download=viewer_no_download, ) await db.create_audit_log( @@ -1473,6 +1636,7 @@ async def create_viewer(request: Request, user: UserContext = Depends(require_ma "username": account["username"], "allowed_chat_ids": json.loads(chat_ids_json) if chat_ids_json else None, "is_active": account["is_active"], + "no_download": account["no_download"], } @@ -1510,6 +1674,9 @@ async def update_viewer(viewer_id: int, request: Request, user: UserContext = De if "is_active" in data: updates["is_active"] = 1 if data["is_active"] else 0 + if "no_download" in data: + updates["no_download"] = 1 if data["no_download"] else 0 + if not updates: raise HTTPException(status_code=400, detail="No fields to update") @@ -1555,7 +1722,7 @@ async def delete_viewer(viewer_id: int, request: Request, user: UserContext = De @app.get("/api/admin/chats") async def admin_list_chats(user: UserContext = Depends(require_master)): - """List all chats for the admin chat picker.""" + """List all chats for the admin chat picker (includes user metadata for display).""" chats = await db.get_all_chats() result = [] for c in chats: @@ -1563,7 +1730,16 @@ async def admin_list_chats(user: UserContext = Depends(require_master)): if not title: parts = [c.get("first_name", ""), c.get("last_name", "")] title = " ".join(p for p in parts if p) or c.get("username") or str(c["id"]) - result.append({"id": c["id"], "title": title, "type": c.get("type")}) + result.append( + { + "id": c["id"], + "title": title, + "type": c.get("type"), + "username": c.get("username"), + "first_name": c.get("first_name"), + "last_name": c.get("last_name"), + } + ) return {"chats": result} @@ -1572,13 +1748,215 @@ async def get_audit_log( limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), username: str | None = Query(None), + action: str | None = Query(None), user: UserContext = Depends(require_master), ): - """Get paginated audit log entries.""" - logs = await db.get_audit_logs(limit=limit, offset=offset, username=username) + """Get paginated audit log entries with optional username and action filters.""" + logs = await db.get_audit_logs(limit=limit, offset=offset, username=username, action=action) return {"logs": logs, "limit": limit, "offset": offset} +# ============================================================================ +# Share Token Admin Endpoints (v7.2.0) — Master-only token management +# ============================================================================ + + +@app.get("/api/admin/tokens") +async def list_tokens(user: UserContext = Depends(require_master)): + """List all share tokens.""" + tokens = await db.get_all_viewer_tokens() + safe = [] + for t in tokens: + safe.append( + { + "id": t["id"], + "label": t["label"], + "created_by": t["created_by"], + "allowed_chat_ids": json.loads(t["allowed_chat_ids"]) if t["allowed_chat_ids"] else None, + "is_revoked": t["is_revoked"], + "no_download": t["no_download"], + "expires_at": t["expires_at"], + "last_used_at": t["last_used_at"], + "use_count": t["use_count"], + "created_at": t["created_at"], + } + ) + return {"tokens": safe} + + +@app.post("/api/admin/tokens") +async def create_token(request: Request, user: UserContext = Depends(require_master)): + """Create a new share token. Returns the plaintext token only once.""" + try: + data = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + label = (data.get("label") or "").strip() or None + allowed_chat_ids = data.get("allowed_chat_ids") + no_download = 1 if data.get("no_download") else 0 + expires_at = None + if data.get("expires_at"): + try: + expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid expires_at format. Use ISO 8601.") + + if not allowed_chat_ids or not isinstance(allowed_chat_ids, list): + raise HTTPException(status_code=400, detail="allowed_chat_ids is required (list of chat IDs)") + + try: + chat_ids_json = json.dumps([int(cid) for cid in allowed_chat_ids]) + except ValueError, TypeError: + raise HTTPException(status_code=400, detail="Invalid chat ID format") + + # Generate token: 32 bytes = 64 hex chars + plaintext_token = secrets.token_hex(32) + salt = secrets.token_hex(32) + token_hash = hashlib.pbkdf2_hmac("sha256", plaintext_token.encode(), bytes.fromhex(salt), 600_000).hex() + + token_record = await db.create_viewer_token( + label=label, + token_hash=token_hash, + token_salt=salt, + created_by=user.username, + allowed_chat_ids=chat_ids_json, + no_download=no_download, + expires_at=expires_at, + ) + + await db.create_audit_log( + username=user.username, + role="master", + action=f"token_created:{token_record['id']}", + endpoint="/api/admin/tokens", + ip_address=request.client.host if request.client else None, + ) + + return { + "id": token_record["id"], + "label": token_record["label"], + "token": plaintext_token, # Only returned once at creation time + "allowed_chat_ids": json.loads(chat_ids_json), + "no_download": token_record["no_download"], + "expires_at": token_record["expires_at"], + "created_at": token_record["created_at"], + } + + +@app.put("/api/admin/tokens/{token_id}") +async def update_token(token_id: int, request: Request, user: UserContext = Depends(require_master)): + """Update a share token (label, allowed_chat_ids, is_revoked, no_download).""" + try: + data = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + updates = {} + if "label" in data: + updates["label"] = (data["label"] or "").strip() or None + if "allowed_chat_ids" in data: + allowed = data["allowed_chat_ids"] + if allowed is None or not isinstance(allowed, list): + raise HTTPException(status_code=400, detail="allowed_chat_ids must be a list") + try: + updates["allowed_chat_ids"] = json.dumps([int(cid) for cid in allowed]) + except ValueError, TypeError: + raise HTTPException(status_code=400, detail="Invalid chat ID format") + if "is_revoked" in data: + updates["is_revoked"] = 1 if data["is_revoked"] else 0 + if "no_download" in data: + updates["no_download"] = 1 if data["no_download"] else 0 + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + updated = await db.update_viewer_token(token_id, **updates) + if not updated: + raise HTTPException(status_code=404, detail="Token not found") + + # Invalidate all active sessions from this token when scope/access changes + scope_changed = any(k in updates for k in ("is_revoked", "allowed_chat_ids", "no_download")) + if scope_changed: + await _invalidate_token_sessions(token_id) + + await db.create_audit_log( + username=user.username, + role="master", + action=f"token_updated:{token_id}", + endpoint=f"/api/admin/tokens/{token_id}", + ip_address=request.client.host if request.client else None, + ) + + return { + "id": updated["id"], + "label": updated["label"], + "allowed_chat_ids": json.loads(updated["allowed_chat_ids"]) if updated["allowed_chat_ids"] else None, + "is_revoked": updated["is_revoked"], + "no_download": updated["no_download"], + "expires_at": updated["expires_at"], + } + + +@app.delete("/api/admin/tokens/{token_id}") +async def delete_token(token_id: int, request: Request, user: UserContext = Depends(require_master)): + """Delete a share token permanently and invalidate all its active sessions.""" + await _invalidate_token_sessions(token_id) + deleted = await db.delete_viewer_token(token_id) + if not deleted: + raise HTTPException(status_code=404, detail="Token not found") + + await db.create_audit_log( + username=user.username, + role="master", + action=f"token_deleted:{token_id}", + endpoint=f"/api/admin/tokens/{token_id}", + ip_address=request.client.host if request.client else None, + ) + + return {"success": True} + + +# ============================================================================ +# App Settings Endpoints (v7.2.0) — Master-only key-value configuration +# ============================================================================ + + +@app.get("/api/admin/settings") +async def get_settings(user: UserContext = Depends(require_master)): + """Get all app settings.""" + settings = await db.get_all_settings() + return {"settings": settings} + + +@app.put("/api/admin/settings/{key}") +async def set_setting(key: str, request: Request, user: UserContext = Depends(require_master)): + """Set an app setting value.""" + if not key or len(key) > 255: + raise HTTPException(status_code=400, detail="Invalid key") + + try: + data = await request.json() + value = data.get("value") + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + if value is None: + raise HTTPException(status_code=400, detail="value is required") + + await db.set_setting(key, str(value)) + + await db.create_audit_log( + username=user.username, + role="master", + action=f"setting_updated:{key}", + endpoint=f"/api/admin/settings/{key}", + ip_address=request.client.host if request.client else None, + ) + + return {"key": key, "value": str(value)} + + # ============================================================================ # Real-time WebSocket Endpoints (v5.0) # ============================================================================ diff --git a/src/web/templates/index.html b/src/web/templates/index.html index 1e824c92..1725d0fd 100644 --- a/src/web/templates/index.html +++ b/src/web/templates/index.html @@ -292,18 +292,38 @@