From 91ee68f6153474041ee9d988ce5b30374f4e8313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=8C=E7=8B=BC=E8=93=9D=E5=A4=A9?= Date: Sat, 30 Nov 2024 19:49:52 +0800 Subject: [PATCH] first commit --- .dockerignore | 3 + .gitattributes | 3 + .gitignore | 156 +++++++++++++++++++++ Dockerfile | 11 ++ LICENSE | 165 ++++++++++++++++++++++ SECURITY.md | 21 +++ apps/__init__.py | 4 + apps/admin/__init__.py | 4 + apps/admin/depends.py | 23 ++++ apps/admin/pydantics.py | 5 + apps/admin/views.py | 93 +++++++++++++ apps/base/__init__.py | 4 + apps/base/depends.py | 45 ++++++ apps/base/models.py | 50 +++++++ apps/base/pydantics.py | 5 + apps/base/utils.py | 92 +++++++++++++ apps/base/views.py | 171 +++++++++++++++++++++++ core/__init__.py | 4 + core/response.py | 15 ++ core/settings.py | 76 ++++++++++ core/storage.py | 298 ++++++++++++++++++++++++++++++++++++++++ core/tasks.py | 29 ++++ core/utils.py | 95 +++++++++++++ main.py | 103 ++++++++++++++ readme.md | 75 ++++++++++ readme_en.md | 63 +++++++++ readme_onedrive.md | 135 ++++++++++++++++++ readme_opendal.md | 30 ++++ requirements.txt | 40 ++++++ 29 files changed, 1818 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 SECURITY.md create mode 100644 apps/__init__.py create mode 100644 apps/admin/__init__.py create mode 100644 apps/admin/depends.py create mode 100644 apps/admin/pydantics.py create mode 100644 apps/admin/views.py create mode 100644 apps/base/__init__.py create mode 100644 apps/base/depends.py create mode 100644 apps/base/models.py create mode 100644 apps/base/pydantics.py create mode 100644 apps/base/utils.py create mode 100644 apps/base/views.py create mode 100644 core/__init__.py create mode 100644 core/response.py create mode 100644 core/settings.py create mode 100644 core/storage.py create mode 100644 core/tasks.py create mode 100644 core/utils.py create mode 100644 main.py create mode 100644 readme.md create mode 100644 readme_en.md create mode 100644 readme_onedrive.md create mode 100644 readme_opendal.md create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38d43dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +*.md +.idea +.git diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3ab78cc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.js linguist-language=python +*.css linguist-language=python +*.html linguist-language=python diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f40e9ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,156 @@ +media/ +logs/ +.idea +/data +__pycache__/ +*.py[cod] +*$py.class +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +database.db +# C extensions +*.so +*.env +# Distribution / packaging +.Python +build/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.vite/ +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test.pdf / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ +*.db +./filecodebox.db-shm +./filecodebox.db-wal +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +data/.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Project +.vscode +.DS_Store +for_test.py +.html +/evaluate/temp.py +/evaluation/back.json +data/.env +.backup/ +/cloc-1.64.exe diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ed5a66a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.9.5-slim-buster +LABEL author="Lan" +LABEL email="vast@tom.com" + +COPY . /app +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime +RUN echo 'Asia/Shanghai' >/etc/timezone +WORKDIR /app +RUN pip install -r requirements.txt +EXPOSE 12345 +CMD ["python","main.py"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..02bbb60 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..034e848 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e4a73e5 --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,4 @@ +# @Time : 2023/8/13 20:43 +# @Author : Lan +# @File : __init__.py.py +# @Software: PyCharm diff --git a/apps/admin/__init__.py b/apps/admin/__init__.py new file mode 100644 index 0000000..c131dce --- /dev/null +++ b/apps/admin/__init__.py @@ -0,0 +1,4 @@ +# @Time : 2023/8/14 14:38 +# @Author : Lan +# @File : __init__.py.py +# @Software: PyCharm diff --git a/apps/admin/depends.py b/apps/admin/depends.py new file mode 100644 index 0000000..66e1134 --- /dev/null +++ b/apps/admin/depends.py @@ -0,0 +1,23 @@ +# @Time : 2023/8/15 17:43 +# @Author : Lan +# @File : depends.py +# @Software: PyCharm +from typing import Union + +from fastapi import Header, HTTPException +from fastapi.requests import Request +from core.settings import settings + + +async def admin_required(authorization: Union[str, None] = Header(default=None), request: Request = None): + + print("auth:",authorization) + print("setting",settings.admin_token) + authorization = authorization.replace("Bearer ","") + is_admin = authorization == str(settings.admin_token) + if request.url.path.startswith('/share/'): + if not settings.openUpload and not is_admin: + raise HTTPException(status_code=403, detail='本站未开启游客上传,如需上传请先登录后台') + else: + if not is_admin: + raise HTTPException(status_code=401, detail='未授权或授权校验失败') diff --git a/apps/admin/pydantics.py b/apps/admin/pydantics.py new file mode 100644 index 0000000..d46f1b7 --- /dev/null +++ b/apps/admin/pydantics.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class IDData(BaseModel): + id: int diff --git a/apps/admin/views.py b/apps/admin/views.py new file mode 100644 index 0000000..c5a5525 --- /dev/null +++ b/apps/admin/views.py @@ -0,0 +1,93 @@ +# @Time : 2023/8/14 14:38 +# @Author : Lan +# @File : views.py +# @Software: PyCharm +import math + +from fastapi import APIRouter, Depends + +from apps.admin.depends import admin_required +from apps.admin.pydantics import IDData +from apps.base.models import FileCodes, KeyValue +from core.response import APIResponse +from core.settings import settings +from core.storage import FileStorageInterface, storages + +admin_api = APIRouter( + prefix='/admin', + tags=['管理'], +) + + +@admin_api.post('/login', dependencies=[Depends(admin_required)]) +async def login(): + return APIResponse() + + +@admin_api.delete('/file/delete', dependencies=[Depends(admin_required)]) +async def file_delete(data: IDData): + file_storage: FileStorageInterface = storages[settings.file_storage]() + file_code = await FileCodes.get(id=data.id) + await file_storage.delete_file(file_code) + await file_code.delete() + return APIResponse() + + +@admin_api.get('/file/list', dependencies=[Depends(admin_required)]) +async def file_list(page: float = 1, size: int = 10): + return APIResponse(detail={ + 'page': page, + 'size': size, + 'data': await FileCodes.all().limit(size).offset((math.ceil(page) - 1) * size), + 'total': await FileCodes.all().count(), + }) + + +@admin_api.get('/config/get', dependencies=[Depends(admin_required)]) +async def get_config(): + return APIResponse(detail=settings.items()) + + +@admin_api.patch('/config/update', dependencies=[Depends(admin_required)]) +async def update_config(data: dict): + admin_token = data.get('admin_token') + for key, value in data.items(): + if key not in settings.default_config: + continue + if key in ['errorCount', 'errorMinute', 'max_save_seconds', 'onedrive_proxy', 'openUpload', 'port', 's3_proxy', 'uploadCount', 'uploadMinute', 'uploadSize']: + data[key] = int(value) + elif key in ['opacity']: + data[key] = float(value) + else: + data[key] = value + if admin_token is None or admin_token == '': + return APIResponse(code=400, detail='管理员密码不能为空') + await KeyValue.filter(key='settings').update(value=data) + for k, v in data.items(): + settings.__setattr__(k, v) + return APIResponse() + + +# 根据code获取文件 +async def get_file_by_id(id): + # 查询文件 + file_code = await FileCodes.filter(id=id).first() + # 检查文件是否存在 + if not file_code: + return False, '文件不存在' + return True, file_code + + +@admin_api.get('/file/download', dependencies=[Depends(admin_required)]) +async def file_download(id: int): + file_storage: FileStorageInterface = storages[settings.file_storage]() + has, file_code = await get_file_by_id(id) + # 检查文件是否存在 + if not has: + # 返回API响应 + return APIResponse(code=404, detail='文件不存在') + # 如果文件是文本,返回文本内容,否则返回文件响应 + if file_code.text: + return APIResponse(detail=file_code.text) + else: + return await file_storage.get_file_response(file_code) diff --git a/apps/base/__init__.py b/apps/base/__init__.py new file mode 100644 index 0000000..e4a73e5 --- /dev/null +++ b/apps/base/__init__.py @@ -0,0 +1,4 @@ +# @Time : 2023/8/13 20:43 +# @Author : Lan +# @File : __init__.py.py +# @Software: PyCharm diff --git a/apps/base/depends.py b/apps/base/depends.py new file mode 100644 index 0000000..324bf0f --- /dev/null +++ b/apps/base/depends.py @@ -0,0 +1,45 @@ +# @Time : 2023/8/14 12:20 +# @Author : Lan +# @File : depends.py +# @Software: PyCharm +from typing import Union +from datetime import datetime, timedelta + +from fastapi import Header, HTTPException, Request + +from core.response import APIResponse + + +class IPRateLimit: + def __init__(self, count, minutes): + self.ips = {} + self.count = count + self.minutes = minutes + + def check_ip(self, ip): + # 检查ip是否被禁止 + if ip in self.ips: + if self.ips[ip]['count'] >= self.count: + if self.ips[ip]['time'] + timedelta(minutes=self.minutes) > datetime.now(): + return False + else: + self.ips.pop(ip) + return True + + def add_ip(self, ip): + ip_info = self.ips.get(ip, {'count': 0, 'time': datetime.now()}) + ip_info['count'] += 1 + ip_info['time'] = datetime.now() + self.ips[ip] = ip_info + return ip_info['count'] + + async def remove_expired_ip(self): + for ip in list(self.ips.keys()): + if self.ips[ip]['time'] + timedelta(minutes=self.minutes) < datetime.now(): + self.ips.pop(ip) + + def __call__(self, request: Request): + ip = request.headers.get('X-Real-IP', request.headers.get('X-Forwarded-For', request.client.host)) + if not self.check_ip(ip): + raise HTTPException(status_code=423, detail=f"请求次数过多,请稍后再试") + return ip diff --git a/apps/base/models.py b/apps/base/models.py new file mode 100644 index 0000000..a56dfc3 --- /dev/null +++ b/apps/base/models.py @@ -0,0 +1,50 @@ +# @Time : 2023/8/13 20:43 +# @Author : Lan +# @File : models.py +# @Software: PyCharm +from datetime import datetime +from typing import Optional + +from tortoise import fields +from tortoise.models import Model +from tortoise.contrib.pydantic import pydantic_model_creator + +from core.utils import get_now + + +class FileCodes(Model): + id: Optional[int] = fields.IntField(pk=True) + code: Optional[int] = fields.CharField(description='分享码', max_length=255, index=True, unique=True) + prefix: Optional[str] = fields.CharField(max_length=255, description='前缀', default='') + suffix: Optional[str] = fields.CharField(max_length=255, description='后缀', default='') + uuid_file_name: Optional[str] = fields.CharField(max_length=255, description='uuid文件名', null=True) + file_path: Optional[str] = fields.CharField(max_length=255, description='文件路径', null=True) + size: Optional[int] = fields.IntField(description='文件大小', default=0) + text: Optional[str] = fields.TextField(description='文本内容', null=True) + expired_at: Optional[datetime] = fields.DatetimeField(null=True, description='过期时间') + expired_count: Optional[int] = fields.IntField(description='可用次数', default=0) + used_count: Optional[int] = fields.IntField(description='已用次数', default=0) + created_at: Optional[datetime] = fields.DatetimeField(auto_now_add=True, description='创建时间') + + async def is_expired(self): + # 按时间 + if self.expired_at is None: + return False + if self.expired_at and self.expired_count < 0: + return self.expired_at < await get_now() + # 按次数 + else: + return self.expired_count <= 0 + + async def get_file_path(self): + return f"{self.file_path}/{self.uuid_file_name}" + + +class KeyValue(Model): + id: Optional[int] = fields.IntField(pk=True) + key: Optional[str] = fields.CharField(max_length=255, description='键', index=True, unique=True) + value: Optional[str] = fields.JSONField(description='值', null=True) + created_at: Optional[datetime] = fields.DatetimeField(auto_now_add=True, description='创建时间') + + +file_codes_pydantic = pydantic_model_creator(FileCodes, name='FileCodes') diff --git a/apps/base/pydantics.py b/apps/base/pydantics.py new file mode 100644 index 0000000..b5d677d --- /dev/null +++ b/apps/base/pydantics.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class SelectFileModel(BaseModel): + code: str diff --git a/apps/base/utils.py b/apps/base/utils.py new file mode 100644 index 0000000..5ba36a8 --- /dev/null +++ b/apps/base/utils.py @@ -0,0 +1,92 @@ +# @Time : 2023/8/14 01:10 +# @Author : Lan +# @File : utils.py +# @Software: PyCharm +import datetime +import uuid +import os +from fastapi import UploadFile, HTTPException + +from apps.base.depends import IPRateLimit +from apps.base.models import FileCodes +from core.settings import settings +from core.utils import get_random_num, get_random_string, max_save_times_desc + + +async def get_file_path_name(file: UploadFile): + """ + 获取文件路径和文件名 + :param file: + :return: { + 'path': 'share/data/2021/08/13', + 'suffix': '.jpg', + 'prefix': 'test', + 'file_uuid': '44a83bbd70e04c8aa7fd93bfd8c88249', + 'uuid_file_name': '44a83bbd70e04c8aa7fd93bfd8c88249.jpg', + 'save_path': 'share/data/2021/08/13/44a83bbd70e04c8aa7fd93bfd8c88249.jpg' + } + """ + today = datetime.datetime.now() + path = f"share/data/{today.strftime('%Y/%m/%d')}" + prefix, suffix = os.path.splitext(file.filename) + file_uuid = f"{uuid.uuid4().hex}" + uuid_file_name = f"{file_uuid}{suffix}" + save_path = f"{path}/{uuid_file_name}" + return path, suffix, prefix, uuid_file_name, save_path + + +async def get_expire_info(expire_value: int, expire_style: str): + """ + 获取过期信息 + :param expire_value: + :param expire_style: + :return: expired_at 过期时间, expired_count 可用次数, used_count 已用次数, code 随机码 + """ + expired_count, used_count, now, code = -1, 0, datetime.datetime.now(), None + if int(settings.max_save_seconds) > 0: + max_timedelta = datetime.timedelta(seconds=settings.max_save_seconds) + detail = await max_save_times_desc(settings.max_save_seconds) + detail = f'限制最长时间为 {detail[0]},可换用其他方式' + else: + max_timedelta = datetime.timedelta(days=7) + detail = '限制最长时间为 7天,可换用其他方式' + if expire_style == 'day': + if datetime.timedelta(days=expire_value) > max_timedelta: + raise HTTPException(status_code=403, detail=detail) + expired_at = now + datetime.timedelta(days=expire_value) + elif expire_style == 'hour': + if datetime.timedelta(hours=expire_value) > max_timedelta: + raise HTTPException(status_code=403, detail=detail) + expired_at = now + datetime.timedelta(hours=expire_value) + elif expire_style == 'minute': + if datetime.timedelta(minutes=expire_value) > max_timedelta: + raise HTTPException(status_code=403, detail=detail) + expired_at = now + datetime.timedelta(minutes=expire_value) + elif expire_style == 'count': + expired_at = now + datetime.timedelta(days=1) + expired_count = expire_value + elif expire_style == 'forever': + expired_at = None + code = await get_random_code(style='string') + else: + expired_at = now + datetime.timedelta(days=1) + if not code: + code = await get_random_code() + return expired_at, expired_count, used_count, code + + +async def get_random_code(style='num'): + """ + 获取随机字符串 + :return: + """ + while True: + code = await get_random_num() if style == 'num' else await get_random_string() + if not await FileCodes.filter(code=code).exists(): + return code + + +ip_limit = { + 'error': IPRateLimit(count=settings.uploadCount, minutes=settings.errorMinute), + 'upload': IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute) +} diff --git a/apps/base/views.py b/apps/base/views.py new file mode 100644 index 0000000..8e553eb --- /dev/null +++ b/apps/base/views.py @@ -0,0 +1,171 @@ +# @Time : 2023/8/14 03:59 +# @Author : Lan +# @File : views.py +# @Software: PyCharm +# 导入所需的库和模块 +from fastapi import APIRouter, Form, UploadFile, File, Depends, HTTPException +from apps.admin.depends import admin_required +from apps.base.models import FileCodes +from apps.base.pydantics import SelectFileModel +from apps.base.utils import get_expire_info, get_file_path_name, ip_limit +from core.response import APIResponse +from core.settings import settings +from core.storage import storages, FileStorageInterface +from core.utils import get_select_token + +# 创建一个API路由 +share_api = APIRouter( + prefix='/share', # 路由前缀 + tags=['分享'], # 标签 +) + + +# 分享文本的API +@share_api.post('/text/', dependencies=[Depends(admin_required)]) +async def share_text(text: str = Form(...), expire_value: int = Form(default=1, gt=0), expire_style: str = Form(default='day'), ip: str = Depends(ip_limit['upload'])): + # 获取大小 + text_size = len(text.encode('utf-8')) + # 限制 222KB + max_txt_size = 222 * 1024 # 转换为字节 + if text_size > max_txt_size: + raise HTTPException(status_code=403, detail=f'内容过多,建议采用文件形式') + # 获取过期信息 + expired_at, expired_count, used_count, code = await get_expire_info(expire_value, expire_style) + # 创建一个新的FileCodes实例 + await FileCodes.create( + code=code, + text=text, + expired_at=expired_at, + expired_count=expired_count, + used_count=used_count, + size=len(text), + prefix='文本分享' + ) + # 添加IP到限制列表 + ip_limit['upload'].add_ip(ip) + # 返回API响应 + return APIResponse(detail={ + 'code': code, + }) + + +# 分享文件的API +@share_api.post('/file/', dependencies=[Depends(admin_required)]) +async def share_file(expire_value: int = Form(default=1, gt=0), expire_style: str = Form(default='day'), file: UploadFile = File(...), + ip: str = Depends(ip_limit['upload'])): + if file.size > settings.uploadSize: + # 转换为 MB 并格式化输出 + max_size_mb = settings.uploadSize / (1024 * 1024) + raise HTTPException(status_code=403, detail=f'大小超过限制,最大为{max_size_mb:.2f} MB') + # 获取过期信息 + if expire_style not in settings.expireStyle: + raise HTTPException(status_code=400, detail='过期时间类型错误') + expired_at, expired_count, used_count, code = await get_expire_info(expire_value, expire_style) + # 获取文件路径和名称 + path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file) + # 保存文件 + file_storage: FileStorageInterface = storages[settings.file_storage]() + await file_storage.save_file(file, save_path) + # 创建一个新的FileCodes实例 + await FileCodes.create( + code=code, + prefix=prefix, + suffix=suffix, + uuid_file_name=uuid_file_name, + file_path=path, + size=file.size, + expired_at=expired_at, + expired_count=expired_count, + used_count=used_count, + ) + # 添加IP到限制列表 + ip_limit['upload'].add_ip(ip) + # 返回API响应 + return APIResponse(detail={ + 'code': code, + 'name': file.filename, + }) + + +# 根据code获取文件 +async def get_code_file_by_code(code, check=True): + # 查询文件 + file_code = await FileCodes.filter(code=code).first() + # 检查文件是否存在 + if not file_code: + return False, '文件不存在' + # 检查文件是否过期 + if await file_code.is_expired() and check: + return False, '文件已过期', + return True, file_code + + +# 获取文件的API +@share_api.get('/select/') +async def get_code_file(code: str, ip: str = Depends(ip_limit['error'])): + file_storage: FileStorageInterface = storages[settings.file_storage]() + # 获取文件 + has, file_code = await get_code_file_by_code(code) + # 检查文件是否存在 + if not has: + # 添加IP到限制列表 + ip_limit['error'].add_ip(ip) + # 返回API响应 + return APIResponse(code=404, detail=file_code) + # 更新文件的使用次数和过期次数 + file_code.used_count += 1 + if file_code.expired_count > 0: + file_code.expired_count -= 1 + # 保存文件 + await file_code.save() + # 返回文件响应 + return await file_storage.get_file_response(file_code) + + +# 选择文件的API +@share_api.post('/select/') +async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit['error'])): + file_storage: FileStorageInterface = storages[settings.file_storage]() + # 获取文件 + has, file_code = await get_code_file_by_code(data.code) + # 检查文件是否存在 + if not has: + # 添加IP到限制列表 + ip_limit['error'].add_ip(ip) + # 返回API响应 + return APIResponse(code=404, detail=file_code) + # 更新文件的使用次数和过期次数 + file_code.used_count += 1 + if file_code.expired_count > 0: + file_code.expired_count -= 1 + # 保存文件 + await file_code.save() + # 返回API响应 + return APIResponse(detail={ + 'code': file_code.code, + 'name': file_code.prefix + file_code.suffix, + 'size': file_code.size, + 'text': file_code.text if file_code.text is not None else await file_storage.get_file_url(file_code), + }) + + +# 下载文件的API +@share_api.get('/download') +async def download_file(key: str, code: str, ip: str = Depends(ip_limit['error'])): + file_storage: FileStorageInterface = storages[settings.file_storage]() + # 检查token是否有效 + is_valid = await get_select_token(code) == key + if not is_valid: + # 添加IP到限制列表 + ip_limit['error'].add_ip(ip) + # 获取文件 + has, file_code = await get_code_file_by_code(code, False) + # 检查文件是否存在 + if not has: + # 返回API响应 + return APIResponse(code=404, detail='文件不存在') + # 如果文件是文本,返回文本内容,否则返回文件响应 + if file_code.text: + return APIResponse(detail=file_code.text) + else: + return await file_storage.get_file_response(file_code) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..bfdb9d4 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,4 @@ +# @Time : 2023/8/11 20:06 +# @Author : Lan +# @File : __init__.py.py +# @Software: PyCharm diff --git a/core/response.py b/core/response.py new file mode 100644 index 0000000..f6b8a38 --- /dev/null +++ b/core/response.py @@ -0,0 +1,15 @@ +# @Time : 2023/8/14 11:48 +# @Author : Lan +# @File : response.py +# @Software: PyCharm +from typing import Generic, TypeVar + +from pydantic.v1.generics import GenericModel + +T = TypeVar('T') + + +class APIResponse(GenericModel, Generic[T]): + code: int = 200 + message: str = 'ok' + detail: T diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..6d2ad6d --- /dev/null +++ b/core/settings.py @@ -0,0 +1,76 @@ +# @Time : 2023/8/15 09:51 +# @Author : Lan +# @File : settings.py +# @Software: PyCharm +import json +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +data_root = BASE_DIR / 'data' + +if not data_root.exists(): + data_root.mkdir(parents=True, exist_ok=True) + +DEFAULT_CONFIG = { + 'file_storage': 'local', + 'name': '文件快递柜 - FileCodeBox', + 'description': '开箱即用的文件快传系统', + 'notify_title': '系统通知', + 'notify_content': '欢迎使用 FileCodeBox,本程序开源于 Github ,欢迎Star和Fork。', + 'page_explain': '请勿上传或分享违法内容。根据《中华人民共和国网络安全法》、《中华人民共和国刑法》、《中华人民共和国治安管理处罚法》等相关规定。 传播或存储违法、违规内容,会受到相关处罚,严重者将承担刑事责任。本站坚决配合相关部门,确保网络内容的安全,和谐,打造绿色网络环境。', + 'keywords': 'FileCodeBox, 文件快递柜, 口令传送箱, 匿名口令分享文本, 文件', + 's3_access_key_id': '', + 's3_secret_access_key': '', + 's3_bucket_name': '', + 's3_endpoint_url': '', + 's3_region_name': 'auto', + 's3_signature_version': 's3v2', + 's3_hostname': '', + 's3_proxy': 0, + 'max_save_seconds': 0, + 'aws_session_token': '', + 'onedrive_domain': '', + 'onedrive_client_id': '', + 'onedrive_username': '', + 'onedrive_password': '', + 'onedrive_root_path': 'filebox_storage', + 'onedrive_proxy': 0, + 'admin_token': 'FileCodeBox2023', + 'openUpload': 1, + 'uploadSize': 1024 * 1024 * 10, + 'expireStyle': ['day', 'hour', 'minute', 'forever', 'count'], + 'uploadMinute': 1, + 'opacity': 0.9, + 'background': '', + 'uploadCount': 10, + 'errorMinute': 1, + 'errorCount': 1, + 'port': 12345, + 'showAdminAddr': 0, + 'robotsText': 'User-agent: *\nDisallow: /', +} + + +class Settings: + def __init__(self, defaults=None): + self.default_config = defaults or {} + self.user_config = {} + + def __getattr__(self, attr): + if attr in self.user_config: + return self.user_config[attr] + if attr in self.default_config: + return self.default_config[attr] + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{attr}'") + + def __setattr__(self, key, value): + if key in ['default_config', 'user_config']: + super().__setattr__(key, value) + else: + self.user_config[key] = value + + def items(self): + return {**self.default_config, **self.user_config}.items() + + +settings = Settings(DEFAULT_CONFIG) diff --git a/core/storage.py b/core/storage.py new file mode 100644 index 0000000..c3fc3a9 --- /dev/null +++ b/core/storage.py @@ -0,0 +1,298 @@ +# @Time : 2023/8/11 20:06 +# @Author : Lan +# @File : storage.py +# @Software: PyCharm +from typing import Optional + +import aiohttp +import asyncio +from pathlib import Path +import datetime +import io +import re +import sys +import aioboto3 +from botocore.config import Config +from fastapi import HTTPException, Response, UploadFile +from core.response import APIResponse +from core.settings import data_root, settings +from apps.base.models import FileCodes +from core.utils import get_file_url +from fastapi.responses import FileResponse + + +class FileStorageInterface: + _instance: Optional['FileStorageInterface'] = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(FileStorageInterface, cls).__new__(cls, *args, **kwargs) + return cls._instance + + async def save_file(self, file: UploadFile, save_path: str): + """ + 保存文件 + """ + raise NotImplementedError + + async def delete_file(self, file_code: FileCodes): + """ + 删除文件 + """ + raise NotImplementedError + + async def get_file_url(self, file_code: FileCodes): + """ + 获取文件分享的url + + 如果服务不支持直接访问文件,可以通过服务器中转下载。 + 此时,此方法可以调用 utils.py 中的 `get_file_url` 方法,获取服务器中转下载的url + """ + raise NotImplementedError + + async def get_file_response(self, file_code: FileCodes): + """ + 获取文件响应 + + 如果服务不支持直接访问文件,则需要实现该方法,返回文件响应 + 其余情况,可以不实现该方法 + """ + raise NotImplementedError + + +class SystemFileStorage(FileStorageInterface): + def __init__(self): + self.chunk_size = 256 * 1024 + self.root_path = data_root + + def _save(self, file, save_path): + with open(save_path, 'wb') as f: + chunk = file.read(self.chunk_size) + while chunk: + f.write(chunk) + chunk = file.read(self.chunk_size) + + async def save_file(self, file: UploadFile, save_path: str): + save_path = self.root_path / save_path + if not save_path.parent.exists(): + save_path.parent.mkdir(parents=True) + await asyncio.to_thread(self._save, file.file, save_path) + + async def delete_file(self, file_code: FileCodes): + save_path = self.root_path / await file_code.get_file_path() + if save_path.exists(): + save_path.unlink() + + async def get_file_url(self, file_code: FileCodes): + return await get_file_url(file_code.code) + + async def get_file_response(self, file_code: FileCodes): + file_path = self.root_path / await file_code.get_file_path() + if not file_path.exists(): + return APIResponse(code=404, detail='文件已过期删除') + return FileResponse(file_path, filename=file_code.prefix + file_code.suffix) + + +class S3FileStorage(FileStorageInterface): + def __init__(self): + self.access_key_id = settings.s3_access_key_id + self.secret_access_key = settings.s3_secret_access_key + self.bucket_name = settings.s3_bucket_name + self.s3_hostname = settings.s3_hostname + self.region_name = settings.s3_region_name + self.signature_version = settings.s3_signature_version + self.endpoint_url = settings.s3_endpoint_url or f'https://{self.s3_hostname}' + self.aws_session_token = settings.aws_session_token + self.proxy = settings.s3_proxy + self.session = aioboto3.Session(aws_access_key_id=self.access_key_id, aws_secret_access_key=self.secret_access_key) + if not settings.s3_endpoint_url: + self.endpoint_url = f'https://{self.s3_hostname}' + else: + # 如果提供了 s3_endpoint_url,则优先使用它 + self.endpoint_url = settings.s3_endpoint_url + + async def save_file(self, file: UploadFile, save_path: str): + async with self.session.client("s3", endpoint_url=self.endpoint_url, aws_session_token=self.aws_session_token, region_name=self.region_name, + config=Config(signature_version=self.signature_version)) as s3: + await s3.put_object(Bucket=self.bucket_name, Key=save_path, Body=await file.read(), ContentType=file.content_type) + + async def delete_file(self, file_code: FileCodes): + async with self.session.client("s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version)) as s3: + await s3.delete_object(Bucket=self.bucket_name, Key=await file_code.get_file_path()) + + async def get_file_response(self, file_code: FileCodes): + try: + filename = file_code.prefix + file_code.suffix + async with self.session.client("s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version)) as s3: + link = await s3.generate_presigned_url('get_object', Params={'Bucket': self.bucket_name, 'Key': await file_code.get_file_path()}, ExpiresIn=3600) + tmp = io.BytesIO() + async with aiohttp.ClientSession() as session: + async with session.get(link) as resp: + tmp.write(await resp.read()) + tmp.seek(0) + content = tmp.read() + tmp.close() + return Response(content, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'}) + except Exception: + raise HTTPException(status_code=503, detail='服务代理下载异常,请稍后再试') + + async def get_file_url(self, file_code: FileCodes): + if file_code.prefix == '文本分享': + return file_code.text + if self.proxy: + return await get_file_url(file_code.code) + else: + async with self.session.client("s3", endpoint_url=self.endpoint_url, region_name=self.region_name, config=Config(signature_version=self.signature_version)) as s3: + result = await s3.generate_presigned_url('get_object', Params={'Bucket': self.bucket_name, 'Key': await file_code.get_file_path()}, ExpiresIn=3600) + return result + + +class OneDriveFileStorage(FileStorageInterface): + def __init__(self): + try: + import msal + from office365.graph_client import GraphClient + from office365.runtime.client_request_exception import ClientRequestException + except ImportError: + raise ImportError('请先安装`msal`和`Office365-REST-Python-Client`') + self.msal = msal + self.domain = settings.onedrive_domain + self.client_id = settings.onedrive_client_id + self.username = settings.onedrive_username + self.password = settings.onedrive_password + self.proxy = settings.onedrive_proxy + self._ClientRequestException = ClientRequestException + + try: + client = GraphClient(self.acquire_token_pwd) + self.root_path = client.me.drive.root.get_by_path(settings.onedrive_root_path).get().execute_query() + except ClientRequestException as e: + if e.code == 'itemNotFound': + client.me.drive.root.create_folder(settings.onedrive_root_path) + self.root_path = client.me.drive.root.get_by_path(settings.onedrive_root_path).get().execute_query() + else: + raise e + except Exception as e: + raise Exception('OneDrive验证失败,请检查配置是否正确\n' + str(e)) + + def acquire_token_pwd(self): + authority_url = f'https://login.microsoftonline.com/{self.domain}' + app = self.msal.PublicClientApplication( + authority=authority_url, + client_id=self.client_id + ) + result = app.acquire_token_by_username_password(username=self.username, + password=self.password, + scopes=['https://graph.microsoft.com/.default']) + return result + + def _get_path_str(self, path): + if isinstance(path, str): + path = path.replace('\\', '/').replace('//', '/').split('/') + elif isinstance(path, Path): + path = str(path).replace('\\', '/').replace('//', '/').split('/') + else: + raise TypeError('path must be str or Path') + path[-1] = path[-1].split('.')[0] + return '/'.join(path) + + def _save(self, file, save_path): + content = file.file.read() + name = file.filename + path = self._get_path_str(save_path) + self.root_path.get_by_path(path).upload(name, content).execute_query() + + async def save_file(self, file: UploadFile, save_path: str): + await asyncio.to_thread(self._save, file, save_path) + + def _delete(self, save_path): + path = self._get_path_str(save_path) + try: + self.root_path.get_by_path(path).delete_object().execute_query() + except self._ClientRequestException as e: + if e.code == 'itemNotFound': + pass + else: + raise e + + async def delete_file(self, file_code: FileCodes): + await asyncio.to_thread(self._delete, await file_code.get_file_path()) + + def _convert_link_to_download_link(self, link): + p1 = re.search(r'https:\/\/(.+)\.sharepoint\.com', link).group(1) + p2 = re.search(r'personal\/(.+)\/', link).group(1) + p3 = re.search(rf'{p2}\/(.+)', link).group(1) + return f'https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}' + + def _get_file_url(self, save_path, name): + path = self._get_path_str(save_path) + remote_file = self.root_path.get_by_path(path + '/' + name) + expiration_datetime = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(hours=1) + expiration_datetime = expiration_datetime.strftime("%Y-%m-%dT%H:%M:%SZ") + premission = remote_file.create_link("view", "anonymous", expiration_datetime=expiration_datetime).execute_query() + return self._convert_link_to_download_link(premission.link.webUrl) + + async def get_file_response(self, file_code: FileCodes): + try: + filename = file_code.prefix + file_code.suffix + link = await asyncio.to_thread(self._get_file_url, await file_code.get_file_path(), filename) + tmp = io.BytesIO() + async with aiohttp.ClientSession() as session: + async with session.get(link) as resp: + tmp.write(await resp.read()) + tmp.seek(0) + content = tmp.read() + tmp.close() + return Response(content, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'}) + except Exception: + raise HTTPException(status_code=503, detail='服务代理下载异常,请稍后再试') + + async def get_file_url(self, file_code: FileCodes): + if self.proxy: + return await get_file_url(file_code.code) + else: + return await asyncio.to_thread(self._get_file_url, await file_code.get_file_path(), f'{file_code.prefix}{file_code.suffix}') + + +class OpenDALFileStorage(FileStorageInterface): + def __init__(self): + try: + import opendal + except ImportError: + raise ImportError('请先安装 `opendal`, 例如: "pip install opendal"') + self.service = settings.opendal_scheme + service_settings = {} + for key, value in settings.items(): + if key.startswith('opendal_' + self.service): + setting_name = key.split('_', 2)[2] + service_settings[setting_name] = value + self.operator = opendal.AsyncOperator(settings.opendal_scheme, **service_settings) + + async def save_file(self, file: UploadFile, save_path: str): + await self.operator.write(save_path, file.file.read()) + + async def delete_file(self, file_code: FileCodes): + await self.operator.delete(await file_code.get_file_path()) + + async def get_file_url(self, file_code: FileCodes): + return await get_file_url(file_code.code) + + async def get_file_response(self, file_code: FileCodes): + try: + filename = file_code.prefix + file_code.suffix + content = await self.operator.read(await file_code.get_file_path()) + headers = { + "Content-Disposition": f'attachment; filename="{filename}"' + } + return Response(content, headers=headers, media_type="application/octet-stream") + except Exception as e: + print(e, file=sys.stderr) + raise HTTPException(status_code=404, detail="文件已过期删除") + + +storages = { + 'local': SystemFileStorage, + 's3': S3FileStorage, + 'onedrive': OneDriveFileStorage, + 'opendal': OpenDALFileStorage, +} diff --git a/core/tasks.py b/core/tasks.py new file mode 100644 index 0000000..7a55fe3 --- /dev/null +++ b/core/tasks.py @@ -0,0 +1,29 @@ +# @Time : 2023/8/15 22:00 +# @Author : Lan +# @File : tasks.py +# @Software: PyCharm +import asyncio + +from tortoise.expressions import Q + +from apps.base.models import FileCodes +from apps.base.utils import ip_limit +from core.settings import settings +from core.storage import FileStorageInterface, storages +from core.utils import get_now + + +async def delete_expire_files(): + file_storage: FileStorageInterface = storages[settings.file_storage]() + while True: + try: + await ip_limit['error'].remove_expired_ip() + await ip_limit['upload'].remove_expired_ip() + expire_data = await FileCodes.filter(Q(expired_at__lt=await get_now()) | Q(expired_count=0)).all() + for exp in expire_data: + await file_storage.delete_file(exp) + await exp.delete() + except Exception as e: + print(e) + finally: + await asyncio.sleep(600) diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..5fded2a --- /dev/null +++ b/core/utils.py @@ -0,0 +1,95 @@ +# @Time : 2023/8/13 19:54 +# @Author : Lan +# @File : utils.py +# @Software: PyCharm +import datetime +import hashlib +import random +import string +import time + +from apps.base.depends import IPRateLimit + + +async def get_random_num(): + """ + 获取随机数 + :return: + """ + return random.randint(10000, 99999) + + +r_s = string.ascii_uppercase + string.digits + + +async def get_random_string(): + """ + 获取随机字符串 + :return: + """ + return ''.join(random.choice(r_s) for _ in range(5)) + + +async def get_now(): + """ + 获取当前时间 + :return: + """ + return datetime.datetime.now( + datetime.timezone(datetime.timedelta(hours=8)) + ) + + +async def get_select_token(code: str): + """ + 获取下载token + :param code: + :return: + """ + token = "123456" + return hashlib.sha256(f"{code}{int(time.time() / 1000)}000{token}".encode()).hexdigest() + + +async def get_file_url(code: str): + """ + 对于需要通过服务器中转下载的服务,获取文件下载地址 + :param code: + :return: + """ + return f'/share/download?key={await get_select_token(code)}&code={code}' + + +async def max_save_times_desc(max_save_seconds: int): + """ + 获取最大保存时间的描述 + :param max_save_seconds: + :return: + """ + + def gen_desc_zh(value: int, desc: str): + if value > 0: + return f'{value}{desc}' + else: + return '' + + def gen_desc_en(value: int, desc: str): + if value > 0: + ret = f'{value} {desc}' + if value > 1: + ret += 's' + ret += ' ' + return ret + else: + return '' + + max_timedelta = datetime.timedelta(seconds=max_save_seconds) + desc_zh, desc_en = '最长保存时间:', 'Max save time: ' + desc_zh += gen_desc_zh(max_timedelta.days, '天') + desc_en += gen_desc_en(max_timedelta.days, 'day') + desc_zh += gen_desc_zh(max_timedelta.seconds // 3600, '小时') + desc_en += gen_desc_en(max_timedelta.seconds // 3600, 'hour') + desc_zh += gen_desc_zh(max_timedelta.seconds % 3600 // 60, '分钟') + desc_en += gen_desc_en(max_timedelta.seconds % 3600 // 60, 'minute') + desc_zh += gen_desc_zh(max_timedelta.seconds % 60, '秒') + desc_en += gen_desc_en(max_timedelta.seconds % 60, 'second') + return desc_zh, desc_en diff --git a/main.py b/main.py new file mode 100644 index 0000000..3658500 --- /dev/null +++ b/main.py @@ -0,0 +1,103 @@ +# @Time : 2023/8/9 23:23 +# @Author : Lan +# @File : main.py +# @Software: PyCharm +import asyncio + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from tortoise.contrib.fastapi import register_tortoise + +from apps.base.depends import IPRateLimit +from apps.base.models import KeyValue +from apps.base.utils import ip_limit +from apps.base.views import share_api +from apps.admin.views import admin_api +from core.response import APIResponse +from core.settings import data_root, settings, BASE_DIR, DEFAULT_CONFIG +from core.tasks import delete_expire_files + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.mount('/assets', StaticFiles(directory='./fcb-fronted/dist/assets'), name="assets") + +register_tortoise( + app, + generate_schemas=True, + add_exception_handlers=True, + config={ + 'connections': { + 'default': f'sqlite://{data_root}/filecodebox.db' + }, + 'apps': { + 'models': { + "models": ["apps.base.models"], + 'default_connection': 'default', + } + }, + "use_tz": False, + "timezone": "Asia/Shanghai", + } +) + +app.include_router(share_api) +app.include_router(admin_api) + + +@app.on_event("startup") +async def startup_event(): + # 启动后台任务,不定时删除过期文件 + asyncio.create_task(delete_expire_files()) + # 读取用户配置 + user_config, created = await KeyValue.get_or_create(key='settings', defaults={'value': DEFAULT_CONFIG}) + settings.user_config = user_config.value + ip_limit['error'].minutes = settings.errorMinute + ip_limit['error'].count = settings.errorCount + ip_limit['upload'].minutes = settings.uploadMinute + ip_limit['upload'].count = settings.uploadCount + + +@app.get('/') +async def index(): + return HTMLResponse( + content=open(BASE_DIR / 'fcb-fronted/dist/index.html', 'r', encoding='utf-8').read() + .replace('{{title}}', str(settings.name)) + .replace('{{description}}', str(settings.description)) + .replace('{{keywords}}', str(settings.keywords)) + .replace('{{opacity}}', str(settings.opacity)) + .replace('{{background}}', str(settings.background)) + , media_type='text/html', headers={'Cache-Control': 'no-cache'}) + + +@app.get('/robots.txt') +async def robots(): + return HTMLResponse(content=settings.robotsText, media_type='text/plain') + + +@app.post('/') +async def get_config(): + return APIResponse(detail={ + 'explain': settings.page_explain, + 'uploadSize': settings.uploadSize, + 'expireStyle': settings.expireStyle, + 'openUpload': settings.openUpload, + 'notify_title': settings.notify_title, + 'notify_content': settings.notify_content, + 'show_admin_address': settings.showAdminAddr, + }) + + +if __name__ == '__main__': + import uvicorn + + uvicorn.run(app='main:app', host="0.0.0.0", port=settings.port, reload=False, workers=1) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e5fce26 --- /dev/null +++ b/readme.md @@ -0,0 +1,75 @@ + +pip install python-multipart +pip install --upgrade aiohttp yarl +pip install uvicorn + +![banner](https://fastly.jsdelivr.net/gh/vastsa/FileCodeBox@V1.6/static/banners/img_1.png) + +
+

文件快递柜-轻量

+

匿名口令分享文本,文件,像拿快递一样取文件

+

交流Q群:739673698

+
+ + +--- + + +
+ 简体中文 | + English | + 部署教程 | + 常见问题 +
+ + +## 部分截图 + + + + + + + + + + + + + + +
寄文件寄文件
寄文件寄文件
寄文件寄文件
+ +## 主要特色 + +- [x] **轻量简洁:** 项目基于Fastapi + Sqlite3 + Vue3 + ElementUI +- [x] **轻松上传:** 支持复制粘贴和拖拽选择 +- [x] **多种类型:** 支持文本和文件 +- [x] **防止爆破:** 错误次数限制 +- [x] **防止滥用:** IP限制上传次数 +- [x] **口令分享:** 随机口令,存取文件,自定义次数及有效期 +- [x] **国际化:** 支持中文简体、繁体以及英文等 +- [x] **匿名分享:** 无需注册,无需登录 +- [x] **管理面板:** 查看和删除文件 +- [x] **一键部署:** 支持Docker一键部署 +- [x] **自由拓展:** 支持S3协议和本地文件流,可根据需求在storage文件中新增存储引擎 +- [x] **简单明了:** 适合新手练手项目 +- [x] **终端下载:** 终端命令`wget https://share.lanol.cn/share/select?code=83432` + +## Badges +
+Featured|HelloGitHub +
+ +## 状态 + +![Alt](https://repobeats.axiom.co/api/embed/7a6c92f1d96ee57e6fb67f0df371528397b0c9ac.svg "Repobeats analytics image") + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=vastsa/FileCodeBox&type=Date)](https://star-history.com/#vastsa/FileCodeBox&Date) + + +## 免责声明 + +本项目开源仅供学习使用,不得用于任何违法用途,否则后果自负,与本人无关。使用请保留项目地址,谢谢。 diff --git a/readme_en.md b/readme_en.md new file mode 100644 index 0000000..05dda3d --- /dev/null +++ b/readme_en.md @@ -0,0 +1,63 @@ +
+

File Delivery Cabinet - Lite

+

FileCodeBox-Lite

+

Anonymous passcode sharing of text and files, picking up files just like picking up express deliveries

+

Join our QQ Group: 739673698

+
+ +![banner](https://fastly.jsdelivr.net/gh/vastsa/FileCodeBox@V1.6/static/banners/img_1.png) + +--- + +
+ 简体中文 | + English | + Deployment Guide | + FAQ +
+ +## Screenshots + + + + + + + + + + + + + + +
Send FilesSend Files
Send FilesSend Files
Send FilesSend Files
+ +## Key Features + +- [x] **Lightweight and Simple:** Built on Fastapi + Sqlite3 + Vue3 + ElementUI +- [x] **Easy Upload:** Supports copy-paste and drag-and-drop selection +- [x] **Multiple Types:** Supports text and files +- [x] **Brute-Force Protection:** Limits on wrong attempts +- [x] **Abuse Prevention:** IP-based upload limits +- [x] **Passcode Sharing:** Random passcodes for storing and retrieving files, customizable retries, and expiration periods +- [x] **Internationalization:** Supports Simplified Chinese, Traditional Chinese, English, etc. +- [x] **Anonymous Sharing:** No registration or login required +- [x] **Admin Panel:** View and delete files +- [x] **One-Click Deployment:** Supports Docker one-click deployment +- [x] **Flexible Expansion:** Supports S3 protocol and local file streams, with the ability to add new storage engines in the storage file +- [x] **Simple and Clear:** Ideal for beginner projects +- [x] **Terminal Download:** Terminal command `wget https://share.lanol.cn/share/select?code=83432` + +## Project Status + +![Alt](https://repobeats.axiom.co/api/embed/7a6c92f1d96ee57e6fb67f0df371528397b0c9ac.svg "Repobeats analytics image") + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=vastsa/FileCodeBox&type=Date)](https://star-history.com/#vastsa/FileCodeBox&Date) + +## Disclaimer + +This project is open-sourced for learning purposes only and shall not be used for any illegal activities. Any consequences are the sole responsibility of the user and have no +relation to the author. Please retain the project link when using. Thank you. diff --git a/readme_onedrive.md b/readme_onedrive.md new file mode 100644 index 0000000..796a3c0 --- /dev/null +++ b/readme_onedrive.md @@ -0,0 +1,135 @@ +# OneDrive作为存储的配置方法 + +**仅支持工作或学校账户,并且需要有管理员权限以授权API** + +## 1. 需要配置的参数 + +``` +file_storage=onedrive +onedrive_domain=XXXXXX +onedrive_client_id=XXXXXX-XXXXXX-XXXXXX-XXXXXX +onedrive_username=XXXXXX@XXXXXX +onedrive_password=XXXXXX +``` + +`onedrive_username`和`onedrive_password`是你的账户名(邮箱)和密码,另外两个参数需要在[微软Azure门户](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)中注册应用后获取。 + +## 2. 应用注册 + +1. 登录[https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade),鼠标置于右上角账号处,浮窗将显示的`域`即为`onedrive_domain`的值。 +![onedrive_domain](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGCiErO85doq9Tcu/root/content) + +2. 点击左上角的`+新注册`,输入名称, + * 受支持的帐户类型:选择任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如,Skype、Xbox) + * 重定向 URI (可选):选择`Web`,并输入`http://localhost` + +3. 完成注册后进入概述页面,在概要中找到`应用程序(客户端)ID`,即为`onedrive_client_id`的值。 +![onedrive_client_id](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGHD4CNyJxm_QBb8/root/content) + +4. 此时还需要配置允许公共客户端流和API权限 + * 在左侧选择`身份验证`,找到`允许的客户端流`,选择`是`,并**点击`保存`**。 + ![允许的客户端流](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGJQMOlOCb2-L0Lh/root/content) + * 在左侧选择`API权限`,点击`+添加权限`,选择`Microsoft Graph`->`委托的权限`,并勾选下述权限:openid、Files中所有权限、User.Read,如下图所示。最后**点击下方的`添加权限`**。 + ![添加权限](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGOZzz7sIrdXkD4w/root/content) + * 最后点击`授予管理员同意`,并**点击`是`**,最终状态变为`已授予`。 + ![授予管理员同意](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGSOAnjnHUlbirbU/root/content) + +## 3. 使用下述代码测试是否配置成功 + +安装依赖:`pip install Office365-REST-Python-Client` + +```python +# common.py +import msal +domain = 'XXXXXX' +client_id = 'XXXXXX' +username = 'XXXXXX' +password = 'XXXXXX' + +def acquire_token_pwd(): + authority_url = f'https://login.microsoftonline.com/{domain}' + app = msal.PublicClientApplication( + authority=authority_url, + client_id=client_id + ) + result = app.acquire_token_by_username_password( + username=username, + password=password, + scopes=['https://graph.microsoft.com/.default'] + ) + return result +``` + +测试登录,如果成功打印出账户名,说明配置成功。 + +```python +from common import acquire_token_pwd + +from office365.graph_client import GraphClient +try: + client = GraphClient(acquire_token_pwd) + me = client.me.get().execute_query() + print(me.user_principal_name) +except Exception as e: + print(e) +``` + +测试文件上传 + +```python +import os +from office365.graph_client import GraphClient +from common import acquire_token_pwd + +remote_path = 'tmp' +local_path = '.tmp/1689843925000.png' + +def convert_link_to_download_link(link): + import re + p1 = re.search(r'https:\/\/(.+)\.sharepoint\.com', link).group(1) + p2 = re.search(r'personal\/(.+)\/', link).group(1) + p3 = re.search(rf'{p2}\/(.+)', link).group(1) + return f'https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}' + +client = GraphClient(acquire_token_pwd) +folder = client.me.drive.root.get_by_path(remote_path) +# 1. upload +file = folder.upload_file(local_path).execute_query() +print(f'File {file.web_url} has been uploaded') +# 2. create sharing link +remote_file = folder.get_by_path(os.path.basename(local_path)) +permission = remote_file.create_link("view", "anonymous").execute_query() +print(f"sharing link: {convert_link_to_download_link(permission.link.webUrl)}") +``` + +测试文件下载 + +```python +import os +from office365.graph_client import GraphClient +from common import acquire_token_pwd + +remote_path = 'tmp/1689843925000.png' +local_path = '.tmp' +if not os.path.exists(local_path): + os.makedirs(local_path) + +client = GraphClient(acquire_token_pwd) +remote_file = client.me.drive.root.get_by_path(remote_path).get().execute_query() +with open(os.path.join(local_path, os.path.basename(remote_path)), 'wb') as local_file: + remote_file.download(local_file).execute_query() + print(f'{remote_file.name} has been downloaded into {local_file.name}') +``` + +测试删除文件 + +```python +from office365.graph_client import GraphClient +from common import acquire_token_pwd + +remote_path = 'tmp/1689843925000.png' + +client = GraphClient(acquire_token_pwd) +file = client.me.drive.root.get_by_path(remote_path) +file.delete_object().execute_query() +``` diff --git a/readme_opendal.md b/readme_opendal.md new file mode 100644 index 0000000..c4d383f --- /dev/null +++ b/readme_opendal.md @@ -0,0 +1,30 @@ +# 通过 OpenDAL 集成存储的配置方法 + +## 需要配置的参数 + +```dotenv +file_storage=opendal +opendal_scheme= +opendal__=... +``` + +以 Gcs 为例,需要配置的参数如下: +```dotenv +file_storage=opendal +opendal_scheme=gcs +opendal_gcs_root= +opendal_gcs_bucket= +opendal_gcs_credential= +``` + +所有支持的服务可以在[此处](https://opendal.apache.org/docs/rust/opendal/services/index.html)查看。 +具体服务的配置参数与 OpenDAL 文档一致。 + +## 补充说明 + +通过 OpenDAL 集成的服务均通过服务器中转下载。因此,每次下载既消耗存储服务的流量,也消耗服务器的流量。 + +OpenDAL 和该项目本身都支持本地存储、`s3`、`onedrive`。不同之处有以下几点: +1. 项目的支持通过预签名实现,不消耗服务器流量。而 OpenDAL 通过服务器中转下载,消耗服务器流量。(本地存储除外) +2. 项目的支持对于异常情况可能会有更多的调试信息,方便排查问题。 +3. OpenDAL 项目本身采用 Rust 编写,性能更好。 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3be8a05 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +aioboto3==11.2.0 +aiobotocore==2.5.0 +aiohttp==3.8.5 +aioitertools==0.11.0 +aiosignal==1.3.1 +aiosqlite==0.17.0 +annotated-types==0.5.0 +anyio==3.7.1 +async-timeout==4.0.3 +atlastk==0.13.2 +attrs==23.1.0 +boto3==1.26.76 +botocore==1.29.76 +charset-normalizer==3.2.0 +click==8.1.6 +exceptiongroup==1.1.2 +fastapi==0.101.0 +frozenlist==1.4.0 +h11==0.14.0 +idna==3.4 +iso8601==1.1.0 +jmespath==1.0.1 +multidict==6.0.4 +pydantic==2.1.1 +pydantic_core==2.4.0 +pypika-tortoise==0.1.6 +python-dateutil==2.8.2 +python-multipart==0.0.6 +pytz==2023.3 +s3transfer==0.6.1 +six==1.16.0 +sniffio==1.3.0 +starlette==0.27.0 +tortoise-orm==0.20.0 +typing_extensions==4.7.1 +urllib3==1.26.16 +uvicorn==0.23.2 +wrapt==1.15.0 +yarl==1.9.2 +Office365-REST-Python-Client==2.5.2