diff --git a/alembic/versions/20260524_013_fix_media_file_paths.py b/alembic/versions/20260524_013_fix_media_file_paths.py new file mode 100644 index 00000000..44d05e50 --- /dev/null +++ b/alembic/versions/20260524_013_fix_media_file_paths.py @@ -0,0 +1,126 @@ +"""Fix stale positive-ID folder prefixes in media.file_path. + +Before v4.0.5, the backup stored media in directories named with raw (positive) +entity IDs. After v4.0.5, directories use Telethon's marked IDs (negative for +groups/channels). The v4.0.6 migration fixed chat_id columns but never updated +file_path strings, leaving ~37% of media rows with paths pointing to the wrong +folder name. + +This migration rewrites file_path values so the folder component matches the +marked chat_id already stored in media.chat_id. + +Revision ID: 013 +Revises: 012 +Create Date: 2026-05-24 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "013" +down_revision = "012" +branch_labels = None +depends_on = None + +_BATCH_SIZE = 5000 + + +def _derive_stale_folder(chat_id: int) -> str | None: + """Derive the old positive folder name from a marked chat_id. + + Basic groups: chat_id = -X → old folder = "X" + Channels: chat_id = -(1000000000000 + X) → old folder = "X" + Users: chat_id > 0 → no mismatch possible, return None + """ + if chat_id >= 0: + return None + raw = -chat_id + if raw > 1000000000000: + return str(raw - 1000000000000) + return str(raw) + + +def upgrade(): + conn = op.get_bind() + dialect = conn.dialect.name + + if dialect == "postgresql": + # Get all distinct negative chat_ids that have media + result = conn.execute(sa.text("SELECT DISTINCT chat_id FROM media WHERE chat_id < 0 AND file_path IS NOT NULL")) + chat_ids = [row[0] for row in result] + + for chat_id in chat_ids: + stale_folder = _derive_stale_folder(chat_id) + if stale_folder is None: + continue + correct_folder = str(chat_id) + + # Only update rows where file_path contains the stale folder + # Use pattern: ...//... → ...//... + stale_pattern = f"%/{stale_folder}/%" + conn.execute( + sa.text( + "UPDATE media SET file_path = REPLACE(file_path, :old_seg, :new_seg) " + "WHERE chat_id = :cid AND file_path LIKE :pattern" + ), + { + "old_seg": f"/{stale_folder}/", + "new_seg": f"/{correct_folder}/", + "cid": chat_id, + "pattern": stale_pattern, + }, + ) + + elif dialect == "sqlite": + result = conn.execute(sa.text("SELECT DISTINCT chat_id FROM media WHERE chat_id < 0 AND file_path IS NOT NULL")) + chat_ids = [row[0] for row in result] + + for chat_id in chat_ids: + stale_folder = _derive_stale_folder(chat_id) + if stale_folder is None: + continue + correct_folder = str(chat_id) + + stale_pattern = f"%/{stale_folder}/%" + conn.execute( + sa.text( + "UPDATE media SET file_path = REPLACE(file_path, :old_seg, :new_seg) " + "WHERE chat_id = :cid AND file_path LIKE :pattern" + ), + { + "old_seg": f"/{stale_folder}/", + "new_seg": f"/{correct_folder}/", + "cid": chat_id, + "pattern": stale_pattern, + }, + ) + + +def downgrade(): + # Reversible: swap the folder components back + conn = op.get_bind() + dialect = conn.dialect.name + + result = conn.execute(sa.text("SELECT DISTINCT chat_id FROM media WHERE chat_id < 0 AND file_path IS NOT NULL")) + chat_ids = [row[0] for row in result] + + for chat_id in chat_ids: + stale_folder = _derive_stale_folder(chat_id) + if stale_folder is None: + continue + correct_folder = str(chat_id) + pattern = f"%/{correct_folder}/%" + + conn.execute( + sa.text( + "UPDATE media SET file_path = REPLACE(file_path, :new_seg, :old_seg) " + "WHERE chat_id = :cid AND file_path LIKE :pattern" + ), + { + "new_seg": f"/{correct_folder}/", + "old_seg": f"/{stale_folder}/", + "cid": chat_id, + "pattern": pattern, + }, + ) diff --git a/pyproject.toml b/pyproject.toml index 99abdaa1..7e94774f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "telegram-archive" -version = "7.10.13" +version = "7.10.14" description = "Automated Telegram backup with Docker. Performs incremental backups of messages and media on a configurable schedule." readme = "README.md" requires-python = ">=3.14" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 34e7cc84..5c19933c 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -92,6 +92,17 @@ if has_tables and not has_alembic: CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) ); \"\"\") + # Check artifact from migration 013: file_path values use negative chat_id folders + # If any media row for a negative chat_id has a correctly-negative folder, 013 has run + cur.execute(\"\"\" + SELECT EXISTS ( + SELECT 1 FROM media + WHERE chat_id < 0 AND file_path LIKE '%/' || CAST(chat_id AS TEXT) || '/%' + LIMIT 1 + ); + \"\"\") + has_013_paths = cur.fetchone()[0] + # Check artifact from migration 012: idx_media_chat_type index cur.execute(\"\"\" SELECT EXISTS ( @@ -198,7 +209,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_012_index: + if has_013_paths: + stamp_version = '013' + elif has_012_index: stamp_version = '012' elif has_011_content_hash: stamp_version = '011' @@ -286,6 +299,10 @@ if has_tables and not has_alembic: ) ''') + # Check artifact from migration 013: file_path values use negative chat_id folders + cur.execute(\"SELECT EXISTS(SELECT 1 FROM media WHERE chat_id < 0 AND file_path LIKE '%/' || CAST(chat_id AS TEXT) || '/%' LIMIT 1)\") + has_013_paths = cur.fetchone()[0] + # Check artifact from migration 012: idx_media_chat_type index cur.execute(\"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_media_chat_type'\") has_012_index = cur.fetchone() is not None @@ -336,7 +353,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_012_index: + if has_013_paths: + stamp_version = '013' + elif has_012_index: stamp_version = '012' elif has_011_content_hash: stamp_version = '011' diff --git a/src/__init__.py b/src/__init__.py index f4f5105a..a5cbc1a3 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ Telegram Backup Automation - Main Package """ -__version__ = "7.10.13" +__version__ = "7.10.14" diff --git a/src/web/main.py b/src/web/main.py index 3ad33864..7af3fd18 100644 --- a/src/web/main.py +++ b/src/web/main.py @@ -820,7 +820,11 @@ def _enforce_media_acl(path: str, user: UserContext, *, thumbnail: bool = False) logger.warning("Blocked restricted media request for non-chat folder: %s", parts[0]) raise HTTPException(status_code=403, detail="Access denied") if media_chat_id not in user_chat_ids: - raise HTTPException(status_code=403, detail="Access denied") + # Legacy fallback: positive folder may correspond to negative marked ID + if media_chat_id > 0 and (-media_chat_id in user_chat_ids or -(1000000000000 + media_chat_id) in user_chat_ids): + pass + else: + raise HTTPException(status_code=403, detail="Access denied") def _strip_original_media_paths(messages: list[dict]) -> None: @@ -918,7 +922,25 @@ async def serve_media(path: str, download: int = Query(0), user: UserContext = D try: resolved = candidate.resolve(strict=True) except OSError, ValueError: - raise HTTPException(status_code=404, detail="File not found") + # Legacy fallback: pre-v4.0.5 paths used positive IDs, disk uses negative marked IDs. + # Try alternate folder names: X→-X (basic group), X→-100X (channel/supergroup) + parts = path.split("/", 1) + resolved = None + if len(parts) == 2: + folder, rest = parts + alt_folders = [] + if not folder.startswith("-"): + alt_folders = [f"-{folder}", f"-100{folder}"] + else: + alt_folders = [folder[1:]] + for alt in alt_folders: + try: + resolved = (_media_root / alt / rest).resolve(strict=True) + break + except OSError, ValueError: + continue + if resolved is None: + raise HTTPException(status_code=404, detail="File not found") if not resolved.is_relative_to(_media_root): raise HTTPException(status_code=403, detail="Access denied") diff --git a/src/web/thumbnails.py b/src/web/thumbnails.py index 0f72fdf3..54a13588 100644 --- a/src/web/thumbnails.py +++ b/src/web/thumbnails.py @@ -124,7 +124,22 @@ async def ensure_thumbnail( return dest if not source.exists(): - return None + # Legacy fallback: pre-v4.0.5 paths used positive IDs, disk uses negative marked IDs. + # Try alternate folder names: X→-X (basic group), X→-100X (channel/supergroup) + alt_folders = [] + if not folder.startswith("-"): + alt_folders = [f"-{folder}", f"-100{folder}"] + else: + alt_folders = [folder[1:]] + found = False + for alt in alt_folders: + alt_source = (media_root / alt / filename).resolve() + if alt_source.is_relative_to(media_root_resolved) and alt_source.exists(): + source = alt_source + found = True + break + if not found: + return None async with _generation_semaphore: loop = asyncio.get_running_loop()