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
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.10"
version = "7.10.11"
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
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.10"
__version__ = "7.10.11"
11 changes: 9 additions & 2 deletions src/web/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,9 @@ async def serve_service_worker():
# Media is served via authenticated endpoint below (not StaticFiles)
_media_root = Path(config.media_path).resolve() if os.path.exists(config.media_path) else None

# Thumbnail cache lives outside media root so it works with read-only media volumes
_thumb_cache_dir: Path | None = None


# Thumbnail endpoint MUST be defined before the catch-all /media/{path:path} route
@app.get("/media/thumb/{size}/{folder:path}/{filename}")
Expand All @@ -881,9 +884,13 @@ async def serve_thumbnail(size: int, folder: str, filename: str, user: UserConte
# Chat-level access check
_enforce_media_acl(f"{folder}/{filename}", user, thumbnail=True)

from .thumbnails import ensure_thumbnail
from .thumbnails import ensure_thumbnail, resolve_cache_dir

global _thumb_cache_dir
if _thumb_cache_dir is None:
_thumb_cache_dir = resolve_cache_dir(_media_root)

thumb_path = await ensure_thumbnail(_media_root, size, folder, filename)
thumb_path = await ensure_thumbnail(_media_root, size, folder, filename, cache_dir=_thumb_cache_dir)
if not thumb_path:
raise HTTPException(status_code=404, detail="Thumbnail not available")

Expand Down
53 changes: 47 additions & 6 deletions src/web/thumbnails.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""On-demand thumbnail generation with disk caching.

Generates WebP thumbnails at whitelisted sizes, stored under
{media_root}/.thumbs/{size}/{folder}/{stem}.webp.
{cache_dir}/{size}/{folder}/{stem}.webp.
Pillow runs in a thread executor to avoid blocking the async event loop.

The cache directory is separate from the media root so thumbnails work
even when the media volume is mounted read-only.
"""

import asyncio
import logging
import os
from pathlib import Path

from PIL import Image
Expand All @@ -25,6 +29,32 @@
# Limit concurrent thumbnail generations to cap peak memory (~15MB per decode)
_generation_semaphore = asyncio.Semaphore(8)

_DEFAULT_CACHE_DIR = "/tmp/telegram-archive-thumbs"


def resolve_cache_dir(media_root: Path | None) -> Path:
"""Determine the thumbnail cache directory.

Priority: THUMBNAIL_CACHE_DIR env > {media_root}/.thumbs (if writable) > /tmp fallback.
"""
env_dir = os.environ.get("THUMBNAIL_CACHE_DIR")
if env_dir:
p = Path(env_dir)
p.mkdir(parents=True, exist_ok=True)
return p

if media_root:
candidate = media_root / ".thumbs"
try:
candidate.mkdir(parents=True, exist_ok=True)
return candidate
except OSError:
pass
Comment on lines +40 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate actual writability before returning a cache directory.

mkdir(..., exist_ok=True) can succeed for an existing read-only directory, so this may still return an unwritable {media_root}/.thumbs and reproduce the thumbnail failure. Also, if THUMBNAIL_CACHE_DIR creation fails, the function currently raises instead of falling back.

Suggested fix
+def _is_writable_dir(path: Path) -> bool:
+    try:
+        path.mkdir(parents=True, exist_ok=True)
+        probe = path / ".write_probe"
+        with probe.open("wb"):
+            pass
+        if probe.exists():
+            probe.unlink()
+        return True
+    except OSError:
+        return False
+
 def resolve_cache_dir(media_root: Path | None) -> Path:
@@
     env_dir = os.environ.get("THUMBNAIL_CACHE_DIR")
     if env_dir:
         p = Path(env_dir)
-        p.mkdir(parents=True, exist_ok=True)
-        return p
+        if _is_writable_dir(p):
+            return p
 
-    if media_root:
+    if media_root is not None:
         candidate = media_root / ".thumbs"
-        try:
-            candidate.mkdir(parents=True, exist_ok=True)
+        if _is_writable_dir(candidate):
             return candidate
-        except OSError:
-            pass
 
     p = Path(_DEFAULT_CACHE_DIR)
     p.mkdir(parents=True, exist_ok=True)
     return p
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web/thumbnails.py` around lines 40 - 52, The function currently returns a
directory after mkdir(...) which can succeed on an existing read-only dir and
will raise if creating THUMBNAIL_CACHE_DIR fails; update the code that handles
env_dir and media_root to catch exceptions from Path(env_dir).mkdir and, on
success, verify the directory is actually writable before returning (e.g.,
attempt to create and remove a small temp file or use os.access(candidate,
os.W_OK) with a real write test); do the same for candidate (media_root /
".thumbs") inside the try/except so that if creation or writability check fails
the function falls back instead of returning an unwritable path. Ensure
references to env_dir, candidate, media_root, Path, and mkdir are used to locate
the changes.


p = Path(_DEFAULT_CACHE_DIR)
p.mkdir(parents=True, exist_ok=True)
return p


def _is_image(filename: str) -> bool:
return Path(filename).suffix.lower() in _IMAGE_EXTENSIONS
Expand All @@ -51,11 +81,16 @@
return False


async def ensure_thumbnail(media_root: Path, size: int, folder: str, filename: str) -> Path | None:
async def ensure_thumbnail(
media_root: Path, size: int, folder: str, filename: str, *, cache_dir: Path | None = None
) -> 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.

When cache_dir is provided, thumbnails are written there instead of
under {media_root}/.thumbs/ — this supports read-only media volumes.
"""
if size not in ALLOWED_SIZES:
return None
Expand All @@ -70,10 +105,16 @@
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 cache_dir:
stem = Path(filename).stem
dest = (cache_dir / str(size) / folder / f"{stem}.webp").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
.
This path depends on a
user-provided value
.
Comment thread
GeiserX marked this conversation as resolved.
Dismissed
if not dest.is_relative_to(cache_dir.resolve()):
return None
else:
dest = _thumb_path(media_root, size, folder, 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
.
This path depends on a
user-provided value
.
Comment thread
GeiserX marked this conversation as resolved.
Dismissed
thumbs_root = (media_root / ".thumbs").resolve()
if not dest.is_relative_to(thumbs_root):
return None

if dest.exists():
return dest
Expand Down
Loading