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
126 changes: 126 additions & 0 deletions alembic/versions/20260524_013_fix_media_file_paths.py
Original file line number Diff line number Diff line change
@@ -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_folder>/... → .../<correct_folder>/...
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,
},
)
2 changes: 1 addition & 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.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"
Expand Down
23 changes: 21 additions & 2 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
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.10.13"
__version__ = "7.10.14"
26 changes: 24 additions & 2 deletions src/web/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,11 @@
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:
Expand Down Expand Up @@ -918,7 +922,25 @@
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)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
Comment thread
GeiserX marked this conversation as resolved.
Dismissed
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")

Expand Down
17 changes: 16 additions & 1 deletion src/web/thumbnails.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,22 @@
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()

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
Comment thread
GeiserX marked this conversation as resolved.
Dismissed
if alt_source.is_relative_to(media_root_resolved) and alt_source.exists():

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.
Comment thread
GeiserX marked this conversation as resolved.
Dismissed
source = alt_source
found = True
break
if not found:
return None

async with _generation_semaphore:
loop = asyncio.get_running_loop()
Expand Down
Loading