snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
import contextlib
import hashlib
import os
from typing import Any
import boto3
import pyvips
from botocore.client import Config
from botocore.exceptions import ClientError
from django.conf import settings
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.db.models import QuerySet
from django.utils.timezone import now as timezone_now
IMAGE_BOMB_TOTAL_PIXELS = 90000000
DEFAULT_AVATAR_SIZE = 100
MEDIUM_AVATAR_SIZE = 500
def resize_avatar(
image_data: bytes | pyvips.Image,
size: int,
) -> bytes | None:
try:
source_image = pyvips.Image.new_from_buffer(image_data, "")
if source_image.width * source_image.height > IMAGE_BOMB_TOTAL_PIXELS:
return None
return pyvips.Image.thumbnail_buffer(
image_data,
size,
height=size,
crop=pyvips.Interesting.CENTRE,
).write_to_buffer(".png")
except pyvips.Error:
return None
def new_hash(user_profile: Any) -> str:
user_key = (
str(user_profile.id) + ":" + str(user_profile.avatar_version) + ":" + settings.AVATAR_SALT
)
return hashlib.sha256(user_key.encode()).hexdigest()[:40]
def old_hash(user_profile: Any) -> str:
user_key = str(user_profile.id) + settings.AVATAR_SALT
return hashlib.sha1(user_key.encode()).hexdigest()
def do_remove_avatar(user_profile: Any, apps: StateApps) -> None:
avatar_source = "G" # UserProfile.AVATAR_FROM_GRAVATAR
user_profile.avatar_source = avatar_source
user_profile.avatar_version += 1
user_profile.save(update_fields=["avatar_source", "avatar_version"])
RealmAuditLog = apps.get_model("zerver", "RealmAuditLog")
RealmAuditLog.objects.create(
realm_id=user_profile.realm_id,
modified_user_id=user_profile.id,
event_type=123, # RealmAuditLog.USER_AVATAR_SOURCE_CHANGED,
extra_data={"avatar_source": avatar_source},
event_time=timezone_now(),
acting_user=None,
)
class SkipImageError(Exception):
def __init__(self, message: str, user: Any) -> None:
super().__init__(message)
self.user = user
# Just the image types from zerver.lib.upload.INLINE_MIME_TYPES
INLINE_IMAGE_MIME_TYPES = [
"image/apng",
"image/avif",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
# To avoid cross-site scripting attacks, DO NOT add types such
# as image/svg+xml.
]
def thumbnail_s3_avatars(users: QuerySet[Any], apps: StateApps) -> None:
avatar_bucket = boto3.resource(
"s3",
aws_access_key_id=settings.S3_KEY,
aws_secret_access_key=settings.S3_SECRET_KEY,
region_name=settings.S3_REGION,
endpoint_url=settings.S3_ENDPOINT_URL,
config=Config(
signature_version=None,
s3={"addressing_style": settings.S3_ADDRESSING_STYLE},
),
).Bucket(settings.S3_AVATAR_BUCKET)
for total_processed, user in enumerate(users):
try:
old_base = os.path.join(str(user.realm_id), old_hash(user))
new_base = os.path.join(str(user.realm_id), new_hash(user))
if total_processed % 100 == 0:
print(f"Processing {total_processed}/{len(users)} user avatars")
with contextlib.suppress(ClientError):
# Check if we've already uploaded this one; if so, continue.
avatar_bucket.Object(new_base + ".original").load()
continue
try:
old_data = avatar_bucket.Object(old_base + ".original").get()
metadata = old_data["Metadata"]
metadata["avatar_version"] = str(user.avatar_version)
original_bytes = old_data["Body"].read()
except ClientError:
raise SkipImageError(f"Failed to fetch {old_base}", user)
# INLINE_IMAGE_MIME_TYPES changing (e.g. adding
# "image/avif") means this may not match the old
# content-disposition.
inline_type = old_data["ContentType"] in INLINE_IMAGE_MIME_TYPES
extra_params = {}
if not inline_type:
extra_params["ContentDisposition"] = "attachment"
avatar_bucket.Object(new_base + ".original").copy_from(
CopySource=f"{settings.S3_AVATAR_BUCKET}/{old_base}.original",
MetadataDirective="REPLACE",
Metadata=metadata,
ContentType=old_data["ContentType"],
CacheControl="public, max-age=31536000, immutable",
**extra_params, # type: ignore[arg-type] # The dynamic kwargs here confuse mypy.
)
small = resize_avatar(original_bytes, DEFAULT_AVATAR_SIZE)
if small is None:
raise SkipImageError(f"Failed to resize {old_base}", user)
avatar_bucket.Object(new_base + ".png").put(
Metadata=metadata,
ContentType="image/png",
CacheControl="public, max-age=31536000, immutable",
Body=small,
)
medium = resize_avatar(original_bytes, MEDIUM_AVATAR_SIZE)
if medium is None:
raise SkipImageError(f"Failed to medium resize {old_base}", user)
avatar_bucket.Object(new_base + "-medium.png").put(
Metadata=metadata,
ContentType="image/png",
CacheControl="public, max-age=31536000, immutable",
Body=medium,
)
except SkipImageError as e:
print(f"{e!s} for {e.user}; reverting to gravatar")
do_remove_avatar(e.user, apps)
def thumbnail_local_avatars(users: QuerySet[Any], apps: StateApps) -> None:
total_processed = 0
assert settings.LOCAL_AVATARS_DIR is not None
for total_processed, user in enumerate(users):
try:
old_base = os.path.join(settings.LOCAL_AVATARS_DIR, str(user.realm_id), old_hash(user))
new_base = os.path.join(settings.LOCAL_AVATARS_DIR, str(user.realm_id), new_hash(user))
if total_processed % 100 == 0:
print(f"Processing {total_processed}/{len(users)} user avatars")
if os.path.exists(new_base + "-medium.png"):
# This user's avatar has already been migrated.
continue
with contextlib.suppress(FileNotFoundError):
# Remove the hard link, if present from a previous failed run.
os.remove(new_base + ".original")
# We hardlink, rather than copying, so we don't take any extra space.
try:
os.link(old_base + ".original", new_base + ".original")
with open(old_base + ".original", "rb") as f:
original_bytes = f.read()
except OSError:
raise SkipImageError(f"Failed to read {old_base}", user)
small = resize_avatar(original_bytes, DEFAULT_AVATAR_SIZE)
if small is None:
raise SkipImageError(f"Failed to resize {old_base}", user)
with open(new_base + ".png", "wb") as f:
f.write(small)
medium = resize_avatar(original_bytes, MEDIUM_AVATAR_SIZE)
if medium is None:
raise SkipImageError(f"Failed to medium resize {old_base}", user)
with open(new_base + "-medium.png", "wb") as f:
f.write(medium)
except SkipImageError as e:
print(f"{e!s} for {e.user}; reverting to gravatar")
do_remove_avatar(e.user, apps)
def thumbnail_avatars(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
UserProfile = apps.get_model("zerver", "UserProfile")
users = (
UserProfile.objects.filter(avatar_source="U")
.only("id", "realm_id", "avatar_version")
.order_by("id")
)
if settings.LOCAL_AVATARS_DIR is not None:
thumbnail_local_avatars(users, apps)
else:
thumbnail_s3_avatars(users, apps)
class Migration(migrations.Migration):
atomic = False
elidable = True
dependencies = [
("zerver", "0543_preregistrationuser_notify_referrer_on_join"),
]
operations = [migrations.RunPython(thumbnail_avatars, elidable=True)]