后端移动文件夹,前端在docker中编译,进一步减小镜像大小
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# @Time : 2023/8/13 20:43
|
||||
# @Author : Lan
|
||||
# @File : __init__.py.py
|
||||
# @Software: PyCharm
|
||||
@@ -0,0 +1,4 @@
|
||||
# @Time : 2023/8/14 14:38
|
||||
# @Author : Lan
|
||||
# @File : __init__.py.py
|
||||
# @Software: PyCharm
|
||||
@@ -0,0 +1,19 @@
|
||||
# @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):
|
||||
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='未授权或授权校验失败')
|
||||
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class IDData(BaseModel):
|
||||
id: int
|
||||
@@ -0,0 +1,82 @@
|
||||
# @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
|
||||
from core.response import APIResponse
|
||||
from core.settings import settings
|
||||
from core.storage import file_storage
|
||||
|
||||
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_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.__dict__)
|
||||
|
||||
|
||||
@admin_api.patch('/config/update', dependencies=[Depends(admin_required)])
|
||||
async def update_config(data: dict):
|
||||
admin_token = data.get('admin_token')
|
||||
if admin_token is None or admin_token == '':
|
||||
return APIResponse(code=400, detail='管理员密码不能为空')
|
||||
|
||||
for k, v in data.items():
|
||||
settings.__setattr__(k, v)
|
||||
return APIResponse()
|
||||
|
||||
|
||||
# 根据id获取文件
|
||||
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):
|
||||
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)
|
||||
@@ -0,0 +1,4 @@
|
||||
# @Time : 2023/8/13 20:43
|
||||
# @Author : Lan
|
||||
# @File : __init__.py.py
|
||||
# @Software: PyCharm
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
# @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}"
|
||||
|
||||
|
||||
file_codes_pydantic = pydantic_model_creator(FileCodes, name='FileCodes')
|
||||
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SelectFileModel(BaseModel):
|
||||
code: str
|
||||
@@ -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 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限制器
|
||||
error_ip_limit = IPRateLimit(count=settings.errorCount, minutes=settings.errorMinute)
|
||||
# 上传文件限制器
|
||||
upload_ip_limit = IPRateLimit(count=settings.uploadCount, minutes=settings.errorMinute)
|
||||
@@ -0,0 +1,157 @@
|
||||
# @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, error_ip_limit, upload_ip_limit
|
||||
from core.response import APIResponse
|
||||
from core.settings import settings
|
||||
from core.storage import file_storage
|
||||
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(upload_ip_limit)):
|
||||
# 获取过期信息
|
||||
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到限制列表
|
||||
upload_ip_limit.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(upload_ip_limit)):
|
||||
# 检查文件大小是否超过限制
|
||||
if file.size > settings.uploadSize:
|
||||
raise HTTPException(status_code=403, detail=f'文件大小超过限制,最大为{settings.uploadSize}字节')
|
||||
# 获取过期信息
|
||||
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)
|
||||
# 保存文件
|
||||
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到限制列表
|
||||
upload_ip_limit.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(error_ip_limit)):
|
||||
# 获取文件
|
||||
has, file_code = await get_code_file_by_code(code)
|
||||
# 检查文件是否存在
|
||||
if not has:
|
||||
# 添加IP到限制列表
|
||||
error_ip_limit.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(error_ip_limit)):
|
||||
# 获取文件
|
||||
has, file_code = await get_code_file_by_code(data.code)
|
||||
# 检查文件是否存在
|
||||
if not has:
|
||||
# 添加IP到限制列表
|
||||
error_ip_limit.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(error_ip_limit)):
|
||||
# 检查token是否有效
|
||||
is_valid = await get_select_token(code) == key
|
||||
if not is_valid:
|
||||
# 添加IP到限制列表
|
||||
error_ip_limit.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)
|
||||
@@ -0,0 +1,4 @@
|
||||
# @Time : 2023/8/11 20:06
|
||||
# @Author : Lan
|
||||
# @File : __init__.py.py
|
||||
# @Software: PyCharm
|
||||
@@ -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
|
||||
@@ -0,0 +1,85 @@
|
||||
# @Time : 2023/8/15 09:51
|
||||
# @Author : Lan
|
||||
# @File : settings.py
|
||||
# @Software: PyCharm
|
||||
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)
|
||||
env_path = data_root / '.env2'
|
||||
default_value = {
|
||||
'file_storage': 'local',
|
||||
'name': '文件快递柜-FileCodeBox',
|
||||
'description': '开箱即用的文件快传系统',
|
||||
'keywords': 'FileCodeBox, 文件快递柜, 口令传送箱, 匿名口令分享文本, 文件',
|
||||
'max_save_seconds': 0,
|
||||
'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,
|
||||
'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,
|
||||
'uploadMinute': 1,
|
||||
'opacity': 0.9,
|
||||
'background': '',
|
||||
'uploadCount': 10,
|
||||
'errorMinute': 1,
|
||||
'errorCount': 1,
|
||||
'port': 12345,
|
||||
}
|
||||
|
||||
|
||||
class Settings:
|
||||
__instance = None
|
||||
|
||||
def __init__(self):
|
||||
# 读取.env
|
||||
if not (env_path).exists():
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
for key, value in default_value.items():
|
||||
f.write(f'{key}={value}\n')
|
||||
# 更新default_value
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
for line in f.readlines():
|
||||
key, value = line.strip().split('=', maxsplit=1)
|
||||
# 将字符串转换为原本的类型
|
||||
if not key.startswith('opendal_') and isinstance(default_value[key], int):
|
||||
value = int(value)
|
||||
default_value[key] = value
|
||||
|
||||
# 更新self
|
||||
for key, value in default_value.items():
|
||||
self.__setattr__(key, value)
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if not cls.__instance:
|
||||
cls.__instance = super(Settings, cls).__new__(cls, *args, **kwargs)
|
||||
return cls.__instance
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if not key.startswith('opendal_') and type(value) == str and value.isnumeric():
|
||||
value = int(value)
|
||||
self.__dict__[key] = value
|
||||
with open(env_path, 'w', encoding='utf-8') as f:
|
||||
for key, value in self.__dict__.items():
|
||||
f.write(f'{key}={value}\n')
|
||||
|
||||
def items(self):
|
||||
return self.__dict__.items()
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,291 @@
|
||||
# @Time : 2023/8/11 20:06
|
||||
# @Author : Lan
|
||||
# @File : storage.py
|
||||
# @Software: PyCharm
|
||||
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:
|
||||
|
||||
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 = file_storage.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,
|
||||
}
|
||||
file_storage: FileStorageInterface = storages[settings.file_storage]()
|
||||
@@ -0,0 +1,27 @@
|
||||
# @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 error_ip_limit, upload_ip_limit
|
||||
from core.storage import file_storage
|
||||
from core.utils import get_now
|
||||
|
||||
|
||||
async def delete_expire_files():
|
||||
while True:
|
||||
try:
|
||||
await error_ip_limit.remove_expired_ip()
|
||||
await upload_ip_limit.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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,94 @@
|
||||
# @Time : 2023/8/9 23:23
|
||||
# @Author : Lan
|
||||
# @File : main.py
|
||||
# @Software: PyCharm
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import HTMLResponse, FileResponse
|
||||
from tortoise.contrib.fastapi import register_tortoise
|
||||
|
||||
from apps.base.views import share_api
|
||||
from apps.admin.views import admin_api
|
||||
from core.settings import data_root, settings, BASE_DIR
|
||||
from core.tasks import delete_expire_files
|
||||
from core.utils import max_save_times_desc
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get('/assets/{file_path:path}')
|
||||
async def assets(file_path: str):
|
||||
if settings.max_save_seconds > 0:
|
||||
if re.match(r'SendView-[\d|a-f|A-F]+\.js', file_path):
|
||||
with open(BASE_DIR / f'./dist/assets/{file_path}', 'r', encoding='utf-8') as f:
|
||||
# 删除永久保存选项
|
||||
content = f.read()
|
||||
content = content.replace('_(c,{label:e(r)("send.expireData.forever"),value:"forever"},null,8,["label"]),', '')
|
||||
return HTMLResponse(content=content, media_type='text/javascript')
|
||||
if re.match(r'index-[\d|a-f|A-F]+\.js', file_path):
|
||||
with open(BASE_DIR / f'./dist/assets/{file_path}', 'r', encoding='utf-8') as f:
|
||||
# 更改本文描述
|
||||
desc_zh, desc_en = await max_save_times_desc(settings.max_save_seconds)
|
||||
content = f.read()
|
||||
content = content.replace('天数<7', desc_zh)
|
||||
content = content.replace('Days <7', desc_en)
|
||||
return HTMLResponse(content=content, media_type='text/javascript')
|
||||
return FileResponse(f'./dist/assets/{file_path}')
|
||||
|
||||
|
||||
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())
|
||||
|
||||
|
||||
@app.get('/')
|
||||
async def index():
|
||||
return HTMLResponse(
|
||||
content=open(BASE_DIR / './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'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app='main:app', host="0.0.0.0", port=settings.port, reload=False, workers=1)
|
||||
Reference in New Issue
Block a user