User avatar and personal information: Gravatar + customized upload combination plan

📂 Stage: Stage 3 - User System (Security) 🔗 Related chapters: Flask-Login 实战 · 静态文件管理

After users enter applications such as communities and blogs, their avatar and profile are the first places to establish their personal identity. If you directly use the default gray villain, the community atmosphere will appear very "cold"; if you only rely on manual uploading, the threshold for new users will be high.

Today we will make a "combination priority plan": by default, the globally accepted Gravatar will be called at zero cost, and at the same time, a custom upload entrance will be opened, so that old users can express their individuality.


1. Zero-cost default avatar: Gravatar integration

1.1 What is Gravatar?

Gravatar (Globally Recognized Avatar) is a "global email binding avatar service" launched by the parent company of WordPress - You only need to bind your email + avatar once on the official website, and all third-party sites connected to Gravatar (such as GitHub, Stack Overflow) can automatically read your avatar without any additional storage costs.

1.2 Quickly generate Gravatar URL

Gravatar's API rules are very simple: the core is the three steps of "Preprocessing email → Obtaining MD5 hash → Splicing parameters".

import hashlib

def get_gravatar_url(email, size=80):
    """生成带默认几何回退的 Gravatar URL"""
    # 邮箱预处理:严格去前后空格+转小写(Gravatar 只认这个规则!)
    cleaned_email = email.strip().lower()
    # 生成 32 位小写 MD5 哈希
    email_hash = hashlib.md5(cleaned_email.encode()).hexdigest()
    # 拼接完整 URL:默认尺寸 80px,无头像返回几何图案 identicon
    return f"https://www.gravatar.com/avatar/{email_hash}?s={size}&d=identicon"

# 测试调用
print(get_gravatar_url("alice@example.com"))
# → 输出类似:https://www.gravatar.com/avatar/5aba7...d1?s=80&d=identicon

1.3 Commonly used Gravatar parameters

We can adjust the size of the avatar, default fallback, and content rating through URL parameters. Commonly used parameters are organized into a table:

ParametersOptional values ​​Description
didenticonDefault: Geometric pattern, the first choice when there is no avatar
dmpGray humanoid silhouette in minimalist style
dretroRetro pixel style avatar (8-bit gamers are ecstatic)
dwavatarCartoon face + hairstyle combination avatar
dCustom pathFor example/static/img/default-avatar.png, but the path must start with HTTPS
sPositive integerThe length of the square side of the avatar (in pixels). It is recommended to use 40px for the navigation bar and 200px for the profile page
rg/pg/rContent rating, defaultg(Friendly for all ages), to prevent third parties from showing unsuitable avatars

Encapsulate these parameters into a more flexible function:

def get_gravatar(
    email,
    size=80,
    rating="g",
    default="identicon"
):
    """支持多参数配置的 Gravatar URL 生成器"""
    cleaned_email = email.strip().lower()
    email_hash = hashlib.md5(cleaned_email.encode()).hexdigest()
    return f"https://www.gravatar.com/avatar/{email_hash}?s={size}&d={default}&r={rating}"

1.4 Use it in Flask projects

We need to register this function as a Jinja2 global function so that all template files can be called directly without passing parameters or importing each time.

Step 1: Put the function in the extension file

avoidapp/__init__.pyIt’s too bloated, let’s put the function in a separate extension tool file first:

# app/extensions.py
import hashlib

def get_gravatar(
    email,
    size=80,
    rating="g",
    default="identicon"
):
    cleaned_email = email.strip().lower()
    email_hash = hashlib.md5(cleaned_email.encode()).hexdigest()
    return f"https://www.gravatar.com/avatar/{email_hash}?s={size}&d={default}&r={rating}"

# 这里可以放其他扩展工具,比如密码加密、日期格式化等

Step 2: Register global functions in the application factory

then increate_app()Add the function to the global namespace of Jinja2:

# app/__init__.py
from flask import Flask
from .extensions import get_gravatar

def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    # ... 注册数据库、蓝图等其他扩展 ...

    # 注册 Jinja2 全局函数
    app.jinja_env.globals["get_gravatar"] = get_gravatar

    return app

Step 3: Call directly in the template

For example, display the avatar of the currently logged in user in the navigation bar:

<!-- templates/base.html → 导航栏用户区域 -->
<div class="navbar-user d-flex align-items-center ms-3">
  <img 
    src="{{ get_gravatar(current_user.email, size=40, rating='g') }}"
    alt="{{ current_user.username }}"
    class="rounded-circle border border-2 border-light"
  >
  <span class="ms-2 text-light">{{ current_user.username }}</span>
</div>

2. User customization: avatar upload function

Although Gravatar is convenient, the domestic access speed may be unstable, and many users want to upload their own life photos and anime avatars - at this time, they need to add a custom upload entrance.

2.1 First extend the User model

Let's add one to the user tableavatarField, used to store the relative static path of the custom avatar (for example/static/uploads/avatars/xxx.jpg). If this field is empty, it will automatically fall back to Gravatar.

# app/models/user.py
from datetime import datetime
from flask_login import UserMixin
from app.extensions import db, get_gravatar  # 注意从 extensions 导入 Gravatar 工具

class User(UserMixin, db.Model):
    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(120), unique=True, nullable=False)
    username = db.Column(db.String(50), unique=True, nullable=False)
    password_hash = db.Column(db.String(256), nullable=False)
    # 新增:自定义头像的相对路径,默认空字符串
    avatar = db.Column(db.String(200), default="")
    # 顺便加个 bio 字段,完善个人资料
    bio = db.Column(db.String(500), default="")
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    def get_avatar(self, size=80):
        """组合优先:先看有没有自定义头像,没有用 Gravatar,最后用本地默认"""
        if self.avatar:
            return self.avatar
        if self.email:
            return get_gravatar(self.email, size=size)
        # 兜底:本地全年龄段友好的默认头像
        return "/static/img/default-avatar.png"

2.2 Write upload route (with image compression and old avatar cleaning)

You need to pay attention to the following security and experience details when using the upload function:

  1. Restrict file formats (only allow images)
  2. Rename files with UUID (to prevent file name conflicts and malicious injection)
  3. Compress/crop images (control storage size and loading speed)
  4. Delete the old custom avatar after successful upload (to avoid wasting space)

We first install two dependencies:

  • Pillow: Process image compression and cropping
  • werkzeug: Flask comes with it and is used to safely handle file names.
pip install Pillow

Then write the route:

# app/routes/profile.py
import os
import uuid
from flask import Blueprint, request, redirect, url_for, flash, current_app, render_template
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from PIL import Image
from app.extensions import db

profile_bp = Blueprint("profile", __name__, url_prefix="/profile")

# 允许的图片格式
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}

def allowed_file(filename):
    """检查文件后缀是否合法"""
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS

@profile_bp.route("/edit", methods=["GET", "POST"])
@login_required
def edit_profile():
    """基础的个人资料编辑页(下一节完善表单)"""
    return render_template("profile/edit.html")

@profile_bp.route("/avatar", methods=["POST"])
@login_required
def upload_avatar():
    """处理自定义头像上传"""
    # 1. 检查有没有上传文件
    if "avatar" not in request.files:
        flash("没有检测到上传的头像文件!", "danger")
        return redirect(url_for("profile.edit_profile"))
    
    file = request.files["avatar"]
    # 2. 检查文件名是否为空
    if file.filename == "":
        flash("请选择一张图片后再提交!", "warning")
        return redirect(url_for("profile.edit_profile"))
    
    # 3. 检查文件格式
    if file and allowed_file(file.filename):
        # 4. 生成安全的文件名,提取后缀
        secure_name = secure_filename(file.filename)
        ext = secure_name.rsplit(".", 1)[1].lower()
        new_filename = f"{uuid.uuid4().hex}.{ext}"

        # 5. 创建上传目录(如果不存在)
        upload_dir = os.path.join(
            current_app.root_path, "static", "uploads", "avatars"
        )
        os.makedirs(upload_dir, exist_ok=True)
        local_path = os.path.join(upload_dir, new_filename)

        # 6. 压缩 / 裁剪图片:统一缩放到 200x200 以内的正方形
        try:
            with Image.open(file) as img:
                # 统一转 RGB 格式,避免 PNG 透明背景存成 JPEG 出错
                if img.mode in ("RGBA", "P"):
                    img = img.convert("RGB")
                # 用 LANCZOS 算法,压缩质量最高
                img.thumbnail((200, 200), Image.LANCZOS)
                # 保存为 JPEG 格式,质量 85
                img.save(local_path, "JPEG", quality=85, optimize=True)
        except Exception as e:
            flash(f"图片处理失败:{str(e)}", "danger")
            return redirect(url_for("profile.edit_profile"))

        # 7. 删除旧的自定义头像(避免浪费空间)
        if current_user.avatar:
            old_local_path = os.path.join(
                current_app.root_path, current_user.avatar.lstrip("/")
            )
            # 确保路径存在且是文件再删除
            if os.path.exists(old_local_path) and os.path.isfile(old_local_path):
                try:
                    os.remove(old_local_path)
                except Exception:
                    # 忽略删除失败的错误(比如权限问题)
                    pass

        # 8. 保存新头像的相对路径到数据库
        current_user.avatar = f"/static/uploads/avatars/{new_filename}"
        db.session.commit()
        flash("头像更新成功!", "success")

    else:
        flash("不支持的图片格式!请上传 png / jpg / jpeg / gif / webp 格式的图片。", "danger")

    return redirect(url_for("profile.edit_profile"))

2.3 Add avatar upload form

Add buttons for avatar preview, upload, and restore Gravatar on the profile editing page:

<!-- templates/profile/edit.html -->
{% extends "base.html" %}

{% block content %}
<div class="container mt-5">
  <h2>编辑个人资料</h2>
  <hr>

  <div class="row">
    <!-- 左侧:头像区域 -->
    <div class="col-md-4 text-center">
      <div class="avatar-upload mb-4">
        <!-- 头像预览:调用 User 模型的 get_avatar 方法 -->
        <img 
          src="{{ current_user.get_avatar(200) }}"
          alt="{{ current_user.username }} 的头像"
          class="avatar-preview rounded-circle border border-3 border-secondary mb-3"
          style="width: 200px; height: 200px; object-fit: cover;"
        >

        <!-- 上传表单:必须加 enctype="multipart/form-data" -->
        <form 
          method="POST" 
          enctype="multipart/form-data"
          action="{{ url_for('profile.upload_avatar') }}"
        >
          <div class="mb-3">
            <input 
              type="file" 
              name="avatar" 
              accept="image/*" 
              class="form-control form-control-sm"
              required
            >
          </div>
          <button type="submit" class="btn btn-primary btn-sm w-100">
            上传新头像
          </button>
        </form>

        <!-- 如果有自定义头像,显示恢复 Gravatar 的按钮 -->
        {% if current_user.avatar %}
        <a 
          href="{{ url_for('profile.remove_avatar') }}"
          class="btn btn-outline-secondary btn-sm w-100 mt-2"
        >
          恢复 Gravatar 头像
        </a>
        {% endif %}
      </div>
    </div>

    <!-- 右侧:个人简介等其他字段(暂时留空,下一节完善) -->
    <div class="col-md-8">
      <p>个人简介、昵称等字段的编辑功能,下一节继续完善~</p>
    </div>
  </div>
</div>
{% endblock %}

3. Summary

Today we have implemented a combined avatar solution that takes into account cost, experience and freedom:

  1. Zero cost by default: Use Gravatar, which is universally available and does not require you to save images yourself.
  2. Domestic Acceleration/Personalization: Add custom upload entrance, use UUID to rename, Pillow compression, and clean up old avatars
  3. Unified Entry: Add to the User modelget_avatar()Method, use this method to call everywhere, no need to rewrite logic

💡 Best Practices:

  • If it is a domestic application, you can change the domain name of Gravatar to "Domestic Mirror" (for examplegravatar.loli.net), the speed will be much faster
  • If the storage space is large enough, you can store avatars in multiple sizes (such as 40px, 80px, 200px) to avoid browser zooming
  • When deleting old avatars, be sure to add "the path exists and is a file" to prevent accidental deletion.

🔗 Extended reading