2019年3月31日 星期日

[筆記] Django - File Validator 上傳檔案與驗證

  Django 的表格 (Django.forms.Form) 能夠從設定的欄位產生對應的 html 表格,並幫助開發者從提交的資料中過濾有害的資訊,將資料轉換成 Python 物件供取用。還可以撰寫額外的程式碼以驗證提交的資料是否有效。
  撰寫驗證資料的方式有三種:在表格的定義中撰寫 clean_<field_name> 函式、繼承對應的欄位類別並提供 validate()、撰寫驗證類別並傳入到對應的欄位中。本篇網誌使用第三種方法來驗證使用者上傳的檔案,檢查檔案副檔名、MIME type、檔案大小。


建立 FileValidator


  首先安裝必要的 package:
$ pip install python-magic python-magic-bin

  建立一個新檔案 validator.py。引入必要的 module:
import magic

from django.utils.deconstruct import deconstructible
from django.template.defaultfilters import filesizeformat
from django.core.exceptions import ValidationError
  • magic 用於判斷檔案的 MIME type,比起只判定檔案的附檔名還要來得保險。
  • deconstructible decorator 用於宣告等等要建立的類別。
  • filesizeformat 幫助轉換容量大小的數值 (byte) 成如 2KB、5MB 等易讀字串。
  • ValidationError 則是當取得的檔案驗證沒有通過時,要觸發的例外。

  宣告類別,並依照要檢查的項目提供對應的錯誤訊息:
@deconstructible
class FileValidator:
    error_messages = {
        "file_extension": "無效副檔名: {file_ext},只能為 {allowed_file_ext}",
        "mime_type": "無效 MIME type: {mime_type},只能為 {allowed_mime_type}",
        "max_size": "上傳的檔案超過 {max_size}",
        "min_size": "上傳的檔案小於 {min_size}",
    }

  初始化函式,指定要檢查的項目:
    def __init__(self, allowed_extension: tuple, allowed_mime_type: tuple, \
        max_size = None, min_size = None):
        self.allowed_extension = allowed_extension
        self.allowed_mime_type = allowed_mime_type
        self.max_size = max_size
        self.min_size = min_size
  • allowed_extension:指定允許的副檔名。
  • allowed_mime_type:指定允許的 MIME type。除了可以直接查表看想要驗證的檔案屬於哪種 MIME type,也可以直接用 python interpreter 來檢查,參考文末的附錄。
  • max_sizemin_size:檔案的大小限制,單位為 byte,可以不指定。注意:在這邊是收到檔案後才能檢查大小,代表無法透過這個 validator 來防止過大檔案的惡意攻擊,必須在使用的網頁伺服器上事先設定上傳上限。

  將驗證的程式碼寫在 __call__() 中,因為 Django 會把我們傳入欄位中的驗證類別的 instance 作為函式直接呼叫。__call__() 需要一個引數供 Django 傳入要被驗證的資料,這裡命名為 file
    def __call__(self, file):
        file_ext = file.name.split('.')[-1].lower()
        if file_ext not in self.allowed_extension:
            raise ValidationError( \
                FileValidator.error_messages["file_extension"] \
                .format(file_ext = file_ext, \
                        allowed_file_ext = ", ".join(self.allowed_extension)))

        mime_type = magic.from_buffer(file.read(), mime = True)
        file.seek(0)
        if mime_type not in self.allowed_mime_type:
            raise ValidationError( \
                FileValidator.error_messages["mime_type"] \
                .format(mime_type = mime_type, \
                        allowed_mime_type = ", ".join(self.allowed_mime_type)))

        if self.max_size and file.size > self.max_size:
            raise ValidationError( \
                FileValidator.error_messages["max_size"] \
                .format(max_size = filesizeformat(self.max_size)))

        if self.min_size and file.size < self.min_size:
            raise ValidationError( \
                FileValidator.error_messages["min_size"] \
                .format(min_size = filesizeformat(self.min_size)))
上傳的檔案會被轉換成 UploadedFile 類別,利用這個類別取得檔案資訊。在這裡依序驗證副檔名、MIME type、檔案大小,如果上傳的檔案並非想要的,就觸發 ValidationError 例外,並回報錯誤原因。
  • 驗證副檔名:透過 UploadedFile.name 取得上傳的檔案檔名,檔名的長度限制可以額外在表格欄位上設定
  • 驗證 MINE type:透過 UploadedFile.read() 取得上傳的檔案內容,並傳入給 magic 判斷 MIME type,記得要呼叫 file.seek(0) 將檔案的讀取位置設回開頭。如果檔案較大 (Django 預設為 2.5 MB),應該使用 UploadedFile.chunks() 批次讀取檔案內容,以免一次讀取過大的檔案,佔滿記憶體。再者,magic 可以從部分內容來推定檔案的 MIME type,所以不必完整讀取檔案。
  • 驗證檔案大小:透過 UploadedFile.size 取得檔案大小,單位為 byte。

使用 FileValidator


建立表格

產生 FileValidator instance,這個 instance 會驗證上傳的檔案是否為 python code:副檔名必須為 "py",MIME type 必須為 "text/x-python" 或 "text/plain",檔案大小 1MB 以內。將這個 instance 傳入到要建立的表格欄位中的 validators 參數,必須以 list 傳入:
# app_name/forms.py

from django import forms
from .validator import FileValidator

class CodeUploadForm(forms.Form):
    source_code_validator = FileValidator(("py",), \
        ("text/x-python", "text/plain"), \
        max_size = 1 * 1024 * 1024)

    source_code_file = forms.FileField(required = True, max_length = 25, \
        validators = [source_code_validator], \
        label = _("Python 程式碼"), \
        help_text = _("副檔名為 .py, 檔案大小上限為 1 MB, 檔名不能超過 25 字元"))
注意如果只有指定一種副檔名,必須要加逗點才會被視為 tuple。

在 view 中使用表格

上傳的檔案都會被放在 request.FILES 中,是 dictionary,key 為表格的欄位變數名稱,value 為 UploadedFile。而「濾過」的檔案則被放在 form.cleaned_data[<field_name>] 中,value 一樣為 UploadedFile
# file app_name/views.py

from django.shortcuts import render
from .forms import CodeUploadForm

def code_upload(request):
    if request.method == "POST":
        form = CodeUploadForm(request.POST, request.FILES)

        if form.is_valid():    # 這裡會驗證表格內容是否有效

            # 將檔案存到硬碟中
            with("path/to/save/file.py", "wb") as f:
                for chunk in form.cleaned_data["source_code_file"].chunks():
                    f.write(chunk)

            return render(reqeust, "code_upload_successful.html")
    else:
        form = CodeUploadForm()

    context = {
        "form": form,
    }

    return render(request, "code_upload.html", context = context)

在 template 中使用表格

這邊要注意的是,form 的 attribute 必須設定 enctype="multipart/form-data",否則 request.FILES 永遠是空的:
# file app_name/template/code_upload.html
...

<form action="" enctype="multipart/form-data" method="post">
    {% csrf_token %}
    <table>
    {{ form.as_table }}
    </table>
    <input type="submit" value="{% trans '上傳' %}" />
</form>

...
網頁上的結果會像這樣:
如果上傳無效的檔案會像這樣:

附錄:使用 magic 取得檔案的 MIME type


  想要確認檔案的 MIME type,可以直接在 python interpreter 上測試:
>>> import magic
>>> magic.from_file('manage.py')
'Python script, ASCII text executable, with CRLF line terminators'
>>> magic.from_file('manage.py', mime=True)
'text/x-python'

參考資料


沒有留言:

張貼留言