make_thumbnail: неатомарная запись WebP (thumbs.py:53) — усечённый thumb навсегда кэшируется и отдаётся как валидный #1603

Closed
opened 2026-06-16 17:16:29 +00:00 by bot-backend · 0 comments
Collaborator

Severity: medium · Категория: data-corruption · Файл: backend/app/services/photos/thumbs.py:44-54

Файл: backend/app/services/photos/thumbs.py:44-54.

Проблема: на стр. 53 im.save(dst, format="WEBP", quality=quality, method=4) пишет PIL прямо в канонический путь dst = thumb_path_for(src) (стр. 44, = <stem>_thumb.webp). Нет паттерна write-temp + os.replace(). Если процесс убит (OOM-kill Celery-воркера, рестарт docker-контейнера) или PIL падает в середине энкодинга, на диске остаётся усечённый/нулевой .webp. На следующем вызове проверка if dst.exists() and not overwrite: return dst (стр. 45) видит файл и возвращает его как УСПЕХ — частично записанный файл неотличим от валидного по факту существования.

Импакт: оба caller (api/v1/photos.py:97 — из локального оригинала, :114 — lazy после fetch) зовут make_thumbnail с overwrite=False (default). Ни один caller не передаёт overwrite=True, поэтому самовосстановление невозможно без ручного удаления файла. Битый thumb отдаётся через FileResponse с Cache-Control: public, max-age=604800, immutable (photos.py:88) — браузер кэширует битую картинку на 7 дней. Запись идёт в data/raw на VPS, где воркеры/контейнеры реально рестартуют.

Фикс: писать в temp-файл рядом и атомарно переименовать:

import os, tempfile
...
dst.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(suffix=".webp", dir=str(dst.parent))
os.close(fd)
tmp = Path(tmp_name)
try:
    im.save(tmp, format="WEBP", quality=quality, method=4)
    os.replace(tmp, dst)  # atomic на одной ФС
except Exception:
    tmp.unlink(missing_ok=True)
    raise

os.replace атомарен в пределах одной файловой системы — конкурентные читатели/писатели видят либо старый, либо полный новый файл, но никогда не усечённый.

Почему баг: Частично записанный файл неотличим от валидного по факту существования. make_thumbnail(overwrite=False) — основной режим (так его зовут и domrf_kn.py:1418, и api/v1/photos.py:97,114). Битый thumb отдаётся через FileResponse с заголовком 'Cache-Control: public, max-age=604800, immutable' (photos.py:88,93) — браузер кэширует битую картинку на 7 дней, и self-heal невозможен без ручного удаления файла или вызова с overwrite=True (которого нет ни у одного caller). Атомарность тут не теоретическая: запись идёт в data/raw на VPS, воркеры Celery и контейнеры рестартятся.

Как чинить: Писать в tmp рядом и атомарно переименовывать: dst_tmp = dst.with_suffix(dst.suffix + '.tmp'); im.save(dst_tmp, ...); os.replace(dst_tmp, dst). В except — удалять tmp. os.replace атомарен в пределах одной FS.


deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.9 · unit B30

**Severity:** medium · **Категория:** data-corruption · **Файл:** `backend/app/services/photos/thumbs.py:44-54` Файл: backend/app/services/photos/thumbs.py:44-54. Проблема: на стр. 53 `im.save(dst, format="WEBP", quality=quality, method=4)` пишет PIL прямо в канонический путь `dst = thumb_path_for(src)` (стр. 44, = `<stem>_thumb.webp`). Нет паттерна write-temp + os.replace(). Если процесс убит (OOM-kill Celery-воркера, рестарт docker-контейнера) или PIL падает в середине энкодинга, на диске остаётся усечённый/нулевой .webp. На следующем вызове проверка `if dst.exists() and not overwrite: return dst` (стр. 45) видит файл и возвращает его как УСПЕХ — частично записанный файл неотличим от валидного по факту существования. Импакт: оба caller (api/v1/photos.py:97 — из локального оригинала, :114 — lazy после fetch) зовут make_thumbnail с overwrite=False (default). Ни один caller не передаёт overwrite=True, поэтому самовосстановление невозможно без ручного удаления файла. Битый thumb отдаётся через FileResponse с `Cache-Control: public, max-age=604800, immutable` (photos.py:88) — браузер кэширует битую картинку на 7 дней. Запись идёт в data/raw на VPS, где воркеры/контейнеры реально рестартуют. Фикс: писать в temp-файл рядом и атомарно переименовать: ```python import os, tempfile ... dst.parent.mkdir(parents=True, exist_ok=True) fd, tmp_name = tempfile.mkstemp(suffix=".webp", dir=str(dst.parent)) os.close(fd) tmp = Path(tmp_name) try: im.save(tmp, format="WEBP", quality=quality, method=4) os.replace(tmp, dst) # atomic на одной ФС except Exception: tmp.unlink(missing_ok=True) raise ``` os.replace атомарен в пределах одной файловой системы — конкурентные читатели/писатели видят либо старый, либо полный новый файл, но никогда не усечённый. **Почему баг:** Частично записанный файл неотличим от валидного по факту существования. make_thumbnail(overwrite=False) — основной режим (так его зовут и domrf_kn.py:1418, и api/v1/photos.py:97,114). Битый thumb отдаётся через FileResponse с заголовком 'Cache-Control: public, max-age=604800, immutable' (photos.py:88,93) — браузер кэширует битую картинку на 7 дней, и self-heal невозможен без ручного удаления файла или вызова с overwrite=True (которого нет ни у одного caller). Атомарность тут не теоретическая: запись идёт в data/raw на VPS, воркеры Celery и контейнеры рестартятся. **Как чинить:** Писать в tmp рядом и атомарно переименовывать: dst_tmp = dst.with_suffix(dst.suffix + '.tmp'); im.save(dst_tmp, ...); os.replace(dst_tmp, dst). В except — удалять tmp. os.replace атомарен в пределах одной FS. --- <sub>deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.9 · unit B30</sub>
bot-backend added the
week ревью 1
label 2026-06-16 17:16:29 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1603
No description provided.