feat(adapters): add SMB share adapter

This commit is contained in:
shiyu committed 2026-10-03 17:58:04 +08:00
1 parent a5b57a77d5
commit 980a1570db
8 files changed
+335 -2

No files matched your search

+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -39,7 +39,7 @@
| 分类 | 适配器 |
|---|---|
| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP |
| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP、SMB |
| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘、天翼云盘 |
| **特殊类型** | Telegram、AList、Foxel 互联 |
+216
View File
@@ -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},
]
+1
View File
@@ -28,4 +28,5 @@ dependencies = [
"tortoise-orm>=1.1.8",
"uvicorn>=0.54.0",
"websockets>=17.1",
"smbprotocol>=1.17.0",
]
+69
View File
@@ -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"))
Generated
+45
View File
@@ -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"
+1
View File
@@ -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",
+1
View File
@@ -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": "自动化任务",