djangoで画像をアップロードした時に、可読性の観点からアップロードついでにリサイズまたはサニタイズで位置情報の削除をしてから保存をしたいのに、適切な方法が見つからない!!なんてことはありませんでしょうか?
本日はその解決方法を紹介します。
またこちらの「django画像アップロード機能を実装する方法」の拡張機能になります。
・djangoで画像アップロードをした時に、リサイズやサニタイズ処理をかけたい人
(※リサイズは画像のサイズ変換ですが、サニタイズ(Sanitize)とは、危険なコードやデータを変換または除去して無力化する処理です。記事内では位置情報を無力化することにします。)
コンテンツ
実行環境
以下が実行環境になります。
・OS : Mac
・django 2.2
・python 3.8
・ImageField(画像)を扱うPillowをインストール済み
djangoでの画像アップロードのリサイズ処理
ちなみにこの画像アップロードからのリサイズの日本語情報は非常に少ないのと可読性が高いものもなく下記英語でも探し回ったりもしました。
django resize image before saving
django resize image before upload
そして結局の解決方法としては仮想ディレクトリを作り出してそこに一度格納した画像に対しての処理をしてから、正規のモデルズに保存をする方法が一番ベターなのではないかという結論に至りました。
保存を阻む要因
なぜdjangoでの画像の処理はこんなにも面倒くさいのか?
formsやmodelsでの画像保存時に処理ができないのか?
この根本的な問題を解決できない一つの理由として、画像を加工する時に新規の画像はImage.open()で画像を読み込む時に、パスを通せないという問題点があります。
そうですね、当たり前ですが新規で画像をアップロードする時に、そもそも読み込む画像はサーバーにありませんよね。
またmodelsで一度保存をしてからviewsで再度画像を呼び出して加工処理するというのは可読性が悪いのと、保存後にもし画像加工に失敗してしまうと保存先のデータが雑多になってしまうという危険性も伴います。
保存を阻む要因の解決方法
この問題を解決する方法としては、仮想空間に架空のディレクトリを用意することが解決方法として一番しっくりくるロジックに至りました。手順としては
- 画像のアップロード(POST)の実行
- 仮想空間に架空のディレクトリを用意
- temfileモジュールを利用して仮想ディレクトリを作成
- pythonのwith構文で3.の仮想ディレクトリ内で処理を実行
- 4に対してPOSTした画像を一時保存してそこからパスを呼び出す
- 呼び出した画像をリサイズ及びサニタイズ処理
- 正規のモデルに保存
ロジックはこんな感じです。
それでは実際に本番のコードを紹介します。
実際のコード
「プロフィール画像のアバターを変更するに当たっての処理」という前提でviewsを書いてきます。なのでインスタンスにそのようなuser_profileと変数を与えています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
import tempfile from PIL import Image from datetime import datetime from .models import UserProfile from .forms import UpLoadProfileImgForm() def edit_profile_avator(request): user_profile = UserProfile.objects.get(user_id=request.user.id) if request.method != 'POST': form = UpLoadProfileImgForm() else: form = UpLoadProfileImgForm(request.POST, request.FILES) if form.is_valid(): avator = form.cleaned_data['avator'] if user_profile.avator: os.remove('media/' + str(user_profile.avator)) user_profile.delete() def handle_uploaded_file(f, path): with open(path, 'wb+') as destination: for chunk in f.chunks(): destination.write(chunk) FILE_NAME = 'uploaded%s.png' % datetime.now().strftime('%Y-%m%d-%H%M') with tempfile.TemporaryDirectory() as tmp_directory: UPLOADED_PATH = '%s%s' % (tmp_directory, FILE_NAME) handle_uploaded_file(request.FILES['avator'], UPLOADED_PATH) img = Image.open(UPLOADED_PATH) if 'exif' in img.info: img.info['exif'] = {} resized_img = img.resize((180, 180)) UPLOADED_PATH_WITH_RESIZED_IMG = '%s%s' % (tmp_directory, 'resized.png') resized_img.save(UPLOADED_PATH_WITH_RESIZED_IMG) f = open(UPLOADED_PATH_WITH_RESIZED_IMG, mode='rb') user_profile.avator = File(f) user_profile.user_id = request.user.id user_profile.save() return HttpResponseRedirect(reverse('inquiry_apps:edit_profile_success')) context = { 'form': form } return render(request, 'アプリ名/ファイル.html', context) |
順番に説明をしていきます。
- 16行目で画像のバリデーションチェックを通る
- 20行目で既に同じuser_idがある画像データが存在するならば削除をする(参考:https://freeheroblog.com/del-filefield/)
- 29行目FILE_NAMEの右辺で仮の.pngファイルを作成する
- 31行目のtemfile.TemporaryDirectory()で一時ディレクトリを作成する(参考:https://docs.python.org/ja/3/library/tempfile.html)
- 4の処理は一度しか使用しない処理で開始と終了を必要とする処理なのでwith構文をセットする(結果としてエラーを防げる。参考:https://docs.python.org/ja/3/reference/compound_stmts.html)
- 32行目で一時ディレクトリを作成し、また29行目で作成した仮想ファイルをセットする
- 33行目で、request.FILES[‘avator’]と、UPLOADED_PATHの値をhandle_uploaded_file関数に与えて処理を実行する
- handle_uploaded_file関数ではImage.open()で仮想ディレクトリとファイルのパスを読み込む。またwb+でバイナリモードを読み書きする
- 26行目でバイナリモードをchunk(分割されたファイルの処理)する
- 34行目で仮想ディレクトリにアップロードしたavatorが格納されているのでこれをImagefieldの画像として扱う準備ができた
- 36行目でもし位置情報があれば位置情報を空に(削除)する
- 39行目で画像を180×180pxにリサイズする(参考:https://note.nkmk.me/python-pillow-image-resize/)
- 40行目で仮想環境へのパスを設定
- 41行目で仮想環境へ画像を保存
- 43行目で始めに読み込んでいるfrom django.core.files import File のFileオオブジェクトを使用する(参考:https://djangoproject.jp/doc/ja/1.0/topics/files.html)
- 45行目で本来のモデルクラスのアバターにサニタイズ及びリサイズしたファイルを格納
- 47行目で保存しwithで閉じられる
少し冗長に感じるかもしれませんが、1行ずつ分解して読んで行けば何も複雑な処理はしていません。
捕捉説明
viewsがこのコードで、以下のような一般的なmodelsに保存をすることでエラー(SuspiciousFileOperation)が出現してしまうことが判明しました。
models.py
1 2 3 4 5 |
class UserProfile(models.Model): avator = models.ImageField( verbose_name = 'avator', upload_to = 'images/', ) |
原因としては、upload_toで保存されるファイル名が100文字の文字上限があります。
withで仮想ディレクトリを作成することで以下のようなディレクトリ+ファイル名が生成されます。
/var/folders/8g/vjr3qy550rl61rkd3qqcsfayscc40000gn/T/tmpsyqmvpxresized.png
更にosパスで指定した、Users/xxxxx/media/が加わって文字数が容易に100文字を超えてしまいます。
これを解決するためには、ファイルの文字数を短くする必要があります。
こちらは参考までですが以前等ブログ内で紹介した記事になります。
この記事のようにmodelsのupload_toを書き換えるとうまく行きます。
models.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
models.py from datetime import datetime def _user_profile_avator_upload_to(instance, filename): current_time = datetime.now() pre_hash_name = '%s%s%s' % (instance.user_id, filename, current_time) extension = str(filename).split('.')[-1] hs_filename = '%s.%s' % (hashlib.md5(pre_hash_name.encode()).hexdigest(), extension) saved_path = 'images/' return '%s%s' % (saved_path, hs_filename) class UserProfile(models.Model): avator = models.ImageField( verbose_name = 'avator', upload_to = _user_profile_avator_upload_to, default = 'images/default_icon.png' ) |
ファイル名を複雑化することで容易にユーザーにファイル名を識別させられないようにする効果もありますのでこちらを是非試してみてください。
プログラミング学習を効率良く進めるには…
私ヒロヤンがプログラミングを始めた頃は以下のような感じでした。
そしてネットで調べていくうちに膨大な時間が過ぎていきました。
私ヒロヤンの実体験より、プログラミングを効率的に学ぶために大切なことは以下のことだと考えています。
1. いつまでもダラダラとやらないで、目標を決定して短期集中する
2. マンツーマンで、わからない箇所は直ぐに質問をして即レスをもらう
.proでは私ヒロヤンが学習してきたプログラミング経験0からのpython/django、その他webサイト・サービス開発のコースが用意されています。
カウンセリング自体は無料なので話を聞いてみるだけでもいかがでしょうか?
また以下のリンク先ではdjangoを教えてくれるスクールをまとめ紹介しています。
ヒロヤンさん、とてもためになる記事ありがとうございます。
ちょうど、会員登録機能の画像アップロード時にリサイズする方法を探しておりましたので、早速試させて頂きました。
その際、
画像自体は処理上リサイズされる事が確認できたのですが、最後のモデルの更新(user_profile.save()に相当する箇所)で「SuspiciousFileOperation」エラーが発生してしまいます。
仮想環境のパスが、Djangoアプリ外のためエラーが出ているように見えます。
もし、何か考えられることがありましたら、ご教授頂きたく。よろしくお願いいたします。
エラー詳細
—————————–
The joined path (/var/folders/8g/xl779ql10yj8djbyk78_66900000gn/T/tmpa3ru48qrresized.png) is located outside of the base path component (/Users/〜〜〜/media)※左記はdjangoアプリのパス
—————————–
▼実行環境
・OS : Mac
・django 2.2.2
・python 3.6.10
・Pillowインストール済み
43行目の f = open(UPLOADED_PATH_WITH_RESIZED_IMG, mode=’rb’)
及び45行目がきちんと動いているのであれば、仮想環境のパスは問題ないのではと考えます。
他サイトでエラーを見てみましたが、通常のmediaルートへの画像保存は問題なくできますでしょうか?
例えば今回のようなリサイズなどしないでの保存はできますでしょうか?
settingsやmodelsのは下記設定で進めています。
https://freeheroblog.com/upload-img/
ヒロヤンさん、お返事ありがとうございます。
settingsやmodelsの設定についても、リンク先を確認させて頂きましたが、特に問題なさそうでした。
ちなみに、45行目で格納しているFile(f)の代わりに、17行目でフォームから取得している画像データを格納してみたところ、問題なく画像が保存されました。
また、
43行目で取得しているファイルオブジェクトをデバッグログで出力したところ、下記と表示されましたので、確かに、仮想環境のパスは問題なさそうです。
f:
エラーの出どころを確認してみると下記となっており、
Exception Location: /Users/xxxx/xxxxxx/xxxxxxx/xxxxxxx/lib/python3.6/site-packages/django/utils/_os.py in safe_join, line 46
ネットで調べていたら、ディレクトリトラバーサルというキーワードが引っかかりましたので調べてみると、下記記述を見つけました(公式ページの情報ではありませんが・・・)
—————————————-
DjangoのテンプレートやストレージAPIでは、ディレクトリトラバーサルを避けるような工夫があり、safe_join (django.utils._os.safe_join)を利用しているので、locationの外側にアクセスしようとするとSuspiciousOperationエラーとなる。
ファイルパスにユーザの入力値などが含まれるもので、openで開いたり、そのままのパス名でファイル操作したりするなど。
—————————————-
Djangoのセキュリティ上の仕様のようですが、何か意識された点などありましたでしょうか?
また、ヒロヤンさんの記事では記載が無かったため分かりませんが、当方は仮想環境(venv)で動かしております。
もし何か気づくことがありましたら、よろしくお願いいたします。
・環境はpipenvです。
>43行目で取得しているファイルオブジェクトをデバッグログで出力したところ、下記と表示されましたので、確かに、仮想環境のパスは問題なさそうです。
43行目ですが私の場合だと
f = open(UPLOADED_PATH_WITH_RESIZED_IMG, mode=’rb’)
print(f) # <_io.BufferedReader name='/var/folders/8g/vjr3qy550rl61rkd3qqyscc40000gn/T/tmp75u4bupiresized.png'>
になりますね。
・djangoのセキュリティ上、このコードは特別何か付与して書いてはおりません。
・modelsでの画像の保存先は
upload_to = ‘images/’
ですよね。
うーん、問い合わせフォームでも良いので一度コード送っていただければ検証させていたきます。
(最悪、views側で一度modelsに保存してから、すぐに呼び出してリサイズという方法も簡単にできますが一つのviewsの中で複数回modelsを呼び出し直ぐに加工保存というやり方はおすすめしません)
ヒロヤンさん、ご回答ありがとうございます。
遅くなりましたが、先程関連コードを問い合わせフォームで送らせて頂きました。
どうぞ、宜しくお願いいたします。