文件上傳
在HTML中,渲染一個文件上傳字段只需要將<input>標簽的type屬性設為file,即<input type=”file”>。
這會在瀏覽器中渲染成一個文件上傳字段,單擊文件選擇按鈕會打開文件選擇窗口,選擇對應的文件后,被選擇的文件名會顯示在文件選擇按鈕旁邊。
在服務器端,可以和普通數據一樣獲取上傳文件數據並保存。不過需要考慮安全問題,文件上傳的漏洞也是比較流行的攻擊方式。除了常規的CSRF防范,我們還需要重點關注這幾個問題:驗證文件類型、驗證文件大小、過濾文件名
定義上傳表單
在python表單類中創建文件上傳字段時,我們使用擴展Flask-WTF提供的FileField類,它集成WTForms提供的上傳字段FileField,添加了對Flask的集成。例如:
創建上傳表單:
from flask_wtf.file import FileField, FileRequired, FileAllowed class UploadForm(FlaskForm): photo = FileField('Upload Image', validators=[FileRequired(), FileAllowed(['jpg','jpeg','png','gif'])]) submit = SubmitField()
在表單類UploadForm()中創建了一個FileField類的photo字段,用來上傳圖片。
和其他字段類似,需要對文件上傳字段進行驗證。Flask-WTF在flask_wtf.file模塊下提供了兩個文件相關的驗證器,用法如下:
我們使用FileRequired確保提交的表單字段中包含文件數據。處於安全考慮,必須對上傳的文件類型進行限制。如果用戶可以上傳HTML文件,而且我們同時提供了視圖函數獲取上傳后的文件,那么很容易導致XSS攻擊。使用FileAllowed設置允許的文件類型,傳入一個包含允許文件類型的后綴名列表。
Flask-WTF提供的FileAllowed是在服務器端驗證上傳文件,使用HTML5中的accept屬性也可以在客戶端實現簡單的類型過濾。這個屬性接收MIME類型字符串或文件格式后綴,多個值之間使用逗號分隔,比如:
<input type=”file” id=”profile_pic” name=”profile_pic” accept=”.jpg, .jpeg, .png, .gif”>
當用戶單擊文件選擇按鈕后,打開的文件選擇窗口會默認將accept屬性之外的文件過濾掉(其實沒有過濾掉)。
盡管如此,用戶還是可以選擇設定之外的文件,所以仍然需要在服務器端驗證。
驗證文件大小,通過設置Flask內置的配置變量MAX_CONTENT_LENGTH,可以顯示請求報文的最大長度,單位是字節,比如:
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024
當上傳文件的大小超過這個限制后,flask內置的開服務器會中斷連接,在生產環境的服務器上會返回413錯誤響應。
渲染上傳表單
在新創建的upload視圖里,我們實例化表單類UploadForm,然后傳入模板:
@app.route('/upload', methods=['GET', 'POST']) def upload(): form = UploadForm() return render_template('upload.html',form = form)
在upload.html中渲染上傳表單
{% from 'macros.html' import form_field %} {% extends 'base.html' %} {% block content %} <form method="post" enctype="multipart/form-data"> {{ form.csrf_token }} {{ form_field(form.photo) }}<br> {{ form.submit }}<br> </form> {% endblock %}
需要注意的是,當表單中包含文件上傳字段時(即type屬性為file的input標簽)需要將表單的enctype屬性設為”multipart/form-data”,這會告訴瀏覽器將上傳數據發送到服務器,否則僅會把文件名作為表單數據提交。
處理上傳文件
和普通的表單數據不同,當包含上傳文件字段的表單提價后,上傳的文件需要在請求對象的files屬性(request.files)中獲取。這個屬性(request.files)是Werkzeug提供的ImmutableMultiDict字典對象,存儲字段name鍵值和文件對象的映射,比如:
ImmutableMultiDict([('photo', <FileStorage: u'xiaxiaoxu.JPG' (image/jpeg)>)])
上傳的文件會被Flask解析為Werkzeug中的FileStorage對象(werkzeug.datastructures.FileStorage)。當手動處理時,需要使用文件上傳字段的name屬性值作為鍵獲取對應的文件對象。比如:
request.files.get(‘photo’)
當使用Flask-WTF時,它會自動幫我們獲取對應的文件對象,這里我們仍然使用表單類屬性的data屬性獲取上傳文件。處理上傳表單提交請求的upload視圖函數如下:
import os app.config['UPLOAD_PATH'] = os.path.join(app.root_path, 'uploads') @app.route('/upload', methods=['GET', 'POST']) def upload(): form = UploadForm() if form.validate_on_submit(): f = form.photo.data filename =random_filename(f.filename) f.save(os.path.join(app.config['UPLOAD_PATH'], filename)) flash('Upload success.') session['filenames'] = [filename] return redirect(url_for('show_images')) return render_template('upload.html', form = form)
里面的函數在后面說明
當表單通過驗證后,我們通過form.photo.data獲取存儲上傳文件的FileStorage對象。接下來,我們需要處理文件名,通常有三種處理方
處理文件名的方式
1)使用原文件名
如果能夠確定文件的來源安全,可以直接使用原文件名,通過FileStorage對象的filename屬性獲取:
filename = f.filename
2)使用過濾后的文件名
如果要支持用戶上傳文件,我們必須對文件名進行處理,因為攻擊者可能會在文件名中加入惡意路徑。比如,如果惡意用戶在文件名中加入表示上級目錄的..(比如../../../home/username/.bashrc或../../etc/passwd),那么當我們保存文件時,如果這里表示上級目錄的../數量正確,就會導致服務器上的系統該文件被覆蓋或篡改,還有可能執行惡意腳本。我們可以使用Werkzeug提供的secure_filename()函數對文件名進行過濾,傳遞文件名作為參數,它會過濾掉所有危險字符,返回“安全的文件名”,如下所示:
>>> from werkzeug import secure_filename
>>> secure_filename('sam!@$%^&.jpg')
'sam.jpg'
>>> secure_filename('sam圖片.jpg')
'sam.jpg'
>>>
3)統一重命名
secure_filename()函數非常方便,它會過濾掉文件名中的非ASCII字符。但如果文件名完全由非ASCII字符組成,那么會得到一個空文件名:
>>> secure_filename('圖像.jpg')
'jpg'
為了避免出現這種情況,更好的做法是使用統一的處理方式對所有上傳的文件重新命名。隨機文件名有很多種方式生成,下面是一個是python內置的uuid模塊生成隨機文件名的random_filename()函數:
import uuid def random_filename(filename): ext = os.path.splitext(filename)[1] new_filename = uuid.uuid4().hex + ext return new_filename
其中os.path.splitext()和uuid.uuid4()的用法如下:
>>> import os
>>> os.path.splitext('d://sam/sam.jpg')
('d://sam/sam', '.jpg')
>>> import uuid
>>> uuid.uuid4()
UUID('b35f485e-5a79-4d98-8cac-af62be1f0a36')
>>> uuid.uuid4().hex
'62f65743d16e4b388f9f6eabe3f8e5b4'
這個函數接收原文件名作為參數,使用內置的uuid模塊中的uuid4()方法生成新的文件名,並使用hex屬性獲取十六進制字符串,最后返回包含后綴的新文件名。
UUID(Universally Unique Identifier,通用唯一識別碼)是用來表示信息的128位數字,比如用作數據庫表的主鍵。使用標准方法生成的UUID出現重復的可能性接近0。在UUID的標准中,UUID分為5個版本,每個版本使用不同的生產方法並且適用於不同的場景。我們使用的uuid4()方法對應的第4個版本:不接受參數而生成的隨機UUID。
在upload視圖中,我們調用這個函數獲取隨機文件名,傳入原文件名作為參數:
filename = random_filename(f.filename)
處理完文件名后,是時候將文件保存到文件系統中了。在form目錄下創建一個uploads文件夾,用於保存上傳后的文件。指向這個文件夾的絕對路徑存儲在自定義配置變量UPLOAD_PATH中:
app.config[‘UPLOAD_PATH’] = os.path.join(app.root_path, ‘uploads’)
這里的路徑通過app.root_path屬性構造,它存儲了程序實例所在腳本的絕對路徑,相當於:
os.path.abspath(os.path.dirname(__file__))。為了保存文件,需要提前手動創建這個文件夾。
調試:
print "__file__:",__file__ print "app.root_path:",app.root_path
結果:
__file__: D:/flask/FLASK_PRACTICE/form/app.py
app.root_path: D:\flask\FLASK_PRACTICE\form
對FileStorage對象調用save()方法即可保存,傳入包含目標文件夾絕對路徑和文件名在內的完整保存路徑:
f.save(os.path.join(app.config[‘upload_path’], filename))
文件保存后,我們希望能夠顯示長傳后的圖片,為了讓上傳后的文件能夠通過URL獲取,我們需要創建一個視圖函數來返回上傳后的文件,如下所示:
@app.route('/uploads/<path:filename>') def get_file(filename): return send_from_directory(app.config['UPLOAD_PATH', filename])
這個視圖的作用與Flask內置的static視圖類似,通過傳入的文件路徑返回對應的靜態文件。在這個uploads視圖中,使用Flask提供的send_from_directory()函數來獲取文件,傳入文件的路徑和文件名作為參數。
在get_file視圖的URL規則中,filename變量使用了path轉換器以支持傳入包含斜線的路徑字符串。
upload視圖里保存文件后,使用flash()發送一個提示,將文件名保存到session中,最后重定向到show_images視圖。show_images視圖返回的uploaded.html模板中將從session獲取文件名,渲染出上傳后的圖片。
flash('Upload success.') session['filenames'] = [filename] return redirect(url_for('show_images'))
這里將filename作為列表傳入session只是為了兼容下面的多文件上傳示例,這兩個視圖使用同一個模板,使用session可以在模板中統一從session獲取文件名列表。
在uploaded.html模板里,我們將傳入的文件名作為URL變量,通過上面的get_file視圖獲取文件URL,作為<img>標簽的src屬性值,如下所示:
<img src="{{ url_for('get_file', filename=filename) }}">
訪問127.0.0.1:5000/upload,打開文件上傳示例,選擇文件並提交后即可看到上傳后的圖片。另外,在uploads文件夾中可以看到上傳的文件。
提交后,看到圖片
uploads目錄下保存的文件:
下面列一下涉及的文件:
app.py:
from flask_wtf.file import FileField, FileRequired, FileAllowed from flask import send_from_directory class UploadForm(FlaskForm): photo = FileField('Upload Image', validators=[FileRequired(), FileAllowed(['jpg','jpeg','png','gif'])]) submit = SubmitField() import os app.config['UPLOAD_PATH'] = os.path.join(app.root_path, 'uploads') import uuid def random_filename(filename): ext = os.path.splitext(filename)[1] new_filename = uuid.uuid4().hex + ext return new_filename @app.route('/uploaded-images') def show_images(): return render_template('uploaded.html') @app.route('/uploads/<path:filename>') def get_file(filename): return send_from_directory(app.config['UPLOAD_PATH'], filename) @app.route('/upload', methods=['GET', 'POST']) def upload(): form = UploadForm() if form.validate_on_submit(): f = form.photo.data filename =random_filename(f.filename) f.save(os.path.join(app.config['UPLOAD_PATH'], filename)) flash('Upload success.') session['filenames'] = [filename] return redirect(url_for('show_images')) return render_template('upload.html', form = form)
uploaded.html:
{% extends 'base.html' %} {% from 'macros.html' import form_field %} {% block title %}Home{% endblock %} {% block content %} {% if session.filenames %} {% for filename in session.filenames %} <a href="{{ url_for('get_file', filename=filename) }}" target="_blank"> <img src="{{ url_for('get_file', filename=filename) }}"> </a> {% endfor %} {% endif %} {% endblock %}
upload.html:
{% from 'macros.html' import form_field %} {% extends 'base.html' %} {% block content %} <form method="post" enctype="multipart/form-data"> {{ form.csrf_token }} {{ form_field(form.photo) }}<br> {{ form.submit }}<br> </form> {% endblock %}
多文件上傳
因為Flask-WTF當前版本中並未添加多多文件上傳到額渲染和驗證支持,因此需要在視圖函數中手動獲取文件並進行驗證。
在客戶端,通過在文件上傳字段(type=file)加入multiple屬性,就可以開啟多選:
<input type=”file” id=”file” multiple>
創建表單類時,可以直接使用WTForms提供的MultipleFileField字段實現,添加一個DataRequired驗證器來確保包含文件:
from wtforms import MultipleFileField class MultiUploadForm(FlaskForm): photo = MultipleFileField('Upload Image', validators={DataRequired()}) submit = SubmitField()
表單提交時,在服務器端的程序中,對request.files屬性調用getlist()方法並傳入字段的name屬性值會返回包含所有上傳文件對象的列表。在multi_upload視圖中,我們遍歷這個列表,然后逐一對文件進行處理:
from flask import url_for, request, session, flash, redirect from flask_wtf.csrf import validate_csrf from wtforms import ValidationError @app.route('/multi-upload', methods=['GET', 'POST']) def multi_upload(): form = MultiUploadForm() if request.method == 'POST': filenames = [] #驗證CSRF令牌 try: validate_csrf(form.csrf_token.data) except ValidationError: flash('CSRF token error.') return redirect(url_for('multi_upload')) #檢查文件是否存在 if 'photo' not in request.files: flash('This field is required.') return redirect(url_for('multi_upload')) for f in request.files.getlist('photo'): #檢查文件類型 if f and allowed_file(f.filename): filename = random_filename(f.filename) f.save(os.path.join(app.config['UPLOAD_PATH'], filename )) filenames.append(filename) else: flash('Invalid file type:') return redirect(url_for('multi_upload')) flash('Upload success.') session['filenames'] = filenames return redirect(url_for('show_images')) return render_template('upload.html', form=form)
在請求方法為POST時,我們對上傳數據進行手動驗證,包含下面幾步:
1) 手動調用flask_wtf.csrf.validate_csrf驗證CSRF令牌,傳入表單中csrf_token隱藏字段的值。如果拋出wtforms.ValidationError異常則表明驗證未通過。
2) 其中if ‘photo’ not in request.files用來確保字段中包含文件數據(相當於FileRequired驗證器),如果用戶沒有選擇文件就提交表單則request_files將是空(實際上,不選擇文件,點擊提交,會觸發瀏覽器內置提示)。
3) if f用來確保文件對象存在,這里也可以檢查f是否是FileStorage實例。
4) allowed_file(f.filename)調用了allowed_file()函數,傳入文件名。這個函數相當於FileAllowed驗證器,用來驗證文件類型,返回布爾值。
allowed_file()函數定義:
app.config['ALLOWED_EXTENSIONS'] = ['png', 'jpg', 'jpeg', 'gif'] def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
在上面的一個驗證語句里,如果沒有通過驗證,我們使用flash()函數顯示錯誤消息,然后重定向到multi_uplaod視圖。
filesnames[]列表是為了方便測試,保存上傳后的文件名到session中。
訪問127.0.0.1:5000/multi-upload,單擊按鈕選擇多個文件,當上傳的文件通過驗證時,程序會重定向到show_images視圖,這個視圖返回的uploaded.html模板中將從session獲取所有文件名,渲染出所有上傳后的圖片。
在新版本的Flask-WTF發布后,可以和上傳單個文件相同的方式處理表單。比如可以使用Flask-WTF提供的的MultipleFileField來創建多文件上傳的字段,使用相應的驗證器對文件進行驗證。在視圖函數中,可以繼續使用form.validate_on_submit()來驗證表單,並通過form.photot.data來獲取字段的數據:包含所有上傳文件對象(werkzeug.datastructures.FileStorage)的列表。
多文件上傳處理通常會使用JavaScript庫在客戶端進行預驗證,並添加進度條來優化用戶體驗。