Flask Web | 文件托管服务

实现一个文件托管服务

Posted by Haauleon on December 3, 2022

本篇所有操作均在基于 Ubuntu 16.04 LTS 的虚拟机下完成,且使用 Vagrant 来操作虚拟机系统,虚拟机系统 VirtualBox Version: 7.0



一、需求列表

环境准备:
Python 2.7.11+
pip==9.0.3
flask==0.11.1
httpie==0.9.4
werkzeug==0.11.10

文件托管服务的需求说明如下:

  1. 上传后的文件可以被永久存放。
  2. 上传后的文件有一个功能完备的预览页。预览页显示文件大小、文件类型、上传时间、下载地址和短链接等信息。
  3. 可以通过传参数对图片进行缩放和剪切。
  4. 不错的页面展示效果。
  5. 为节省空间,相同文件不重复上传,如果文件已经上传过,则直接返回之前上传的文件。



二、项目准备

1、环境准备

先安装一些依赖:

1
2
> sudo apt-get install libjpeg8-dev -yq
> sudo pip install -r requirements.txt

requirements.txt 的 pip 第三方包列表如下:

1
2
3
4
python-magic==0.4.10  # libmagic 的 Python 绑定,用于确定文件类型 
Pillow==3.2.0         # PIL(Python Imaging Library) 的分支,用来替代 PIL 
cropresize2==0.1.9    # 用来剪切和调整图片大小
short-url==1.2.1      # 创建短链接


2、建表语句

文件托管服务的建表语句如下:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `PasteFile` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `filename` varchar(5000) NOT NULL,
    `filehash` varchar(128) NOT NULL,
    `filemd5` varchar(128) NOT NULL,
    `uploadtime` datetime NOT NULL,
    `mimetype` varchar(256) NOT NULL,
    `size` int(11) unsigned NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `filehash` (`filehash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

  建表时指定了 ENGINE=InnoDB,意味着这个表会使用 InnoDB 引擎,这是 MySQL 的默认存储引擎。现在创建一个文件 databases/schema.sql,写入以上建表 SQL 语句,然后使用命令行将该文件导入到数据库中:

1
2
❯ vim databases/schema.sql
> (echo "use r"; cat databases/schema.sql) | mysql --user='web' --password='web'

  将表导入到数据库后,可以通过以下命令检查是否导入成功:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> sudo mysql -u root
mysql> use r;
mysql> DESC PasteFile;
+------------+------------------+------+-----+---------+----------------+
| Field      | Type             | Null | Key | Default | Extra          |
+------------+------------------+------+-----+---------+----------------+
| id         | int(11)          | NO   | PRI | NULL    | auto_increment |
| filename   | varchar(5000)    | NO   |     | NULL    |                |
| filehash   | varchar(128)     | NO   |     | NULL    |                |
| filemd5    | varchar(128)     | NO   |     | NULL    |                |
| uploadtime | datetime         | NO   |     | NULL    |                |
| mimetype   | varchar(256)     | NO   |     | NULL    |                |
| size       | int(11) unsigned | NO   |     | NULL    |                |
+------------+------------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)



三、项目结构

1
2
3
4
5
6
7
8
9
10
11
12
 ❯ tree web
web
├── __init__.py
├── config.py         # 用于存放配置
├── utils.py          # 用于存放功能函数
├── mimes.py          # 只接受文件中定义了的媒体类型
├── ext.py            # 存放扩展的封装
├── models.py         # 存放模型
├── app.py            # 存放主程序
└── requirements.txt  # 项目依赖集合文件

0 directories, 8 files


1、init.py

  __init__.py 会在 import 的时候被执行,而空的 __init__.py 在 Python 新版本(Python3.8 版本)中已经不需要你额外去定义了,因为就算你不定义 init, Python 也知道你导入的包路径。但如果想做一些初始化操作或者预先导入相关的模块,那么定义 __init__.py 还是很有必要的。

  该项目仍使用的是 python 2.7.11+,需要定义一个 __init__.py 文件,此文件为空文件。



2、config.py

1
2
3
4
5
6
7
8
9
10
# coding=utf-8
"""
@File    :   config.py
@Function:   用于存放配置
"""
DEBUG = True
SQLALCHEMY_DATABASE_URI = 'mysql://web:web@localhost:3306/r'
# 指定存放上传文件的目录
UPLOAD_FOLDER = '/tmp/permdir'
SQLALCHEMY_TRACK_MODIFICATIONS = False



3、utils.py

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
# coding=utf-8
"""
@File    :   utils.py
@Function:   用于存放功能函数
"""
import os
import hashlib
from functools import partial

from config import UPLOAD_FOLDER

HERE = os.path.abspath(os.path.dirname(__file__))


def get_file_md5(f, chunk_size=8192):
    """
    获得文件的 md5 值
    @param f:
    @param chunk_size:
    @return:
    """
    h = hashlib.md5()
    while True:
        chunk = f.read(chunk_size)
        if not chunk:
            break
        h.update(chunk)
    return h.hexdigest()


def humanize_bytes(bytesize, precision=2):
    """
    返回可读的文件大小
    @param bytesize:
    @param precision: 保留小数点后多少位,默认是精确到后两位
    @return:
    """
    abbrevs = (
        (1 << 50, 'PB'),  # 1 << 50 == 2^50 == 1125899906842624 bytes == 1PB
        (1 << 40, 'TB'),  # 1 << 40 == 2^40 == 1099511627776 bytes == 1TB
        (1 << 30, 'GB'),  # 1 << 30 == 2^30 == 1073741824 bytes == 1GB
        (1 << 20, 'MB'),  # 1 << 20 == 2^20 == 1048576 bytes == 1MB
        (1 << 10, 'kB'),  # 1 << 10 == 2^10 == 1024 bytes == 1KB
        (1, 'bytes')
    )
    if bytesize == 1:
        return '1 byte'
    for factor, suffix in abbrevs:
        if bytesize >= factor:
            break
    return '%.*f %s' % (precision, bytesize / factor, suffix)


get_file_path = partial(os.path.join, HERE, UPLOAD_FOLDER)



4、mimes.py

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
# coding=utf-8
"""
@File    :   mimes.py
@Function:   只接受文件中定义了的媒体类型
"""
AUDIO_MIMES = [
    'audio/x-aac',
    'audio/mp4',
    'audio/ogg',
    'audio/mpeg',
    'audio/x-m4a',
    'audio/mp3'
]

IMAGE_MIMES = [
    'image/x-icon',
    'image/svg+xml',
    'image/jpeg',
    'image/gif',
    'image/png',
    'image/webp'
]

VIDEO_MIMES = [
    'video/x-msvideo',
    'video/quicktime',
    'video/mpeg',
    'video/h264',
    'video/mp4',
    'video/ogg',
    'video/webm',
]



5、ext.py

1
2
3
4
5
6
7
8
9
10
# coding=utf-8
"""
@File    :   ext.py
@Function:   存放扩展的封装
"""
from flask_mako import MakoTemplates, render_template  # noqa
from flask_sqlalchemy import SQLAlchemy

mako = MakoTemplates()
db = SQLAlchemy()



6、models.py

  文件中只包含了 PasteFile 模型,字段定义和初始化方法如下:

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
# coding=utf-8
"""
@File    :   models.py
@Function:   存放模型
"""
from ext import db


class PasteFile(db.Model):
    __tablename__ = 'PasteFile'
    id = db.Column(db.Integer, primary_key=True)
    filename = db.Column(db.String(5000), nullable=False)
    filehash = db.Column(db.String(128), nullable=False, unique=True)
    filemd5 = db.Column(db.String(128), nullable=False, unique=True)
    uploadtime = db.Column(db.DateTime, nullable=False)
    mimetype = db.Column(db.String(256), nullable=False)
    size = db.Column(db.Integer, nullable=False)

    def __init__(self, filename='', mimetype='application/octet-stream',
                 size=0, filehash=None, filemd5=None):
        self.uploadtime = datetime.now()
        self.mimetype = mimetype
        self.size = int(size)
        self.filehash = filehash if filehash else self._hash_filename(filename)
        self.filename = filename if filename else self.filehash
        self.filemd5 = filemd5

    @staticmethod
    def _hash_filename(filename):
        """文件名加密"""
        _, _, suffix = filename.rpartition('.')
        return '%s.%s' % (uuid.uuid4().hex, suffix)



7、app.py

  使用 SharedDataMiddleware 是实现在页面读取源文件的最简单的方法。

  如下代码片段,只是把第三方扩展初始化放在了 app.py 文件中,而没有使用 db = SQLAlchemy(app) 这样的方法。这是因为在大型应用中如果 db 被多个模型文件引用的话,会造成 from app import db 这样的方式,但是往往在 app.py 中也会引用模型文件定义的类,这样就造成了循环引用。所以最好的方式是把它放在不依赖其他模块的独立文件中。

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
# coding=utf-8
"""
@File    :   app.py
@Function:   应用主程序
"""
import os

from werkzeug import SharedDataMiddleware
from flask import abort, Flask, request, jsonify, redirect, send_file

from ext import db, mako, render_template
from models import PasteFile
from utils import get_file_path, humanize_bytes

ONE_MONTH = 60 * 60 * 24 * 30

app = Flask(__name__, template_folder='../../templates/r',
            static_folder='../../static')
app.config.from_object('config')

app.wsgi_app = SharedDataMiddleware(app.wsgi_app, {
    '/i/': get_file_path()
})

mako.init_app(app)
db.init_app(app)



8、requirements.txt

  项目依赖的第三方 pip 包集合文件,可以使用命令行 > pip install -r requirements.txt 进行批量安装。

1
2
3
4
python-magic==0.4.10
Pillow==3.2.0
cropresize2==0.1.9
short-url==1.2.1



四、视图实现

  以下分别是首页、重置设置图片页、下载页、预览页和短链接页的视图及其实现逻辑。


1、首页

  首页就是上传图片页,通过这个页面可以上传图片,并生成预览页:

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
@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        """如果是POST请求则通过 PasteFile.create_by_upload_file 创建一个 paste_file 实例"""
        uploaded_file = request.files['file']
        w = request.form.get('w')
        h = request.form.get('h')
        if not uploaded_file:
            return abort(400)

        if w and h:
            """如果传入的图片长和宽不为空,则按照指定的长宽进行图片裁剪并保存"""
            paste_file = PasteFile.rsize(uploaded_file, w, h)
        else:
            """否则保存上传的图片"""
            paste_file = PasteFile.create_by_upload_file(uploaded_file)
        db.session.add(paste_file)
        db.session.commit()

        return jsonify({
            'url_d': paste_file.url_d,
            'url_i': paste_file.url_i,
            'url_s': paste_file.url_s,
            'url_p': paste_file.url_p,
            'filename': paste_file.filename,
            'size': humanize_bytes(paste_file.size),
            'time': str(paste_file.uploadtime),
            'type': paste_file.type,
            'quoteurl': paste_file.quoteurl
        })
    """如果是GET请求则直接渲染 index.html"""
    return render_template('index.html', **locals())
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
56
...

class PasteFile(db.Model):
    ...

    @property
    def path(self):
        # 为了防止不同的用户上传了同名文件造成文件的替换问题,使用了随机命名 filehash
        return get_file_path(self.filehash)

    @classmethod
    def get_by_md5(cls, filemd5):
        return cls.query.filter_by(filemd5=filemd5).first()

    @classmethod
    def create_by_upload_file(cls, uploaded_file):
        """
        创建文件
        @param uploaded_file:
        @return:
        """
        rst = cls(uploaded_file.filename, uploaded_file.mimetype, 0)
        # 创建 PasteFile 实例前会先保存文件,保存的文件名是 rst.path
        uploaded_file.save(rst.path)
        with open(rst.path, 'rb') as f:
            # 通过文件的 md5 值判断该文件之前是否已经上传过,如果是就直接删掉刚保存的文件 rst.path 并返回之前创建的文件
            filemd5 = get_file_md5(f)
            uploaded_file = cls.get_by_md5(filemd5)
            if uploaded_file:
                os.remove(rst.path)
                return uploaded_file
        filestat = os.stat(rst.path)
        rst.size = filestat.st_size
        rst.filemd5 = filemd5
        return rst

    @classmethod
    def rsize(cls, old_paste, weight, height):
        """
        根据指定的长和宽裁剪图片并保存
        @param old_paste: 旧图片
        @param weight: 长
        @param height: 宽
        @return:
        """
        assert old_paste.is_image, TypeError('Unsupported Image Type.')
        f = open(old_paste.path, 'rb')
        im = Image.open(f)

        img = cropresize2.crop_resize(im, (int(weight), int(height)))

        rst = cls(old_paste.filename, old_paste.mimetype, 0)
        img.save(rst.path)
        filestat = os.stat(rst.path)
        rst.size = filestat.st_size
        return rst



2、重置设置图片页

  支持对现有的图片重新设置大小,返回新的图片地址:

1
2
3
4
5
6
7
8
9
@app.route('/r/<img_hash>')
def rsize(img_hash):
    w = request.args['w']
    h = request.args['h']

    old_paste = PasteFile.get_by_filehash(img_hash)
    new_paste = PasteFile.rsize(old_paste, w, h)

    return new_paste.url_i
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
class PasteFile(db.Model):
    ...

    @classmethod
    def get_by_filehash(cls, filehash, code=404):
        """
        从数据库中找到匹配 filehash 的条目
        @param filehash:
        @param code:
        @return:
        """
        return cls.query.filter_by(filehash=filehash).first() or abort(code)

    def get_url(self, subtype, is_symlink=False):
        """
        通过 get_url 可以拼不同类型的请求地址
        @param subtype:
        @param is_symlink:
        @return:
        """
        hash_or_link = self.symlink if is_symlink else self.filehash
        return 'http://{host}/{subtype}/{hash_or_link}'.format(
            subtype=subtype, host=request.host, hash_or_link=hash_or_link)

    @property
    def url_i(self):
        """
        获取源文件的地址
        @return:
        """
        return self.get_url('i')

    @property
    def url_p(self):
        """
        获取文件预览地址
        @return:
        """
        return self.get_url('p')

    @property
    def url_s(self):
        """
        获取文件短链接地址
        @return:
        """
        return self.get_url('s', is_symlink=True)

    @property
    def url_d(self):
        """
        获取文件下载地址
        @return:
        """
        return self.get_url('d')



3、下载页

  下载文件时使用 /d/img_hash.jpg 这样的地址,可以用 Flask 提供的 send_file 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import send_file

ONE_MONTH = 60 * 60 * 24 * 30


@app.route('/d/<filehash>', methods=['GET'])
def download(filehash):
    paste_file = PasteFile.get_by_filehash(filehash)

    return send_file(open(paste_file.path, 'rb'),
                     mimetype='application/octet-stream',
                     cache_timeout=ONE_MONTH,
                     as_attachment=True,
                     attachment_filename=paste_file.filename.encode('utf-8'))



4、预览页

  预览文件使用 /p/img_hash.jpg 这样的地址。在首页上传完毕时也会在地址栏显示了这样的地址,但事实上并没有发生跳转,只是用了 JavaScript 修改了地址。由于它们使用了同一个文件卡片组件,所以看起来一模一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@app.route('/p/<filehash>')
def preview(filehash):
    paste_file = PasteFile.get_by_filehash(filehash)

    if not paste_file:
        filepath = get_file_path(filehash)
        if not(os.path.exists(filepath) and (not os.path.islink(filepath))):
            return abort(404)

        paste_file = PasteFile.create_by_old_paste(filehash)
        db.session.add(paste_file)
        db.session.commit()

    return render_template('success.html', p=paste_file)



5、短链接页

  由于 hash 值太长,支持使用短链接的方式访问,使用 /s/short_url 这样的地址。但是并不需要把短链接存放进数据库,正确的做法是用 id 这个唯一标识生成短链接地址。

1
2
3
4
5
@app.route('/s/<symlink>')
def s(symlink):
    paste_file = PasteFile.get_by_symlink(symlink)

    return redirect(paste_file.url_p)
1
2
3
4
5
6
7
8
9
10
11
12
class PasteFile(db.Model):
    ...

    @cached_property
    def symlink(self):
        return short_url.encode_url(self.id)

    @classmethod
    def get_by_symlink(cls, symlink, code=404):
        """通过短链接获得对应数据库条目的方法"""
        id = short_url.decode_url(symlink)
        return cls.query.filter_by(id=id).first() or abort(code)