first commit
This commit is contained in:
commit
91ee68f615
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.md
|
||||||
|
.idea
|
||||||
|
.git
|
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.js linguist-language=python
|
||||||
|
*.css linguist-language=python
|
||||||
|
*.html linguist-language=python
|
156
.gitignore
vendored
Normal file
156
.gitignore
vendored
Normal file
@ -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
|
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -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"]
|
165
LICENSE
Normal file
165
LICENSE
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
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.
|
21
SECURITY.md
Normal file
21
SECURITY.md
Normal file
@ -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.
|
4
apps/__init__.py
Normal file
4
apps/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# @Time : 2023/8/13 20:43
|
||||||
|
# @Author : Lan
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software: PyCharm
|
4
apps/admin/__init__.py
Normal file
4
apps/admin/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# @Time : 2023/8/14 14:38
|
||||||
|
# @Author : Lan
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software: PyCharm
|
23
apps/admin/depends.py
Normal file
23
apps/admin/depends.py
Normal file
@ -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='未授权或授权校验失败')
|
5
apps/admin/pydantics.py
Normal file
5
apps/admin/pydantics.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class IDData(BaseModel):
|
||||||
|
id: int
|
93
apps/admin/views.py
Normal file
93
apps/admin/views.py
Normal file
@ -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)
|
4
apps/base/__init__.py
Normal file
4
apps/base/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# @Time : 2023/8/13 20:43
|
||||||
|
# @Author : Lan
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software: PyCharm
|
45
apps/base/depends.py
Normal file
45
apps/base/depends.py
Normal file
@ -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
|
50
apps/base/models.py
Normal file
50
apps/base/models.py
Normal file
@ -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')
|
5
apps/base/pydantics.py
Normal file
5
apps/base/pydantics.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SelectFileModel(BaseModel):
|
||||||
|
code: str
|
92
apps/base/utils.py
Normal file
92
apps/base/utils.py
Normal file
@ -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)
|
||||||
|
}
|
171
apps/base/views.py
Normal file
171
apps/base/views.py
Normal file
@ -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)
|
4
core/__init__.py
Normal file
4
core/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# @Time : 2023/8/11 20:06
|
||||||
|
# @Author : Lan
|
||||||
|
# @File : __init__.py.py
|
||||||
|
# @Software: PyCharm
|
15
core/response.py
Normal file
15
core/response.py
Normal file
@ -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
|
76
core/settings.py
Normal file
76
core/settings.py
Normal file
@ -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,本程序开源于 <a href="https://github.com/vastsa/FileCodeBox" target="_blank">Github</a> ,欢迎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)
|
298
core/storage.py
Normal file
298
core/storage.py
Normal file
@ -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,
|
||||||
|
}
|
29
core/tasks.py
Normal file
29
core/tasks.py
Normal file
@ -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)
|
95
core/utils.py
Normal file
95
core/utils.py
Normal file
@ -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
|
103
main.py
Normal file
103
main.py
Normal file
@ -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)
|
75
readme.md
Normal file
75
readme.md
Normal file
@ -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)
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<h1>文件快递柜-轻量</h1>
|
||||||
|
<p><em>匿名口令分享文本,文件,像拿快递一样取文件</em></p>
|
||||||
|
<p>交流Q群:739673698</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
<div align="center" style="text-align: center;margin: 20px">
|
||||||
|
<a href="./readme.md">简体中文</a> |
|
||||||
|
<a href="./readme_en.md">English</a> |
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/wiki/%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B">部署教程</a> |
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/wiki/%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B">常见问题</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
## 部分截图
|
||||||
|
|
||||||
|
<table style="width:100%">
|
||||||
|
<tr style="width: 100%">
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img.png" alt="寄文件"></td>
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_1.png" alt="寄文件"></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="width: 100%">
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_2.png" alt="寄文件"></td>
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_3.png" alt="寄文件"></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="width: 100%">
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_4.png" alt="寄文件"></td>
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_5.png" alt="寄文件"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## 主要特色
|
||||||
|
|
||||||
|
- [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
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://hellogithub.com/repository/75ad7ffedd404a6485b4d621ec5b47e6" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=75ad7ffedd404a6485b4d621ec5b47e6&claim_uid=beSz6INEkCM4mDH" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
![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)
|
||||||
|
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
本项目开源仅供学习使用,不得用于任何违法用途,否则后果自负,与本人无关。使用请保留项目地址,谢谢。
|
63
readme_en.md
Normal file
63
readme_en.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<div align="center">
|
||||||
|
<h1>File Delivery Cabinet - Lite</h1>
|
||||||
|
<h2>FileCodeBox-Lite</h2>
|
||||||
|
<p><em>Anonymous passcode sharing of text and files, picking up files just like picking up express deliveries</em></p>
|
||||||
|
<p>Join our QQ Group: 739673698</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
![banner](https://fastly.jsdelivr.net/gh/vastsa/FileCodeBox@V1.6/static/banners/img_1.png)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center" style="text-align: center;margin: 20px 0">
|
||||||
|
<a href="./readme.md">简体中文</a> |
|
||||||
|
<a href="./readme_en.md">English</a> |
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/wiki/Deployment-Tutorial">Deployment Guide</a> |
|
||||||
|
<a href="https://github.com/vastsa/FileCodeBox/wiki/Frequently-asked-questions">FAQ</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<table style="width:100%">
|
||||||
|
<tr style="width: 100%">
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img.png" alt="Send Files"></td>
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_1.png" alt="Send Files"></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="width: 100%">
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_2.png" alt="Send Files"></td>
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_3.png" alt="Send Files"></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="width: 100%">
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_4.png" alt="Send Files"></td>
|
||||||
|
<td style="width: 50%"><img src="./.github/images/img_5.png" alt="Send Files"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## 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.
|
135
readme_onedrive.md
Normal file
135
readme_onedrive.md
Normal file
@ -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()
|
||||||
|
```
|
30
readme_opendal.md
Normal file
30
readme_opendal.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# 通过 OpenDAL 集成存储的配置方法
|
||||||
|
|
||||||
|
## 需要配置的参数
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
file_storage=opendal
|
||||||
|
opendal_scheme=<service_name>
|
||||||
|
opendal_<service_name>_<service_setting>=...
|
||||||
|
```
|
||||||
|
|
||||||
|
以 Gcs 为例,需要配置的参数如下:
|
||||||
|
```dotenv
|
||||||
|
file_storage=opendal
|
||||||
|
opendal_scheme=gcs
|
||||||
|
opendal_gcs_root=<root>
|
||||||
|
opendal_gcs_bucket=<bucket_name>
|
||||||
|
opendal_gcs_credential=<base64_credential>
|
||||||
|
```
|
||||||
|
|
||||||
|
所有支持的服务可以在[此处](https://opendal.apache.org/docs/rust/opendal/services/index.html)查看。
|
||||||
|
具体服务的配置参数与 OpenDAL 文档一致。
|
||||||
|
|
||||||
|
## 补充说明
|
||||||
|
|
||||||
|
通过 OpenDAL 集成的服务均通过服务器中转下载。因此,每次下载既消耗存储服务的流量,也消耗服务器的流量。
|
||||||
|
|
||||||
|
OpenDAL 和该项目本身都支持本地存储、`s3`、`onedrive`。不同之处有以下几点:
|
||||||
|
1. 项目的支持通过预签名实现,不消耗服务器流量。而 OpenDAL 通过服务器中转下载,消耗服务器流量。(本地存储除外)
|
||||||
|
2. 项目的支持对于异常情况可能会有更多的调试信息,方便排查问题。
|
||||||
|
3. OpenDAL 项目本身采用 Rust 编写,性能更好。
|
40
requirements.txt
Normal file
40
requirements.txt
Normal file
@ -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
|
Loading…
Reference in New Issue
Block a user