后端移动文件夹,前端在docker中编译,进一步减小镜像大小

This commit is contained in:
buyfakett
2024-05-02 00:29:33 +08:00
parent 68acfc2176
commit b0817517b6
20 changed files with 24 additions and 8 deletions
+4
View File
@@ -0,0 +1,4 @@
# @Time : 2023/8/13 20:43
# @Author : Lan
# @File : __init__.py.py
# @Software: PyCharm
+4
View File
@@ -0,0 +1,4 @@
# @Time : 2023/8/14 14:38
# @Author : Lan
# @File : __init__.py.py
# @Software: PyCharm
+19
View File
@@ -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='未授权或授权校验失败')
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class IDData(BaseModel):
id: int
+82
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
# @Time : 2023/8/13 20:43
# @Author : Lan
# @File : __init__.py.py
# @Software: PyCharm
+45
View 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
+44
View File
@@ -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')
+5
View File
@@ -0,0 +1,5 @@
from pydantic import BaseModel
class SelectFileModel(BaseModel):
code: str
+92
View 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 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)
+157
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
# @Time : 2023/8/11 20:06
# @Author : Lan
# @File : __init__.py.py
# @Software: PyCharm
+15
View 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
+85
View File
@@ -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()
+291
View File
@@ -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]()
+27
View File
@@ -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)
+95
View 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
+94
View File
@@ -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)