🖼️

画像アップロードのWeb標準って何?①:アバター変更

に公開

はじめに

こんにちは!
PortalKeyの しゃり です。

画像投稿機能の実装に当たり身近なサービスの手法を調べました。何が一般的な手法かも知らなかったので良い勉強になりました。
備忘録的に共有したいと思います。

今回の記事は、ユーザー自身を象徴するアバター画像の変更についての調査結果です。

経緯

弊社では通話とチャット機能を備えたプラットフォームを開発しています。

https://zenn.dev/portalkeyinc/articles/c34241e9368089

開発初期では効率良くドッグフーディングを行うため、開発段階ではUXに影響しない実装を後回しにしていました。

そうして画像の保存先にデータベースをそのまま使っていましたが、本番リリースに備えて外部ストレージに変更することになりました。
そこで、有名サービスの実装を調査してみました。


何のサービスを調べたか

Discord, Slackは開発者向けドキュメントが公開されています。
これらについては、ドキュメントの確認とブラウザ版での動作確認の両方を行いました。

GitHubについてはドキュメントが公開されてはいるものの、アバター変更についてはAPIが公開されていませんでした。
Zennについてはドキュメントが見当たりませんでした。
よって、これらは動作確認のみ行いました。

XやQiitaについてはここまでで紹介したサービスとおおよそ同じ動作であり、ドキュメントが見つからなかっため省略しました。

では、1つずつ紹介していきます。

Discord

アバターを消す機能は無いようでした。
また、アバター変更専用のエンドポイントは無く、プロフィール全てを変更するapiに組み込まれてました。

開発者向けドキュメント

アバター変更

https://discord.com/developers/docs/resources/user#modify-current-user

  • エンドポイント /users/@me
  • HTTPリクエスト PATCH
  • avatarパラメータに値を渡した場合にアバターを変更する
  • avatarパラメータはImage Data
    これはData URI schemeに則る
  • 補足情報
    • 対応拡張子はJPG, GIF, PNG
    • アバター削除については記載無し

動作確認

基本的にドキュメント通りでしたがクライアントに便利実装がありました。

アバター画像の変更

  • ドキュメント通りだがクライアントに便利実装有り
  • content-type
    content-type application/json
  • 画像はbase64へ変換し、jsonのavatarプロパティで送付
  • アバターは消せない
    (ニックネームは空文字を送ることで消せる)
  • 補足
    • 正方形にトリミングしてから送付
    • 画像縮小はしてなさそう
    • 別リソースから直近6つのアバターを取得できる
      画像変更のモーダルを開いた時点でusers/@me/avatarsGETで取得し、過去アバターからも選択できるようにしてある
    • 拡張子はwebpも対応していた
request&response
users/@me PATCH Request
{
  avatar : "data:image/png;base64,<略>"
  avatar_description: "start_24dp_E8EAED_FILL0_wght400_GRAD0_opsz24 (1)、2025年10月24日 15:01に追加済み"
}
user/me PATCH resp 変更後のレスポンス
{
    "id": 一応伏せるよ!,
    "username": "shari_sushi",
    "avatar": "aacf632e5bcefb64de0b50cbce89ad2d",
    "discriminator": "0",
    "public_flags": 0,
    "flags": 0,
    "banner": null,
    "accent_color": 7890516,
    "global_name": "sushi",
    "avatar_decoration_data": null,
    "collectibles": null,
    "display_name_styles": null,
    "banner_color": "#786654",
    "clan": null,
    "primary_guild": null,
    "mfa_enabled": false,
    "locale": "ja",
    "premium_type": 0,
    "email": 見せられないよ!,
    "verified": true,
    "token": 見せられないよ!,
    "phone": 見せられないよ!,
    "nsfw_allowed": true,
    "linked_users": [],
    "bio": "test2",
    "authenticator_types": [],
    "age_verification_status": 1
}
users/@me/avatars GET Response
  {
    "avatars": [
        {
            "id": "1431159440954101860",
            "storage_hash": "[a-z0-9]*32ぽい",
            "description": null
        },
        {
            "id": "1431160743113392148",
            "storage_hash": "[a-z0-9*32]ぽい",
            "description": "start_24dp_E8EAED_FILL0_wght400_GRAD0_opsz24 (1)\u30012025\u5e7410\u670824\u65e5 15:01\u306b\u8ffd\u52a0\u6e08\u307f"
        }
    ]
   }

個人的感想

PATCHでこの分岐をする実装好き

  • 値を送る→新規or更新
  • 値を送らない→何もしない(現状維持)
  • 空を送る→削除

その上で、deleteメソッド対応や、プロフ項目ごとのapiが不要になるので、エンドポイントを減らしつつ、機能改修も簡単で後方互換性を保ちやすそうです。
クライアントとしては、リセットしたい=無に更新するという直感に反しませんし、どのKeyを送るか空を送るかだけ考えれば良くなり実装も簡単そうです。

気になった点

少し気になったのは、base64に変換して33%容量増して、サーバーサイドでデコードするというある意味無駄ともいえる実装だけ気になりました。

ただ、アバターはそんなに頻繁に更新されるわけでもないでしょうし、正方形にトリムされるのでサイズも小さくなります。
画像通信量の33%増よりも、jsonに組み込んでエンドポイントをまとめられるメリットを取ったということでしょうか。
(実際、Dicordはチャットの画像投稿ではバイナリのまま扱っています。)

Slack

ドキュメントと実装でやや違う点がありました。
チャットの画像投稿もなのですが、Slackの公開APIは外部開発者が楽に実装できたり、実装者の好みに合わせられるようにしている様子がうかがえます。

開発者向けドキュメント

ドキュメントではユーザーの選択肢を増やす設計になっていました。
認証情報を乗せる場所をAuthorization headerとリクエストbodyで選べます。
Content-Typeをimage/jpegで画像バイナリを添付するか、multipart/form-dataでimageフィールドに画像バイナリを添付するか選べます。

アバター変更

https://docs.slack.dev/reference/methods/users.setPhoto/

  • エンドポイント:users.setPhoto
  • Content types
    application/x-www-form-urlencoded, application/json
    ただし、multipart/form-data
  • imageパラメーターで送るならファイル形式を正確に入力すること
  • 補足
    • 画像形式はimage/gif, image/jpeg, image/png, etc.
    • 最大サイズは 1024 x 1024 ピクセル、最小サイズは 512 x 512 ピクセル

アバター削除

https://docs.slack.dev/reference/methods/users.deletePhoto/

  • users.deletePhoto method
  • GETリクエストで認証トークンを送るだけ
    🤔MDN GET

    HTTP の GET メソッドは、特定のリソースの表現をリクエストします。 GET を使用したリクエストはデータをリクエストするためだけに使用してください(データを含めるべきではありません)。

動作確認

アバター変更については開発者用ドキュメントと違い、2段階のリクエストを送っていました。
ドキュメントではusers.setPhotoに画像を直接送っていましたが、アプリ上では先に別のapiへアップロードし、返って来た画像idをusers.setPhotoで送っていました。

アバター変更

  1. 画像添付時
    • エンドポイント:https://[サーバー名].slack.com/api/users.preparePhoto?_x_id=17652a08-1763809110.794&_x_csid=Uuxn0gZKaI4&slack_route=T06M6TZHC3V&_x_version_ts=1763795857&_x_frontend_build_type=current&_x_desktop_ia=4&_x_gantry=true&fp=ea&_x_num_retries=0
    • リクエスト メソッド POST
    • 補足情報
      • 添付時点でモーダルを表示
        • トリミングやズームができる
        • 保存ボタンがある
      • 画像を送る際はトリミングせず、トリミング位置(crop_x, crop_y)を送っている様子
request&response
クエリ文字パラーメータ
_x_id 17652a08-1763809110.794
_x_csid Uuxn0gZKaI4
slack_route T06M6TZHC3V
_x_version_ts 1763795857
_x_frontend_build_type current
_x_desktop_ia 4
_x_gantry true
fp ea
_x_num_retries 0
form-data
token 見せられないよ!
image (バイナリ)
_x_reason EditProfileModal
_x_mode online
_x_sonic true
_x_app_name client
response
{
  "ok": true,
  "id": "10002482304576",
  "url": "https:\/\/avatars.slack-edge.com\/temp\/2025-11-22\/10002482304576_514456c70c6422336327.jpg"
}
  1. 確定時
  • リクエスト URL https://[サーバー名].slack.com/api/users.setPhoto?_x_id=17652a08-1763809331.777&_x_csid=idTtYweG6Ps&slack_route=T06M6TZHC3V&_x_version_ts=1763795857&_x_frontend_build_type=current&_x_desktop_ia=4&_x_gantry=true&fp=ea&_x_num_retries=0
  • リクエスト POST
  • ステータス コード 200 OK
request&response
クエリ文字パラーメータ
_x_id 17652a08-1763809331.777
_x_csid idTtYweG6Ps
slack_route T06M6TZHC3V
_x_version_ts 1763795857
_x_frontend_build_type current
_x_desktop_ia 4
_x_gantry true
fp ea
_x_num_retries 0
token 見せられないよ!
id 10002482304576
crop_x 9.975000000000023
crop_y 9.975000000000023
crop_w 379.04999999999995
_x_reason edit-profile-set-photo
_x_mode online
_x_sonic true
_x_app_name client

レスポンスデータについては、読み込み失敗しました。

アバター削除

  • エンドポイント:https://[サーバー名].slack.com/api/users.deletePhoto?_x_id=17652a08-1763809642.595&_x_csid=-CXLjJueErg&slack_route=T06M6TZHC3V&_x_version_ts=1763795857&_x_frontend_build_type=current&_x_desktop_ia=4&_x_gantry=true&fp=ea&_x_num_retries=0
  • リクエスト POST
request&response
クエリ文字列パラメータ
_x_id 17652a08-1763809642.595
_x_csid -CXLjJueErg
slack_route T06M6TZHC3V
_x_version_ts 1763795857
_x_frontend_build_type current
_x_desktop_ia 4 _x_gantry true
fp ea
_x_num_retries 0
フォームデータ
token 見せられないよ!
_x_reason edit_profile_delete_photo
_x_mode online
_x_sonic true
_x_app_name client

まとめ

Discordと違い、更新と削除で別のapiが用意されていました。
また、それによりバイナリのまま画像をアップロードすることができ、通信量は削減できていそうです。
エンコードデコードの処理も不要になりますし、インフラコスト的にはこちらの方が有利でしょうか?
ただ、トリミングしないでサーバーへ送付していそうなので、インフラコスト削減よりも、クライアントの処理負荷を少しでも下げる方が目的でしょうか。
それとも、その辺りは結果論や誤差であり、画像以外の全体設計に合わせただけといった歴史的経緯があったりするのでしょうか。

GitHub

ドキュメントを見た感じ、該当のエンドポイントは見つけられませんでした。
そのため動作確認のみとなりましたが、4段階のリクエストを送っていて驚きました。

開発者向けドキュメント

プロフィールのアップデートドキュメントはあったのですが…。

アバター変更についての解説は無さそうです。
https://github.com/orgs/community/discussions/65206

I can't find any endpoint (for OAuth or GitHub Apps) to change the users profile picture on Github?

I'm guessing it's because githubs rest API does not have any file sharing capabilities,

動作確認

GitHubではなんと作成/更新のために4段階のリクエストを送っていました。
さらに、削除とは別のリクエストが飛ばされていたり、アップロード後に変更しなかった場合に取り消し画像の削除のためであろうリクエストが送られている丁寧設計でした。

アバター更新

  • エンドポイント:画像添付時に一瞬のうちに連続3回と、更新確定時に1回の4種類がありました
  • form要素を使用
    ※送信モーダルを開く前後でdocument.getElementsByTagName('form') で取得できる要素が1つ増えていた。
  • 補足
    • 添付できる画像は「形式はPNG, GIF, JPG」「1MB未満」のようです。
  • 不明点
    • 1回目2回目のリクエストを分けている理由
    • リクエストbodyにowner_idやアクセストークンを乗せている理由
      (普通はheaderでは?)
  1. 添付時点の最初のリクエスト

    • エンドポイント /upload/policies/avatars
    • リクエスト POST
    • Content-type application/json; charset=utf-8
    request&response
    /avatars POST request
    name tdWcYD4f_400x400.jpg
    size 21349
    content_type image/jpeg
    authenticity_token 見せられないよ!
    owner_type User
    owner_id 見せられないよ!
    
    1回目 /avatars POST response
    {
      "upload_url": "https://uploads.github.com/avatars",
      "header": {
          "Accept": "application/vnd.github.assets+json; charset=utf-8",
          "GitHub-Remote-Auth": 一応伏せるよ!`[a-z].`だったよ!
      },
      "asset": {
          "owner_type": "User",
          "owner_id": 見せられないよ!,
          "size": 21349,
          "content_type": "image/jpeg"
      },
      "form": {
          "owner_type": "User",
          "owner_id": 見せられないよ!,
          "size": 21349,
          "content_type": "image/jpeg"
      },
      "same_origin": true,
      "upload_authenticity_token": "見せられないよ!"
    }
    
  2. バイナリの送付

    • エンドポイント uploads.github.com/avatars
    • リクエスト POST
    • バイナリを送付し、画像のidを受け取る
    request&response
    request
    authenticity_token 見せられないよ!
    owner_type User
    owner_id 一応伏せるよ!
    size 21349
    content_type image/jpeg
    file (バイナリ)
    
    resp
    {
      "id": 50572997,
      "cropped_dimensions": null,
      "width": 399,
      "height": 399,
      "url": "https://media.githubusercontent.com/avatars/50572997?token=[一応伏せるよ!]",
      "path_prefix": "alambic"
    }
    
  3. 送った画像をGET

    • 2で受け取った画像のidをPATHにしてGETリクエスト
    • 結果は 302 Found
      リダイレクトされてレスポンスが確認できず

以上の結果、画面が更新されてモーダルが開きます。
このモーダルは添付した画像の表示、トリミング、設定完了操作ができます。

GETなのでリクエストボディは無し
リダイレクトされたのでレスポンスも無し

  1. 画像の保存or破棄
     - エンドポイント github.com/settings/avatars/50572997
     - リクエスト POST
    1. 更新確定時
    • ここでは画像idは使われていない
    • ストレージなりCDNに対してはusercontentという名前で扱っていそう
      https://avatars.githubusercontent.com/u/[owner_id]?s=400&u=dcbf18262a0c43838c648c6b88276b51a958f36b&v=4
    • ステータス コード 302 Found
    • op saveを送っている
    1. 編集の破棄時
    • モーダルを閉じたときにリクエスト
      アップロードした画像の破棄でしょうか。
    • op destroyを送っている
request&response
  • 更新確定ボタン押下
    リクエスト
    op save
    authenticity_token 見せられないよ!
    cropped_x 0
    cropped_y 0
    cropped_width 399
    cropped_height 399
    
    レスポンスはありませんでした。
    リダイレクトされたため取得できなかったということです。
  • 保存せずにモーダルを閉じる
    request
    op destroy
    

アバター削除

エンドポイントやHTTPメソッドは更新と同じですが、送ってるものが違いました

Zenn

Zennも開発者向けドキュメント、公開APiドキュメントは見つかりませんでした。
アバターを消す機能は無いようでした。

また、アバター変更機能は非常にシンプルでした。

動作確認

アバター変更

アバター変更では保存時の1回のみのリクエストでした。

  • エンドポイント zenn.dev/api/me/avatar
  • リクエスト PUT
  • file バイナリ
request&response
{
  "current_user": {
      "id": 120136,
      "username": "sharinprog",
      "name": "しゃり",
      "bio": [略],
      "github_username": "shari_sushi",
      "twitter_username": "shari_susi",
      "website_url": "https://v-karaoke.com/",
      "is_support_open": false,
      "tokusyo_name": null,
      "tokusyo_contact": null,
      "avatar_url": "https://storage.googleapis.com/zenn-user-upload/avatar/6d97c417e6.jpeg",
      "email_notify_comments": true,
      "email_notify_purchased": true,
      "email_notify_following": true,
      "email_notify_newsletters": true,
      "email_notify_announcement": true,
      "ga_tracking_id": null,
      "following_user_ids": [略],
      "following_publication_ids": [略],
      "following_topic_ids": [],
      "muting_user_ids": [],
      "github_connected": false,
      "hatena_id": null,
      "contact": null,
      "invoice_issuer_number": null,
      "sign_in_types": {一応伏せるよ!},
      "stats_reveal": true,
      "onboarding_completed": true,
      "article_review_enabled": true,
      "has_unchecked_timeline_items": true,
      "notifications_count": 0,
      "is_stripe_customer": false,
      "show_instruction": false,
      "publications": [{組織アカウントの情報だったよ!}],
   }
}

まとめ

シンプルにバイナリを送っていました。
これが一番単純な手法な気がします。

おさらい

超ざっくりとですが、ここまでの調査結果を整理します。

  • Discord
    • 更新と削除はapiが同じ
      (プロフィール変更の一部でしかない)
    • 値が有れば変更、送っていなければ現状維持
      (アバターは削除できないが、他のプロパティは空文字送信で削除)
    • 画像をbase64に変換して送っている(jsonに乗せるため?)
  • Slack
    • 更新と削除はエンドポイントが別
    • 更新については、開発者向けドキュメントとアプリ実装で異なる
      • 開発者向けにはバイナリのまま送付する方法が公開されている
      • アプリ実装では、画像バイナリの送付と、実際のアバター変更は別リクエスト
  • GitHub
    • 開発者向けドキュメントはあるものの、アバター変更の記述は見つからず
    • 更新は4リクエストに分かれている
      ①メタデータの送付とエンドポイントの確認(?)、②画像バイナリの送付、③画像プレビューの取得、④/{id}で確定/破棄
    • 更新の④と削除が全く同じapi
      更新確定、更新破棄、削除の3つをopプロパティの値で区別?
    • form要素を使用
  • Zenn
    • 開発者ドキュメントは見つからず
    • アバターは削除不可
    • アバター変更専用apiに画像バイナリを送付

終わりに

以上、アバター変更1つとってもサービスによって違う実装がされていました。
それぞれ異なった歴史的経緯や目的があるのでしょうね。
個人的にはDiscordの、PATCHリクエストで、値有り→新規作成か変更、空→削除、送らない→変更しないという実装が好きです。

他にもQiitaやTwitterも少し触ってみましたが、新しい発見は無かったので割愛しました。
(単純な画像アップロード以外に付随的に何か複雑なこともしてる気もしましたが…)
Twitterはアバター画像添付後、保存ボタン押下時に、アバター画像アップロード→id取得→アバター変更apiにidを送信という手順を踏んでいました。
最終確定までは画像データをアップロードしない設計ということですね。

おまけ

PATCHの設計良いなーなんて書きましたが。
弊社はRPCを採用しているので自分が実装するときは区別できないんですけどね。全て関数に対するPOSTになります。
https://aws.amazon.com/jp/compare/the-difference-between-rpc-and-rest/

PortalKey Tech Blog

Discussion