diff --git a/core/settings.py b/core/settings.py index ed31376..1becaa8 100644 --- a/core/settings.py +++ b/core/settings.py @@ -17,6 +17,11 @@ default_value = { 's3_secret_access_key': '', 's3_bucket_name': '', 's3_endpoint_url': '', + 'onedrive_domain': '', + 'onedrive_client_id': '', + 'onedrive_username': '', + 'onedrive_password': '', + 'onedrive_root_path': 'filebox_storage', 'admin_token': 'FileCodeBox2023', 'openUpload': 1, 'uploadSize': 1024 * 1024 * 10, @@ -24,6 +29,7 @@ default_value = { 'uploadCount': 10, 'errorMinute': 1, 'errorCount': 1, + 'port': 12345, } diff --git a/core/storage.py b/core/storage.py index 406695d..bf4ba8f 100644 --- a/core/storage.py +++ b/core/storage.py @@ -5,6 +5,9 @@ import asyncio import hashlib import time +from pathlib import Path +import datetime +import re import aioboto3 from fastapi import UploadFile @@ -69,6 +72,88 @@ class S3FileStorage: return result +class OneDriveFileStorage: + 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 + + 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') + return '/'.join(path[:-1]), path[-1] + + def _save(self, file, save_path): + content = file.read() + path, name = 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.file, save_path) + + def _delete(self, save_path): + path, name = self._get_path_str(save_path) + self.root_path.get_by_path(path + '/' + name).delete_object().execute_query() + + 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): + path, name = 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_url(self, file_code: FileCodes): + if file_code.prefix == '文本分享': + return file_code.text + result = await asyncio.to_thread(self._get_file_url, await file_code.get_file_path()) + return result + + class FileStorageTemplate: def __init__(self): ... @@ -85,6 +170,7 @@ class FileStorageTemplate: storages = { 'local': SystemFileStorage, - 's3': S3FileStorage + 's3': S3FileStorage, + 'onedrive': OneDriveFileStorage, } file_storage = storages[settings.file_storage]() diff --git a/readme_onedrive.md b/readme_onedrive.md new file mode 100644 index 0000000..fd864bd --- /dev/null +++ b/readme_onedrive.md @@ -0,0 +1,137 @@ +# OneDrive作为存储的配置方法 + +**仅支持工作或学习账户,并且需要有管理员权限以授权API** + +已知问题:下载文件时文件名会变为uuid,与上传时的文件名不一致。 + +## 1. 需要配置的参数 + +``` +file_storage=onedrive +onedrive_domain=XXXXXX +onedrive_client_id=XXXXXX-XXXXXX-XXXXXX-XXXXXX +onedrive_username=XXXXXX@XXXXXX +onedrive_password=XXXXXX +``` + +`onedrive_username`和`onedrive_password`是你的账户名(邮箱)和密码,另外两个参数需要在[微软Azure门户](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade)中注册应用后获取。 + +## 2. 应用注册 + +1. 登录[https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade),鼠标置于右上角账号处,浮窗将显示的`域`即为`onedrive_domain`的值。 +![onedrive_domain](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGCiErO85doq9Tcu/root/content) + +2. 点击左上角的`+新注册`,输入名称, + * 受支持的帐户类型:选择任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如,Skype、Xbox) + * 重定向 URI (可选):选择`Web`,并输入`http://localhost` + +3. 完成注册后进入概述页面,在概要中找到`应用程序(客户端)ID`,即为`onedrive_client_id`的值。 +![onedrive_client_id](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGHD4CNyJxm_QBb8/root/content) + +4. 此时还需要配置允许公共客户端流和API权限 + * 在左侧选择`身份验证`,找到`允许的客户端流`,选择`是`,并**点击`保存`**。 + ![允许的客户端流](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGJQMOlOCb2-L0Lh/root/content) + * 在左侧选择`API权限`,点击`+添加权限`,选择`Microsoft Graph`->`委托的权限`,并勾选下述权限:openid、Files中所有权限、User.Read,如下图所示。最后**点击下方的`添加权限`**。 + ![添加权限](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGOZzz7sIrdXkD4w/root/content) + * 最后点击`授予管理员同意`,并**点击`是`**,最终状态变为`已授予`。 + ![授予管理员同意](https://api.onedrive.com/v1.0/shares/s!Au-BDzXcM6_VmGSOAnjnHUlbirbU/root/content) + +## 3. 使用下述代码测试是否配置成功 + +安装依赖:`pip install Office365-REST-Python-Client` + +```python +# common.py +import msal +domain = 'XXXXXX' +client_id = 'XXXXXX' +username = 'XXXXXX' +password = 'XXXXXX' + +def acquire_token_pwd(): + authority_url = f'https://login.microsoftonline.com/{domain}' + app = msal.PublicClientApplication( + authority=authority_url, + client_id=client_id + ) + result = app.acquire_token_by_username_password( + username=username, + password=password, + scopes=['https://graph.microsoft.com/.default'] + ) + return result +``` + +测试登录,如果成功打印出账户名,说明配置成功。 + +```python +from common import acquire_token_pwd + +from office365.graph_client import GraphClient +try: + client = GraphClient(acquire_token_pwd) + me = client.me.get().execute_query() + print(me.user_principal_name) +except Exception as e: + print(e) +``` + +测试文件上传 + +```python +import os +from office365.graph_client import GraphClient +from common import acquire_token_pwd + +remote_path = 'tmp' +local_path = '.tmp/1689843925000.png' + +def convert_link_to_download_link(link): + import re + p1 = re.search(r'https:\/\/(.+)\.sharepoint\.com', link).group(1) + p2 = re.search(r'personal\/(.+)\/', link).group(1) + p3 = re.search(rf'{p2}\/(.+)', link).group(1) + return f'https://{p1}.sharepoint.com/personal/{p2}/_layouts/52/download.aspx?share={p3}' + +client = GraphClient(acquire_token_pwd) +folder = client.me.drive.root.get_by_path(remote_path) +# 1. upload +file = folder.upload_file(local_path).execute_query() +print(f'File {file.web_url} has been uploaded') +# 2. create sharing link +remote_file = folder.get_by_path(os.path.basename(local_path)) +permission = remote_file.create_link("view", "anonymous").execute_query() +print(f"sharing link: {convert_link_to_download_link(permission.link.webUrl)}") +``` + +测试文件下载 + +```python +import os +from office365.graph_client import GraphClient +from common import acquire_token_pwd + +remote_path = 'tmp/1689843925000.png' +local_path = '.tmp' +if not os.path.exists(local_path): + os.makedirs(local_path) + +client = GraphClient(acquire_token_pwd) +remote_file = client.me.drive.root.get_by_path(remote_path).get().execute_query() +with open(os.path.join(local_path, os.path.basename(remote_path)), 'wb') as local_file: + remote_file.download(local_file).execute_query() + print(f'{remote_file.name} has been downloaded into {local_file.name}') +``` + +测试删除文件 + +```python +from office365.graph_client import GraphClient +from common import acquire_token_pwd + +remote_path = 'tmp/1689843925000.png' + +client = GraphClient(acquire_token_pwd) +file = client.me.drive.root.get_by_path(remote_path) +file.delete_object().execute_query() +```