Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Dockerfile.viewer
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
35 changes: 35 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions requirements-viewer.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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




Expand Down
42 changes: 40 additions & 2 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Telegram Backup Automation - Main Package
"""

__version__ = "7.0.3"
__version__ = "7.2.0"
4 changes: 4 additions & 0 deletions src/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -48,6 +49,7 @@
ViewerAccount,
ViewerAuditLog,
ViewerSession,
ViewerToken,
)

__all__ = [
Expand All @@ -66,6 +68,8 @@
"ViewerAccount",
"ViewerAuditLog",
"ViewerSession",
"ViewerToken",
"AppSettings",
# Database management
"DatabaseManager",
"init_database",
Expand Down
Loading
Loading