feat: 新增webdav存储
This commit is contained in:
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import UploadFile, HTTPException
|
from fastapi import UploadFile, HTTPException
|
||||||
from typing import Tuple, Optional
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from apps.base.dependencies import IPRateLimit
|
from apps.base.dependencies import IPRateLimit
|
||||||
from apps.base.models import FileCodes
|
from apps.base.models import FileCodes
|
||||||
|
|||||||
+5
-1
@@ -135,4 +135,8 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit['error']
|
|||||||
if not has:
|
if not has:
|
||||||
return APIResponse(code=404, detail='文件不存在')
|
return APIResponse(code=404, detail='文件不存在')
|
||||||
|
|
||||||
return APIResponse(detail=file_code.text) if file_code.text else await file_storage.get_file_response(file_code)
|
return (
|
||||||
|
APIResponse(detail=file_code.text)
|
||||||
|
if file_code.text
|
||||||
|
else await file_storage.get_file_response(file_code)
|
||||||
|
)
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ DEFAULT_CONFIG = {
|
|||||||
'uploadSize': 1024 * 1024 * 10,
|
'uploadSize': 1024 * 1024 * 10,
|
||||||
'expireStyle': ['day', 'hour', 'minute', 'forever', 'count'],
|
'expireStyle': ['day', 'hour', 'minute', 'forever', 'count'],
|
||||||
'uploadMinute': 1,
|
'uploadMinute': 1,
|
||||||
|
'webdav_url': '',
|
||||||
|
'webdav_password': '',
|
||||||
|
'webdav_username': '',
|
||||||
'opacity': 0.9,
|
'opacity': 0.9,
|
||||||
'background': '',
|
'background': '',
|
||||||
'uploadCount': 10,
|
'uploadCount': 10,
|
||||||
|
|||||||
+254
-60
@@ -3,7 +3,7 @@
|
|||||||
# @File : storage.py
|
# @File : storage.py
|
||||||
# @Software: PyCharm
|
# @Software: PyCharm
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -22,11 +22,13 @@ from fastapi.responses import FileResponse
|
|||||||
|
|
||||||
|
|
||||||
class FileStorageInterface:
|
class FileStorageInterface:
|
||||||
_instance: Optional['FileStorageInterface'] = None
|
_instance: Optional["FileStorageInterface"] = None
|
||||||
|
|
||||||
def __new__(cls, *args, **kwargs):
|
def __new__(cls, *args, **kwargs):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super(FileStorageInterface, cls).__new__(cls, *args, **kwargs)
|
cls._instance = super(FileStorageInterface, cls).__new__(
|
||||||
|
cls, *args, **kwargs
|
||||||
|
)
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
async def save_file(self, file: UploadFile, save_path: str):
|
async def save_file(self, file: UploadFile, save_path: str):
|
||||||
@@ -66,7 +68,7 @@ class SystemFileStorage(FileStorageInterface):
|
|||||||
self.root_path = data_root
|
self.root_path = data_root
|
||||||
|
|
||||||
def _save(self, file, save_path):
|
def _save(self, file, save_path):
|
||||||
with open(save_path, 'wb') as f:
|
with open(save_path, "wb") as f:
|
||||||
chunk = file.read(self.chunk_size)
|
chunk = file.read(self.chunk_size)
|
||||||
while chunk:
|
while chunk:
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
@@ -89,7 +91,7 @@ class SystemFileStorage(FileStorageInterface):
|
|||||||
async def get_file_response(self, file_code: FileCodes):
|
async def get_file_response(self, file_code: FileCodes):
|
||||||
file_path = self.root_path / await file_code.get_file_path()
|
file_path = self.root_path / await file_code.get_file_path()
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
return APIResponse(code=404, detail='文件已过期删除')
|
return APIResponse(code=404, detail="文件已过期删除")
|
||||||
return FileResponse(file_path, filename=file_code.prefix + file_code.suffix)
|
return FileResponse(file_path, filename=file_code.prefix + file_code.suffix)
|
||||||
|
|
||||||
|
|
||||||
@@ -101,30 +103,62 @@ class S3FileStorage(FileStorageInterface):
|
|||||||
self.s3_hostname = settings.s3_hostname
|
self.s3_hostname = settings.s3_hostname
|
||||||
self.region_name = settings.s3_region_name
|
self.region_name = settings.s3_region_name
|
||||||
self.signature_version = settings.s3_signature_version
|
self.signature_version = settings.s3_signature_version
|
||||||
self.endpoint_url = settings.s3_endpoint_url or f'https://{self.s3_hostname}'
|
self.endpoint_url = settings.s3_endpoint_url or f"https://{self.s3_hostname}"
|
||||||
self.aws_session_token = settings.aws_session_token
|
self.aws_session_token = settings.aws_session_token
|
||||||
self.proxy = settings.s3_proxy
|
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)
|
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:
|
if not settings.s3_endpoint_url:
|
||||||
self.endpoint_url = f'https://{self.s3_hostname}'
|
self.endpoint_url = f"https://{self.s3_hostname}"
|
||||||
else:
|
else:
|
||||||
# 如果提供了 s3_endpoint_url,则优先使用它
|
# 如果提供了 s3_endpoint_url,则优先使用它
|
||||||
self.endpoint_url = settings.s3_endpoint_url
|
self.endpoint_url = settings.s3_endpoint_url
|
||||||
|
|
||||||
async def save_file(self, file: UploadFile, save_path: str):
|
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,
|
async with self.session.client(
|
||||||
config=Config(signature_version=self.signature_version)) as s3:
|
"s3",
|
||||||
await s3.put_object(Bucket=self.bucket_name, Key=save_path, Body=await file.read(), ContentType=file.content_type)
|
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 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:
|
async with self.session.client(
|
||||||
await s3.delete_object(Bucket=self.bucket_name, Key=await file_code.get_file_path())
|
"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):
|
async def get_file_response(self, file_code: FileCodes):
|
||||||
try:
|
try:
|
||||||
filename = file_code.prefix + file_code.suffix
|
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:
|
async with self.session.client(
|
||||||
link = await s3.generate_presigned_url('get_object', Params={'Bucket': self.bucket_name, 'Key': await file_code.get_file_path()}, ExpiresIn=3600)
|
"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()
|
tmp = io.BytesIO()
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(link) as resp:
|
async with session.get(link) as resp:
|
||||||
@@ -132,18 +166,36 @@ class S3FileStorage(FileStorageInterface):
|
|||||||
tmp.seek(0)
|
tmp.seek(0)
|
||||||
content = tmp.read()
|
content = tmp.read()
|
||||||
tmp.close()
|
tmp.close()
|
||||||
return Response(content, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'})
|
return Response(
|
||||||
|
content,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=503, detail='服务代理下载异常,请稍后再试')
|
raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试")
|
||||||
|
|
||||||
async def get_file_url(self, file_code: FileCodes):
|
async def get_file_url(self, file_code: FileCodes):
|
||||||
if file_code.prefix == '文本分享':
|
if file_code.prefix == "文本分享":
|
||||||
return file_code.text
|
return file_code.text
|
||||||
if self.proxy:
|
if self.proxy:
|
||||||
return await get_file_url(file_code.code)
|
return await get_file_url(file_code.code)
|
||||||
else:
|
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:
|
async with self.session.client(
|
||||||
result = await s3.generate_presigned_url('get_object', Params={'Bucket': self.bucket_name, 'Key': await file_code.get_file_path()}, ExpiresIn=3600)
|
"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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -152,9 +204,11 @@ class OneDriveFileStorage(FileStorageInterface):
|
|||||||
try:
|
try:
|
||||||
import msal
|
import msal
|
||||||
from office365.graph_client import GraphClient
|
from office365.graph_client import GraphClient
|
||||||
from office365.runtime.client_request_exception import ClientRequestException
|
from office365.runtime.client_request_exception import (
|
||||||
|
ClientRequestException,
|
||||||
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError('请先安装`msal`和`Office365-REST-Python-Client`')
|
raise ImportError("请先安装`msal`和`Office365-REST-Python-Client`")
|
||||||
self.msal = msal
|
self.msal = msal
|
||||||
self.domain = settings.onedrive_domain
|
self.domain = settings.onedrive_domain
|
||||||
self.client_id = settings.onedrive_client_id
|
self.client_id = settings.onedrive_client_id
|
||||||
@@ -165,36 +219,45 @@ class OneDriveFileStorage(FileStorageInterface):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
client = GraphClient(self.acquire_token_pwd)
|
client = GraphClient(self.acquire_token_pwd)
|
||||||
self.root_path = client.me.drive.root.get_by_path(settings.onedrive_root_path).get().execute_query()
|
self.root_path = (
|
||||||
|
client.me.drive.root.get_by_path(settings.onedrive_root_path)
|
||||||
|
.get()
|
||||||
|
.execute_query()
|
||||||
|
)
|
||||||
except ClientRequestException as e:
|
except ClientRequestException as e:
|
||||||
if e.code == 'itemNotFound':
|
if e.code == "itemNotFound":
|
||||||
client.me.drive.root.create_folder(settings.onedrive_root_path)
|
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()
|
self.root_path = (
|
||||||
|
client.me.drive.root.get_by_path(settings.onedrive_root_path)
|
||||||
|
.get()
|
||||||
|
.execute_query()
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise e
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception('OneDrive验证失败,请检查配置是否正确\n' + str(e))
|
raise Exception("OneDrive验证失败,请检查配置是否正确\n" + str(e))
|
||||||
|
|
||||||
def acquire_token_pwd(self):
|
def acquire_token_pwd(self):
|
||||||
authority_url = f'https://login.microsoftonline.com/{self.domain}'
|
authority_url = f"https://login.microsoftonline.com/{self.domain}"
|
||||||
app = self.msal.PublicClientApplication(
|
app = self.msal.PublicClientApplication(
|
||||||
authority=authority_url,
|
authority=authority_url, client_id=self.client_id
|
||||||
client_id=self.client_id
|
)
|
||||||
|
result = app.acquire_token_by_username_password(
|
||||||
|
username=self.username,
|
||||||
|
password=self.password,
|
||||||
|
scopes=["https://graph.microsoft.com/.default"],
|
||||||
)
|
)
|
||||||
result = app.acquire_token_by_username_password(username=self.username,
|
|
||||||
password=self.password,
|
|
||||||
scopes=['https://graph.microsoft.com/.default'])
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_path_str(self, path):
|
def _get_path_str(self, path):
|
||||||
if isinstance(path, str):
|
if isinstance(path, str):
|
||||||
path = path.replace('\\', '/').replace('//', '/').split('/')
|
path = path.replace("\\", "/").replace("//", "/").split("/")
|
||||||
elif isinstance(path, Path):
|
elif isinstance(path, Path):
|
||||||
path = str(path).replace('\\', '/').replace('//', '/').split('/')
|
path = str(path).replace("\\", "/").replace("//", "/").split("/")
|
||||||
else:
|
else:
|
||||||
raise TypeError('path must be str or Path')
|
raise TypeError("path must be str or Path")
|
||||||
path[-1] = path[-1].split('.')[0]
|
path[-1] = path[-1].split(".")[0]
|
||||||
return '/'.join(path)
|
return "/".join(path)
|
||||||
|
|
||||||
def _save(self, file, save_path):
|
def _save(self, file, save_path):
|
||||||
content = file.file.read()
|
content = file.file.read()
|
||||||
@@ -210,7 +273,7 @@ class OneDriveFileStorage(FileStorageInterface):
|
|||||||
try:
|
try:
|
||||||
self.root_path.get_by_path(path).delete_object().execute_query()
|
self.root_path.get_by_path(path).delete_object().execute_query()
|
||||||
except self._ClientRequestException as e:
|
except self._ClientRequestException as e:
|
||||||
if e.code == 'itemNotFound':
|
if e.code == "itemNotFound":
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise e
|
raise e
|
||||||
@@ -219,23 +282,29 @@ class OneDriveFileStorage(FileStorageInterface):
|
|||||||
await asyncio.to_thread(self._delete, await file_code.get_file_path())
|
await asyncio.to_thread(self._delete, await file_code.get_file_path())
|
||||||
|
|
||||||
def _convert_link_to_download_link(self, link):
|
def _convert_link_to_download_link(self, link):
|
||||||
p1 = re.search(r'https:\/\/(.+)\.sharepoint\.com', link).group(1)
|
p1 = re.search(r"https:\/\/(.+)\.sharepoint\.com", link).group(1)
|
||||||
p2 = re.search(r'personal\/(.+)\/', link).group(1)
|
p2 = re.search(r"personal\/(.+)\/", link).group(1)
|
||||||
p3 = re.search(rf'{p2}\/(.+)', 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}'
|
return f"https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}"
|
||||||
|
|
||||||
def _get_file_url(self, save_path, name):
|
def _get_file_url(self, save_path, name):
|
||||||
path = self._get_path_str(save_path)
|
path = self._get_path_str(save_path)
|
||||||
remote_file = self.root_path.get_by_path(path + '/' + name)
|
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 = datetime.datetime.now(
|
||||||
|
tz=datetime.timezone.utc
|
||||||
|
) + datetime.timedelta(hours=1)
|
||||||
expiration_datetime = expiration_datetime.strftime("%Y-%m-%dT%H:%M:%SZ")
|
expiration_datetime = expiration_datetime.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
premission = remote_file.create_link("view", "anonymous", expiration_datetime=expiration_datetime).execute_query()
|
premission = remote_file.create_link(
|
||||||
|
"view", "anonymous", expiration_datetime=expiration_datetime
|
||||||
|
).execute_query()
|
||||||
return self._convert_link_to_download_link(premission.link.webUrl)
|
return self._convert_link_to_download_link(premission.link.webUrl)
|
||||||
|
|
||||||
async def get_file_response(self, file_code: FileCodes):
|
async def get_file_response(self, file_code: FileCodes):
|
||||||
try:
|
try:
|
||||||
filename = file_code.prefix + file_code.suffix
|
filename = file_code.prefix + file_code.suffix
|
||||||
link = await asyncio.to_thread(self._get_file_url, await file_code.get_file_path(), filename)
|
link = await asyncio.to_thread(
|
||||||
|
self._get_file_url, await file_code.get_file_path(), filename
|
||||||
|
)
|
||||||
tmp = io.BytesIO()
|
tmp = io.BytesIO()
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(link) as resp:
|
async with session.get(link) as resp:
|
||||||
@@ -243,15 +312,25 @@ class OneDriveFileStorage(FileStorageInterface):
|
|||||||
tmp.seek(0)
|
tmp.seek(0)
|
||||||
content = tmp.read()
|
content = tmp.read()
|
||||||
tmp.close()
|
tmp.close()
|
||||||
return Response(content, media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'})
|
return Response(
|
||||||
|
content,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode("latin-1")}"'
|
||||||
|
},
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=503, detail='服务代理下载异常,请稍后再试')
|
raise HTTPException(status_code=503, detail="服务代理下载异常,请稍后再试")
|
||||||
|
|
||||||
async def get_file_url(self, file_code: FileCodes):
|
async def get_file_url(self, file_code: FileCodes):
|
||||||
if self.proxy:
|
if self.proxy:
|
||||||
return await get_file_url(file_code.code)
|
return await get_file_url(file_code.code)
|
||||||
else:
|
else:
|
||||||
return await asyncio.to_thread(self._get_file_url, await file_code.get_file_path(), f'{file_code.prefix}{file_code.suffix}')
|
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):
|
class OpenDALFileStorage(FileStorageInterface):
|
||||||
@@ -263,10 +342,12 @@ class OpenDALFileStorage(FileStorageInterface):
|
|||||||
self.service = settings.opendal_scheme
|
self.service = settings.opendal_scheme
|
||||||
service_settings = {}
|
service_settings = {}
|
||||||
for key, value in settings.items():
|
for key, value in settings.items():
|
||||||
if key.startswith('opendal_' + self.service):
|
if key.startswith("opendal_" + self.service):
|
||||||
setting_name = key.split('_', 2)[2]
|
setting_name = key.split("_", 2)[2]
|
||||||
service_settings[setting_name] = value
|
service_settings[setting_name] = value
|
||||||
self.operator = opendal.AsyncOperator(settings.opendal_scheme, **service_settings)
|
self.operator = opendal.AsyncOperator(
|
||||||
|
settings.opendal_scheme, **service_settings
|
||||||
|
)
|
||||||
|
|
||||||
async def save_file(self, file: UploadFile, save_path: str):
|
async def save_file(self, file: UploadFile, save_path: str):
|
||||||
await self.operator.write(save_path, file.file.read())
|
await self.operator.write(save_path, file.file.read())
|
||||||
@@ -281,18 +362,131 @@ class OpenDALFileStorage(FileStorageInterface):
|
|||||||
try:
|
try:
|
||||||
filename = file_code.prefix + file_code.suffix
|
filename = file_code.prefix + file_code.suffix
|
||||||
content = await self.operator.read(await file_code.get_file_path())
|
content = await self.operator.read(await file_code.get_file_path())
|
||||||
headers = {
|
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"'
|
return Response(
|
||||||
}
|
content, headers=headers, media_type="application/octet-stream"
|
||||||
return Response(content, headers=headers, media_type="application/octet-stream")
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e, file=sys.stderr)
|
print(e, file=sys.stderr)
|
||||||
raise HTTPException(status_code=404, detail="文件已过期删除")
|
raise HTTPException(status_code=404, detail="文件已过期删除")
|
||||||
|
|
||||||
|
|
||||||
|
class WebDAVFileStorage(FileStorageInterface):
|
||||||
|
_instance: Optional["WebDAVFileStorage"] = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not hasattr(self, "_initialized"):
|
||||||
|
self.base_url = settings.webdav_url.rstrip("/") + "/"
|
||||||
|
self.auth = aiohttp.BasicAuth(
|
||||||
|
login=settings.webdav_username, password=settings.webdav_password
|
||||||
|
)
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def _build_url(self, path: str) -> str:
|
||||||
|
encoded_path = quote(str(path).lstrip("/"))
|
||||||
|
return f"{self.base_url}{encoded_path}"
|
||||||
|
|
||||||
|
async def _mkdir_p(self, directory_path: str):
|
||||||
|
"""递归创建目录(类似mkdir -p)"""
|
||||||
|
path_obj = Path(unquote(directory_path))
|
||||||
|
current_path = ""
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(auth=self.auth) as session:
|
||||||
|
# 逐级检查目录是否存在
|
||||||
|
for part in path_obj.parts:
|
||||||
|
current_path = str(Path(current_path) / part)
|
||||||
|
url = self._build_url(current_path)
|
||||||
|
|
||||||
|
# 检查目录是否存在
|
||||||
|
async with session.head(url) as resp:
|
||||||
|
if resp.status == 404:
|
||||||
|
# 创建目录
|
||||||
|
async with session.request("MKCOL", url) as mkcol_resp:
|
||||||
|
if mkcol_resp.status not in (200, 201, 409):
|
||||||
|
content = await mkcol_resp.text()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=mkcol_resp.status,
|
||||||
|
detail=f"目录创建失败: {content[:200]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_file(self, file: UploadFile, save_path: str):
|
||||||
|
"""保存文件(自动创建目录)"""
|
||||||
|
# 分离文件名和目录路径
|
||||||
|
path_obj = Path(save_path)
|
||||||
|
directory_path = str(path_obj.parent)
|
||||||
|
file_name = path_obj.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 先创建目录结构
|
||||||
|
await self._mkdir_p(directory_path)
|
||||||
|
|
||||||
|
# 上传文件
|
||||||
|
url = self._build_url(save_path)
|
||||||
|
async with aiohttp.ClientSession(auth=self.auth) as session:
|
||||||
|
content = await file.read() # 注意:大文件需要分块读取
|
||||||
|
async with session.put(
|
||||||
|
url, data=content, headers={"Content-Type": file.content_type}
|
||||||
|
) as resp:
|
||||||
|
if resp.status not in (200, 201, 204):
|
||||||
|
content = await resp.text()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=resp.status,
|
||||||
|
detail=f"文件上传失败: {content[:200]}",
|
||||||
|
)
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"WebDAV连接异常: {str(e)}")
|
||||||
|
|
||||||
|
async def delete_file(self, file_code: FileCodes):
|
||||||
|
"""删除WebDAV文件"""
|
||||||
|
file_path = await file_code.get_file_path()
|
||||||
|
url = self._build_url(file_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(auth=self.auth) as session:
|
||||||
|
async with session.delete(url) as resp:
|
||||||
|
if resp.status not in (200, 204):
|
||||||
|
content = await resp.text()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=resp.status,
|
||||||
|
detail=f"WebDAV删除失败: {content[:200]}",
|
||||||
|
)
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"WebDAV连接异常: {str(e)}")
|
||||||
|
|
||||||
|
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
|
||||||
|
url = self._build_url(await file_code.get_file_path())
|
||||||
|
async with aiohttp.ClientSession(auth=self.auth) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=resp.status,
|
||||||
|
detail=f"文件获取失败: {await resp.text()}",
|
||||||
|
)
|
||||||
|
# 读取内容到内存
|
||||||
|
content = await resp.read()
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=resp.headers.get(
|
||||||
|
"Content-Type", "application/octet-stream"
|
||||||
|
),
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename.encode("utf-8").decode()}"'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"WebDAV连接异常: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
storages = {
|
storages = {
|
||||||
'local': SystemFileStorage,
|
"local": SystemFileStorage,
|
||||||
's3': S3FileStorage,
|
"s3": S3FileStorage,
|
||||||
'onedrive': OneDriveFileStorage,
|
"onedrive": OneDriveFileStorage,
|
||||||
'opendal': OpenDALFileStorage,
|
"opendal": OpenDALFileStorage,
|
||||||
|
"webdav": WebDAVFileStorage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from core.utils import get_now
|
|||||||
|
|
||||||
async def delete_expire_files():
|
async def delete_expire_files():
|
||||||
file_storage: FileStorageInterface = storages[settings.file_storage]()
|
file_storage: FileStorageInterface = storages[settings.file_storage]()
|
||||||
print(settings.file_storage)
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除
|
# 遍历 share目录下的所有文件夹,删除空的文件夹,并判断父目录是否为空,如果为空也删除
|
||||||
|
|||||||
+2
-4
@@ -7,9 +7,7 @@ import hashlib
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
|
from core.settings import settings
|
||||||
from apps.base.dependencies import IPRateLimit
|
|
||||||
|
|
||||||
|
|
||||||
async def get_random_num():
|
async def get_random_num():
|
||||||
"""
|
"""
|
||||||
@@ -46,7 +44,7 @@ async def get_select_token(code: str):
|
|||||||
:param code:
|
:param code:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
token = "123456"
|
token = settings.admin_token
|
||||||
return hashlib.sha256(f"{code}{int(time.time() / 1000)}000{token}".encode()).hexdigest()
|
return hashlib.sha256(f"{code}{int(time.time() / 1000)}000{token}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as i,B as k,r as h,o as u,I as v,d as x,e as t,n as o,g as e,i as w,f as n,X as _,F as M,q as C,p as z,y as B,J as L,m as D,K as F,t as I,z as d}from"./index-BIfJi_O-.js";import{B as j}from"./box-C9xlYTT6.js";/**
|
import{c as i,B as k,r as h,o as u,I as v,d as x,e as t,n as o,g as e,i as w,f as n,X as _,F as M,q as C,p as z,y as B,J as L,m as D,K as F,t as I,z as d}from"./index-CiI2cp0V.js";import{B as j}from"./box-BA8SMkDM.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as g,B as v,H as w,o as _,d as u,e as t,n as a,g as e,i as k,t as i,f as y,F as U,q as F,A as M,z as p,J as z,K as C}from"./index-BIfJi_O-.js";import{F as m}from"./file-AHrKtGwY.js";import{H as D,T as S}from"./trash-wtxrZ93m.js";/**
|
import{c as g,B as v,H as w,o as _,d as u,e as t,n as a,g as e,i as k,t as i,f as y,F as U,q as F,A as M,z as p,J as z,K as C}from"./index-CiI2cp0V.js";import{F as m}from"./file-CWusGEpI.js";import{H as D,T as S}from"./trash-dDHutUhM.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as V,B as T,u as q,r as _,b as k,d as c,e as t,n as o,g as a,i as A,j as P,f as m,v as H,m as I,F as v,q as w,t as d,A as S,z as g}from"./index-BIfJi_O-.js";import{F as L}from"./file-AHrKtGwY.js";/**
|
import{c as V,B as T,u as q,r as _,b as k,d as c,e as t,n as o,g as a,i as A,j as P,f as m,v as H,m as I,F as v,q as w,t as d,A as S,z as g}from"./index-CiI2cp0V.js";import{F as L}from"./file-CWusGEpI.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1 +1 @@
|
|||||||
import{G as y,r as u,B as v,u as b,d as w,e,n as l,g as o,i as x,f as h,h as k,j as S,v as A,m as V,t as B,y as D,z as j,A as P,_}from"./index-BIfJi_O-.js";import{B as z}from"./box-C9xlYTT6.js";const M=y("adminData",()=>{const d=u(localStorage.getItem("adminPassword")||"");function n(t){d.value=t,localStorage.setItem("token",t)}return{adminPassword:d,updateAdminPwd:n}}),I={class:"mx-auto h-16 w-16 relative"},L={class:"rounded-md shadow-sm -space-y-px"},N=["disabled"],T=v({__name:"LoginView",setup(d){const n=b(),t=u(""),i=u(!1),s=x("isDarkMode"),c=M(),p=()=>{let a=!0;return t.value?t.value.length<6&&(n.showAlert("密码长度至少为6位","error"),a=!1):(n.showAlert("无效的密码","error"),a=!1),a},m=D(),f=async()=>{if(p()){c.updateAdminPwd(t.value),P.post("/admin/login",{password:t.value}).then(()=>{m.push("/admin")}).catch(a=>{n.showAlert(a.response.data.detail,"error")}),i.value=!0;try{await new Promise(a=>setTimeout(a,2e3))}catch{}finally{i.value=!1}}};return(a,r)=>(j(),w("div",{class:l(["min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 transition-colors duration-200 relative overflow-hidden",o(s)?"bg-gray-900":"bg-gray-50"])},[r[6]||(r[6]=e("div",{class:"absolute inset-0 z-0"},[e("div",{class:"cyber-grid"}),e("div",{class:"floating-particles"})],-1)),e("div",{class:l(["max-w-md w-full space-y-8 backdrop-blur-lg bg-opacity-20 p-8 rounded-xl border border-opacity-20",[o(s)?"bg-gray-800 border-gray-600":"bg-white/70 border-gray-200"]])},[e("div",null,[e("div",I,[r[1]||(r[1]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full animate-spin-slow"},null,-1)),r[2]||(r[2]=e("div",{class:"absolute -inset-2 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full opacity-50 blur-md animate-pulse"},null,-1)),e("div",{class:l(["absolute inset-1 rounded-full flex items-center justify-center",o(s)?"bg-gray-800":"bg-white"])},[h(o(z),{class:l(["h-8 w-8",o(s)?"text-cyan-400":"text-cyan-600"])},null,8,["class"])],2)]),e("h2",{class:l(["mt-6 text-center text-3xl font-extrabold",o(s)?"text-white":"text-gray-900"])}," 登录 ",2)]),e("form",{class:"mt-8 space-y-6",onSubmit:k(f,["prevent"])},[r[5]||(r[5]=e("input",{type:"hidden",name:"remember",value:"true"},null,-1)),e("div",L,[e("div",null,[r[3]||(r[3]=e("label",{for:"password",class:"sr-only"},"密码",-1)),S(e("input",{id:"password",name:"password",type:"password",autocomplete:"current-password",required:"","onUpdate:modelValue":r[0]||(r[0]=g=>t.value=g),class:l(["appearance-none rounded-t-md relative block w-full px-4 py-3 border transition-all duration-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm backdrop-blur-sm",o(s)?"bg-gray-800/50 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500":"bg-white/50 border-gray-300 text-gray-900 hover:border-gray-400"]),placeholder:"密码"},null,2),[[A,t.value]])])]),e("div",null,[e("button",{type:"submit",class:l(["group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 shadow-lg hover:shadow-cyan-500/50",o(s)?"bg-gradient-to-r from-cyan-500 to-purple-500 hover:from-cyan-600 hover:to-purple-600":"bg-gradient-to-r from-cyan-600 to-purple-600 hover:from-cyan-700 hover:to-purple-700",i.value?"opacity-75 cursor-not-allowed":""]),disabled:i.value},[r[4]||(r[4]=e("span",{class:"absolute left-0 inset-y-0 flex items-center pl-3"},null,-1)),V(" "+B(i.value?"登录中...":"登录"),1)],10,N)])],32)],2)],2))}}),E=_(T,[["__scopeId","data-v-2e50c3fa"]]);export{E as default};
|
import{G as y,r as u,B as v,u as b,d as w,e,n as l,g as o,i as x,f as h,h as k,j as S,v as A,m as V,t as B,y as D,z as j,A as P,_}from"./index-CiI2cp0V.js";import{B as z}from"./box-BA8SMkDM.js";const M=y("adminData",()=>{const d=u(localStorage.getItem("adminPassword")||"");function n(t){d.value=t,localStorage.setItem("token",t)}return{adminPassword:d,updateAdminPwd:n}}),I={class:"mx-auto h-16 w-16 relative"},L={class:"rounded-md shadow-sm -space-y-px"},N=["disabled"],T=v({__name:"LoginView",setup(d){const n=b(),t=u(""),i=u(!1),s=x("isDarkMode"),c=M(),p=()=>{let a=!0;return t.value?t.value.length<6&&(n.showAlert("密码长度至少为6位","error"),a=!1):(n.showAlert("无效的密码","error"),a=!1),a},m=D(),f=async()=>{if(p()){c.updateAdminPwd(t.value),P.post("/admin/login",{password:t.value}).then(()=>{m.push("/admin")}).catch(a=>{n.showAlert(a.response.data.detail,"error")}),i.value=!0;try{await new Promise(a=>setTimeout(a,2e3))}catch{}finally{i.value=!1}}};return(a,r)=>(j(),w("div",{class:l(["min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 transition-colors duration-200 relative overflow-hidden",o(s)?"bg-gray-900":"bg-gray-50"])},[r[6]||(r[6]=e("div",{class:"absolute inset-0 z-0"},[e("div",{class:"cyber-grid"}),e("div",{class:"floating-particles"})],-1)),e("div",{class:l(["max-w-md w-full space-y-8 backdrop-blur-lg bg-opacity-20 p-8 rounded-xl border border-opacity-20",[o(s)?"bg-gray-800 border-gray-600":"bg-white/70 border-gray-200"]])},[e("div",null,[e("div",I,[r[1]||(r[1]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full animate-spin-slow"},null,-1)),r[2]||(r[2]=e("div",{class:"absolute -inset-2 bg-gradient-to-r from-cyan-500 via-purple-500 to-pink-500 rounded-full opacity-50 blur-md animate-pulse"},null,-1)),e("div",{class:l(["absolute inset-1 rounded-full flex items-center justify-center",o(s)?"bg-gray-800":"bg-white"])},[h(o(z),{class:l(["h-8 w-8",o(s)?"text-cyan-400":"text-cyan-600"])},null,8,["class"])],2)]),e("h2",{class:l(["mt-6 text-center text-3xl font-extrabold",o(s)?"text-white":"text-gray-900"])}," 登录 ",2)]),e("form",{class:"mt-8 space-y-6",onSubmit:k(f,["prevent"])},[r[5]||(r[5]=e("input",{type:"hidden",name:"remember",value:"true"},null,-1)),e("div",L,[e("div",null,[r[3]||(r[3]=e("label",{for:"password",class:"sr-only"},"密码",-1)),S(e("input",{id:"password",name:"password",type:"password",autocomplete:"current-password",required:"","onUpdate:modelValue":r[0]||(r[0]=g=>t.value=g),class:l(["appearance-none rounded-t-md relative block w-full px-4 py-3 border transition-all duration-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm backdrop-blur-sm",o(s)?"bg-gray-800/50 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500":"bg-white/50 border-gray-300 text-gray-900 hover:border-gray-400"]),placeholder:"密码"},null,2),[[A,t.value]])])]),e("div",null,[e("button",{type:"submit",class:l(["group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white transition-all duration-300 transform hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 shadow-lg hover:shadow-cyan-500/50",o(s)?"bg-gradient-to-r from-cyan-500 to-purple-500 hover:from-cyan-600 hover:to-purple-600":"bg-gradient-to-r from-cyan-600 to-purple-600 hover:from-cyan-700 hover:to-purple-700",i.value?"opacity-75 cursor-not-allowed":""]),disabled:i.value},[r[4]||(r[4]=e("span",{class:"absolute left-0 inset-y-0 flex items-center pl-3"},null,-1)),V(" "+B(i.value?"登录中...":"登录"),1)],10,N)])],32)],2)],2))}}),E=_(T,[["__scopeId","data-v-2e50c3fa"]]);export{E as default};
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
var De=Object.defineProperty;var Pe=(d,e,t)=>e in d?De(d,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):d[e]=t;var v=(d,e,t)=>Pe(d,typeof e!="symbol"?e+"":e,t);import{c as _e,_ as Fe,u as Ze,s as Oe,r as D,o as Ue,a as Qe,w as He,b as Ne,d as I,e as p,f as $,n as w,g as f,i as Ve,t as j,h as Ge,j as We,k as Y,v as Xe,l as Q,m as Z,p as Ke,X as xe,T as Je,F as Ye,q as et,x as le,y as tt,z as L,A as nt}from"./index-BIfJi_O-.js";import{c as H,u as st,S as it,C as rt,a as ot,Q as lt,E as at}from"./_commonjsHelpers-CljsJuVy.js";import{B as ct}from"./box-C9xlYTT6.js";import{F as ke}from"./file-AHrKtGwY.js";import{H as ut,T as pt}from"./trash-wtxrZ93m.js";/**
|
var De=Object.defineProperty;var Pe=(d,e,t)=>e in d?De(d,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):d[e]=t;var v=(d,e,t)=>Pe(d,typeof e!="symbol"?e+"":e,t);import{c as _e,_ as Fe,u as Ze,s as Oe,r as D,o as Ue,a as Qe,w as He,b as Ne,d as I,e as p,f as $,n as w,g as f,i as Ve,t as j,h as Ge,j as We,k as Y,v as Xe,l as Q,m as Z,p as Ke,X as xe,T as Je,F as Ye,q as et,x as le,y as tt,z as L,A as nt}from"./index-CiI2cp0V.js";import{c as H,u as st,S as it,C as rt,a as ot,Q as lt,E as at}from"./_commonjsHelpers-GlvjlJV-.js";import{B as ct}from"./box-BA8SMkDM.js";import{F as ke}from"./file-CWusGEpI.js";import{H as ut,T as pt}from"./trash-dDHutUhM.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as W,B as oe,r as z,o as ne,w as he,d as F,z as D,e as a,_ as se,u as ae,y as ge,i as pe,b as ye,p as ve,f as M,n as f,g as i,t as U,h as Q,l as E,k as N,j as J,v as X,x as K,C as be,F as Y,q as Z,m as V,X as xe,T as me,A as ee}from"./index-BIfJi_O-.js";import{g as we,u as _e,S as Ce,C as Ae,E as Me,a as Se,Q as Be}from"./_commonjsHelpers-CljsJuVy.js";import{F as te}from"./file-AHrKtGwY.js";import{T as Ie,H as Te}from"./trash-wtxrZ93m.js";/**
|
import{c as W,B as oe,r as z,o as ne,w as he,d as F,z as D,e as a,_ as se,u as ae,y as ge,i as pe,b as ye,p as ve,f as M,n as f,g as i,t as U,h as Q,l as E,k as N,j as J,v as X,x as K,C as be,F as Y,q as Z,m as V,X as xe,T as me,A as ee}from"./index-CiI2cp0V.js";import{g as we,u as _e,S as Ce,C as Ae,E as Me,a as Se,Q as Be}from"./_commonjsHelpers-GlvjlJV-.js";import{F as te}from"./file-CWusGEpI.js";import{T as Ie,H as Te}from"./trash-dDHutUhM.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as k,B as F,D as R,r as _,E as K,o as V,F as X,G as x,H as Y}from"./index-BIfJi_O-.js";/**
|
import{c as k,B as F,D as R,r as _,E as K,o as V,F as X,G as x,H as Y}from"./index-CiI2cp0V.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{c as a}from"./index-BIfJi_O-.js";/**
|
import{c as a}from"./index-CiI2cp0V.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import{c as a}from"./index-BIfJi_O-.js";/**
|
import{c as a}from"./index-CiI2cp0V.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
import{c as a}from"./index-BIfJi_O-.js";/**
|
import{c as a}from"./index-CiI2cp0V.js";/**
|
||||||
* @license lucide-vue-next v0.445.0 - ISC
|
* @license lucide-vue-next v0.445.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<meta name="keywords" content="{{keywords}}" />
|
<meta name="keywords" content="{{keywords}}" />
|
||||||
<meta name="generator" content="FileCodeBox2.2" />
|
<meta name="generator" content="FileCodeBox2.2" />
|
||||||
<title>{{title}}</title>
|
<title>{{title}}</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BIfJi_O-.js"></script>
|
<script type="module" crossorigin src="/assets/index-CiI2cp0V.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bx419AMb.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bx419AMb.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user