diff --git a/README.md b/README.md index e6a073e..490c805 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Utilizes an extensible adapter pattern to easily integrate various storage types | Category | Adapters | |---|---| -| **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP | +| **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP, SMB | | **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115, 189 | | **Special** | Telegram, AList, Foxel-to-Foxel | diff --git a/README_zh.md b/README_zh.md index b270051..5ffaa12 100644 --- a/README_zh.md +++ b/README_zh.md @@ -39,7 +39,7 @@ | 分类 | 适配器 | |---|---| -| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP | +| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP、SMB | | **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘、天翼云盘 | | **特殊类型** | Telegram、AList、Foxel 互联 | diff --git a/domain/adapters/providers/smb.py b/domain/adapters/providers/smb.py new file mode 100644 index 0000000..c6d734d --- /dev/null +++ b/domain/adapters/providers/smb.py @@ -0,0 +1,216 @@ +import asyncio +import mimetypes +import posixpath +import stat as statmod + +import smbclient +from smbclient import shutil as smb_shutil +from fastapi import HTTPException +from fastapi.responses import StreamingResponse + +from ._remote import clean_path, entry, paginate + + +class SMBAdapter: + """SMB2/3 share adapter backed by smbprotocol.""" + + def __init__(self, record): + self.record = record + cfg = record.config or {} + self.server = str(cfg.get("server") or cfg.get("address") or "").strip() + self.share = str(cfg.get("share") or cfg.get("share_name") or "").strip("/") + self.username = cfg.get("username") or "" + self.password = cfg.get("password") or "" + self.root = clean_path(cfg.get("root") or "") + self.timeout = float(cfg.get("timeout", 30)) + self.port = int(cfg.get("port", 445)) + if not self.server or not self.share: + raise ValueError("SMB requires server and share") + if any(c in self.server for c in "/\\") or any(c in self.share for c in "/\\"): + raise ValueError("SMB server and share must be single path components") + self.base = "\\\\" + self.server.strip("\\/") + "\\" + self.share.replace("/", "\\") + self._options = {"username": self.username, "password": self.password, "port": self.port, + "connection_timeout": self.timeout, "connection_cache": {}} + + def get_effective_root(self, sub_path): + return "/".join(filter(None, (self.root, clean_path(sub_path)))) + + def _path(self, root, rel=""): + combined = clean_path("/".join(filter(None, (root or self.root, rel)))) + return self.base if not combined else self.base + "\\" + combined.replace("/", "\\") + + async def _call(self, func, *args, **kwargs): + return await asyncio.to_thread(func, *args, **kwargs) + + async def list_dir(self, root, rel, page_num=1, page_size=50, sort_by="name", sort_order="asc"): + path = self._path(root, rel) + def scan(): + result = [] + with smbclient.scandir(path, **self._options) as iterator: + for item in iterator: + info = item.stat(follow_symlinks=False) + is_dir = statmod.S_ISDIR(info.st_mode) + result.append(entry(item.name, is_dir, info.st_size, info.st_mtime, path=item.path)) + return result + items = await self._call(scan) + return paginate(items, page_num, page_size, sort_by, sort_order) + + async def stat_file(self, root, rel): + path = self._path(root, rel) + try: + info = await self._call(smbclient.stat, path, follow_symlinks=False, **self._options) + except FileNotFoundError: + raise FileNotFoundError(rel) + return entry(posixpath.basename(clean_path(rel)), statmod.S_ISDIR(info.st_mode), info.st_size, info.st_mtime, + path=path) + + async def exists(self, root, rel): + try: + await self.stat_file(root, rel) + return True + except FileNotFoundError: + return False + + async def stat_path(self, root, rel): + try: + item = await self.stat_file(root, rel) + return {**item, "exists": True, "path": rel} + except FileNotFoundError: + return {"exists": False, "is_dir": None, "path": rel} + + async def read_file(self, root, rel): + path = self._path(root, rel) + def read(): + with smbclient.open_file(path, mode="rb", **self._options) as file: + return file.read() + return await self._call(read) + + async def stream_file(self, root, rel, range_header): + item = await self.stat_file(root, rel) + if item["is_dir"]: + raise IsADirectoryError(rel) + start, end = 0, item["size"] - 1 + if range_header: + try: + if not range_header.startswith("bytes=") or "," in range_header: + raise ValueError + begin, finish = range_header[6:].split("-") + if begin: + start = int(begin) + end = int(finish) if finish else item["size"] - 1 + else: + suffix = int(finish) + if suffix <= 0: + raise ValueError + start, end = max(0, item["size"] - suffix), item["size"] - 1 + if start < 0 or start >= item["size"] or end < start: + raise ValueError + end = min(end, item["size"] - 1) + except ValueError: + raise HTTPException(416, "Requested Range Not Satisfiable", + headers={"Content-Range": f"bytes */{item['size']}"}) + path = self._path(root, rel) + length = max(0, end - start + 1) + + async def body(): + file = await self._call(smbclient.open_file, path, mode="rb", **self._options) + remaining = length + try: + await self._call(file.seek, start) + while remaining: + chunk = await self._call(file.read, min(1024 * 1024, remaining)) + if not chunk: + raise IOError("SMB file was truncated during download") + remaining -= len(chunk) + yield chunk + finally: + await self._call(file.close) + + headers = {"Content-Length": str(length), "Accept-Ranges": "bytes"} + if range_header: + headers["Content-Range"] = f"bytes {start}-{end}/{item['size']}" + return StreamingResponse(body(), status_code=206 if range_header else 200, headers=headers, + media_type=mimetypes.guess_type(rel)[0] or "application/octet-stream") + + async def write_file(self, root, rel, data): + async def chunks(): + yield data + return await self.write_file_stream(root, rel, chunks()) + + async def write_file_stream(self, root, rel, data_iter): + if not clean_path(rel): + raise ValueError("Cannot overwrite mount root") + path = self._path(root, rel) + await self._call(smbclient.makedirs, path.rsplit("\\", 1)[0], exist_ok=True, **self._options) + file = await self._call(smbclient.open_file, path, mode="wb", **self._options) + size = 0 + try: + async for chunk in data_iter: + if chunk: + written = await self._call(file.write, chunk) + if written != len(chunk): + raise IOError("Incomplete SMB write") + size += written + finally: + await self._call(file.close) + return size + + async def write_upload_file(self, root, rel, file_obj, filename=None, file_size=None, content_type=None): + file_obj.seek(0) + async def chunks(): + while chunk := await self._call(file_obj.read, 1024 * 1024): + yield chunk + return {"size": await self.write_file_stream(root, rel, chunks())} + + async def mkdir(self, root, rel): + await self._call(smbclient.makedirs, self._path(root, rel), exist_ok=False, **self._options) + + async def delete(self, root, rel): + if not clean_path(rel): + raise ValueError("Cannot delete mount root") + item = await self.stat_file(root, rel) + path = self._path(root, rel) + await self._call(smb_shutil.rmtree if item["is_dir"] else smbclient.remove, path, **self._options) + + async def move(self, root, src_rel, dst_rel): + self._validate_transfer(src_rel, dst_rel) + if await self.exists(root, dst_rel): + raise FileExistsError(dst_rel) + await self._call(smbclient.rename, self._path(root, src_rel), self._path(root, dst_rel), **self._options) + + async def rename(self, root, src_rel, dst_rel): + return await self.move(root, src_rel, dst_rel) + + async def copy(self, root, src_rel, dst_rel, overwrite=False): + self._validate_transfer(src_rel, dst_rel) + if await self.exists(root, dst_rel): + if not overwrite: + raise FileExistsError(dst_rel) + await self.delete(root, dst_rel) + source = self._path(root, src_rel) + target = self._path(root, dst_rel) + item = await self.stat_file(root, src_rel) + if item["is_dir"]: + await self._call(smb_shutil.copytree, source, target, **self._options) + else: + await self._call(smbclient.makedirs, target.rsplit("\\", 1)[0], exist_ok=True, **self._options) + await self._call(smb_shutil.copyfile, source, target, **self._options) + + @staticmethod + def _validate_transfer(src, dst): + src, dst = clean_path(src), clean_path(dst) + if not src or not dst or src == dst or dst.startswith(src + "/"): + raise ValueError("Invalid transfer destination") + + +ADAPTER_TYPE = "smb" +ADAPTER_FACTORY = SMBAdapter +CONFIG_SCHEMA = [ + {"key": "server", "label": "Server", "type": "string", "required": True, "placeholder": "nas.example.com"}, + {"key": "share", "label": "Share name", "type": "string", "required": True}, + {"key": "port", "label": "Port", "type": "number", "default": 445}, + {"key": "username", "label": "Username", "type": "string", "required": False}, + {"key": "password", "label": "Password", "type": "password", "required": False}, + {"key": "root", "label": "Root directory", "type": "string", "default": "/"}, + {"key": "timeout", "label": "Timeout (seconds)", "type": "number", "default": 30}, +] diff --git a/pyproject.toml b/pyproject.toml index 222749f..684ca4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,4 +28,5 @@ dependencies = [ "tortoise-orm>=1.1.8", "uvicorn>=0.54.0", "websockets>=17.1", + "smbprotocol>=1.17.0", ] diff --git a/tests/test_smb_adapter.py b/tests/test_smb_adapter.py new file mode 100644 index 0000000..2a2031d --- /dev/null +++ b/tests/test_smb_adapter.py @@ -0,0 +1,69 @@ +import io +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from fastapi import HTTPException + +from domain.adapters.providers.smb import SMBAdapter + + +class SMBAdapterTests(unittest.IsolatedAsyncioTestCase): + def make(self): + return SMBAdapter(SimpleNamespace(config={"server": "nas", "share": "data", "username": "u", "password": "p"})) + + def test_no_network_in_constructor_and_sessions_are_isolated(self): + first, second = self.make(), self.make() + self.assertIsNot(first._options["connection_cache"], second._options["connection_cache"]) + self.assertEqual(first._path("docs", "a.txt"), r"\\nas\data\docs\a.txt") + with self.assertRaises(ValueError): + first._path("docs", "../secret") + + async def test_download_ranges_and_handle_cleanup(self): + data = b"0123456789" + for header, expected in [(None, data), ("bytes=2-4", b"234"), ("bytes=-3", b"789"), ("bytes=8-99", b"89")]: + with self.subTest(header=header): + file = io.BytesIO(data) + adapter = self.make() + with patch("domain.adapters.providers.smb.smbclient.stat", return_value=SimpleNamespace( + st_mode=0, st_size=10, st_mtime=0)), patch("domain.adapters.providers.smb.smbclient.open_file", return_value=file): + response = await adapter.stream_file("", "file", header) + self.assertEqual(b"".join([chunk async for chunk in response.body_iterator]), expected) + self.assertEqual(int(response.headers["content-length"]), len(expected)) + self.assertTrue(file.closed) + + async def test_invalid_ranges_never_open_a_file(self): + adapter = self.make() + for header in ["bytes=9-", "bytes=-0", "bytes=2-1", "other=1-2", "bytes=1-2,3-4"]: + with patch("domain.adapters.providers.smb.smbclient.stat", return_value=SimpleNamespace( + st_mode=0, st_size=4, st_mtime=0)), patch("domain.adapters.providers.smb.smbclient.open_file") as opened: + with self.assertRaises(HTTPException) as caught: + await adapter.stream_file("", "file", header) + self.assertEqual(caught.exception.status_code, 416) + opened.assert_not_called() + + async def test_stream_upload_is_incremental(self): + written = [] + class File: + closed = False + def write(self, chunk): written.append(chunk); return len(chunk) + def close(self): self.closed = True + file = File() + async def chunks(): + yield b"first" + self.assertEqual(written, [b"first"]) + yield b"second" + with patch("domain.adapters.providers.smb.smbclient.makedirs"), patch( + "domain.adapters.providers.smb.smbclient.open_file", return_value=file): + size = await self.make().write_file_stream("", "file", chunks()) + self.assertEqual(size, 11) + self.assertTrue(file.closed) + + async def test_copy_uses_sdk_and_rejects_self_overwrite(self): + adapter = self.make() + with self.assertRaises(ValueError): + await adapter.copy("", "file", "file", overwrite=True) + with patch.object(adapter, "exists", return_value=False), patch.object(adapter, "stat_file", return_value={"is_dir": False}), patch( + "domain.adapters.providers.smb.smbclient.makedirs"), patch("domain.adapters.providers.smb.smb_shutil.copyfile") as copy: + await adapter.copy("", "src", "dst") + self.assertEqual(copy.call_args.args, (r"\\nas\data\src", r"\\nas\data\dst")) diff --git a/uv.lock b/uv.lock index f4d4300..e05975f 100644 --- a/uv.lock +++ b/uv.lock @@ -603,6 +603,7 @@ dependencies = [ { name = "python-multipart" }, { name = "qdrant-client" }, { name = "setuptools" }, + { name = "smbprotocol" }, { name = "telethon" }, { name = "tortoise-orm" }, { name = "uvicorn" }, @@ -630,6 +631,7 @@ requires-dist = [ { name = "python-multipart", specifier = ">=0.0.32" }, { name = "qdrant-client", specifier = ">=1.19.1" }, { name = "setuptools", specifier = ">=84.0.0" }, + { name = "smbprotocol", specifier = ">=1.17.0" }, { name = "telethon", specifier = ">=1.45.0" }, { name = "tortoise-orm", specifier = ">=1.1.8" }, { name = "uvicorn", specifier = ">=0.54.0" }, @@ -1541,6 +1543,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, ] +[[package]] +name = "pyspnego" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "sspilib", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/8f/30bb9568554899a6147bed657762a3c707e2d3dcaabf3492161cc9550bb4/pyspnego-0.12.3.tar.gz", hash = "sha256:c4982c9f92e6aa5979c9d9a142a21339ff82ebcfbdb4e0649320cb050961a3cd", size = 226528, upload-time = "2026-09-29T21:23:02.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fd/0e09467d5b8e229388c897126b4aed1e5fef2295a8661e2cad21fa9a8214/pyspnego-0.12.3-py3-none-any.whl", hash = "sha256:39b87aa00491e554cef05441bf2f7e52e85b09b5229ff62a89c35b59548794be", size = 130323, upload-time = "2026-09-29T21:23:00.877Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1738,6 +1753,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smbprotocol" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyspnego" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/ff/1a86a76423210bc9558e6ea978c576b13da448644401f16ee2260d4d3316/smbprotocol-1.17.0.tar.gz", hash = "sha256:bcc27edfff7d727a7eb30424138766e1ee216ae2ffc1af03dab0448f29c4731c", size = 190845, upload-time = "2026-07-07T03:23:20.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/d3/250e13e7b3473a0f441f4d680d511ad30ce432d0180115dec9840d88fe89/smbprotocol-1.17.0-py3-none-any.whl", hash = "sha256:bd1abff5417f5af83ca516a64ab8e5acece3dbdcf58d4e5e23e47f5165a77349", size = 128208, upload-time = "2026-07-07T03:23:19.178Z" }, +] + [[package]] name = "sse-starlette" version = "3.5.0" @@ -1751,6 +1779,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] +[[package]] +name = "sspilib" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/70/946df678028019b3bbdd6e4fa1fb46f7ccdf46fab1b807813e79aff30e84/sspilib-0.6.0.tar.gz", hash = "sha256:b4d4e3402230f14cd93999f3953619163075bcaca3e13dcf2ab5942c269627f4", size = 59976, upload-time = "2026-08-24T06:44:15.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/89/d54087115dd1bdeafaaaee610defdcb940ebc241e1e2e7c52ec323e077bc/sspilib-0.6.0-cp311-abi3-win32.whl", hash = "sha256:70223bdfefde9656c48dd17bb58fd0fb7e879e5b08ab028267d585206f393886", size = 473115, upload-time = "2026-08-24T06:43:47.914Z" }, + { url = "https://files.pythonhosted.org/packages/4f/07/2519c128aad9c4062aa46bb8d584e01f8154d119e0a09191258da5935788/sspilib-0.6.0-cp311-abi3-win_amd64.whl", hash = "sha256:b841fabe630101d9e2be6ba56407a2a539087ee85d6ca1477748c21135c9f636", size = 557383, upload-time = "2026-08-24T06:43:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/a0aacae71a731a7575e2412b43549214b3fae898a8358a3d63b7c805aa42/sspilib-0.6.0-cp311-abi3-win_arm64.whl", hash = "sha256:9073afb8f30dc0e7f3a40b39fef0fc29e4bda6a3d4e77936f3c496cedc4beade", size = 484383, upload-time = "2026-08-24T06:43:50.291Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1d/679cee04cca4244d2a829686df201662618c2e87c6a19390861b04ffd9ee/sspilib-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:0482631c67a57d8710ecbba3bbe49a9fb60e153dfcfee6d1f9b8a8ff6e419548", size = 592349, upload-time = "2026-08-24T06:43:59.426Z" }, + { url = "https://files.pythonhosted.org/packages/82/bc/77c0ec2fee5d94446d245b2ce1dba2f6f2dcfecef20be8d362d2b9bc6a5f/sspilib-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5937dbe41c34cbbfabbafb84238a06aa8912210cae8a4b909fa20f965868cc89", size = 713678, upload-time = "2026-08-24T06:44:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/12/afd3c28e75a863aa2f29b40887570ebc91b12407818f2812d366b7fce1c2/sspilib-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea194eeb68d8848fde7125a7ac67fbd4a9052442b7ce69ce6c0c8115227ec67", size = 558596, upload-time = "2026-08-24T06:44:02.428Z" }, + { url = "https://files.pythonhosted.org/packages/89/b2/68ae0e965f0d4ad6cab30291adb490d73a5a898735452b3c99158d98bfbb/sspilib-0.6.0-cp315-cp315t-win32.whl", hash = "sha256:03335e14f8563e350506b47a15887a99f08102a80cfb6bd715ad83ab1c2c89ae", size = 589548, upload-time = "2026-08-24T06:44:11.251Z" }, + { url = "https://files.pythonhosted.org/packages/d0/4f/1d3c64284faf9fa9b7d5ec2b4b9152cbcd59d0bf260883bd485be5d58551/sspilib-0.6.0-cp315-cp315t-win_amd64.whl", hash = "sha256:071e4e2a617a39a33e31de2c1f313427533879acabe475fedea31f129f7970bb", size = 707549, upload-time = "2026-08-24T06:44:12.826Z" }, + { url = "https://files.pythonhosted.org/packages/82/9d/26db0d0998bf4b20bc14809e30c20845b9ef34eb6114e9fe39541c7ee0a8/sspilib-0.6.0-cp315-cp315t-win_arm64.whl", hash = "sha256:97bb2e9d4d916d4d6f566888a81997bb3ab0e05ac41549fa0fbebabac66de0e8", size = 555718, upload-time = "2026-08-24T06:44:14.359Z" }, +] + [[package]] name = "starlette" version = "1.7.0" diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index cc9c6cd..fc75c52 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -594,6 +594,7 @@ "adapter.type.pan123": "123Pan (Account)", "adapter.type.pan115": "115 Cloud (Cookie)", "adapter.type.cloud189": "189 Cloud", + "adapter.type.smb": "SMB Share", "adapter.type.alist": "AList", "adapter.type.openlist": "OpenList", "Automation Tasks": "Automation Tasks", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 7ca4d70..901becf 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -585,6 +585,7 @@ "adapter.type.pan123": "123 网盘(账号)", "adapter.type.pan115": "115 网盘(Cookie)", "adapter.type.cloud189": "天翼云盘", + "adapter.type.smb": "SMB 共享", "adapter.type.alist": "AList", "adapter.type.openlist": "OpenList", "Automation Tasks": "自动化任务",