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 @@

Telegram Archive

-
- - -
-
- - + +
+ +
+ + + + + + +

{{ loginError }}

- @@ -1192,6 +1213,9 @@

Admin Settings

+ @@ -1222,6 +1246,9 @@

{{ adminEditingId ? 'Edit Vie +

@@ -1250,12 +1278,94 @@

{{ adminEditingId ? 'Edit Vie + +
+ +
+

Create Share Token

+
{{ adminTokenError }}
+
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+
Token created! Copy it now — it won't be shown again.
+
+ {{ adminNewToken }} + +
+
+ + +
+
+
+
+
{{ token.label || `Token #${token.id}` }}
+
+ + · Used {{ token.use_count || 0 }}x + + + + +
+
+ + +
+
+
No share tokens yet
+
+
+
+ +
+ +
{{ new Date(log.created_at).toLocaleString() }} - {{ log.role }} + {{ log.role }} {{ log.username }} {{ log.action }} {{ log.ip_address }} @@ -1294,7 +1404,10 @@

{{ adminEditingId ? 'Edit Vie const loginError = ref('') const userRole = ref('') const currentUsername = ref('') + const noDownload = ref(false) const showAdminPanel = ref(false) + const loginMode = ref('password') // 'password' or 'token' + const tokenInput = ref('') const chatsError = ref('') // Error message when database is busy const lastBackupTime = ref(null) // Last backup time from stats const lastBackupTimeSource = ref('metadata') // Source of last backup time: 'metadata' (UTC) or 'sync_status' (server local) @@ -1815,6 +1928,7 @@

{{ adminEditingId ? 'Edit Vie isAuthenticated.value = !!data.authenticated userRole.value = data.role || '' currentUsername.value = data.username || '' + noDownload.value = !!data.no_download console.log('[DEBUG] authRequired:', authRequired.value, 'isAuthenticated:', isAuthenticated.value) if (isAuthenticated.value) { @@ -2252,16 +2366,32 @@

{{ adminEditingId ? 'Edit Vie loginError.value = '' loadingAuth.value = true try { - const res = await fetch('/api/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(loginForm.value), - }) + let res + if (loginMode.value === 'token') { + // Token-based login + res = await fetch('/auth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ token: tokenInput.value }), + }) + } else { + // Password-based login + res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(loginForm.value), + }) + } if (!res.ok) { if (res.status === 401) { - loginError.value = 'Invalid username or password' + loginError.value = loginMode.value === 'token' ? 'Invalid or expired token' : 'Invalid username or password' + return + } + if (res.status === 429) { + loginError.value = 'Too many attempts. Try again later.' return } throw new Error('Login failed') @@ -2272,6 +2402,7 @@

{{ adminEditingId ? 'Edit Vie isAuthenticated.value = true userRole.value = data.role || 'master' currentUsername.value = data.username || '' + noDownload.value = !!data.no_download await loadChats() await loadStats() await loadFolders() @@ -3278,10 +3409,16 @@

{{ adminEditingId ? 'Edit Vie const adminViewers = ref([]) const adminChats = ref([]) const adminAuditLogs = ref([]) - const adminViewerForm = ref({ username: '', password: '', allowed_chat_ids: [], is_active: true }) + const adminAuditAction = ref('') + const adminViewerForm = ref({ username: '', password: '', allowed_chat_ids: [], is_active: true, no_download: false }) const adminEditingId = ref(null) const adminError = ref('') const adminActiveTab = ref('viewers') + // v7.2.0: Share tokens + const adminTokens = ref([]) + const adminTokenForm = ref({ label: '', allowed_chat_ids: [], no_download: false, expires_at: '' }) + const adminTokenError = ref('') + const adminNewToken = ref('') const loadAdminViewers = async () => { try { @@ -3299,14 +3436,16 @@

{{ adminEditingId ? 'Edit Vie const loadAdminAudit = async () => { try { - const res = await fetch('/api/admin/audit?limit=50', { credentials: 'include' }) + let url = '/api/admin/audit?limit=50' + if (adminAuditAction.value) url += `&action=${encodeURIComponent(adminAuditAction.value)}` + const res = await fetch(url, { credentials: 'include' }) if (res.ok) { const data = await res.json(); adminAuditLogs.value = data.logs } } catch (e) { console.error('Failed to load audit logs:', e) } } const openAdminPanel = async () => { showAdminPanel.value = true - await Promise.all([loadAdminViewers(), loadAdminChats(), loadAdminAudit()]) + await Promise.all([loadAdminViewers(), loadAdminChats(), loadAdminAudit(), loadAdminTokens()]) } const saveViewer = async () => { @@ -3316,7 +3455,8 @@

{{ adminEditingId ? 'Edit Vie username: adminViewerForm.value.username, password: adminViewerForm.value.password, allowed_chat_ids: (chatIds && chatIds.length > 0) ? chatIds : null, - is_active: adminViewerForm.value.is_active ? 1 : 0 + is_active: adminViewerForm.value.is_active ? 1 : 0, + no_download: adminViewerForm.value.no_download ? 1 : 0 } try { let res @@ -3338,7 +3478,7 @@

{{ adminEditingId ? 'Edit Vie adminError.value = err.detail || 'Failed to save' return } - adminViewerForm.value = { username: '', password: '', allowed_chat_ids: [], is_active: true } + adminViewerForm.value = { username: '', password: '', allowed_chat_ids: [], is_active: true, no_download: false } adminEditingId.value = null await loadAdminViewers() } catch (e) { adminError.value = 'Network error' } @@ -3350,7 +3490,8 @@

{{ adminEditingId ? 'Edit Vie username: viewer.username, password: '', allowed_chat_ids: viewer.allowed_chat_ids ? [...viewer.allowed_chat_ids] : [], - is_active: !!viewer.is_active + is_active: !!viewer.is_active, + no_download: !!viewer.no_download } } @@ -3366,10 +3507,77 @@

{{ adminEditingId ? 'Edit Vie const cancelEdit = () => { adminEditingId.value = null - adminViewerForm.value = { username: '', password: '', allowed_chat_ids: [], is_active: true } + adminViewerForm.value = { username: '', password: '', allowed_chat_ids: [], is_active: true, no_download: false } adminError.value = '' } + // v7.2.0: Token management + const loadAdminTokens = async () => { + try { + const res = await fetch('/api/admin/tokens', { credentials: 'include' }) + if (res.ok) { const data = await res.json(); adminTokens.value = data.tokens } + } catch (e) { console.error('Failed to load tokens:', e) } + } + + const createToken = async () => { + adminTokenError.value = '' + adminNewToken.value = '' + const chatIds = adminTokenForm.value.allowed_chat_ids + if (!chatIds || chatIds.length === 0) { + adminTokenError.value = 'Select at least one chat' + return + } + // Convert local datetime to UTC ISO for consistent expiry handling + let expiresUtc = null + if (adminTokenForm.value.expires_at) { + expiresUtc = new Date(adminTokenForm.value.expires_at).toISOString() + } + const body = { + label: adminTokenForm.value.label || null, + allowed_chat_ids: chatIds, + no_download: !!adminTokenForm.value.no_download, + expires_at: expiresUtc, + } + try { + const res = await fetch('/api/admin/tokens', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + if (!res.ok) { + const err = await res.json() + adminTokenError.value = err.detail || 'Failed to create token' + return + } + const data = await res.json() + adminNewToken.value = data.token + adminTokenForm.value = { label: '', allowed_chat_ids: [], no_download: false, expires_at: '' } + await loadAdminTokens() + } catch (e) { adminTokenError.value = 'Network error' } + } + + const revokeToken = async (token) => { + if (!confirm(`Revoke token "${token.label || token.id}"?`)) return + try { + await fetch(`/api/admin/tokens/${token.id}`, { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_revoked: true }) + }) + await loadAdminTokens() + } catch (e) { console.error('Failed to revoke token:', e) } + } + + const deleteToken = async (token) => { + if (!confirm(`Permanently delete token "${token.label || token.id}"?`)) return + try { + await fetch(`/api/admin/tokens/${token.id}`, { + method: 'DELETE', credentials: 'include' + }) + await loadAdminTokens() + } catch (e) { console.error('Failed to delete token:', e) } + } + return { chats, filteredChats, @@ -3439,11 +3647,15 @@

{{ adminEditingId ? 'Edit Vie // Admin (v7.0.0) userRole, currentUsername, + noDownload, showAdminPanel, performLogout, + loginMode, + tokenInput, adminViewers, adminChats, adminAuditLogs, + adminAuditAction, adminViewerForm, adminEditingId, adminError, @@ -3454,6 +3666,15 @@

{{ adminEditingId ? 'Edit Vie deleteViewer, cancelEdit, loadAdminAudit, + // v7.2.0: Tokens + adminTokens, + adminTokenForm, + adminTokenError, + adminNewToken, + loadAdminTokens, + createToken, + revokeToken, + deleteToken, // Colors / avatars getSenderStyle, getSenderNameColor, diff --git a/src/web/thumbnails.py b/src/web/thumbnails.py new file mode 100644 index 00000000..3544e66a --- /dev/null +++ b/src/web/thumbnails.py @@ -0,0 +1,83 @@ +"""On-demand thumbnail generation with disk caching. + +Generates WebP thumbnails at whitelisted sizes, stored under +{media_root}/.thumbs/{size}/{folder}/{stem}.webp. +Pillow runs in a thread executor to avoid blocking the async event loop. +""" + +import asyncio +import logging +from pathlib import Path + +from PIL import Image + +logger = logging.getLogger(__name__) + +# Limit decompression to prevent pixel-bomb OOM attacks (~50 megapixels) +Image.MAX_IMAGE_PIXELS = 50_000_000 + +ALLOWED_SIZES: set[int] = {200, 400} +WEBP_QUALITY = 80 +_MAX_SOURCE_BYTES = 50 * 1024 * 1024 # 50 MB + +_IMAGE_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} + + +def _is_image(filename: str) -> bool: + return Path(filename).suffix.lower() in _IMAGE_EXTENSIONS + + +def _thumb_path(media_root: Path, size: int, folder: str, filename: str) -> Path: + stem = Path(filename).stem + return media_root / ".thumbs" / str(size) / folder / f"{stem}.webp" + + +def _generate_sync(source: Path, dest: Path, size: int) -> bool: + """Blocking thumbnail generation -- meant for run_in_executor.""" + try: + if source.stat().st_size > _MAX_SOURCE_BYTES: + logger.warning("Source too large for thumbnail: %s (%d bytes)", source, source.stat().st_size) + return False + dest.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source) as img: + img.thumbnail((size, size), Image.LANCZOS) + img.save(dest, "WEBP", quality=WEBP_QUALITY) + return True + except Exception as e: + logger.warning("Thumbnail generation failed for %s: %s", source, e) + return False + + +async def ensure_thumbnail(media_root: Path, size: int, folder: str, filename: str) -> Path | None: + """Return the path to a cached thumbnail, generating it if needed. + + Returns None when the request is invalid or generation fails. + Includes path traversal protection. + """ + if size not in ALLOWED_SIZES: + return None + + if not _is_image(filename): + return None + + # Path traversal protection: resolve and verify containment + media_root_resolved = media_root.resolve() + + source = (media_root / folder / filename).resolve() + if not source.is_relative_to(media_root_resolved): + return None + + dest = _thumb_path(media_root, size, folder, filename).resolve() + thumbs_root = (media_root / ".thumbs").resolve() + if not dest.is_relative_to(thumbs_root): + return None + + if dest.exists(): + return dest + + if not source.exists(): + return None + + loop = asyncio.get_running_loop() + ok = await loop.run_in_executor(None, _generate_sync, source, dest, size) + return dest if ok else None diff --git a/tests/test_multi_user_auth.py b/tests/test_multi_user_auth.py index 7080df4f..9dc95d51 100644 --- a/tests/test_multi_user_auth.py +++ b/tests/test_multi_user_auth.py @@ -284,6 +284,7 @@ def test_create_viewer(self, auth_env): "salt": "y", "allowed_chat_ids": None, "is_active": 1, + "no_download": 0, "created_by": "admin", "created_at": "2026-01-01T00:00:00", "updated_at": "2026-01-01T00:00:00", diff --git a/tests/test_v720_features.py b/tests/test_v720_features.py new file mode 100644 index 00000000..1e74e649 --- /dev/null +++ b/tests/test_v720_features.py @@ -0,0 +1,638 @@ +"""Tests for v7.2.0 features: share tokens, thumbnails, settings, no_download.""" + +import json +import os +import tempfile +import time +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def _reset_auth_module(tmp_path): + with patch.dict( + os.environ, + { + "BACKUP_PATH": str(tmp_path / "backups"), + "MEDIA_PATH": str(tmp_path / "media"), + }, + ): + os.makedirs(tmp_path / "backups", exist_ok=True) + os.makedirs(tmp_path / "media", exist_ok=True) + import src.web.main as main_mod + + main_mod._sessions.clear() + main_mod._login_attempts.clear() + yield + main_mod._sessions.clear() + main_mod._login_attempts.clear() + + +def _make_mock_db(): + db = AsyncMock() + db.get_all_chats = AsyncMock( + return_value=[ + {"id": -1001, "title": "Chat A", "type": "channel"}, + ] + ) + db.get_chat_count = AsyncMock(return_value=1) + db.get_cached_statistics = AsyncMock(return_value={"total_chats": 1, "total_messages": 10}) + db.get_metadata = AsyncMock(return_value=None) + db.get_viewer_by_username = AsyncMock(return_value=None) + db.get_all_viewer_accounts = AsyncMock(return_value=[]) + db.get_all_viewer_tokens = AsyncMock(return_value=[]) + db.create_viewer_token = AsyncMock() + db.verify_viewer_token = AsyncMock(return_value=None) + db.update_viewer_token = AsyncMock() + db.delete_viewer_token = AsyncMock(return_value=True) + db.get_all_settings = AsyncMock(return_value={}) + db.set_setting = AsyncMock() + db.get_setting = AsyncMock(return_value=None) + db.create_audit_log = AsyncMock() + db.get_audit_logs = AsyncMock(return_value=[]) + db.get_all_folders = AsyncMock(return_value=[]) + db.get_archived_chat_count = AsyncMock(return_value=0) + db.get_session = AsyncMock(return_value=None) + db.delete_session = AsyncMock() + db.save_session = AsyncMock() + db.delete_user_sessions = AsyncMock() + db.delete_sessions_by_source_token_id = AsyncMock(return_value=0) + db.load_all_sessions = AsyncMock(return_value=[]) + db.calculate_and_store_statistics = AsyncMock(return_value={"total_chats": 1}) + return db + + +@pytest.fixture +def auth_env(): + with patch.dict( + os.environ, + { + "VIEWER_USERNAME": "admin", + "VIEWER_PASSWORD": "testpass123", + "AUTH_SESSION_DAYS": "1", + "SECURE_COOKIES": "false", + }, + ): + yield + + +def _get_client(mock_db=None): + import importlib + + import src.web.main as main_mod + + importlib.reload(main_mod) + if mock_db is None: + mock_db = _make_mock_db() + main_mod.db = mock_db + return TestClient(main_mod.app, raise_server_exceptions=False), main_mod, mock_db + + +def _login_master(client): + resp = client.post("/api/login", json={"username": "admin", "password": "testpass123"}) + return resp.cookies.get("viewer_auth") + + +class TestTokenAuth: + """Tests for share token authentication.""" + + def test_token_auth_invalid_token(self, auth_env): + client, _, db = _get_client() + db.verify_viewer_token.return_value = None + resp = client.post("/auth/token", json={"token": "badtoken"}) + assert resp.status_code == 401 + + def test_token_auth_valid_token(self, auth_env): + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 1, + "label": "test-token", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 0, + } + resp = client.post("/auth/token", json={"token": "validtoken123"}) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["role"] == "token" + assert "viewer_auth" in resp.cookies + + def test_token_auth_no_download(self, auth_env): + client, _, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 2, + "label": "restricted", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 1, + } + resp = client.post("/auth/token", json={"token": "validtoken456"}) + assert resp.status_code == 200 + data = resp.json() + assert data["no_download"] is True + + def test_token_auth_empty_token(self, auth_env): + client, _, _ = _get_client() + resp = client.post("/auth/token", json={"token": ""}) + assert resp.status_code == 400 + + def test_token_auth_rate_limited(self, auth_env): + client, mod, db = _get_client() + db.verify_viewer_token.return_value = None + # Exhaust rate limit + for _ in range(16): + client.post("/auth/token", json={"token": "bad"}) + resp = client.post("/auth/token", json={"token": "bad"}) + assert resp.status_code == 429 + + +class TestTokenCRUD: + """Tests for token admin CRUD endpoints.""" + + def test_create_token(self, auth_env): + client, _, db = _get_client() + cookie = _login_master(client) + db.create_viewer_token.return_value = { + "id": 1, + "label": "my-token", + "token_hash": "h", + "token_salt": "s", + "created_by": "admin", + "allowed_chat_ids": json.dumps([-1001]), + "is_revoked": 0, + "no_download": 0, + "expires_at": None, + "last_used_at": None, + "use_count": 0, + "created_at": "2026-01-01T00:00:00", + } + resp = client.post( + "/api/admin/tokens", + json={"label": "my-token", "allowed_chat_ids": [-1001]}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + data = resp.json() + assert "token" in data # plaintext token returned + assert len(data["token"]) == 64 # 32 bytes hex + + def test_create_token_requires_chat_ids(self, auth_env): + client, _, _ = _get_client() + cookie = _login_master(client) + resp = client.post( + "/api/admin/tokens", + json={"label": "bad"}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 400 + + def test_list_tokens(self, auth_env): + client, _, db = _get_client() + cookie = _login_master(client) + db.get_all_viewer_tokens.return_value = [ + { + "id": 1, + "label": "tok", + "created_by": "admin", + "allowed_chat_ids": json.dumps([-1001]), + "is_revoked": 0, + "no_download": 0, + "expires_at": None, + "last_used_at": None, + "use_count": 5, + "created_at": "2026-01-01", + } + ] + resp = client.get("/api/admin/tokens", cookies={"viewer_auth": cookie}) + assert resp.status_code == 200 + assert len(resp.json()["tokens"]) == 1 + + def test_delete_token(self, auth_env): + client, _, db = _get_client() + cookie = _login_master(client) + resp = client.delete("/api/admin/tokens/1", cookies={"viewer_auth": cookie}) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + def test_revoke_token(self, auth_env): + client, _, db = _get_client() + cookie = _login_master(client) + db.update_viewer_token.return_value = { + "id": 1, + "label": "tok", + "allowed_chat_ids": json.dumps([-1001]), + "is_revoked": 1, + "no_download": 0, + "expires_at": None, + } + resp = client.put( + "/api/admin/tokens/1", + json={"is_revoked": True}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + assert resp.json()["is_revoked"] == 1 + + def test_tokens_require_master(self, auth_env): + client, mod, db = _get_client() + # Login as viewer (no master access) + resp = client.get("/api/admin/tokens") + assert resp.status_code == 401 + + +class TestSettings: + """Tests for app settings endpoints.""" + + def test_get_settings(self, auth_env): + client, _, db = _get_client() + cookie = _login_master(client) + db.get_all_settings.return_value = {"theme": "dark"} + resp = client.get("/api/admin/settings", cookies={"viewer_auth": cookie}) + assert resp.status_code == 200 + assert resp.json()["settings"]["theme"] == "dark" + + def test_set_setting(self, auth_env): + client, _, db = _get_client() + cookie = _login_master(client) + resp = client.put( + "/api/admin/settings/theme", + json={"value": "light"}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + assert resp.json()["key"] == "theme" + db.set_setting.assert_called_once_with("theme", "light") + + +class TestThumbnails: + """Tests for thumbnail path traversal protection.""" + + def test_thumbnail_module_traversal_protection(self): + """Test that path traversal is blocked in thumbnails module.""" + from src.web.thumbnails import ALLOWED_SIZES, _is_image + + assert 200 in ALLOWED_SIZES + assert _is_image("photo.jpg") is True + assert _is_image("document.pdf") is False + + def test_thumbnail_disallowed_size(self): + import asyncio + + from src.web.thumbnails import ensure_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + result = asyncio.run(ensure_thumbnail(Path(tmpdir), 999, "folder", "file.jpg")) + assert result is None # 999 not in ALLOWED_SIZES + + def test_thumbnail_non_image(self): + import asyncio + + from src.web.thumbnails import ensure_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + result = asyncio.run(ensure_thumbnail(Path(tmpdir), 200, "folder", "file.pdf")) + assert result is None # .pdf not an image + + def test_thumbnail_path_traversal(self): + import asyncio + + from src.web.thumbnails import ensure_thumbnail + + with tempfile.TemporaryDirectory() as tmpdir: + result = asyncio.run(ensure_thumbnail(Path(tmpdir), 200, "../../../etc", "passwd.jpg")) + assert result is None # path traversal blocked + + +class TestNoDownload: + """Tests for no_download enforcement on media endpoint.""" + + def test_auth_check_includes_no_download(self, auth_env): + client, mod, db = _get_client() + cookie = _login_master(client) + resp = client.get("/api/auth/check", cookies={"viewer_auth": cookie}) + assert resp.status_code == 200 + data = resp.json() + # Master should not have no_download + assert data.get("no_download") is False or data.get("no_download") is None or not data.get("no_download") + + def test_no_download_blocks_explicit_download(self, auth_env, tmp_path): + """no_download users cannot explicitly download files (download=1).""" + client, mod, db = _get_client() + # Create a token session with no_download=True + db.verify_viewer_token.return_value = { + "id": 10, + "label": "restricted-tok", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 1, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + assert resp.status_code == 200 + cookie = resp.cookies.get("viewer_auth") + + # Create a test media file + media_dir = tmp_path / "media" / "-1001" + media_dir.mkdir(parents=True) + (media_dir / "photo.jpg").write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) + + # Override media root + mod._media_root = tmp_path / "media" + + # Explicit download should be blocked + resp = client.get("/media/-1001/photo.jpg?download=1", cookies={"viewer_auth": cookie}) + assert resp.status_code == 403 + + def test_no_download_allows_inline_media(self, auth_env, tmp_path): + """no_download users can still view media inline (without download=1).""" + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 10, + "label": "restricted-tok", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 1, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + cookie = resp.cookies.get("viewer_auth") + + media_dir = tmp_path / "media" / "-1001" + media_dir.mkdir(parents=True) + (media_dir / "photo.jpg").write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) + mod._media_root = tmp_path / "media" + + # Inline request (no download param) should succeed + resp = client.get("/media/-1001/photo.jpg", cookies={"viewer_auth": cookie}) + assert resp.status_code == 200 + + def test_no_download_blocks_export(self, auth_env): + """no_download users cannot export chat history.""" + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 10, + "label": "restricted-tok", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 1, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + cookie = resp.cookies.get("viewer_auth") + + resp = client.get("/api/chats/-1001/export", cookies={"viewer_auth": cookie}) + assert resp.status_code == 403 + + +class TestTokenRevocation: + """Tests that token revocation/deletion invalidates active sessions.""" + + def test_revoke_token_invalidates_sessions(self, auth_env): + """Revoking a token should invalidate all sessions created from it.""" + client, mod, db = _get_client() + # Authenticate with a token + db.verify_viewer_token.return_value = { + "id": 5, + "label": "my-token", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 0, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + assert resp.status_code == 200 + token_cookie = resp.cookies.get("viewer_auth") + + # Verify session is active + assert token_cookie in mod._sessions + assert mod._sessions[token_cookie].source_token_id == 5 + + # Now login as master and revoke the token + cookie = _login_master(client) + db.update_viewer_token.return_value = { + "id": 5, + "label": "my-token", + "allowed_chat_ids": json.dumps([-1001]), + "is_revoked": 1, + "no_download": 0, + "expires_at": None, + } + resp = client.put( + "/api/admin/tokens/5", + json={"is_revoked": True}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + + # The token session should be invalidated + assert token_cookie not in mod._sessions + db.delete_sessions_by_source_token_id.assert_called_with(5) + + def test_delete_token_invalidates_sessions(self, auth_env): + """Deleting a token should invalidate all sessions created from it.""" + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 7, + "label": "temp-token", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 0, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + token_cookie = resp.cookies.get("viewer_auth") + assert token_cookie in mod._sessions + + cookie = _login_master(client) + resp = client.delete("/api/admin/tokens/7", cookies={"viewer_auth": cookie}) + assert resp.status_code == 200 + + # Session should be gone + assert token_cookie not in mod._sessions + db.delete_sessions_by_source_token_id.assert_called_with(7) + + def test_update_token_scope_invalidates_sessions(self, auth_env): + """Changing a token's allowed_chat_ids should invalidate sessions.""" + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 8, + "label": "scoped", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 0, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + token_cookie = resp.cookies.get("viewer_auth") + assert token_cookie in mod._sessions + + cookie = _login_master(client) + db.update_viewer_token.return_value = { + "id": 8, + "label": "scoped", + "allowed_chat_ids": json.dumps([-1002]), + "is_revoked": 0, + "no_download": 0, + "expires_at": None, + } + resp = client.put( + "/api/admin/tokens/8", + json={"allowed_chat_ids": [-1002]}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + assert token_cookie not in mod._sessions + + def test_update_token_label_only_keeps_sessions(self, auth_env): + """Changing only a token's label should NOT invalidate sessions.""" + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 9, + "label": "old-label", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 0, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + token_cookie = resp.cookies.get("viewer_auth") + + cookie = _login_master(client) + db.update_viewer_token.return_value = { + "id": 9, + "label": "new-label", + "allowed_chat_ids": json.dumps([-1001]), + "is_revoked": 0, + "no_download": 0, + "expires_at": None, + } + resp = client.put( + "/api/admin/tokens/9", + json={"label": "new-label"}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + # Label-only change should NOT invalidate + assert token_cookie in mod._sessions + + +class TestSessionPersistence: + """Tests that no_download and source_token_id survive session persistence.""" + + def test_no_download_persisted_in_session(self, auth_env): + """no_download should be passed to save_session for DB persistence.""" + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 3, + "label": "nd-token", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 1, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + assert resp.status_code == 200 + + # Verify save_session was called with no_download=1 and source_token_id=3 + db.save_session.assert_called() + call_kwargs = db.save_session.call_args + assert call_kwargs.kwargs.get("no_download") == 1 or call_kwargs[1].get("no_download") == 1 + + def test_source_token_id_persisted(self, auth_env): + """source_token_id should be stored in the session.""" + client, mod, db = _get_client() + db.verify_viewer_token.return_value = { + "id": 42, + "label": "tracked", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 0, + } + resp = client.post("/auth/token", json={"token": "validtoken"}) + cookie = resp.cookies.get("viewer_auth") + + assert mod._sessions[cookie].source_token_id == 42 + + # Check DB persistence + db.save_session.assert_called() + call_kwargs = db.save_session.call_args + assert call_kwargs.kwargs.get("source_token_id") == 42 or call_kwargs[1].get("source_token_id") == 42 + + def test_no_download_restored_from_db(self, auth_env): + """no_download should be correctly restored when loading session from DB.""" + client, mod, db = _get_client() + # Simulate a DB-backed session with no_download + db.get_session.return_value = { + "token": "fake-session-token", + "username": "token:test", + "role": "token", + "allowed_chat_ids": json.dumps([-1001]), + "no_download": 1, + "source_token_id": 99, + "created_at": time.time(), + "last_accessed": time.time(), + } + + # Attempt auth check with the fake session token + resp = client.get("/api/auth/check", cookies={"viewer_auth": "fake-session-token"}) + assert resp.status_code == 200 + data = resp.json() + assert data["no_download"] is True + + +class TestCreateViewerFlags: + """Tests that create_viewer passes is_active and no_download correctly.""" + + def test_create_viewer_with_no_download(self, auth_env): + """Creating a viewer with no_download=1 should persist the flag.""" + client, _, db = _get_client() + cookie = _login_master(client) + db.create_viewer_account.return_value = { + "id": 1, + "username": "testviewer", + "password_hash": "h", + "salt": "s", + "allowed_chat_ids": None, + "is_active": 1, + "no_download": 1, + "created_by": "admin", + "created_at": "2026-01-01", + "updated_at": "2026-01-01", + } + resp = client.post( + "/api/admin/viewers", + json={"username": "testviewer", "password": "securepass1", "no_download": 1}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + assert resp.json()["no_download"] == 1 + # Verify the flag was passed to the DB method + db.create_viewer_account.assert_called_once() + call_kwargs = db.create_viewer_account.call_args + assert call_kwargs.kwargs.get("no_download") == 1 or call_kwargs[1].get("no_download") == 1 + + def test_create_viewer_with_inactive(self, auth_env): + """Creating a viewer with is_active=0 should persist the flag.""" + client, _, db = _get_client() + cookie = _login_master(client) + db.create_viewer_account.return_value = { + "id": 2, + "username": "inactive", + "password_hash": "h", + "salt": "s", + "allowed_chat_ids": None, + "is_active": 0, + "no_download": 0, + "created_by": "admin", + "created_at": "2026-01-01", + "updated_at": "2026-01-01", + } + resp = client.post( + "/api/admin/viewers", + json={"username": "inactive", "password": "securepass1", "is_active": 0}, + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + db.create_viewer_account.assert_called_once() + call_kwargs = db.create_viewer_account.call_args + assert call_kwargs.kwargs.get("is_active") == 0 or call_kwargs[1].get("is_active") == 0 + + +class TestAuditLogFilter: + """Tests for audit log action filter.""" + + def test_audit_log_action_filter(self, auth_env): + client, _, db = _get_client() + cookie = _login_master(client) + db.get_audit_logs.return_value = [] + resp = client.get( + "/api/admin/audit?action=login_success", + cookies={"viewer_auth": cookie}, + ) + assert resp.status_code == 200 + db.get_audit_logs.assert_called_once_with(limit=100, offset=0, username=None, action="login_success")