Skip to content

Commit 7fc72bb

Browse files
committed
fix: address all review findings from PR #89
High: - Token revocation now invalidates active sessions immediately. Sessions track source_token_id; revoke/delete/scope-change calls _invalidate_token_sessions() to purge both in-memory and DB sessions. - Migration 010 stamping checks all 3 artifacts (viewer_tokens, app_settings, viewer_accounts.no_download) before stamping complete. Medium-High: - no_download and source_token_id persisted in viewer_sessions table. save_session() stores both; _viewer_session_to_dict() returns both; _resolve_session() restores them on DB fallback/restart. Medium: - create_viewer() now reads and passes is_active and no_download from request body to create_viewer_account(). - no_download no longer breaks inline media. Only explicit downloads (download=1 query param) and exports are blocked. Frontend hides download buttons for restricted users. - Token expiry timezone fixed: frontend converts datetime-local to UTC ISO before sending. Low-Medium: - Audit log filter uses startswith() instead of exact match, so "viewer_updated" matches "viewer_updated:username". Standards: - Version bumped to 7.2.0 in pyproject.toml and src/__init__.py - SECURITY.md: added 7.x.x as supported - pyproject.toml: added viewer optional dep group for Pillow - CHANGELOG updated with all security/fix entries Tests (12 new): - Token revocation invalidates sessions (revoke, delete, scope change) - Label-only update preserves sessions - no_download blocks explicit download but allows inline - no_download blocks export - no_download persisted via save_session - source_token_id persisted and tracked - no_download restored from DB session - create_viewer passes no_download and is_active flags
1 parent fb3b0d8 commit 7fc72bb

11 files changed

Lines changed: 464 additions & 43 deletions

File tree

SECURITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
| Version | Supported |
66
| ------- | ------------------ |
7+
| 7.x.x | :white_check_mark: |
78
| 6.x.x | :white_check_mark: |
89
| 5.x.x | :x: |
910
| < 5.0 | :x: |

alembic/versions/20260310_010_add_tokens_settings_no_download.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
"""Add viewer tokens, app settings, and no_download columns (v7.2.0).
1+
"""Add viewer tokens, app settings, session fields, and no_download columns (v7.2.0).
22
33
Creates:
44
1. viewer_tokens table for share-token authentication
55
2. app_settings table for cross-container configuration
66
3. no_download column on viewer_accounts
7+
4. no_download + source_token_id columns on viewer_sessions
78
89
Revision ID: 010
910
Revises: 009
@@ -70,8 +71,22 @@ def upgrade() -> None:
7071
if "no_download" not in existing_va_cols:
7172
op.add_column("viewer_accounts", sa.Column("no_download", sa.Integer(), server_default="0", nullable=True))
7273

74+
# -- no_download and source_token_id columns on viewer_sessions --
75+
if "viewer_sessions" in existing_tables:
76+
existing_vs_cols = {c["name"] for c in inspector.get_columns("viewer_sessions")}
77+
if "no_download" not in existing_vs_cols:
78+
op.add_column("viewer_sessions", sa.Column("no_download", sa.Integer(), server_default="0", nullable=True))
79+
if "source_token_id" not in existing_vs_cols:
80+
op.add_column("viewer_sessions", sa.Column("source_token_id", sa.Integer(), nullable=True))
81+
existing_vs_indexes = {idx["name"] for idx in inspector.get_indexes("viewer_sessions")}
82+
if "idx_viewer_sessions_source_token" not in existing_vs_indexes:
83+
op.create_index("idx_viewer_sessions_source_token", "viewer_sessions", ["source_token_id"])
84+
7385

7486
def downgrade() -> None:
87+
op.drop_index("idx_viewer_sessions_source_token", table_name="viewer_sessions")
88+
op.drop_column("viewer_sessions", "source_token_id")
89+
op.drop_column("viewer_sessions", "no_download")
7590
op.drop_column("viewer_accounts", "no_download")
7691
op.drop_table("app_settings")
7792
op.drop_index("idx_viewer_tokens_is_revoked", table_name="viewer_tokens")

docs/CHANGELOG.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,35 @@ For upgrade instructions, see [Upgrading](#upgrading) at the bottom.
1111
### Added
1212

1313
- **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
14-
- **Download restrictions**`no_download` flag on both viewer accounts and share tokens. Server-side enforcement blocks media file downloads (avatars and thumbnails remain accessible)
14+
- **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
1515
- **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
1616
- **App settings** — Key-value `app_settings` table for cross-container configuration, with admin CRUD endpoints
17-
- **Audit log improvements** — Action-based filtering in admin panel, token auth events tracked (`token_auth_success`, `token_auth_failed`, `token_created`, etc.)
17+
- **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.)
1818
- **Admin chat picker metadata** — Chat picker now returns `username`, `first_name`, `last_name` for better display
1919
- **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
2020
- **Token login UI** — Login page has a "Share Token" tab for token-based authentication
2121

22+
### Security
23+
24+
- **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
25+
- **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
26+
- **Export endpoint respects no_download** — The `GET /api/chats/{chat_id}/export` endpoint now returns 403 for restricted users
27+
2228
### Fixed
2329

24-
- **Python 2 except syntax** — Fixed `except X, Y:` patterns (valid but semantically wrong in Python 3.14 — catches X and binds to Y instead of catching both) to `except (X, Y):` throughout `main.py`
30+
- **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
31+
- **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
32+
- **Audit filter matches suffixed actions** — Filter now uses prefix matching so "viewer_updated" catches "viewer_updated:username"
33+
- **Migration stamping checks all artifacts** — Entrypoint now checks `viewer_tokens`, `app_settings`, AND `viewer_accounts.no_download` before stamping migration 010 as complete
2534

2635
### Changed
2736

28-
- **Migration 010** — Consolidated idempotent migration creates `viewer_tokens`, `app_settings` tables and adds `no_download` column to `viewer_accounts`
29-
- **Entrypoint stamping** — Updated both PostgreSQL and SQLite stamping blocks to detect migration 010 artifacts
37+
- **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`
38+
- **Entrypoint stamping** — Updated both PostgreSQL and SQLite stamping blocks to detect all migration 010 artifacts
3039
- **Dockerfile.viewer** — Added Pillow system dependencies (libjpeg, libwebp) for thumbnail generation
31-
- **requirements-viewer.txt** — Added `Pillow>=10.0.0`
40+
- **Version declarations**`pyproject.toml` and `src/__init__.py` both set to 7.2.0
41+
- **SECURITY.md** — Added 7.x.x as a supported version
42+
- **pyproject.toml** — Added `viewer` optional dependency group for Pillow
3243

3344
## [7.1.3] - 2026-03-05
3445

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "telegram-archive"
7-
version = "7.1.3"
7+
version = "7.2.0"
88
description = "Automated Telegram backup with Docker. Performs incremental backups of messages and media on a configurable schedule."
99
readme = "README.md"
1010
requires-python = ">=3.14"
@@ -43,6 +43,9 @@ dependencies = [
4343
]
4444

4545
[project.optional-dependencies]
46+
viewer = [
47+
"Pillow>=10.0.0", # v7.2.0: thumbnail generation
48+
]
4649
dev = [
4750
"pytest>=7.0",
4851
"pytest-asyncio>=0.21.0",

scripts/entrypoint.sh

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,29 @@ if has_tables and not has_alembic:
8181
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
8282
);
8383
\"\"\")
84-
# Check if viewer_tokens table exists (added in migration 010)
84+
# Check all artifacts from migration 010: viewer_tokens, app_settings, viewer_accounts.no_download
8585
cur.execute(\"\"\"
8686
SELECT EXISTS (
8787
SELECT FROM information_schema.tables
8888
WHERE table_name = 'viewer_tokens'
8989
);
9090
\"\"\")
91-
has_010_table = cur.fetchone()[0]
91+
has_010_tokens = cur.fetchone()[0]
92+
cur.execute(\"\"\"
93+
SELECT EXISTS (
94+
SELECT FROM information_schema.tables
95+
WHERE table_name = 'app_settings'
96+
);
97+
\"\"\")
98+
has_010_settings = cur.fetchone()[0]
99+
cur.execute(\"\"\"
100+
SELECT EXISTS (
101+
SELECT FROM information_schema.columns
102+
WHERE table_name = 'viewer_accounts' AND column_name = 'no_download'
103+
);
104+
\"\"\")
105+
has_010_no_download = cur.fetchone()[0]
106+
has_010_all = has_010_tokens and has_010_settings and has_010_no_download
92107
93108
# Check if viewer_sessions table exists (added in migration 009)
94109
cur.execute(\"\"\"
@@ -154,7 +169,7 @@ if has_tables and not has_alembic:
154169
has_push_subs = cur.fetchone()[0]
155170
156171
# Determine which version to stamp based on existing schema
157-
if has_010_table:
172+
if has_010_all:
158173
stamp_version = '010'
159174
elif has_009_table:
160175
stamp_version = '009'
@@ -223,9 +238,15 @@ if has_tables and not has_alembic:
223238
)
224239
''')
225240
226-
# Check if viewer_tokens table exists (added in migration 010)
241+
# Check all artifacts from migration 010: viewer_tokens, app_settings, viewer_accounts.no_download
227242
cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='viewer_tokens'\")
228-
has_010_table = cur.fetchone() is not None
243+
has_010_tokens = cur.fetchone() is not None
244+
cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'\")
245+
has_010_settings = cur.fetchone() is not None
246+
cur.execute(\"PRAGMA table_info(viewer_accounts)\")
247+
va_columns = {row[1] for row in cur.fetchall()}
248+
has_010_no_download = 'no_download' in va_columns
249+
has_010_all = has_010_tokens and has_010_settings and has_010_no_download
229250
230251
# Check if viewer_sessions table exists (added in migration 009)
231252
cur.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name='viewer_sessions'\")
@@ -258,7 +279,7 @@ if has_tables and not has_alembic:
258279
has_push_subs = cur.fetchone() is not None
259280
260281
# Determine which version to stamp based on existing schema
261-
if has_010_table:
282+
if has_010_all:
262283
stamp_version = '010'
263284
elif has_009_table:
264285
stamp_version = '009'

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Telegram Backup Automation - Main Package
33
"""
44

5-
__version__ = "7.0.3"
5+
__version__ = "7.2.0"

src/db/adapter.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,8 @@ async def create_viewer_account(
16801680
salt: str,
16811681
allowed_chat_ids: str | None = None,
16821682
created_by: str | None = None,
1683+
is_active: int = 1,
1684+
no_download: int = 0,
16831685
) -> dict[str, Any]:
16841686
"""Create a new viewer account. Returns the created account dict."""
16851687
async with self.db_manager.async_session_factory() as session:
@@ -1689,6 +1691,8 @@ async def create_viewer_account(
16891691
salt=salt,
16901692
allowed_chat_ids=allowed_chat_ids,
16911693
created_by=created_by,
1694+
is_active=is_active,
1695+
no_download=no_download,
16921696
)
16931697
session.add(account)
16941698
await session.commit()
@@ -1786,7 +1790,7 @@ async def get_audit_logs(
17861790
if username:
17871791
stmt = stmt.where(ViewerAuditLog.username == username)
17881792
if action:
1789-
stmt = stmt.where(ViewerAuditLog.action == action)
1793+
stmt = stmt.where(ViewerAuditLog.action.startswith(action))
17901794
stmt = stmt.limit(limit).offset(offset)
17911795
result = await session.execute(stmt)
17921796
return [
@@ -1817,31 +1821,29 @@ async def save_session(
18171821
allowed_chat_ids: str | None,
18181822
created_at: float,
18191823
last_accessed: float,
1824+
no_download: int = 0,
1825+
source_token_id: int | None = None,
18201826
) -> None:
18211827
"""Save or update a session in the database."""
18221828
async with self.db_manager.async_session_factory() as session:
1829+
values = {
1830+
"token": token,
1831+
"username": username,
1832+
"role": role,
1833+
"allowed_chat_ids": allowed_chat_ids,
1834+
"no_download": no_download,
1835+
"source_token_id": source_token_id,
1836+
"created_at": created_at,
1837+
"last_accessed": last_accessed,
1838+
}
18231839
if self._is_sqlite:
1824-
stmt = sqlite_insert(ViewerSession).values(
1825-
token=token,
1826-
username=username,
1827-
role=role,
1828-
allowed_chat_ids=allowed_chat_ids,
1829-
created_at=created_at,
1830-
last_accessed=last_accessed,
1831-
)
1840+
stmt = sqlite_insert(ViewerSession).values(**values)
18321841
stmt = stmt.on_conflict_do_update(
18331842
index_elements=["token"],
18341843
set_={"last_accessed": last_accessed},
18351844
)
18361845
else:
1837-
stmt = pg_insert(ViewerSession).values(
1838-
token=token,
1839-
username=username,
1840-
role=role,
1841-
allowed_chat_ids=allowed_chat_ids,
1842-
created_at=created_at,
1843-
last_accessed=last_accessed,
1844-
)
1846+
stmt = pg_insert(ViewerSession).values(**values)
18451847
stmt = stmt.on_conflict_do_update(
18461848
index_elements=["token"],
18471849
set_={"last_accessed": last_accessed},
@@ -1889,13 +1891,23 @@ async def cleanup_expired_sessions(self, max_age_seconds: float) -> int:
18891891
await session.commit()
18901892
return result.rowcount
18911893

1894+
@retry_on_locked()
1895+
async def delete_sessions_by_source_token_id(self, token_id: int) -> int:
1896+
"""Delete all sessions created from a specific share token."""
1897+
async with self.db_manager.async_session_factory() as session:
1898+
result = await session.execute(delete(ViewerSession).where(ViewerSession.source_token_id == token_id))
1899+
await session.commit()
1900+
return result.rowcount
1901+
18921902
@staticmethod
18931903
def _viewer_session_to_dict(row: ViewerSession) -> dict[str, Any]:
18941904
return {
18951905
"token": row.token,
18961906
"username": row.username,
18971907
"role": row.role,
18981908
"allowed_chat_ids": row.allowed_chat_ids,
1909+
"no_download": row.no_download,
1910+
"source_token_id": row.source_token_id,
18991911
"created_at": row.created_at,
19001912
"last_accessed": row.last_accessed,
19011913
}

src/db/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,14 +381,17 @@ class ViewerSession(Base):
381381

382382
token: Mapped[str] = mapped_column(String(64), primary_key=True)
383383
username: Mapped[str] = mapped_column(String(255), nullable=False)
384-
role: Mapped[str] = mapped_column(String(20), nullable=False) # "master" or "viewer"
384+
role: Mapped[str] = mapped_column(String(20), nullable=False) # "master", "viewer", or "token"
385385
allowed_chat_ids: Mapped[str | None] = mapped_column(Text) # JSON array or NULL = all chats
386+
no_download: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # v7.2.0
387+
source_token_id: Mapped[int | None] = mapped_column(Integer) # v7.2.0: FK to viewer_tokens.id for revocation
386388
created_at: Mapped[float] = mapped_column(Float, nullable=False)
387389
last_accessed: Mapped[float] = mapped_column(Float, nullable=False)
388390

389391
__table_args__ = (
390392
Index("idx_viewer_sessions_username", "username"),
391393
Index("idx_viewer_sessions_created_at", "created_at"),
394+
Index("idx_viewer_sessions_source_token", "source_token_id"),
392395
)
393396

394397

0 commit comments

Comments
 (0)